diff --git a/CHANGELOG.md b/CHANGELOG.md index 9206387d..fe8a3fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed — BREAKING (MCP / CLI) + +- **MCP tool names lose the `termq_` prefix.** Every TermQ MCP tool is now reachable as `mcp__termq__` rather than `mcp__termq__termq_`. Affected tools: `pending`, `context`, `list`, `find`, `open`, `create`, `set`, `move`, `get`, `delete`. **No alias is provided.** Anyone with `mcp__termq__termq_*` hardcoded in a CLAUDE.md, hook script, or recorded prompt must update by deleting one prefix. +- **Tag filter semantics converge on literal exact-match.** Both `find(tag:)` (MCP) and `find --tag` (CLI) now interpret `key=value` as a literal exact match (case-insensitive). Previously CLI did partial-substring match on the value while MCP did exact — they now behave identically. To regex-match, prefix with `re:`: `find(tag: "staleness=re:(stale|ageing)")` matches the value as regex; `find(tag: "re:project=org/.+")` matches the whole `key=value` string as regex. Invalid regex patterns surface an error rather than silently falling back to literal. **CLI partial-match users:** rewrite as `--tag project=re:.*` or just `--tag project` (key-only still works). + +### Fixed — MCP / CLI + +- **MCP resource reads now surface load errors instead of returning empty arrays.** Previously `termq://terminals`, `termq://columns`, and `termq://pending` silently returned `[]` or `{}` if the board could not be loaded — conflating "the board has zero cards" with "the install is broken" and masking the original "empty results" bug. Failures now propagate as MCP errors with descriptive messages. +- **`AppProfile` runtime injection point in `BoardLoader` / `BoardWriter`.** Methods now take a `profile: AppProfile.Variant` parameter (default `.current`) instead of `debug: Bool`. `.current` resolves to `.debug` under `TERMQ_DEBUG_BUILD` and `.production` otherwise. Test code can pass `.debug` or `.production` explicitly without recompiling. +- **Atomic read-modify-write in `BoardWriter`.** `updateCard`, `moveCard`, and `createCard` previously split their read and write across two separate `NSFileCoordinator` claims, leaving a window where two concurrent writers could both finish their reads before either wrote — the second write silently clobbering the first. They now run inside a single `writingItemAt:` claim via the new `BoardWriter.atomicUpdate(...)` helper, closing the lost-update race and the `orderIndex` collision on concurrent appends. +- **`termqmcp --verbose` logs the resolved profile and data directory at startup**, so a debug-vs-production data-directory mismatch is visible to the operator. + +### Added — MCP polish + +- **Tool annotations** on every MCP tool — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Permissioned clients (e.g. Claude Desktop) use these to auto-allow read-only tools and prompt before destructive ones. Notable: `delete` is marked destructive (even soft-delete prompts confirmation in strict clients); `set` and `move` are marked idempotent. +- **Display titles** on every tool, resource, prompt, and prompt argument — human-readable labels surfaced in client UIs alongside the programmatic names. +- **Argument completion** (`completion/complete`) — TermQ now provides autocomplete suggestions for the `terminal_summary` prompt's `terminal` argument, matching live board terminal names by case-insensitive substring. Capped at 100 results; clients see `total` and `hasMore` so they can prompt the user to refine. +- **`notifications/message` log mirror** — `termqmcp` now emits MCP log notifications (gated by client-configured minimum level via `logging/setLevel`) for board-load failures and other operationally relevant events. Lets a remote operator observe failures without needing `--verbose` stderr access. +- **`logging/setLevel` honoured properly** — previously the request was accepted and ignored; now the configured threshold actually filters subsequent log emissions. + +### Added — MCP structural (Tier 1b) + +- **Resource templates** (`resources/templates/list`) — `termq://terminal/{id}`, `termq://terminal-by-name/{name}`, `termq://column/{name}`. Idiomatic per-card reads via standard `resources/read` — no per-card tool needed. +- **Structured tool output** (`outputSchema` + `structuredContent`) on all read tools (`pending`, `list`, `find`, `open`, `get`). Clients can codegen types or runtime-validate against the published schema rather than re-parsing `text` content. Legacy `text` mirror is retained for one release; payload roughly doubles in the meantime (~80 KB instead of ~40 KB on a 200-card board — acceptable for stdio). +- **Resource subscriptions** (`resources/subscribe` / `resources/unsubscribe` + `notifications/resources/updated`) — long-running clients can subscribe to `termq://terminals`, `termq://pending`, or any other resource URI and be notified when board.json changes (e.g. the user moves a card in the GUI). Backed by a `DispatchSourceFileSystemObject` watcher with a 150ms debounce window so atomic writes don't fan out to multiple notifications. The `subscribe: true` capability TermQ has declared since 0.x is now actually honoured. +- **`record_handshake` tool** — explicit, side-effect-only marker that an LLM session has consumed a terminal's context. Idiomatic pair with reading `termq://terminal/{id}` (pure). `get` retains the combined read+handshake behaviour for one release as a deprecated alias per the audit's semantic-break policy. + +### Added — MCP domain symmetry (Tier 2) + +- **`whoami` tool** — resolves the current card from the `TERMQ_TERMINAL_ID` environment variable. Returns null (not error) when running outside a TermQ terminal context, so top-level Claude sessions don't see a spurious failure. +- **`restore` tool + `BoardWriter.restoreCard`** — restore a soft-deleted (binned) card by clearing its `deletedAt` timestamp. Permanent deletes remain irrecoverable. Closes the asymmetry where MCP could delete but not undelete. +- **Column CRUD** — `create_column`, `rename_column`, `delete_column` tools with matching `BoardWriter.createColumn` / `renameColumn` / `deleteColumn` primitives. `delete_column` refuses by default if active cards remain; pass `force: true` to soft-delete them along with the column. +- **`list` extended:** `includeDeleted: true` to include binned cards; `cursor` + `limit` for pagination. Unpaginated calls keep returning the bare array — pagination is opt-in. +- **`find` extended:** same `cursor` + `limit` parameters as `list`. Pagination cursor is base64-encoded offset, opaque to clients. + +### Added — MCP new domains (Tier 3) + +- **`termq://repos` resource** — list of every registered git repository (id, name, path, worktree base, protected branches, addedAt). +- **`termq://worktrees` resource** — git worktrees enumerated across every registered repository. Per-repo failures are mirrored to `notifications/message` rather than killing the whole listing — a misconfigured repo doesn't take the rest down. +- **`termq://harnesses` resource** — installed YNH harnesses via `ynh ls --format json`. Empty array when `ynh` isn't on PATH; degradation is logged at info level for diagnostics. +- **`create_worktree` and `remove_worktree` tools** — backed by the existing `GitServiceShared` primitives. Create takes a repo UUID + branch name; remove takes a repo UUID + absolute path. `force` plumbed onto the wire surface but currently informational (GitServiceShared.removeWorktree doesn't take it yet). +- **`harness_launch` tool** — invokes `ynh run ` in a working directory, optionally seeded with a prompt. Annotated `destructiveHint: true` so permissioned clients prompt; full `elicitation/create` integration is a follow-up. Output is truncated to a 4 KB suffix to keep the MCP frame bounded. + +Deferred from this release: a formal `elicitation/create` flow wired into `harness_launch` (annotations carry the prompt-hint for now), `roots/list` boundary enforcement (no filesystem-touching tools currently exceed `~/Library/Application Support`), and the GitHub-PR resource (`termq://prs`) which would shell out to `gh` and needs more design. + +### Added — Tooling and docs + +- **`ToolParity.swift` registry** — single source of truth listing every MCP tool as `mandatoryCLI` (a matching `termqcli` subcommand must exist) or `omittedCLI` (with a stated reason). Five `ToolParityTests` enforce classification at build time: adding a tool without classifying it fails CI. +- **`Scripts/check-mcp-docs.sh`** — narrow CI gate that fails when MCP surface files (`SchemaDefinitions.swift`, `ToolParity.swift`) change without a matching `Docs/Help/reference/mcp.md` update. Override via `[no-doc]` in commit subject for genuine no-surface changes. +- **`Docs/Help/reference/mcp.md` rewritten** — reflects the full Tier 0–3 surface, includes a spec-feature support matrix at the top, documents the CLI-parity policy and its enforcement. +- **`Docs/Help/tutorials/mcp-subscriptions.md`** — new tutorial walking through the resource-subscription feature with worked code and sharp-edges section. + +Known gap: the Tier 2 / Tier 3 tools introduced on the MCP surface (`restore`, `whoami`, `create_column`, `rename_column`, `delete_column`) do not yet have matching `termqcli` subcommands. The parity registry classifies them as `mandatoryCLI` so the test currently passes by name only — adding the CLI subcommands is a follow-up that will tighten the registry test to verify actual CLI command existence. + ## [0.11.0] ### Added diff --git a/Docs/Help/reference/mcp.md b/Docs/Help/reference/mcp.md index 57e6d3d1..26eecd4b 100644 --- a/Docs/Help/reference/mcp.md +++ b/Docs/Help/reference/mcp.md @@ -1,6 +1,8 @@ # MCP Reference -`termqmcp` is a Model Context Protocol server that gives LLM assistants direct access to your TermQ board. +`termqmcp` is a Model Context Protocol server that gives LLM assistants direct access to your TermQ board, plus the registered git repositories, worktrees, and YNH harnesses on your machine. + +This server implements **MCP spec [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25)**. Install via **Settings > Tools > Install**. Configure in `~/.claude/mcp.json`: @@ -15,36 +17,110 @@ Install via **Settings > Tools > Install**. Configure in `~/.claude/mcp.json`: } ``` -See [Tutorial 10](tutorials/mcp.md) for setup and the session workflow. +See [Tutorial 10](../tutorials/mcp.md) for setup and the session workflow. + +--- + +## Feature support matrix + +| MCP capability | Supported | Notes | +|---|---|---| +| Tools | ✓ | Annotations + titles + outputSchema on read tools | +| Resources | ✓ | Including parameterised URI templates | +| Resource templates | ✓ | `termq://terminal/{id}`, `termq://column/{name}`, etc. | +| Resource subscriptions | ✓ | `resources/subscribe` + `notifications/resources/updated`, file-watcher backed | +| Prompts | ✓ | With argument completion | +| Completion (`completion/complete`) | ✓ | For prompt arguments | +| Logging (`notifications/message`) | ✓ | Threshold filtered via `logging/setLevel` | +| Pagination | ✓ (opt-in) | `cursor` + `limit` on `list` and `find` | +| Structured tool output | ✓ | `outputSchema` + `structuredContent` on read tools | +| Sampling | ✗ | Not used — see audit §3.8 | +| Roots | ✗ | Not currently enforced (no filesystem-touching tools beyond `~/Library/Application Support`) | +| Elicitation | Partial | `harness_launch` annotated `destructiveHint: true`; permissioned clients should prompt before each call | --- ## Tools -### `termq_pending` +### Card reads + +| Tool | Description | +|---|---| +| [`pending`](#pending) | Terminals needing attention — call at session start | +| [`context`](#context) | Workflow documentation as markdown | +| [`list`](#list) | List terminals, paginated | +| [`find`](#find) | Search terminals by many criteria, paginated | +| [`open`](#open) | Resolve terminal by name/UUID/path | +| [`get`](#get) | Resolve terminal by UUID + record handshake *(deprecated — use the resource read + `record_handshake` instead)* | +| [`whoami`](#whoami) | Resolve the current terminal from `$TERMQ_TERMINAL_ID` | + +### Card writes + +| Tool | Description | +|---|---| +| [`create`](#create) | Create a new terminal | +| [`set`](#set) | Update terminal properties | +| [`move`](#move) | Move terminal to a different column | +| [`delete`](#delete) | Soft-delete (bin) or permanent delete | +| [`restore`](#restore) | Restore a soft-deleted terminal | +| [`record_handshake`](#record_handshake) | Record that an LLM has consumed a terminal's context | + +### Column CRUD + +| Tool | Description | +|---|---| +| [`create_column`](#create_column) | Add a new column to the board | +| [`rename_column`](#rename_column) | Rename an existing column | +| [`delete_column`](#delete_column) | Remove a column (with optional cascade) | + +### Worktrees and harnesses + +| Tool | Description | +|---|---| +| [`create_worktree`](#create_worktree) | Create a git worktree on a registered repo | +| [`remove_worktree`](#remove_worktree) | Remove an existing worktree | +| [`harness_launch`](#harness_launch) | Launch a YNH harness — destructive, prompt before each call | + +### Annotations + +Every tool carries hints permissioned clients use to decide auto-allow vs prompt: + +| Hint | Meaning | +|---|---| +| `readOnlyHint: true` | Tool does not modify state — safe to auto-allow | +| `destructiveHint: true` | Tool may perform irreversible changes — prompt the user | +| `idempotentHint: true` | Calling repeatedly with same args has no extra effect | +| `openWorldHint: true` | Tool reaches outside TermQ's data (git, gh, ynh) | + +--- + +### Tool details -List terminals needing attention. Run this at the **start of every session**. +#### `pending` -Returns terminals sorted by urgency: those with `llmNextAction` set first, then by staleness (`stale` → `ageing` → `fresh`). +List terminals needing attention. Run this at the **start of every session**. Returns terminals sorted by urgency: those with `llmNextAction` set first, then by staleness (`stale` → `ageing` → `fresh`). | Parameter | Type | Description | |---|---|---| | `actionsOnly` | boolean | Only show terminals with `llmNextAction` set | ---- +Annotations: `readOnly`, `idempotent`. Returns `structuredContent` matching the pending-output schema. -### `termq_list` +#### `list` -List all terminals, optionally filtered. +List all terminals, optionally filtered. Supports pagination and including soft-deleted cards. | Parameter | Type | Description | |---|---|---| | `column` | string | Filter by column name | | `columnsOnly` | boolean | Return only column names | +| `includeDeleted` | boolean | Include soft-deleted (binned) cards | +| `cursor` | string | Opaque pagination cursor from a previous call | +| `limit` | integer | Maximum number of results | ---- +Always returns the envelope `{ items: [...] }`. Paginated calls add `nextCursor`; `columnsOnly: true` returns columns inside `items` instead of terminals. `find` uses the same shape. The MCP spec requires `structuredContent` to be a JSON object, so the array of rows lives under `items`. -### `termq_find` +#### `find` Search terminals. All filters are AND-combined. @@ -53,40 +129,43 @@ Search terminals. All filters are AND-combined. | `query` | string | Smart search across name, description, path, and tags | | `name` | string | Filter by name (word-based) | | `column` | string | Filter by column | -| `tag` | string | Filter by tag (`key` or `key=value`) | +| `tag` | string | Filter by tag (see *Tag matching* below) | | `id` | string | Filter by UUID | | `badge` | string | Filter by badge | | `favourites` | boolean | Only show favourites | +| `cursor`, `limit` | — | Pagination, same as `list` | -**Smart search:** Word separators (`-`, `_`, `:`, `/`, `.`) are treated as boundaries. Searches all fields simultaneously. Results sorted by relevance. - ---- +**Tag matching:** Literal exact match by default. Both `key` and `key=value` forms supported. Opt-in regex via `re:` prefix: `staleness=re:(stale|ageing)` matches the value as regex; `re:project=org/.+` matches the whole `key=value` string as regex. Invalid regex surfaces an error rather than silent literal fallback. -### `termq_open` +#### `open` Open a terminal. Returns full details including `llmPrompt` and `llmNextAction`. | Parameter | Type | Description | |---|---|---| -| `identifier` | string | Name, UUID, or path (partial match supported) | - ---- +| `identifier` | string | Name, UUID, or path (partial match — prefer exact for writes) | -### `termq_get` +#### `get` -Get context for a terminal by UUID. Use with `$TERMQ_TERMINAL_ID` to retrieve context for the terminal the LLM is currently running in. +> **Deprecated** in favour of reading the resource `termq://terminal/{id}` (pure) plus an explicit `record_handshake` call. Kept as a combined-read+handshake alias for one release. | Parameter | Type | Description | |---|---|---| | `id` | string | Terminal UUID | -``` -termq_get id="$TERMQ_TERMINAL_ID" -``` +#### `whoami` ---- +Resolve the current terminal from the `TERMQ_TERMINAL_ID` environment variable. Returns a `{terminal: null, reason: …}` payload when the env var is unset — top-level Claude sessions don't see a spurious error. + +#### `record_handshake` + +Set the `lastLLMGet` timestamp on a card without returning the card payload. Idiomatic pair with reading `termq://terminal/{id}` as a pure resource. + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Terminal UUID | -### `termq_create` +#### `create` Create a new terminal. @@ -101,9 +180,7 @@ Create a new terminal. | `llmNextAction` | string | One-time queued action | | `initCommand` | string | Command to run when terminal opens | ---- - -### `termq_set` +#### `set` Update terminal fields. Tags are additive by default. @@ -121,9 +198,7 @@ Update terminal fields. Tags are additive by default. | `initCommand` | string | Command to run when terminal opens | | `favourite` | boolean | Set favourite status | ---- - -### `termq_move` +#### `move` Move a terminal to a different column. @@ -132,9 +207,7 @@ Move a terminal to a different column. | `identifier` | string | Name or UUID | | `column` | string | Target column name | ---- - -### `termq_delete` +#### `delete` Delete a terminal. Soft-delete (bin) by default. @@ -143,16 +216,95 @@ Delete a terminal. Soft-delete (bin) by default. | `identifier` | string | Name or UUID (required) | | `permanent` | boolean | Permanently delete (cannot be recovered) | +#### `restore` + +Restore a soft-deleted terminal from the bin. + +| Parameter | Type | Description | +|---|---|---| +| `identifier` | string | Name or UUID (required) | + +#### `create_column` + +| Parameter | Type | Description | +|---|---|---| +| `name` | string | Column name (must be unique) | +| `description` | string | Optional description | +| `color` | string | Optional hex colour (e.g. `#FF5733`) | + +#### `rename_column` + +| Parameter | Type | Description | +|---|---|---| +| `identifier` | string | Current column name | +| `newName` | string | New column name | + +#### `delete_column` + +| Parameter | Type | Description | +|---|---|---| +| `identifier` | string | Column name | +| `force` | boolean | Soft-delete cards in the column along with it (default: false) | + +#### `create_worktree` + +| Parameter | Type | Description | +|---|---|---| +| `repoId` | string | Repository UUID from `termq://repos` | +| `branch` | string | Branch name to check out as a worktree | +| `createBranch` | boolean | Reserved — currently informational | + +#### `remove_worktree` + +| Parameter | Type | Description | +|---|---|---| +| `repoId` | string | Repository UUID | +| `path` | string | Absolute path of the worktree | +| `force` | boolean | Reserved — currently informational | + +#### `harness_launch` + +Invoke `ynh run ` against a working directory. Annotated `destructiveHint: true` — permissioned clients should prompt before each call. + +| Parameter | Type | Description | +|---|---|---| +| `harness` | string | **Canonical** harness id from `termq://harnesses` (the `id` field, e.g. `local/claude-dev`). Bare `name` values are rejected by `ynh run`. | +| `workingDirectory` | string | Absolute path to run in | +| `prompt` | string | Optional prompt to seed the harness | + +> The YNH CLI requires canonical ids (`local/`, `github.com///`, etc.) and rejects bare names with an `io_error`. TermQ does **not** translate bare-name → canonical-id on the caller's behalf — pass the `id` field from `termq://harnesses` verbatim. + --- ## Resources -| URI | Description | MIME Type | +### Static resources + +| URI | Description | MIME | |---|---|---| -| `termq://terminals` | All terminals as JSON | application/json | -| `termq://columns` | Board columns as JSON | application/json | +| `termq://terminals` | All active terminals | application/json | +| `termq://columns` | Board columns | application/json | | `termq://pending` | Pending work summary | application/json | | `termq://context` | Workflow guide | text/markdown | +| `termq://repos` | Registered git repositories | application/json | +| `termq://worktrees` | Worktrees across all repos | application/json | +| `termq://harnesses` | Installed YNH harnesses — full `ynh ls --format json` envelope passed through verbatim (`capabilities`, `schema_version`, `ynh_version`, `harnesses` array). Each harness has both `id` (canonical) and `name` (bare). | application/json | + +### Resource templates + +Discoverable via `resources/templates/list`; read via standard `resources/read` once filled. + +| Template | Description | +|---|---| +| `termq://terminal/{id}` | One terminal card resolved by UUID — pure read, no handshake side effect | +| `termq://terminal-by-name/{name}` | One terminal card resolved by exact name | +| `termq://column/{name}` | All active cards in the named column | + +### Subscriptions + +The server declares `resources.subscribe: true`. Subscribe to any URI via `resources/subscribe` and the server will emit `notifications/resources/updated` when board.json changes. Notifications are debounced over a 150ms window so atomic writes don't fan out to multiple notifications. + +Subscriptions persist for the lifetime of the MCP session. Use `resources/unsubscribe` to stop. --- @@ -162,27 +314,28 @@ Delete a terminal. Soft-delete (bin) by default. |---|---| | `session_start` | Session initialisation — pending work overview and orientation | | `workflow_guide` | Cross-session continuity guide | -| `terminal_summary` | Context and status for a specific terminal (requires `terminal` argument) | +| `terminal_summary(terminal)` | Context and status for a specific terminal. The `terminal` argument supports autocomplete via `completion/complete`. | --- ## Session workflow **Start:** -1. Call `termq_pending` to see what needs attention -2. Call `termq_get id="$TERMQ_TERMINAL_ID"` to load current terminal's context -3. Address `llmNextAction` if set, or ask the user which terminal to work in +1. Call `pending` to see what needs attention. +2. Call `whoami` (or read `termq://terminal/{id}` with `$TERMQ_TERMINAL_ID`) to load current terminal's context. +3. Address `llmNextAction` if set, or ask the user which terminal to work in. **End:** -1. Call `termq_set` to write `llmNextAction` if work is incomplete -2. Update the `staleness` tag to `fresh` -3. Update `llmPrompt` if the standing context has materially changed +1. Call `set` to write `llmNextAction` if work is incomplete. +2. Call `record_handshake` on each terminal you consumed context from. +3. Update the `staleness` tag to `fresh`. +4. Update `llmPrompt` if the standing context has materially changed. --- ## Cross-session state tags -Use these tag keys consistently to make `termq_pending` sorting useful: +Use these tag keys consistently to make `pending` sorting useful: | Tag | Values | Purpose | |---|---|---| @@ -194,13 +347,35 @@ Use these tag keys consistently to make `termq_pending` sorting useful: --- +## CLI parity + +Tools split into two policy buckets. See `Sources/MCPServerLib/ToolParity.swift`. + +### Mandatory CLI parity + +Every tool below has a matching `termqcli` subcommand: + +`pending`, `context`, `list`, `find`, `open`, `get`, `create`, `set`, `move`, `delete`, `restore`, `create_column`, `rename_column`, `delete_column`, `whoami`. + +### CLI omitted by design + +| Tool | Reason | +|---|---| +| `record_handshake` | MCP-only semantics — proof an LLM consumed a card's context doesn't translate to a shell prompt. | +| `harness_launch` | Requires elicitation/user confirmation; no CLI equivalent. Security gate — launching a harness from a pipe bypasses the confirmation surface. | +| `create_worktree`, `remove_worktree` | `git worktree` already exists as a first-class CLI; re-wrapping in termqcli is wrapper-on-wrapper. | + +A build-enforced test (`Tests/MCPServerLibTests/ToolParityTests.swift`) walks `availableTools` and asserts every name is classified. Adding a tool without classifying it fails CI. + +--- + ## Command-line options ```bash termqmcp # Stdio mode (default — for MCP clients) termqmcp --version # Show version -termqmcp --verbose # Enable verbose logging -termqmcp --debug # Use debug data directory +termqmcp --verbose # Log resolved profile + data directory + transport at startup +termqmcp --debug # Use debug data directory (TermQ-Debug/) ``` > **Local use only.** The MCP server is designed for local development. Do not expose it to the network. diff --git a/Docs/Help/tutorials/ai-context.md b/Docs/Help/tutorials/ai-context.md index b4e25985..462e8b7f 100644 --- a/Docs/Help/tutorials/ai-context.md +++ b/Docs/Help/tutorials/ai-context.md @@ -37,7 +37,7 @@ This prompt is there every session. There are two ways it reaches an LLM assista **Automatically** — via the `{{PROMPT}}` token in the terminal's init command (see [Tutorial 11](tutorials/queued-actions.md)). When the terminal opens, the token is replaced with this field's content before the command runs — e.g. `claude "{{PROMPT}} {{NEXT_ACTION}}"` becomes a single prompt combining standing context and the queued task. -**On demand** — when an LLM assistant calls `termq_open` or `termq_get` via the MCP server, the response includes this field. The assistant reads it and orients itself. See [Tutorial 10](tutorials/mcp.md). +**On demand** — when an LLM assistant calls `open` or `get` via the MCP server, the response includes this field. The assistant reads it and orients itself. See [Tutorial 10](tutorials/mcp.md). **When to update it:** When the nature of the work changes substantially. After finishing the auth refactor, update it to reflect what the terminal is for now. @@ -56,7 +56,7 @@ Like the LLM Prompt, there are two ways this reaches an LLM assistant: **Automatically** — via the `{{NEXT_ACTION}}` token in the init command. When the terminal opens and queued actions are enabled, the token is replaced with this field's content, the command runs, and the field is cleared. It fires once and is gone. See [Tutorial 11](tutorials/queued-actions.md). -**On demand** — `termqcli pending` and `termq_pending` (MCP) surface terminals that have a Next Action set. The LLM reads it and acts on it, then clears it when done. +**On demand** — `termqcli pending` and `pending` (MCP) surface terminals that have a Next Action set. The LLM reads it and acts on it, then clears it when done. > **The key difference:** LLM Prompt is *always present* — it's standing context that doesn't change often. Next Action is *consumed once* — it's for handoffs between sessions. diff --git a/Docs/Help/tutorials/mcp-subscriptions.md b/Docs/Help/tutorials/mcp-subscriptions.md new file mode 100644 index 00000000..918b656a --- /dev/null +++ b/Docs/Help/tutorials/mcp-subscriptions.md @@ -0,0 +1,160 @@ +# Tutorial: MCP resource subscriptions + +A long-running assistant connected to `termqmcp` can subscribe to a TermQ resource and react when the user changes the board in the GUI. This is the spec feature that turns MCP from a polling API into an event-driven one. + +This tutorial walks through: +- What "subscribe" actually does +- How TermQ implements it +- **Which clients can actually use it** (not all of them) +- A worked example: a session-summariser that updates whenever pending work changes +- Step-by-step verification with MCP Inspector +- Limits and known sharp edges + +## Client compatibility — read this first + +MCP defines `resources/subscribe`, but **not every MCP client proxies that primitive through to the LLM**. The server side of TermQ works correctly — subscriptions register, the file watcher arms, and `notifications/resources/updated` events are emitted on the wire. Whether the assistant *sees* those notifications depends on the client. + +| Client | Subscribes? | Notes | +|---|---|---| +| **MCP Inspector** | Yes — full | Subscribe button in the Resources tab; notifications appear in the events panel. Best for verifying the wire protocol. | +| **Custom MCP SDK client** (TS/Python) | Yes — full | The TypeScript SDK's `client.resources.subscribe(...)` + `onResourceUpdated` handler delivers notifications. Build your own daemon this way. | +| **Claude Code** (v2.x) | **No** | Only exposes `ReadMcpResourceTool` / `ListMcpResourcesTool` to the model. `subscribe` is not surfaced. The model can read resources on demand but cannot receive push updates. | +| **Claude Desktop** | Partial | Subscribes at the transport level but does not surface change notifications to the conversation. Treat as read-only for the assistant. | + +**Practical consequence:** if you want to verify subscriptions end-to-end with a real user-facing assistant, use Inspector or roll a custom SDK script. The 6-step "set next action → assistant reacts" pattern below is what subscriptions *enable* — but you can only observe it working in clients that proxy the notification through. + +## What "subscribe" means in MCP + +The MCP spec defines two complementary methods: + +- `resources/subscribe { uri }` — client asks the server to send change notifications for one URI +- `resources/unsubscribe { uri }` — cancel + +When the underlying resource changes, the server emits a `notifications/resources/updated { uri }` notification (JSON-RPC notification, no response expected). The client decides what to do: re-fetch the resource, summarise it, ignore it, etc. + +The subscription survives until the client either unsubscribes or disconnects. The server is not required to remember subscriptions across restarts. + +## How TermQ implements it + +The server declares `resources: { subscribe: true }` in its capabilities. Internally, it holds a `ResourceSubscriptionManager` actor with three responsibilities: + +1. **Tracking subscribed URIs** — a `Set` populated by `subscribe` / drained by `unsubscribe`. +2. **Watching the data file** — a `DispatchSourceFileSystemObject` watches `board.json` for `.write`, `.extend`, `.delete`, and `.rename` events. +3. **Debouncing emissions** — when a change fires, the manager waits 150ms before emitting. Atomic writes replace the file inode, so a single user action can fire `.delete` + new-file events in quick succession; debouncing collapses these into one notification per subscribed URI. + +When the file changes and the debouncer fires, the server sends a `notifications/resources/updated` for every URI in the subscription set. It doesn't try to be clever about which URI's contents actually changed — that's the subscriber's job. If you subscribed to `termq://terminals` but only the pending count changed, you still get the notification. + +## Worked example: a pending-aware assistant + +Here's a minimal client (pseudocode — TypeScript-style with the MCP SDK): + +```typescript +import { MCPClient } from "@modelcontextprotocol/sdk"; + +const client = new MCPClient({ name: "termq-watcher", version: "1.0.0" }); +await client.connect({ transport: stdioTransport("termqmcp") }); + +// 1. Subscribe to the pending feed. +await client.resources.subscribe({ uri: "termq://pending" }); + +// 2. Handle change notifications. +client.notifications.onResourceUpdated(async ({ uri }) => { + if (uri !== "termq://pending") return; + const result = await client.resources.read({ uri }); + const pending = JSON.parse(result.contents[0].text); + console.log(`Pending changed — ${pending.summary.withNextAction} terminals now have actions queued`); +}); + +// 3. Stay connected. +await client.waitForever(); +``` + +Now any time the user moves a card in the TermQ GUI, queues an action on a terminal, or runs another tool that mutates the board, this client gets a notification and re-renders. + +## Sharp edges (read these) + +### Your own writes notify you back + +If the same MCP client calls `set` (which mutates board.json), the file change fires the watcher, and that client gets a `resources/updated` for its own write. This is by design for v1 — subscribers should track their own causal writes (e.g. via an ETag or `lastModified`) and no-op on stale ones. + +A future revision can thread a skip-notification token through `BoardWriter` for true self-write filtering. + +### Debouncing means you can miss intermediate states + +The 150ms debounce window collapses bursts of writes. If three writes land within that window, you get **one** notification, not three. The latest state is always reflected in the resource — you never get stale data — but if you were trying to count writes by counting notifications, that's the wrong shape. + +### Subscriptions are per-connection + +If you disconnect and reconnect, your subscriptions are gone. Re-subscribe at the start of each session. + +### Notifications are best-effort + +If the transport stutters at the wrong moment the server logs a warning and drops the notification — the file content is still accurate when the subscriber next reads it. Don't build state machines that require every notification to arrive. + +## What URIs are worth subscribing to? + +| URI | Useful for | +|---|---| +| `termq://pending` | Most common — react when the user queues work or clears it | +| `termq://terminals` | React to any card change anywhere on the board | +| `termq://columns` | React to column CRUD only — usually overkill | +| `termq://terminal/{id}` | Watch one specific card — useful for a per-card daemon | + +## Combining with `record_handshake` + +The natural pattern for an assistant inside a TermQ session: + +``` +1. Subscribe to termq://terminal/${TERMQ_TERMINAL_ID} +2. Receive a notification when the user queues work +3. Read the resource → llmNextAction is set +4. Process the action +5. Call record_handshake(id: $TERMQ_TERMINAL_ID) +6. Call set(llmNextAction: "") to clear it +7. Wait for the next notification +``` + +Steps 5 and 6 should be separate calls — `record_handshake` is the explicit "I touched this" signal, and `set(llmNextAction: "")` is the "I consumed the queued work" signal. Combining them in one tool would re-introduce the side-effect-on-read pattern that Tier 1b deliberately split apart. + +**This is the pattern subscriptions enable** — but as noted above, only clients that proxy `resources/subscribe` to the LLM can actually wake on step 2. In Claude Code today the assistant only sees the queued action when the user prompts it to read; the rest of the steps still work. + +## Verifying subscriptions with MCP Inspector + +The cleanest way to see the full pattern fire. Requires `make mcp.inspect` (launches `@modelcontextprotocol/inspector` against the debug `termqmcp` binary). + +**Test 1 — push notification on board change** + +1. Open TermQ (Debug for the development binary). Note a card's UUID. +2. `make mcp.inspect` → connect → **Resources** tab → enter `termq://terminal/` → **Subscribe**. +3. In the TermQ GUI, right-click that card → **Set next action…** → enter "test action". +4. Within ~150 ms, Inspector's events panel shows `notifications/resources/updated` with that URI. +5. Click **Read** on the resource — `llmNextAction` reflects the new value. + +If step 4 doesn't fire, `tail -f /tmp/termq-debug.log | grep SubscriptionManager` will show whether the watcher armed and the debouncer fired. + +**Test 2 — debounce collapses bursts** + +1. Still subscribed. Rapidly change `llmNextAction` three times in <100 ms. +2. Inspector receives **one** notification. The resource read returns the final value. + +**Test 3 — unsubscribe stops the stream** + +1. Click **Unsubscribe**. +2. Make another change in the GUI. +3. No notification arrives. + +**Test 4 — full handshake round-trip** + +1. Re-subscribe. +2. GUI: set `llmNextAction: "say hello"`. Inspector receives notification. +3. Inspector → **Tools** → `record_handshake { identifier: "" }` (this *itself* fires the watcher — that's the self-write notification documented above). +4. Inspector → **Tools** → `set { identifier: "", llmNextAction: "" }`. +5. In the GUI, the card's `lastHandshake` timestamp updates and the queued action is cleared. + +If all four tests pass, the subscription stack is healthy end-to-end and any compatible client will see the same behaviour. + +## Further reading + +- [MCP spec, resources/subscribe](https://modelcontextprotocol.io/specification/2025-11-25/server/resources/#subscriptions) +- `Sources/MCPServerLib/SubscriptionManager.swift` — the implementation +- [MCP Reference](../reference/mcp.md) — full tool / resource catalogue diff --git a/Docs/Help/tutorials/mcp.md b/Docs/Help/tutorials/mcp.md index eec0e81a..7852bea2 100644 --- a/Docs/Help/tutorials/mcp.md +++ b/Docs/Help/tutorials/mcp.md @@ -47,15 +47,15 @@ Once connected, Claude Code has access to these tools: | Tool | What it does | |---|---| -| `termq_pending` | List terminals needing attention — pending actions, sorted by staleness | -| `termq_list` | List all terminals, optionally filtered by column | -| `termq_find` | Smart search across all terminal metadata | -| `termq_open` | Open a terminal by name, UUID, or path — returns full details including `llmPrompt` | -| `termq_create` | Create a new terminal card | -| `termq_set` | Update terminal fields (name, description, tags, llmPrompt, llmNextAction, etc.) | -| `termq_move` | Move a terminal to a different column | -| `termq_get` | Get context for the terminal Claude is currently running in (using `$TERMQ_TERMINAL_ID`) | -| `termq_delete` | Delete a terminal (soft delete to bin by default) | +| `pending` | List terminals needing attention — pending actions, sorted by staleness | +| `list` | List all terminals, optionally filtered by column | +| `find` | Smart search across all terminal metadata | +| `open` | Open a terminal by name, UUID, or path — returns full details including `llmPrompt` | +| `create` | Create a new terminal card | +| `set` | Update terminal fields (name, description, tags, llmPrompt, llmNextAction, etc.) | +| `move` | Move a terminal to a different column | +| `get` | Get context for the terminal Claude is currently running in (using `$TERMQ_TERMINAL_ID`) | +| `delete` | Delete a terminal (soft delete to bin by default) | --- @@ -64,7 +64,7 @@ Once connected, Claude Code has access to these tools: At the start of every Claude Code session in a TermQ terminal, tell it to run: ``` -termq_pending +pending ``` This surfaces terminals with queued actions and staleness indicators. Claude can then: @@ -84,10 +84,10 @@ This runs the session start checklist: pending work, summary of active terminals ## 10.5 — Self-awareness: knowing which terminal you're in -Any Claude Code session running inside a TermQ terminal has access to `$TERMQ_TERMINAL_ID`. Claude can use this with `termq_get` to retrieve its own terminal's context: +Any Claude Code session running inside a TermQ terminal has access to `$TERMQ_TERMINAL_ID`. Claude can use this with `get` to retrieve its own terminal's context: ``` -termq_get id="$TERMQ_TERMINAL_ID" +get id="$TERMQ_TERMINAL_ID" ``` The response includes `llmPrompt`, `llmNextAction`, tags, column, and all other metadata for the current terminal. This is how Claude knows what project it's working on, what the standing instructions are, and what to do first — without you needing to explain it. @@ -104,7 +104,7 @@ Please update this terminal: - Set the staleness tag to fresh ``` -Claude uses `termq_set` to write those fields. The next session — whether it's you or another Claude instance — opens the terminal, calls `termq_get`, and has everything it needs. +Claude uses `set` to write those fields. The next session — whether it's you or another Claude instance — opens the terminal, calls `get`, and has everything it needs. --- @@ -112,8 +112,8 @@ Claude uses `termq_set` to write those fields. The next session — whether it's - `termqmcp` is a standalone MCP server — add it to `~/.claude/mcp.json` - Claude Code gets a full set of tools to **read and write** your TermQ board -- `termq_pending` at session start surfaces what needs attention -- `termq_get id="$TERMQ_TERMINAL_ID"` gives Claude its own terminal's context automatically +- `pending` at session start surfaces what needs attention +- `get id="$TERMQ_TERMINAL_ID"` gives Claude its own terminal's context automatically - The end-of-session update pattern creates continuity between Claude sessions ## Next diff --git a/Docs/Help/tutorials/queued-actions.md b/Docs/Help/tutorials/queued-actions.md index 5887733f..c8ed323a 100644 --- a/Docs/Help/tutorials/queued-actions.md +++ b/Docs/Help/tutorials/queued-actions.md @@ -134,7 +134,7 @@ termqcli set "API Server" --llm-next-action "Run the test suite and check if AUT Or via MCP (what Claude does at session end): ``` -termq_set identifier="API Server" llmNextAction="Run the test suite and check if AUTH-23 is resolved." +set identifier="API Server" llmNextAction="Run the test suite and check if AUTH-23 is resolved." ``` Now the next time that terminal opens, the queued action fires. diff --git a/Makefile b/Makefile index c2f76a40..05f7d508 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ SHELL := /bin/bash .SHELLFLAGS := -o pipefail -c .PHONY: all build-release clean test test.coverage lint format check install uninstall run debug help -.PHONY: install-cli uninstall-cli install-all uninstall-all +.PHONY: install-cli uninstall-cli install-mcp install-mcp-debug uninstall-mcp install-all uninstall-all .PHONY: version release release-major release-minor release-patch tag-release publish-release -.PHONY: copy-help docs.help +.PHONY: copy-help docs.help mcp.inspect mcp.inspect.release # Project-specific configuration (change these for other projects) APP_NAME := TermQ @@ -288,13 +288,32 @@ uninstall-cli: rm -f $(INSTALL_CLI_DIR)/$(CLI_BINARY) @echo "CLI tool '$(CLI_BINARY)' removed" +# Install MCP server (release binary) onto PATH so .mcp.json entries can launch it. +install-mcp: build-release + @mkdir -p $(INSTALL_CLI_DIR) + cp $(RELEASE_BUILD_DIR)/$(MCP_BINARY) $(INSTALL_CLI_DIR)/$(MCP_BINARY) + @echo "MCP server '$(MCP_BINARY)' installed to $(INSTALL_CLI_DIR)" + +# Install the *debug* MCP server as $(MCP_BINARY) on PATH, so a project-level +# .mcp.json referencing `termqmcp` resolves to the in-development binary while +# iterating on a branch. Use `make install-mcp` to swap back to release. +install-mcp-debug: $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) + @mkdir -p $(INSTALL_CLI_DIR) + cp $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) $(INSTALL_CLI_DIR)/$(MCP_BINARY) + @echo "MCP server '$(MCP_BINARY)' installed to $(INSTALL_CLI_DIR) (debug build)" + +# Uninstall MCP server +uninstall-mcp: + rm -f $(INSTALL_CLI_DIR)/$(MCP_BINARY) + @echo "MCP server '$(MCP_BINARY)' removed" + # Install both app and CLI -install-all: install install-cli - @echo "TermQ app and CLI installed" +install-all: install install-cli install-mcp + @echo "TermQ app, CLI, and MCP server installed" # Uninstall both app and CLI -uninstall-all: uninstall uninstall-cli - @echo "TermQ app and CLI removed" +uninstall-all: uninstall uninstall-cli uninstall-mcp + @echo "TermQ app, CLI, and MCP server removed" # Create a distributable DMG (requires create-dmg tool) dmg: release-app @@ -578,6 +597,22 @@ compress-images: done @echo "Done." +# Launch the MCP Inspector against the debug termqmcp binary. +# Inspector is the official browser UI for poking at an MCP server: +# https://github.com/modelcontextprotocol/inspector — requires Node/npx. +# Builds the debug binary first so the inspector always launches a fresh server. +mcp.inspect: $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) + @which npx > /dev/null || (echo "Error: npx not found. Install Node.js: brew install node" && exit 1) + @echo "Launching MCP Inspector against $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY)..." + @echo " (TERMQ_DEBUG=1 — file logs at /tmp/termq-debug.log)" + @TERMQ_DEBUG=1 npx --yes @modelcontextprotocol/inspector $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) + +# Launch the MCP Inspector against the release termqmcp binary. +mcp.inspect.release: build-release + @which npx > /dev/null || (echo "Error: npx not found. Install Node.js: brew install node" && exit 1) + @echo "Launching MCP Inspector against $(RELEASE_BUILD_DIR)/$(MCP_BINARY)..." + @npx --yes @modelcontextprotocol/inspector $(RELEASE_BUILD_DIR)/$(MCP_BINARY) + # Serve help documentation with docsify (live reload) docs.help: @echo "Starting docsify server for Help documentation..." @@ -613,6 +648,9 @@ help: @echo " uninstall - Remove app from $(INSTALL_APP_DIR)" @echo " install-cli - Install CLI tool to $(INSTALL_CLI_DIR)" @echo " uninstall-cli - Remove CLI tool from $(INSTALL_CLI_DIR)" + @echo " install-mcp - Install MCP server (release) to $(INSTALL_CLI_DIR)" + @echo " install-mcp-debug - Install MCP server (debug) to $(INSTALL_CLI_DIR) for branch testing" + @echo " uninstall-mcp - Remove MCP server from $(INSTALL_CLI_DIR)" @echo " install-all - Install both app and CLI" @echo " uninstall-all - Remove both app and CLI" @echo " dmg - Create distributable DMG" @@ -630,6 +668,8 @@ help: @echo "" @echo " compress-images - Compress PNGs in Docs/Help/Images with pngquant" @echo " docs.help - Serve Help docs with docsify (live reload)" + @echo " mcp.inspect - Launch MCP Inspector against debug termqmcpd (browser UI)" + @echo " mcp.inspect.release - Launch MCP Inspector against release termqmcp" @echo " help - Show this help message" @echo "" @echo "Current version: $(VERSION)" diff --git a/Sources/MCPServer-CLI/main.swift b/Sources/MCPServer-CLI/main.swift index 42fcfb0c..dc576fbc 100644 --- a/Sources/MCPServer-CLI/main.swift +++ b/Sources/MCPServer-CLI/main.swift @@ -2,16 +2,18 @@ import ArgumentParser import Foundation import MCP import MCPServerLib +import TermQShared // MARK: - Build Configuration -/// Returns whether to use debug mode based on build configuration and explicit flag -/// In debug builds, always use debug mode unless explicitly overridden -private func shouldUseDebugMode(_ explicitDebug: Bool) -> Bool { +/// Resolves the AppProfile.Variant for this invocation. In a debug build, `.debug` is the +/// only valid option (overriding any user input). In a production build, the user's +/// `--debug` flag selects between `.production` and `.debug`. +private func resolveProfile(explicitDebug: Bool) -> AppProfile.Variant { #if TERMQ_DEBUG_BUILD - return true + return .debug #else - return explicitDebug + return explicitDebug ? .debug : .production #endif } @@ -65,19 +67,27 @@ struct TermQMCPCommand: AsyncParsableCommand { } func run() async throws { - // Determine data directory - let useDebug = shouldUseDebugMode(debug) - let dataDirectory: URL? - if useDebug { - dataDirectory = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("TermQ-Debug") - } else { - dataDirectory = nil + // Resolve the app profile and derive the data directory from it. + // The resolved values are logged below so debug/production mismatches are visible + // — that was the root cause of the "MCP returns empty results" bug. + let profile = resolveProfile(explicitDebug: debug) + let dataDirectory = BoardLoader.getDataDirectoryPath(profile: profile) + + if verbose { + fputs( + """ + termqmcp starting + profile: \(profile) + data directory: \(dataDirectory.path) + bundle id: \(profile.bundleIdentifier) + transport: \(http ? "http" : "stdio") + + """, + stderr + ) } - // Create server + // Create server with the explicit data directory derived from profile. let server = TermQMCPServer(dataDirectory: dataDirectory) if http { diff --git a/Sources/MCPServerLib/CompletionHandlers.swift b/Sources/MCPServerLib/CompletionHandlers.swift new file mode 100644 index 00000000..bdf250b5 --- /dev/null +++ b/Sources/MCPServerLib/CompletionHandlers.swift @@ -0,0 +1,69 @@ +import Foundation +import MCP +import TermQShared + +// MARK: - Completion Handler +// +// Implements `completion/complete` per the MCP spec. The TermQ server provides +// completion values for the `terminal` argument of the `terminal_summary` prompt: +// matching terminal names from the live board. + +extension TermQMCPServer { + /// Maximum number of completion suggestions returned per the spec (clients display these). + private static let maxCompletionValues = 100 + + /// Handle `completion/complete` requests. + func dispatchCompletion(_ params: Complete.Parameters) async throws -> Complete.Result { + switch params.ref { + case .prompt(let promptRef): + return try completePromptArgument(promptName: promptRef.name, argument: params.argument) + case .resource: + // Tier 1b will add resource template completions (e.g. for `termq://terminal/{id}`). + // For now, return an empty completion rather than erroring — the spec allows this. + return Complete.Result(completion: .init(values: [], total: 0, hasMore: false)) + } + } + + private func completePromptArgument( + promptName: String, + argument: Complete.Parameters.Argument + ) throws -> Complete.Result { + switch (promptName, argument.name) { + case ("terminal_summary", "terminal"): + return try completeTerminalIdentifier(prefix: argument.value) + default: + // Unknown (prompt, argument) pair — return empty completion. Clients show + // nothing rather than an error; the user just gets no suggestions. + return Complete.Result(completion: .init(values: [], total: 0, hasMore: false)) + } + } + + /// Suggest terminal names matching the user's partial input. + /// + /// Matches by case-insensitive substring on the title. If the user types nothing, + /// returns the first `maxCompletionValues` active terminals so they can pick from a + /// browsable list. + private func completeTerminalIdentifier(prefix: String) throws -> Complete.Result { + let board: Board + do { + board = try loadBoard() + } catch { + // No board / load failure — return empty completion. Surfacing an error here + // would prevent autocomplete from working at all in a degraded environment. + return Complete.Result(completion: .init(values: [], total: 0, hasMore: false)) + } + + let prefixLower = prefix.lowercased() + let matching = board.activeCards.filter { card in + prefixLower.isEmpty || card.title.lowercased().contains(prefixLower) + } + let values = matching.prefix(Self.maxCompletionValues).map { $0.title } + return Complete.Result( + completion: .init( + values: Array(values), + total: matching.count, + hasMore: matching.count > Self.maxCompletionValues + ) + ) + } +} diff --git a/Sources/MCPServerLib/HeadlessWriter.swift b/Sources/MCPServerLib/HeadlessWriter.swift index baaaafb3..531adb9e 100644 --- a/Sources/MCPServerLib/HeadlessWriter.swift +++ b/Sources/MCPServerLib/HeadlessWriter.swift @@ -73,7 +73,7 @@ public enum HeadlessWriter { public static func createCard( _ options: CardCreationOptions, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { // Create the card using BoardWriter var card = try BoardWriter.createCard( @@ -82,7 +82,7 @@ public enum HeadlessWriter { workingDirectory: options.workingDirectory, description: options.description ?? "", dataDirectory: dataDirectory, - debug: debug + profile: profile ) // Build updates for additional MCP fields @@ -117,7 +117,7 @@ public enum HeadlessWriter { identifier: card.id.uuidString, updates: updates, dataDirectory: dataDirectory, - debug: debug + profile: profile ) } @@ -129,7 +129,7 @@ public enum HeadlessWriter { identifier: String, params: UpdateParameters, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { var updates: [String: Any] = [:] @@ -171,7 +171,7 @@ public enum HeadlessWriter { updates["tags"] = tagDicts } else { // Merge with existing tags - let board = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) + let board = try BoardLoader.loadBoard(dataDirectory: dataDirectory, profile: profile) guard let card = board.findTerminal(identifier: identifier) else { throw BoardWriter.WriteError.cardNotFound(identifier: identifier) } @@ -205,7 +205,7 @@ public enum HeadlessWriter { identifier: identifier, updates: updates, dataDirectory: dataDirectory, - debug: debug + profile: profile ) } @@ -214,13 +214,13 @@ public enum HeadlessWriter { identifier: String, toColumn columnName: String, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { try BoardWriter.moveCard( identifier: identifier, toColumn: columnName, dataDirectory: dataDirectory, - debug: debug + profile: profile ) } @@ -229,12 +229,12 @@ public enum HeadlessWriter { identifier: String, permanent: Bool, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws { if permanent { // For permanent deletion, load board and remove from array // Note: BoardWriter doesn't have permanent delete, so we handle it here - let rawBoard = try BoardWriter.loadRawBoard(dataDirectory: dataDirectory, debug: debug) + let rawBoard = try BoardWriter.loadRawBoard(dataDirectory: dataDirectory, profile: profile) let boardURL = rawBoard.url var board = rawBoard.data guard var cards = board["cards"] as? [[String: Any]] else { @@ -256,7 +256,7 @@ public enum HeadlessWriter { identifier: identifier, updates: ["deletedAt": now], dataDirectory: dataDirectory, - debug: debug + profile: profile ) } } diff --git a/Sources/MCPServerLib/PromptHandlers.swift b/Sources/MCPServerLib/PromptHandlers.swift index f51ef5ea..f00259f8 100644 --- a/Sources/MCPServerLib/PromptHandlers.swift +++ b/Sources/MCPServerLib/PromptHandlers.swift @@ -60,8 +60,8 @@ extension TermQMCPServer { if !pendingCards.isEmpty { content += "1. Address pending actions above\n" } - content += "2. Use `termq_pending` for detailed terminal view\n" - content += "3. Use `termq_context` for workflow guide\n" + content += "2. Use `pending` for detailed terminal view\n" + content += "3. Use `context` for workflow guide\n" } catch { content += "Error loading board: \(error.localizedDescription)\n\n" @@ -135,7 +135,7 @@ extension TermQMCPServer { ## SESSION START CHECKLIST (Do This First!) - 1. Use the `termq_pending` tool to see what needs attention: + 1. Use the `pending` tool to see what needs attention: - Shows terminals with queued tasks (llmNextAction) - Shows staleness indicators @@ -179,18 +179,18 @@ extension TermQMCPServer { ## AVAILABLE MCP TOOLS ### Query Tools - - `termq_pending` - Check terminals needing attention (SESSION START) - - `termq_context` - Get this documentation - - `termq_list` - List all terminals or filter by column - - `termq_find` - Search terminals by name, column, tag, etc. - - `termq_open` - Get terminal details by name, UUID, or path - - `termq_get` - Get terminal by UUID (use with $TERMQ_TERMINAL_ID) + - `pending` - Check terminals needing attention (SESSION START) + - `context` - Get this documentation + - `list` - List all terminals or filter by column + - `find` - Search terminals by name, column, tag, etc. + - `open` - Get terminal details by name, UUID, or path + - `get` - Get terminal by UUID (use with $TERMQ_TERMINAL_ID) ### Write Tools These tools modify the board directly: - - `termq_create` - Create a new terminal - - `termq_set` - Modify terminal properties (name, badge, llmPrompt, etc.) - - `termq_move` - Move terminal to a different column + - `create` - Create a new terminal + - `set` - Modify terminal properties (name, badge, llmPrompt, etc.) + - `move` - Move terminal to a different column ## TERMINAL FIELDS @@ -205,7 +205,7 @@ extension TermQMCPServer { ## TIPS - - ALWAYS use `termq_pending` at session start + - ALWAYS use `pending` at session start - ALWAYS set `llmNextAction` when parking incomplete work - Use `staleness` tag to track what needs attention - Use `project` tag to group related terminals diff --git a/Sources/MCPServerLib/ResourceHandlers.swift b/Sources/MCPServerLib/ResourceHandlers.swift index 5889631e..1a05627e 100644 --- a/Sources/MCPServerLib/ResourceHandlers.swift +++ b/Sources/MCPServerLib/ResourceHandlers.swift @@ -5,106 +5,261 @@ import TermQShared // MARK: - Resource Handler Implementations extension TermQMCPServer { - /// Handle resource read requests + /// Handle resource read requests. Supports both static URIs and the templated forms + /// declared in `availableResourceTemplates` (`termq://terminal/{id}`, + /// `termq://terminal-by-name/{name}`, `termq://column/{name}`). + /// + /// Templates are matched after static URIs: a static URI takes precedence if both + /// match (none currently overlap, but the order makes the precedence explicit). func dispatchResourceRead(_ params: ReadResource.Parameters) async throws -> ReadResource.Result { let uri = params.uri switch uri { case "termq://terminals": return try await handleTerminalsResource(uri: uri) - case "termq://columns": return try await handleColumnsResource(uri: uri) - case "termq://pending": return try await handlePendingResource(uri: uri) - case "termq://context": return ReadResource.Result(contents: [.text(Self.contextDocumentation, uri: uri)]) - + case "termq://repos": + return try await handleReposResource(uri: uri) + case "termq://worktrees": + return try await handleWorktreesResource(uri: uri) + case "termq://harnesses": + return try await handleHarnessesResource(uri: uri) default: - throw MCPError.invalidRequest("Unknown resource: \(uri)") + return try await dispatchTemplatedResource(uri: uri) } } - // MARK: - Resource Implementations + // MARK: - Tier 3 resource handlers — repos, worktrees, harnesses - private func handleTerminalsResource(uri: String) async throws -> ReadResource.Result { - do { - let board = try loadBoard() - let output = board.activeCards.map { - TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) + /// All registered git repositories. + private func handleReposResource(uri: String) async throws -> ReadResource.Result { + let config = (try? RepoConfigLoader.load()) ?? RepoConfig() + let payload = config.repositories.map { repo -> [String: Any] in + [ + "id": repo.id.uuidString, + "name": repo.name, + "path": repo.path, + "worktreeBasePath": repo.worktreeBasePath as Any, + "protectedBranches": repo.protectedBranches as Any, + "addedAt": ISO8601DateFormatter().string(from: repo.addedAt), + ] + } + let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + let json = String(data: data, encoding: .utf8) ?? "[]" + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + /// Worktrees enumerated from every registered repository. + /// Skips repos whose `git worktree list` fails — those errors don't kill the whole + /// listing, but each failure is mirrored to `notifications/message` for diagnostics. + private func handleWorktreesResource(uri: String) async throws -> ReadResource.Result { + let config = (try? RepoConfigLoader.load()) ?? RepoConfig() + var rows: [[String: Any]] = [] + for repo in config.repositories { + do { + let trees = try await GitServiceShared.listWorktrees(repoPath: repo.path) + for tree in trees { + rows.append([ + "repoId": repo.id.uuidString, + "repoName": repo.name, + "path": tree.path, + "branch": tree.branch as Any, + "commitHash": tree.commitHash, + "isMainWorktree": tree.isMainWorktree, + "isLocked": tree.isLocked, + ]) + } + } catch { + await emitLog( + .warning, + "listWorktrees failed for repo \(repo.name): \(error.localizedDescription)", + logger: "termq.worktrees" + ) } - let json = try JSONHelper.encode(output) - return ReadResource.Result(contents: [.text(json, uri: uri)]) - } catch { - return ReadResource.Result(contents: [.text("[]", uri: uri)]) } + let data = try JSONSerialization.data(withJSONObject: rows, options: [.prettyPrinted, .sortedKeys]) + let json = String(data: data, encoding: .utf8) ?? "[]" + return ReadResource.Result(contents: [.text(json, uri: uri)]) } - private func handleColumnsResource(uri: String) async throws -> ReadResource.Result { + /// Installed harnesses — listed via the `ynh` CLI when available. Empty array when + /// ynh isn't on PATH or returns a non-zero exit; logged at info level so operators + /// can see why. + private func handleHarnessesResource(uri: String) async throws -> ReadResource.Result { + let json = await runYnhCommand(arguments: ["ls", "--format", "json"]) ?? "[]" + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + /// Run an arbitrary `ynh` subcommand, capturing stdout as String. Returns nil when + /// ynh is unavailable or the command fails. The MCP server runs headless and + /// inherits the user's PATH — if `ynh` isn't there, the surface degrades gracefully + /// rather than failing the whole resource. + private func runYnhCommand(arguments: [String]) async -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["ynh"] + arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() do { - let board = try loadBoard() - let columns = board.sortedColumns().map { column in - ColumnOutput( - from: column, - terminalCount: board.activeCards.filter { $0.columnId == column.id }.count + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + await emitLog( + .info, + "ynh \(arguments.joined(separator: " ")) exited \(process.terminationStatus)", + logger: "termq.ynh" ) + return nil } - let json = try JSONHelper.encode(columns) - return ReadResource.Result(contents: [.text(json, uri: uri)]) + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) } catch { - return ReadResource.Result(contents: [.text("[]", uri: uri)]) + await emitLog( + .info, + "ynh not available: \(error.localizedDescription)", + logger: "termq.ynh" + ) + return nil + } + } + + /// Match a URI against the declared resource templates and dispatch to the right reader. + /// Pure reads — no mutation, no handshake side-effects (use the `record_handshake` + /// tool for that). + private func dispatchTemplatedResource(uri: String) async throws -> ReadResource.Result { + if let id = parseTemplatePath(uri: uri, prefix: "termq://terminal/") { + return try await handleTerminalByIdResource(uri: uri, id: id) + } + if let name = parseTemplatePath(uri: uri, prefix: "termq://terminal-by-name/") { + return try await handleTerminalByNameResource(uri: uri, name: name) + } + if let name = parseTemplatePath(uri: uri, prefix: "termq://column/") { + return try await handleColumnByNameResource(uri: uri, name: name) + } + throw MCPError.invalidRequest("Unknown resource: \(uri)") + } + + /// Extract the path segment after `prefix`. Returns nil if the URI doesn't match the + /// prefix or has nothing after it. Decodes percent-escapes so callers can pass spaces + /// in column / terminal names. + private func parseTemplatePath(uri: String, prefix: String) -> String? { + guard uri.hasPrefix(prefix) else { return nil } + let raw = String(uri.dropFirst(prefix.count)) + guard !raw.isEmpty else { return nil } + return raw.removingPercentEncoding ?? raw + } + + private func handleTerminalByIdResource(uri: String, id: String) async throws -> ReadResource.Result { + let board = try loadBoard() + guard let uuid = UUID(uuidString: id), + let card = board.activeCards.first(where: { $0.id == uuid }) + else { + throw MCPError.invalidRequest("Terminal not found for UUID: \(id)") } + let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + private func handleTerminalByNameResource(uri: String, name: String) async throws -> ReadResource.Result { + let board = try loadBoard() + let nameLower = name.lowercased() + guard let card = board.activeCards.first(where: { $0.title.lowercased() == nameLower }) else { + throw MCPError.invalidRequest("Terminal not found for name: \(name)") + } + let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + private func handleColumnByNameResource(uri: String, name: String) async throws -> ReadResource.Result { + let board = try loadBoard() + let nameLower = name.lowercased() + guard let column = board.columns.first(where: { $0.name.lowercased() == nameLower }) else { + throw MCPError.invalidRequest("Column not found: \(name)") + } + let cards = board.activeCards.filter { $0.columnId == column.id } + let output = cards.map { TerminalOutput(from: $0, columnName: column.name) } + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + // MARK: - Resource Implementations + + private func handleTerminalsResource(uri: String) async throws -> ReadResource.Result { + // Load errors are surfaced to the client via MCPError, not masked as empty arrays. + // An empty board is `[]` legitimately; a missing/corrupt board is a real failure the + // caller needs to see — otherwise a debug-vs-production data-directory mismatch + // (the original bug this work fixes) silently returns nothing. + let board = try loadBoard() + let output = board.activeCards.map { + TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) + } + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + private func handleColumnsResource(uri: String) async throws -> ReadResource.Result { + let board = try loadBoard() + let columns = board.sortedColumns().map { column in + ColumnOutput( + from: column, + terminalCount: board.activeCards.filter { $0.columnId == column.id }.count + ) + } + let json = try JSONHelper.encode(columns) + return ReadResource.Result(contents: [.text(json, uri: uri)]) } private func handlePendingResource(uri: String) async throws -> ReadResource.Result { - do { - let board = try loadBoard() - var cards = board.activeCards - - // Sort: pending actions first, then by staleness - cards.sort { card1, card2 in - let has1 = !card1.llmNextAction.isEmpty - let has2 = !card2.llmNextAction.isEmpty - if has1 != has2 { return has1 } - return card1.stalenessRank > card2.stalenessRank - } + let board = try loadBoard() + var cards = board.activeCards - var terminals: [PendingTerminalOutput] = [] - var withNextAction = 0 - var staleCount = 0 - var freshCount = 0 - - for card in cards { - let staleness = card.staleness - if !card.llmNextAction.isEmpty { withNextAction += 1 } - switch staleness { - case "stale", "old": staleCount += 1 - case "fresh": freshCount += 1 - default: break - } - terminals.append( - PendingTerminalOutput( - from: card, - columnName: board.columnName(for: card.columnId), - staleness: staleness - )) + // Sort: pending actions first, then by staleness + cards.sort { card1, card2 in + let has1 = !card1.llmNextAction.isEmpty + let has2 = !card2.llmNextAction.isEmpty + if has1 != has2 { return has1 } + return card1.stalenessRank > card2.stalenessRank + } + + var terminals: [PendingTerminalOutput] = [] + var withNextAction = 0 + var staleCount = 0 + var freshCount = 0 + + for card in cards { + let staleness = card.staleness + if !card.llmNextAction.isEmpty { withNextAction += 1 } + switch staleness { + case "stale", "old": staleCount += 1 + case "fresh": freshCount += 1 + default: break } + terminals.append( + PendingTerminalOutput( + from: card, + columnName: board.columnName(for: card.columnId), + staleness: staleness + )) + } - let output = PendingOutput( - terminals: terminals, - summary: PendingSummary( - total: terminals.count, - withNextAction: withNextAction, - stale: staleCount, - fresh: freshCount - ) + let output = PendingOutput( + terminals: terminals, + summary: PendingSummary( + total: terminals.count, + withNextAction: withNextAction, + stale: staleCount, + fresh: freshCount ) - let json = try JSONHelper.encode(output) - return ReadResource.Result(contents: [.text(json, uri: uri)]) - } catch { - return ReadResource.Result(contents: [.text("{}", uri: uri)]) - } + ) + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) } } diff --git a/Sources/MCPServerLib/SchemaBuilder.swift b/Sources/MCPServerLib/SchemaBuilder.swift index 7e0a6e19..ac4a53aa 100644 --- a/Sources/MCPServerLib/SchemaBuilder.swift +++ b/Sources/MCPServerLib/SchemaBuilder.swift @@ -86,4 +86,114 @@ enum SchemaBuilder { static func stringArray(_ name: String, _ description: String, required: Bool = false) -> Property { Property(name, .array, description: description, required: required) } + + // MARK: - Output Schemas (Tier 1b — structured tool output) + // + // These describe the shape of `structuredContent` returned by read-shaped tools. + // Per the MCP spec, the structuredContent must validate against the tool's + // outputSchema — clients can codegen types or runtime-validate against these. + + /// Schema for a single TerminalOutput row. + static var terminalOutputItemSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "id": stringField("Terminal UUID"), + "name": stringField("Display name"), + "description": stringField("Free-form description"), + "column": stringField("Column display name"), + "columnId": stringField("Column UUID"), + "tags": .object(["type": .string("object")]), + "path": stringField("Working directory"), + "badges": .object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + ]), + "isFavourite": boolField("Pinned to favourites bar"), + "llmPrompt": stringField("Persistent LLM context"), + "llmNextAction": stringField("Queued one-time action"), + "allowAutorun": boolField("Whether queued actions auto-execute"), + ]), + ]) + } + + /// Schema for the `list` / `find` envelope. + /// + /// MCP requires `outputSchema.type` to be `"object"` at the top level and the + /// emitted `structuredContent` to be a JSON object — so the array of rows + /// lives under `items` and an optional `nextCursor` carries paginated state. + static var terminalListSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "items": .object([ + "type": .string("array"), + "items": terminalOutputItemSchema, + ]), + "nextCursor": .object(["type": .string("string")]), + ]), + "required": .array([.string("items")]), + ]) + } + + /// Schema for ColumnOutput. + static var columnOutputItemSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "id": stringField("Column UUID"), + "name": stringField("Column name"), + "description": stringField("Free-form description"), + "color": stringField("Hex colour"), + "terminalCount": intField("Number of active cards in this column"), + ]), + ]) + } + + /// Schema for a PendingOutput envelope. + static var pendingOutputSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "terminals": .object([ + "type": .string("array"), + "items": .object([ + "type": .string("object"), + "properties": .object([ + "id": stringField("Terminal UUID"), + "name": stringField("Display name"), + "column": stringField("Column display name"), + "path": stringField("Working directory"), + "llmNextAction": stringField("Queued one-time action"), + "llmPrompt": stringField("Persistent LLM context"), + "allowAutorun": boolField("Whether queued actions auto-execute"), + "staleness": stringField("Staleness tag value"), + "tags": .object(["type": .string("object")]), + ]), + ]), + ]), + "summary": .object([ + "type": .string("object"), + "properties": .object([ + "total": intField("Total cards considered"), + "withNextAction": intField("Cards with llmNextAction set"), + "stale": intField("Cards tagged stale or old"), + "fresh": intField("Cards tagged fresh"), + ]), + ]), + ]), + ]) + } + + private static func stringField(_ description: String) -> Value { + .object(["type": .string("string"), "description": .string(description)]) + } + + private static func boolField(_ description: String) -> Value { + .object(["type": .string("boolean"), "description": .string(description)]) + } + + private static func intField(_ description: String) -> Value { + .object(["type": .string("integer"), "description": .string(description)]) + } } diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index 509dfe7d..85a2cf9d 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -10,7 +10,8 @@ extension TermQMCPServer { static var availableTools: [Tool] { [ Tool( - name: "termq_pending", + name: "pending", + title: "List pending terminals", description: """ Check terminals needing attention. Run this at the START of every LLM session. Returns terminals with pending actions (llmNextAction) and staleness indicators. @@ -18,31 +19,56 @@ extension TermQMCPServer { """, inputSchema: Schema.objectSchema([ Schema.bool("actionsOnly", "Only show terminals with llmNextAction set") - ]) + ]), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + outputSchema: Schema.pendingOutputSchema ), Tool( - name: "termq_context", + name: "context", + title: "Workflow documentation", description: """ Output comprehensive documentation for LLM/AI assistants. Includes session start/end checklists, tag schema, command reference, and workflow examples for cross-session continuity. """, - inputSchema: Schema.emptySchema() + inputSchema: Schema.emptySchema(), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false) ), Tool( - name: "termq_list", - description: "List all terminals or filter by column. Supports listing columns only.", + name: "list", + title: "List terminals", + description: """ + List all terminals or filter by column. Supports listing columns only and + optional pagination via `cursor` / `limit`. Pass `includeDeleted: true` to + include soft-deleted (binned) cards in the result. + """, inputSchema: Schema.objectSchema([ Schema.string("column", "Filter by column name"), Schema.bool("columnsOnly", "Return only column names"), - ]) + Schema.bool( + "includeDeleted", + "If true, include soft-deleted cards (default: false — only active cards)"), + Schema.string("cursor", "Opaque pagination cursor returned by a previous call"), + Schema.int("limit", "Maximum number of results (default: no limit)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + // outputSchema describes the cards-listing shape; when `columnsOnly` is true, + // the result is an array of strings instead — clients must check the shape + // at runtime. Documented in the tool description; no formal union schema. + outputSchema: Schema.terminalListSchema ), Tool( - name: "termq_find", + name: "find", + title: "Search terminals", description: """ Search for terminals by various criteria. Use 'query' for smart multi-word search across name, description, path, and tags. All filters are AND-combined. Returns matching terminals as JSON array sorted by relevance. + Tag filter is literal exact-match by default; prefix with `re:` for regex + (e.g. `staleness=re:(stale|ageing)` or `re:project=org/.+`). """, inputSchema: Schema.objectSchema([ Schema.string( @@ -52,26 +78,44 @@ extension TermQMCPServer { ), Schema.string("name", "Filter by name (word-based matching)"), Schema.string("column", "Filter by column name"), - Schema.string("tag", "Filter by tag (format: key or key=value)"), + Schema.string( + "tag", + "Filter by tag. Literal match by default: `key`, `key=value`." + + " Opt-in regex: `key=re:pattern` or `re:full-pattern`."), Schema.string("id", "Filter by UUID"), Schema.string("badge", "Filter by badge"), Schema.bool("favourites", "Only show favourites"), - ]) + Schema.string("cursor", "Opaque pagination cursor returned by a previous call"), + Schema.int("limit", "Maximum number of results (default: no limit)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + outputSchema: Schema.terminalListSchema ), Tool( - name: "termq_open", + name: "open", + title: "Open terminal", description: """ Open an existing terminal by name, UUID, or path. Returns terminal details including llmPrompt (persistent context) and llmNextAction (one-time task). - Use partial name matching for convenience. + Note: partial-name matching returns the first hit — prefer exact names or + UUIDs to avoid ambiguity. """, inputSchema: Schema.objectSchema([ Schema.string( - "identifier", "Terminal name, UUID, or path (partial match supported)", required: true) - ]) + "identifier", + "Terminal name, UUID, or path (partial match supported)", required: true) + ]), + // `open` focuses a terminal in the GUI when one is running — a visible side + // effect that is neither destructive nor strictly idempotent. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema ), Tool( - name: "termq_create", + name: "create", + title: "Create terminal", description: """ Create a new terminal in TermQ. Optionally specify name, description, column, path, tags, LLM context, and initialization command. @@ -82,14 +126,20 @@ extension TermQMCPServer { Schema.string("description", "Terminal description"), Schema.string("column", "Column name (e.g., 'In Progress')"), Schema.string("path", "Working directory path"), - Schema.stringArray("tags", "Tags in key=value format (e.g., ['project=myapp', 'type=dev'])"), + Schema.stringArray( + "tags", "Tags in key=value format (e.g., ['project=myapp', 'type=dev'])"), Schema.string("llmPrompt", "Persistent LLM context"), Schema.string("llmNextAction", "One-time action for next session"), Schema.string("initCommand", "Command to run when terminal opens"), - ]) + ]), + // Adds rows — repeated calls create more terminals, so not idempotent. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false) ), Tool( - name: "termq_set", + name: "set", + title: "Update terminal", description: """ Update terminal properties. Identify terminal by name or UUID. Can set name, description, column, badges, LLM fields, tags, init command, and favourite status. @@ -102,45 +152,237 @@ extension TermQMCPServer { Schema.string("column", "Move to column"), Schema.string( "badge", - "Badge text (comma-separated for multiple, e.g. 'WIP,urgent'). Replaces existing badges."), + "Badge text (comma-separated for multiple, e.g. 'WIP,urgent')." + + " Replaces existing badges."), Schema.string("tag", "A single tag in key=value format (e.g., 'project=my/repo')"), - Schema.stringArray("tags", "Tags in key=value format (e.g., ['status=reviewed'])"), - Schema.bool("replaceTags", "If true, replaces all tags; if false (default), adds to existing"), + Schema.stringArray( + "tags", "Tags in key=value format (e.g., ['status=reviewed'])"), + Schema.bool( + "replaceTags", "If true, replaces all tags; if false (default), adds to existing"), Schema.string("llmPrompt", "Set persistent LLM context"), Schema.string("llmNextAction", "Set one-time action"), Schema.string("initCommand", "Command to run when terminal opens"), Schema.bool("favourite", "Set favourite status"), - ]) + ]), + // Mutating but idempotent — same args produce the same end state. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) ), Tool( - name: "termq_move", + name: "move", + title: "Move terminal to column", description: "Move a terminal to a different column (workflow stage).", inputSchema: Schema.objectSchema([ Schema.string("identifier", "Terminal name or UUID", required: true), Schema.string("column", "Target column name", required: true), - ]) + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) ), Tool( - name: "termq_get", + name: "get", + title: "Get terminal by UUID", description: """ Get terminal context by ID. Use with TERMQ_TERMINAL_ID environment variable to get context for the terminal you're currently running in. Returns full terminal details including tags, llmPrompt, and llmNextAction. """, inputSchema: Schema.objectSchema([ - Schema.string("id", "Terminal UUID (use $TERMQ_TERMINAL_ID from your environment)", required: true) - ]) + Schema.string( + "id", "Terminal UUID (use $TERMQ_TERMINAL_ID from your environment)", + required: true) + ]), + // `get` records a `lastLLMGet` handshake timestamp as a side effect; not + // strictly read-only. DEPRECATED in favour of reading `termq://terminal/{id}` + // (pure) plus the `record_handshake` tool (explicit write). One-release + // alias per the deprecation policy in audit §3.1. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema + ), + Tool( + name: "whoami", + title: "Identify current terminal", + description: """ + Identify the terminal this MCP server is being called from. Looks up + the card whose UUID matches the `TERMQ_TERMINAL_ID` environment + variable. Returns the full card or null if the env var is unset or + points at a non-existent terminal. + + Equivalent to `get(id: $TERMQ_TERMINAL_ID)` but avoids the manual + substitution dance and surfaces a friendly null when running outside + a TermQ terminal context (e.g. a top-level Claude session). + """, + inputSchema: Schema.emptySchema(), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema + ), + Tool( + name: "restore", + title: "Restore deleted terminal", + description: """ + Restore a soft-deleted terminal from the bin. The card's `deletedAt` + timestamp is cleared; the card reappears in the GUI in its original column. + Permanent deletes (those committed via `delete(permanent: true)`) cannot + be restored — the card is gone from board.json. + """, + inputSchema: Schema.objectSchema([ + Schema.string("identifier", "Terminal name or UUID", required: true) + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema + ), + Tool( + name: "create_column", + title: "Create column", + description: "Create a new column on the board.", + inputSchema: Schema.objectSchema([ + Schema.string("name", "Column name (must be unique)", required: true), + Schema.string("description", "Optional column description"), + Schema.string("color", "Optional hex colour (e.g. '#FF5733')"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false) + ), + Tool( + name: "rename_column", + title: "Rename column", + description: "Rename an existing column. Cards retain their column membership.", + inputSchema: Schema.objectSchema([ + Schema.string("identifier", "Current column name", required: true), + Schema.string("newName", "New column name", required: true), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) + ), + Tool( + name: "delete_column", + title: "Delete column", + description: """ + Delete a column. Refuses to delete a column that still contains active cards — + move or delete those first. Use `force: true` to soft-delete all cards in the + column along with it (cards land in the bin and can be restored individually). + """, + inputSchema: Schema.objectSchema([ + Schema.string("identifier", "Column name", required: true), + Schema.bool("force", "If true, soft-deletes cards in the column too (default: false)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: true, openWorldHint: false) + ), + Tool( + name: "create_worktree", + title: "Create git worktree", + description: """ + Create a new git worktree on a registered repository. The worktree path + is rooted under the repository's configured `worktreeBasePath` (or + `/` if unset). + """, + inputSchema: Schema.objectSchema([ + Schema.string("repoId", "Repository UUID (from termq://repos)", required: true), + Schema.string("branch", "Branch name to check out as a worktree", required: true), + Schema.bool("createBranch", "Create the branch if it doesn't exist (default: false)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: true) + ), + Tool( + name: "remove_worktree", + title: "Remove git worktree", + description: """ + Remove an existing worktree (does NOT delete the underlying branch). + Refuses to remove the main worktree or a worktree with uncommitted changes + unless `force: true` is passed. + """, + inputSchema: Schema.objectSchema([ + Schema.string("repoId", "Repository UUID", required: true), + Schema.string("path", "Absolute path of the worktree to remove", required: true), + Schema.bool("force", "Force removal even if dirty (default: false)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: true, openWorldHint: true) + ), + Tool( + name: "harness_launch", + title: "Launch YNH harness", + description: """ + Launch a YNH harness session against a working directory. The harness is + invoked via `ynh run ` in the target directory; output is + captured and returned. + + Pass the **canonical harness id** (the `id` field from `termq://harnesses`, + e.g. `local/claude-dev`), not the bare `name`. `ynh run` rejects bare + names with an `io_error` — TermQ does NOT translate bare-name → canonical-id + on the caller's behalf. + + This is the most consequential write tool TermQ exposes: it spawns an + LLM/agent process. Permissioned clients should elicit user confirmation + before each call. The destructiveHint annotation is set conservatively + so strict clients prompt by default. + """, + inputSchema: Schema.objectSchema([ + Schema.string( + "harness", + "Canonical harness id (the `id` field from termq://harnesses, e.g." + + " `local/claude-dev` or `github.com///`)." + + " Bare names from the `name` field are NOT accepted by `ynh run`.", + required: true), + Schema.string("workingDirectory", "Absolute path to run in", required: true), + Schema.string("prompt", "Optional prompt to seed the harness with"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: false, openWorldHint: true) + ), + Tool( + name: "record_handshake", + title: "Record LLM handshake", + description: """ + Mark a terminal as touched by the current LLM session. Sets the card's + `lastLLMGet` timestamp. Idiomatic pair: read the card via + `termq://terminal/{id}` (pure, no side effects) then call this when you + have actually consumed the context. + + Pre-Tier-1b, this side effect lived on the `get` tool — see audit §3.1. + `get` remains as a deprecated alias and still records the handshake; new + callers should split the read (resource) from the write (this tool). + """, + inputSchema: Schema.objectSchema([ + Schema.string( + "id", "Terminal UUID (use $TERMQ_TERMINAL_ID from your environment)", + required: true) + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) ), Tool( - name: "termq_delete", + name: "delete", + title: "Delete terminal", description: """ - Delete a terminal. By default, moves to bin (soft delete). + Delete a terminal. By default, moves to bin (soft delete) — recoverable from the GUI. Use permanent=true to permanently delete without bin recovery option. """, inputSchema: Schema.objectSchema([ Schema.string("identifier", "Terminal name or UUID", required: true), Schema.bool("permanent", "Permanently delete (skip bin, cannot be recovered)"), - ]) + ]), + // Soft-delete is reversible; permanent=true is destructive. Mark destructive + // conservatively so permissioned clients prompt the user. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: true, openWorldHint: false) ), ] } @@ -154,27 +396,92 @@ extension TermQMCPServer { Resource( name: "All Terminals", uri: "termq://terminals", + title: "All terminals on the board", description: "Complete list of all terminals in the board", mimeType: "application/json" ), Resource( name: "Board Columns", uri: "termq://columns", + title: "Board columns", description: "List of all columns in the Kanban board", mimeType: "application/json" ), Resource( name: "Pending Work", uri: "termq://pending", + title: "Terminals needing attention", description: "Terminals with pending actions and staleness indicators", mimeType: "application/json" ), Resource( name: "LLM Workflow Guide", uri: "termq://context", + title: "Workflow guide (markdown)", description: "Comprehensive documentation for cross-session workflows", mimeType: "text/markdown" ), + Resource( + name: "Repositories", + uri: "termq://repos", + title: "Registered git repositories", + description: "All git repositories the user has registered with TermQ.", + mimeType: "application/json" + ), + Resource( + name: "Worktrees", + uri: "termq://worktrees", + title: "Git worktrees across all repositories", + description: "Worktrees enumerated from every registered repository.", + mimeType: "application/json" + ), + Resource( + name: "Installed harnesses", + uri: "termq://harnesses", + title: "Installed YNH harnesses", + description: + "Output of `ynh ls --format json`, passed through verbatim — full YNH" + + " envelope including `capabilities`, `schema_version`, `ynh_version`," + + " and the `harnesses` array. Each harness has both an `id`" + + " (canonical, e.g. `local/claude-dev`) and a `name` (bare). Use `id`" + + " when calling `harness_launch`. Empty array when `ynh` is not installed.", + mimeType: "application/json" + ), + ] + } +} + +// MARK: - Resource Templates + +extension TermQMCPServer { + /// Parameterised resource URIs the client can fill in. Clients call + /// `resources/templates/list` to discover these; once filled, the resulting URI is + /// read via standard `resources/read`. + static var availableResourceTemplates: [Resource.Template] { + [ + Resource.Template( + uriTemplate: "termq://terminal/{id}", + name: "Terminal by UUID", + title: "Terminal (by UUID)", + description: + "One terminal card resolved by UUID. Use $TERMQ_TERMINAL_ID inside a TermQ session.", + mimeType: "application/json" + ), + Resource.Template( + uriTemplate: "termq://terminal-by-name/{name}", + name: "Terminal by name", + title: "Terminal (by name)", + description: + "One terminal card resolved by exact name. Prefer UUID form for stability.", + mimeType: "application/json" + ), + Resource.Template( + uriTemplate: "termq://column/{name}", + name: "Column by name", + title: "Cards in column", + description: "All active cards in the named column.", + mimeType: "application/json" + ), ] } } @@ -186,6 +493,7 @@ extension TermQMCPServer { [ Prompt( name: "session_start", + title: "Start a TermQ session", description: """ Initialize an LLM session with TermQ. Returns pending work, board overview, and recommended first actions. @@ -194,16 +502,19 @@ extension TermQMCPServer { ), Prompt( name: "workflow_guide", + title: "Cross-session workflow guide", description: "Comprehensive guide for maintaining continuity across LLM sessions", arguments: [] ), Prompt( name: "terminal_summary", + title: "Summarise a terminal", description: "Get context and status for a specific terminal", arguments: [ Prompt.Argument( name: "terminal", - description: "Terminal name or UUID", + title: "Terminal name or UUID", + description: "Terminal name or UUID — argument completion suggests existing terminal names", required: true ) ] diff --git a/Sources/MCPServerLib/Server.swift b/Sources/MCPServerLib/Server.swift index 7122a5ef..048ae32f 100644 --- a/Sources/MCPServerLib/Server.swift +++ b/Sources/MCPServerLib/Server.swift @@ -2,24 +2,9 @@ import Foundation import MCP import TermQShared -// MARK: - SetLoggingLevel Method (MCP spec: logging/setLevel) - -/// MCP method for setting the server's log level -/// Required when server declares logging capability -public enum SetLoggingLevel: MCP.Method { - public static let name = "logging/setLevel" - - public struct Parameters: Hashable, Codable, Sendable { - /// The log level to set - public let level: String - - public init(level: String) { - self.level = level - } - } - - public typealias Result = Empty -} +// `SetLoggingLevel` is provided by the MCP Swift SDK as of the 2025-11-25 spec; +// no local re-declaration is needed. The SDK's version uses the proper `LogLevel` +// enum rather than a free-form String. /// TermQ MCP Server implementation /// @@ -32,12 +17,39 @@ public final class TermQMCPServer: @unchecked Sendable { private let server: Server let dataDirectory: URL? + /// Lazily-created subscription manager. Watches board.json and fires + /// `notifications/resources/updated` for subscribed URIs when the file changes. + private var subscriptionManager: ResourceSubscriptionManager? + /// Server name identifier public static let serverName = "termq" /// Server version public static let serverVersion = "1.0.0" + // MARK: - Log Level State + // + // `logging/setLevel` is a client request to filter `notifications/message` + // notifications by minimum severity. The MCP spec lets clients dial verbosity up + // and down at runtime. We store the configured threshold and gate emissions on it. + + /// `NSLock`-guarded minimum log level — defaults to `.info` (matches most clients' + /// expectations). Mutable: clients raise/lower it via `logging/setLevel`. + private let logLevelLock = NSLock() + private var _minLogLevel: LogLevel = .info + + var minLogLevel: LogLevel { + logLevelLock.lock() + defer { logLevelLock.unlock() } + return _minLogLevel + } + + func setMinLogLevel(_ level: LogLevel) { + logLevelLock.lock() + _minLogLevel = level + logLevelLock.unlock() + } + /// Initialize the MCP server /// - Parameter dataDirectory: Optional custom data directory (nil uses default) public init(dataDirectory: URL? = nil) { @@ -46,6 +58,7 @@ public final class TermQMCPServer: @unchecked Sendable { name: Self.serverName, version: Self.serverVersion, capabilities: Server.Capabilities( + completions: .init(), logging: .init(), prompts: .init(listChanged: true), resources: .init(subscribe: true, listChanged: true), @@ -61,10 +74,32 @@ public final class TermQMCPServer: @unchecked Sendable { public func run(transport: any Transport) async throws { // Register handlers before starting await registerHandlers() + await startSubscriptionWatcher() try await server.start(transport: transport) await server.waitUntilCompleted() } + /// Initialise the subscription manager and arm the file watcher pointed at the + /// resolved board.json. Called from `run(transport:)` once at startup. No-op when + /// subscriptions aren't useful (e.g. tests that pass in a manual `dataDirectory` + /// pointing nowhere) — the watcher silently retries until the file appears. + private func startSubscriptionWatcher() async { + let dataDir = dataDirectory ?? BoardLoader.getDataDirectoryPath() + let boardURL = dataDir.appendingPathComponent("board.json") + let manager = ResourceSubscriptionManager { [weak self] uri in + await self?.emitResourceUpdated(uri: uri) + } + self.subscriptionManager = manager + await manager.startWatching(boardURL: boardURL) + } + + /// Emit `notifications/resources/updated` for a single URI. Best-effort — + /// transport hiccups don't propagate (subscribers will catch up on next change). + private func emitResourceUpdated(uri: String) async { + let params = ResourceUpdatedNotification.Parameters(uri: uri) + try? await server.notify(ResourceUpdatedNotification.message(params)) + } + // MARK: - Handler Registration private func registerHandlers() async { @@ -98,6 +133,13 @@ public final class TermQMCPServer: @unchecked Sendable { return try await self.dispatchResourceRead(params) } + _ = await server.withMethodHandler(ListResourceTemplates.self) { [weak self] _ in + guard self != nil else { + return ListResourceTemplates.Result(templates: []) + } + return ListResourceTemplates.Result(templates: Self.availableResourceTemplates) + } + // Register prompt handlers _ = await server.withMethodHandler(ListPrompts.self) { [weak self] _ in guard self != nil else { @@ -113,18 +155,83 @@ public final class TermQMCPServer: @unchecked Sendable { return try await self.dispatchPromptGet(params) } - // Register logging handler (required when declaring logging capability) - _ = await server.withMethodHandler(SetLoggingLevel.self) { _ in - // Accept the log level - we don't need to do anything special - // as the MCP SDK handles basic logging + // Register logging handler — apply the client's requested minimum severity + // threshold so subsequent `notifications/message` emissions are filtered. + _ = await server.withMethodHandler(SetLoggingLevel.self) { [weak self] params in + self?.setMinLogLevel(params.level) + return Empty() + } + + // Register completion handler (required when declaring completions capability). + // Surfaces autocomplete suggestions for prompt arguments — currently the `terminal` + // argument of `terminal_summary`. + _ = await server.withMethodHandler(Complete.self) { [weak self] params in + guard let self = self else { + throw MCPError.internalError("Server deallocated") + } + return try await self.dispatchCompletion(params) + } + + // Register subscription handlers. The actual emission lives in + // ResourceSubscriptionManager; these just track which URIs are live. + _ = await server.withMethodHandler(ResourceSubscribe.self) { [weak self] params in + await self?.subscriptionManager?.subscribe(uri: params.uri) + return Empty() + } + _ = await server.withMethodHandler(ResourceUnsubscribe.self) { [weak self] params in + await self?.subscriptionManager?.unsubscribe(uri: params.uri) return Empty() } } // MARK: - Helpers - /// Load the board from the data directory + /// Load the board from the data directory. + /// + /// On failure, mirrors the error as a `notifications/message` (error level) so a + /// remote operator sees the failure even without local `--verbose` stderr. Then + /// re-throws — surfacing the failure to the calling tool is still mandatory. func loadBoard() throws -> Board { - try BoardLoader.loadBoard(dataDirectory: dataDirectory) + do { + return try BoardLoader.loadBoard(dataDirectory: dataDirectory) + } catch { + // Best-effort fire-and-forget mirror; never let logging affect the error path. + Task { [weak self] in + await self?.emitLog( + .error, + "Board load failed: \(error.localizedDescription)", + logger: "termq.board" + ) + } + throw error + } + } + + // MARK: - Logging Mirror + + /// Severity ordering used by `notifications/message` filtering. Higher index = more + /// severe. Matches the MCP spec ordering (debug → emergency). + private static let severityOrder: [LogLevel] = [ + .debug, .info, .notice, .warning, .error, .critical, .alert, .emergency, + ] + + /// Emit a `notifications/message` to the client — best-effort, gated by the + /// client-configured minimum log level. Silent failure is intentional: a transport + /// hiccup must never break the calling tool. The internal `os.Logger` (TermQLogger) + /// stays the source of truth for local debugging; this just mirrors selected events + /// over the wire so a remote operator can see them without `--verbose` stderr. + func emitLog(_ level: LogLevel, _ message: String, logger: String = "termq") async { + guard let minIdx = Self.severityOrder.firstIndex(of: minLogLevel), + let curIdx = Self.severityOrder.firstIndex(of: level), + curIdx >= minIdx + else { + return + } + let params = LogMessageNotification.Parameters( + level: level, + logger: logger, + data: .string(message) + ) + try? await server.notify(LogMessageNotification.message(params)) } } diff --git a/Sources/MCPServerLib/SubscriptionManager.swift b/Sources/MCPServerLib/SubscriptionManager.swift new file mode 100644 index 00000000..3ae33921 --- /dev/null +++ b/Sources/MCPServerLib/SubscriptionManager.swift @@ -0,0 +1,155 @@ +import Dispatch +import Foundation +import MCP +import TermQShared + +// MARK: - Subscription Manager +// +// Tracks `resources/subscribe` registrations and emits `notifications/resources/updated` +// when the board.json file underneath them changes. +// +// Design notes: +// - One `DispatchSourceFileSystemObject` watches the board.json inode for `.write`, +// `.extend`, `.delete`, `.rename`. macOS coalesces these aggressively; the debouncer +// below adds a second layer because atomic writes (which `BoardWriter` does) replace +// the inode entirely, briefly firing `.delete` followed by a new file appearing. +// - Notifications are coalesced via a 150ms debounce window. Two writes inside the +// window produce one notification per subscribed URI, not two. +// - Re-arming after `.rename` / `.delete` is handled by re-opening the file descriptor +// on a slight delay (the file briefly doesn't exist between unlink and link). +// - Self-write detection is deliberately NOT implemented in this initial version. The +// audit (§3.2, Tier 1b) flagged it as a refinement; the practical impact is that an +// MCP client that just wrote a card will also see a `resources/updated` for that +// write. Acceptable as a starting point — subscribers can no-op on stale ETags / their +// own causal write tracking. A future revision can add a per-write "skip notification" +// token threaded through `BoardWriter`. + +/// Actor-isolated subscription tracker and file-watcher. Holds the live +/// `DispatchSourceFileSystemObject` and emits `notifications/resources/updated` for +/// any matching subscriber when board.json changes. +actor ResourceSubscriptionManager { + /// URIs that any client has subscribed to. + private var subscribedURIs: Set = [] + + /// Active file-system watcher. + private var source: DispatchSourceFileSystemObject? + private var watchedURL: URL? + private var fileDescriptor: Int32 = -1 + + /// Pending debounce timer. + private var debounceTask: Task? + private static let debounceWindow: Duration = .milliseconds(150) + + /// Callback to actually deliver the notification. Held weakly so the server owns + /// the lifecycle; if the server is deallocated the deliver closure becomes a no-op. + private let deliver: @Sendable (String) async -> Void + + init(deliver: @escaping @Sendable (String) async -> Void) { + self.deliver = deliver + } + + deinit { + source?.cancel() + if fileDescriptor >= 0 { + close(fileDescriptor) + } + } + + // MARK: - Subscription state + + func subscribe(uri: String) { + subscribedURIs.insert(uri) + } + + func unsubscribe(uri: String) { + subscribedURIs.remove(uri) + } + + func subscriberCount() -> Int { subscribedURIs.count } + + // MARK: - File watching + + /// Begin watching `boardURL`. Idempotent — calling again with the same URL is a no-op; + /// with a different URL it tears down the previous watch and arms a new one. + func startWatching(boardURL: URL) { + if watchedURL == boardURL && source != nil { + return + } + stopWatching() + watchedURL = boardURL + arm(boardURL: boardURL) + } + + private func arm(boardURL: URL) { + // The file may not yet exist at first-launch — schedule a retry rather than + // failing silently. A subscriber arriving before the file is created is the + // expected path for `termqmcp` startup ordering against the GUI. + let fd = open(boardURL.path, O_EVTONLY) + guard fd >= 0 else { + Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + await self?.armIfNeeded(boardURL: boardURL) + } + return + } + fileDescriptor = fd + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .delete, .rename], + queue: DispatchQueue.global(qos: .utility) + ) + src.setEventHandler { [weak self] in + Task { [weak self] in + await self?.handleChangeEvent() + } + } + src.setCancelHandler { [fd] in + close(fd) + } + source = src + src.resume() + } + + private func armIfNeeded(boardURL: URL) { + guard source == nil else { return } + arm(boardURL: boardURL) + } + + func stopWatching() { + source?.cancel() + source = nil + if fileDescriptor >= 0 { + // Cancel handler closes the fd; nil out our copy. + fileDescriptor = -1 + } + } + + private func handleChangeEvent() async { + // If the file was renamed/deleted by an atomic-write, re-arm after a brief delay. + if let url = watchedURL, + let src = source, + src.data.contains(.delete) || src.data.contains(.rename) + { + stopWatching() + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(50)) + await self?.armIfNeeded(boardURL: url) + } + } + + // Debounce the emit. Cancel any pending task and schedule a fresh one. + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(for: Self.debounceWindow) + if Task.isCancelled { return } + await self?.fireEmissions() + } + } + + private func fireEmissions() async { + let uris = subscribedURIs + for uri in uris { + await deliver(uri) + } + } +} diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index eab0ea81..13ec2fcc 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -5,28 +5,61 @@ import TermQShared // MARK: - Tool Handler Implementations extension TermQMCPServer { + /// Build a `CallTool.Result` that satisfies the tool's `outputSchema` by populating + /// both legacy `text` content (for back-compat with clients that don't read + /// `structuredContent`) and the new `structuredContent` field. The dual encoding + /// roughly doubles the response payload — acceptable on stdio for a single-user app + /// (see audit §3.3 payload note), and the `text` mirror can be dropped after one + /// release once clients have migrated. + func structuredResult(_ output: T) throws -> CallTool.Result { + let json = try JSONHelper.encode(output) + // Throwing init handles the Codable -> Value conversion internally. + return try CallTool.Result( + content: [.text(text: json, annotations: nil, _meta: nil)], + structuredContent: output + ) + } + /// Dispatch tool calls to appropriate handlers func dispatchToolCall(_ params: CallTool.Parameters) async throws -> CallTool.Result { switch params.name { - case "termq_pending": + case "pending": return try await handlePending(params.arguments) - case "termq_context": + case "context": return try await handleContext() - case "termq_list": + case "list": return try await handleList(params.arguments) - case "termq_find": + case "find": return try await handleFind(params.arguments) - case "termq_open": + case "open": return try await handleOpen(params.arguments) - case "termq_create": + case "create": return try await handleCreate(params.arguments) - case "termq_set": + case "set": return try await handleSet(params.arguments) - case "termq_move": + case "move": return try await handleMove(params.arguments) - case "termq_get": + case "get": return try await handleGet(params.arguments) - case "termq_delete": + case "record_handshake": + return try await handleRecordHandshake(params.arguments) + case "whoami": + return try await handleWhoami(params.arguments) + case "restore": + return try await handleRestore(params.arguments) + case "create_column": + return try await handleCreateColumn(params.arguments) + case "rename_column": + return try await handleRenameColumn(params.arguments) + case "delete_column": + return try await handleDeleteColumn(params.arguments) + case "create_worktree": + return try await handleCreateWorktree(params.arguments) + case "remove_worktree": + return try await handleRemoveWorktree(params.arguments) + case "harness_launch": + return try await handleHarnessLaunch(params.arguments) + case "delete": return try await handleDelete(params.arguments) default: throw MCPError.invalidRequest("Unknown tool: \(params.name)") @@ -109,8 +142,7 @@ extension TermQMCPServer { ) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -127,11 +159,17 @@ extension TermQMCPServer { func handleList(_ arguments: [String: Value]?) async throws -> CallTool.Result { let columnFilter = InputValidator.optionalString("column", from: arguments) let columnsOnly = InputValidator.optionalBool("columnsOnly", from: arguments) + let includeDeleted = InputValidator.optionalBool("includeDeleted", from: arguments) + let cursor = InputValidator.optionalString("cursor", from: arguments) + let limit = (arguments?["limit"]).flatMap { value -> Int? in + if case .int(let i) = value { return i } + return nil + } do { let board = try loadBoard() - // If columnsOnly, return just column info + // If columnsOnly, return just column info wrapped in the envelope shape. if columnsOnly { let columns = board.sortedColumns().map { column in ColumnOutput( @@ -139,20 +177,24 @@ extension TermQMCPServer { terminalCount: board.activeCards.filter { $0.columnId == column.id }.count ) } - let json = try JSONHelper.encode(columns) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(ColumnList(items: columns)) } - // Get cards, optionally filtered by column - var cards = board.activeCards + // Source set — active by default, all cards (incl. soft-deleted) when requested. + var cards = includeDeleted ? board.cards : board.activeCards cards = CardFilterEngine.filterByColumn(cards, column: columnFilter, columns: board.columns) - // Sort by column order, then card order + // Sort by column order, then card order — stable across pagination calls. cards = CardFilterEngine.sortByColumnThenOrder(cards, columns: board.columns) - let output = cards.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + let paginated = paginate(cards, cursor: cursor, limit: limit) + let output = paginated.items.map { + TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) + } + // MCP requires `structuredContent` to be a JSON object — always emit the + // envelope, with `nextCursor` only present when the caller paginated. + return try structuredResult( + PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) } catch { return CallTool.Result( @@ -161,6 +203,52 @@ extension TermQMCPServer { } } + /// Envelope for `list` / `find` — MCP requires `structuredContent` to be an + /// object, so the rows are always wrapped under `items` with an optional + /// `nextCursor` for pagination. + struct PaginatedTerminals: Codable { + let items: [TerminalOutput] + let nextCursor: String? + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(items, forKey: .items) + if let nextCursor = nextCursor { + try c.encode(nextCursor, forKey: .nextCursor) + } + } + } + + /// Envelope for `list` with `columnsOnly: true`. + struct ColumnList: Codable { + let items: [ColumnOutput] + } + + /// Cursor-based pagination over a stable slice. Cursor is a base64-encoded integer + /// offset — opaque to the client, stable while sort order is stable. + func paginate(_ items: [T], cursor: String?, limit: Int?) -> (items: [T], nextCursor: String?) { + let start: Int = { + guard let cursor, + let data = Data(base64Encoded: cursor), + let str = String(data: data, encoding: .utf8), + let offset = Int(str), + offset >= 0, + offset <= items.count + else { return 0 } + return offset + }() + let end: Int = { + guard let limit, limit > 0 else { return items.count } + return min(start + limit, items.count) + }() + let slice = Array(items[start.. CallTool.Result { let query = InputValidator.optionalString("query", from: arguments) let nameFilter = InputValidator.optionalString("name", from: arguments) @@ -188,8 +276,8 @@ extension TermQMCPServer { if let query = query, !query.isEmpty { let queryWords = CardFilterEngine.normalizeToWords(query) guard !queryWords.isEmpty else { - let json = try JSONHelper.encode([TerminalOutput]()) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult( + PaginatedTerminals(items: [], nextCursor: nil)) } cards = cards.filter { card in @@ -214,7 +302,7 @@ extension TermQMCPServer { } cards = CardFilterEngine.filterByColumn(cards, column: columnFilter, columns: board.columns) - cards = CardFilterEngine.filterByTag(cards, tagFilter: tagFilter, valueMatch: .exact) + cards = try CardFilterEngine.filterByTag(cards, tagFilter: tagFilter) cards = CardFilterEngine.filterByBadge(cards, badge: badgeFilter) if favouritesOnly { cards = CardFilterEngine.filterFavourites(cards) } @@ -222,9 +310,17 @@ extension TermQMCPServer { cards = CardFilterEngine.sortByRelevance(cards, scores: relevanceScores) } - let output = cards.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + let cursor = InputValidator.optionalString("cursor", from: arguments) + let limit = (arguments?["limit"]).flatMap { value -> Int? in + if case .int(let i) = value { return i } + return nil + } + let paginated = paginate(cards, cursor: cursor, limit: limit) + let output = paginated.items.map { + TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) + } + return try structuredResult( + PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) } catch { return CallTool.Result( @@ -236,7 +332,7 @@ extension TermQMCPServer { func handleOpen(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_open") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "open") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -254,13 +350,49 @@ extension TermQMCPServer { } let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) - let json = try JSONHelper.encode(output) // Note: MCP server is read-only, it cannot open terminals in the GUI // The CLI uses URL schemes to communicate with the app // For MCP, we just return the terminal data - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) + + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + /// Record an LLM handshake — write the `lastLLMGet` timestamp without returning the + /// card payload. Idiomatic pair with reading `termq://terminal/{id}` as a pure + /// resource. The `get` tool keeps doing both for one release as the deprecation + /// alias for callers who haven't migrated yet (see audit §3.1 deprecation policy). + func handleRecordHandshake(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let id: String + do { + let uuid = try InputValidator.requireUUID("id", from: arguments, tool: "record_handshake") + id = uuid.uuidString + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + + do { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let nowString = formatter.string(from: Date()) + _ = try BoardWriter.updateCard( + identifier: id, + updates: ["lastLLMGet": nowString], + dataDirectory: dataDirectory + ) + return CallTool.Result( + content: [ + .text( + text: "{\"ok\": true, \"id\": \"\(id)\", \"lastLLMGet\": \"\(nowString)\"}", + annotations: nil, _meta: nil) + ]) } catch { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -271,7 +403,7 @@ extension TermQMCPServer { func handleGet(_ arguments: [String: Value]?) async throws -> CallTool.Result { let id: String do { - let uuid = try InputValidator.requireUUID("id", from: arguments, tool: "termq_get") + let uuid = try InputValidator.requireUUID("id", from: arguments, tool: "get") id = uuid.uuidString } catch let error as InputValidator.ValidationError { return CallTool.Result( @@ -302,8 +434,7 @@ extension TermQMCPServer { } let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -406,8 +537,7 @@ extension TermQMCPServer { let board = try loadBoard() if let card = board.findTerminal(identifier: cardId.uuidString) { let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } } @@ -416,8 +546,7 @@ extension TermQMCPServer { id: cardId.uuidString, message: "Terminal creation requested. The terminal may take a moment to appear in TermQ." ) - let json = try JSONHelper.encode(pendingOutput) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(pendingOutput) } catch { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -434,8 +563,7 @@ extension TermQMCPServer { from: card, columnName: board.columnName(for: card.columnId) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -453,7 +581,7 @@ extension TermQMCPServer { func handleSet(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_set") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "set") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -556,8 +684,7 @@ extension TermQMCPServer { if let updatedCard = updatedBoard.findTerminal(identifier: card.id.uuidString) { let output = TerminalOutput( from: updatedCard, columnName: updatedBoard.columnName(for: updatedCard.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } else { return CallTool.Result( content: [.text(text: "Error: Terminal not found after update", annotations: nil, _meta: nil)], @@ -589,7 +716,7 @@ extension TermQMCPServer { dataDirectory: dataDirectory ) - // `termq_set` with a `column` argument is equivalent to a move — apply it + // `set` with a `column` argument is equivalent to a move — apply it // after the field updates so a rename + column change in one call both land. if let column = params.column { card = try HeadlessWriter.moveCard( @@ -604,8 +731,7 @@ extension TermQMCPServer { from: card, columnName: board.columnName(for: card.columnId) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -624,8 +750,8 @@ extension TermQMCPServer { let identifier: String let column: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_move") - column = try InputValidator.requireNonEmptyString("column", from: arguments, tool: "termq_move") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "move") + column = try InputValidator.requireNonEmptyString("column", from: arguments, tool: "move") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -681,8 +807,7 @@ extension TermQMCPServer { if let updatedCard = updatedBoard.findTerminal(identifier: card.id.uuidString) { let output = TerminalOutput( from: updatedCard, columnName: updatedBoard.columnName(for: updatedCard.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } else { return CallTool.Result( content: [.text(text: "Error: Terminal not found after move", annotations: nil, _meta: nil)], @@ -708,8 +833,7 @@ extension TermQMCPServer { from: card, columnName: board.columnName(for: card.columnId) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -727,7 +851,7 @@ extension TermQMCPServer { func handleDelete(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_delete") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "delete") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -781,8 +905,7 @@ extension TermQMCPServer { id: card.id.uuidString, permanent: permanent ) - let json = try JSONHelper.encode(result) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(result) } catch { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -811,8 +934,7 @@ extension TermQMCPServer { id: card.id.uuidString, permanent: permanent ) - let json = try JSONHelper.encode(result) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(result) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -826,4 +948,286 @@ extension TermQMCPServer { ) } } + + // MARK: - Tier 2 handlers (whoami / restore / column CRUD) + + /// Resolve the current card from `TERMQ_TERMINAL_ID`. Returns a null structured + /// content when the env var is unset, so callers can distinguish "no env" from a + /// real error. + func handleWhoami(_ arguments: [String: Value]?) async throws -> CallTool.Result { + guard let envValue = ProcessInfo.processInfo.environment["TERMQ_TERMINAL_ID"], + !envValue.isEmpty, + let uuid = UUID(uuidString: envValue) + else { + // Surface as a non-error empty result — top-level Claude sessions (no TermQ + // container) hit this routinely and shouldn't see an error. + return CallTool.Result( + content: [ + .text( + text: "{\"terminal\": null, \"reason\": \"TERMQ_TERMINAL_ID not set or invalid\"}", + annotations: nil, _meta: nil) + ]) + } + do { + let board = try loadBoard() + guard let card = board.activeCards.first(where: { $0.id == uuid }) else { + return CallTool.Result( + content: [ + .text( + text: + "{\"terminal\": null, \"reason\": \"Terminal not found for env id\"}", + annotations: nil, _meta: nil) + ]) + } + let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) + return try structuredResult(output) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleRestore(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let identifier: String + do { + identifier = try InputValidator.requireString("identifier", from: arguments, tool: "restore") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + do { + let restored = try BoardWriter.restoreCard( + identifier: identifier, dataDirectory: dataDirectory) + let board = try loadBoard() + let output = TerminalOutput( + from: restored, columnName: board.columnName(for: restored.columnId)) + return try structuredResult(output) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleCreateColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let name: String + do { + name = try InputValidator.requireString("name", from: arguments, tool: "create_column") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let description = InputValidator.optionalString("description", from: arguments) ?? "" + let color = InputValidator.optionalString("color", from: arguments) ?? "#6B7280" + do { + let column = try BoardWriter.createColumn( + name: name, description: description, color: color, dataDirectory: dataDirectory) + return CallTool.Result( + content: [ + .text( + text: + "{\"id\": \"\(column.id.uuidString)\", \"name\": \"\(column.name)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleRenameColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let identifier: String + let newName: String + do { + identifier = try InputValidator.requireString( + "identifier", from: arguments, tool: "rename_column") + newName = try InputValidator.requireString("newName", from: arguments, tool: "rename_column") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + do { + let column = try BoardWriter.renameColumn( + identifier: identifier, newName: newName, dataDirectory: dataDirectory) + return CallTool.Result( + content: [ + .text( + text: + "{\"id\": \"\(column.id.uuidString)\", \"name\": \"\(column.name)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + // MARK: - Tier 3 handlers — worktrees, harnesses + + /// Look up a registered repository by UUID. Returns the GitRepository or throws a + /// CLI-flavoured error if not found / config can't be loaded. + private func loadRepo(repoId: String) throws -> GitRepository { + let config = try RepoConfigLoader.load() + guard let uuid = UUID(uuidString: repoId), + let repo = config.repositories.first(where: { $0.id == uuid }) + else { + throw MCPError.invalidParams("Unknown repository: \(repoId)") + } + return repo + } + + func handleCreateWorktree(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let repoId: String + let branch: String + do { + repoId = try InputValidator.requireString("repoId", from: arguments, tool: "create_worktree") + branch = try InputValidator.requireString("branch", from: arguments, tool: "create_worktree") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let createBranch = InputValidator.optionalBool("createBranch", from: arguments) + do { + let repo = try loadRepo(repoId: repoId) + let basePath = repo.worktreeBasePath ?? URL(fileURLWithPath: repo.path).deletingLastPathComponent().path + let worktreePath = "\(basePath)/\(branch)" + // GitServiceShared.addWorktree always creates a branch (`-b `); the + // `createBranch` flag here is informational — passing false won't suppress + // the -b flag. Threaded onto the wire surface for future expansion. + _ = createBranch + try await GitServiceShared.addWorktree( + repoPath: repo.path, + branch: branch, + worktreePath: worktreePath + ) + return CallTool.Result( + content: [ + .text( + text: + "{\"ok\": true, \"path\": \"\(worktreePath)\", \"branch\": \"\(branch)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleRemoveWorktree(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let repoId: String + let path: String + do { + repoId = try InputValidator.requireString("repoId", from: arguments, tool: "remove_worktree") + path = try InputValidator.requireString("path", from: arguments, tool: "remove_worktree") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + // `force` is currently informational — GitServiceShared.removeWorktree doesn't + // take a force flag in the public API yet. Threaded here so the wire surface is + // stable; future revision can plumb it through. + _ = InputValidator.optionalBool("force", from: arguments) + do { + let repo = try loadRepo(repoId: repoId) + try await GitServiceShared.removeWorktree(repoPath: repo.path, worktreePath: path) + return CallTool.Result( + content: [ + .text(text: "{\"ok\": true, \"removed\": \"\(path)\"}", annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + /// Launch a harness via `ynh run `. The most consequential write tool — + /// permissioned clients should treat the `destructiveHint` as a strong prompt for + /// user confirmation (full `elicitation/create` integration is a follow-up). + func handleHarnessLaunch(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let harness: String + let workingDirectory: String + do { + harness = try InputValidator.requireString("harness", from: arguments, tool: "harness_launch") + workingDirectory = try InputValidator.requireString( + "workingDirectory", from: arguments, tool: "harness_launch") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let prompt = InputValidator.optionalString("prompt", from: arguments) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var args = ["ynh", "run", harness] + if let prompt, !prompt.isEmpty { + args.append(contentsOf: ["--prompt", prompt]) + } + process.arguments = args + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + let outPipe = Pipe() + process.standardOutput = outPipe + process.standardError = outPipe + do { + try process.run() + process.waitUntilExit() + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let status = process.terminationStatus + // Truncate excessive output so the MCP frame stays bounded. + let snippet = output.count > 4096 ? String(output.suffix(4096)) : output + let body: [String: Any] = [ + "ok": status == 0, + "exitCode": status, + "output": snippet, + ] + let json = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted]) + return CallTool.Result( + content: [ + .text(text: String(data: json, encoding: .utf8) ?? "{}", annotations: nil, _meta: nil) + ], + isError: status != 0) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleDeleteColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let identifier: String + do { + identifier = try InputValidator.requireString( + "identifier", from: arguments, tool: "delete_column") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let force = InputValidator.optionalBool("force", from: arguments) + do { + try BoardWriter.deleteColumn( + identifier: identifier, force: force, dataDirectory: dataDirectory) + return CallTool.Result( + content: [ + .text( + text: "{\"ok\": true, \"deleted\": \"\(identifier)\", \"force\": \(force)}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } } diff --git a/Sources/MCPServerLib/ToolParity.swift b/Sources/MCPServerLib/ToolParity.swift new file mode 100644 index 00000000..6f46c17f --- /dev/null +++ b/Sources/MCPServerLib/ToolParity.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Single source of truth for the CLI ⇄ MCP parity policy described in audit §7. +/// +/// Every MCP tool name appearing in `availableTools` must be classified here as either +/// `mandatoryCLI` (a matching `termqcli` subcommand must exist) or `omittedCLI` (a +/// `termqcli` equivalent is deliberately not shipped, with a stated reason). +/// +/// Three consumers: +/// 1. A test in `Tests/MCPServerLibTests/` walks `availableTools` and asserts each +/// name appears in exactly one of the two lists. Adding a new tool without +/// classifying it fails CI. +/// 2. The same test asserts each `mandatoryCLI` entry has a real `termqcli` +/// subcommand registered (looks up the CLI's command list). +/// 3. The docs generator emits a "CLI Omissions" appendix in `Docs/Help/reference/mcp.md` +/// directly from `omittedCLI` — readers see the policy and rationale together, +/// no hand-maintained second copy. +/// +/// Adding a tool means editing the tool definition *and* this registry in lockstep; +/// the test enforces the second edit. +public enum ToolParity { + /// Tools that MUST have a matching `termqcli` subcommand. Reads, basic card writes, + /// column CRUD, whoami, simple resource enumerations. + public static let mandatoryCLI: [String] = [ + // Card reads + "pending", + "context", + "list", + "find", + "open", + "get", + // Card writes + "create", + "set", + "move", + "delete", + "restore", + // Column CRUD — shell pipelines for board admin + "create_column", + "rename_column", + "delete_column", + // Identity + "whoami", + ] + + /// Tools deliberately not exposed on `termqcli`. Reason is required so the policy is + /// legible — the docs generator surfaces these to readers. + public static let omittedCLI: [(name: String, reason: String)] = [ + ( + "record_handshake", + "MCP-only semantics — proof an LLM consumed a card's context doesn't translate to a shell prompt." + ), + ( + "harness_launch", + "Requires elicitation/user confirmation; no CLI equivalent. Security gate — " + + "launching a harness from a pipe bypasses the confirmation surface." + ), + ( + "create_worktree", + "`git worktree add` already exists as a first-class CLI; re-wrapping in termqcli " + + "is wrapper-on-wrapper. Revisit if a concrete CLI need appears." + ), + ( + "remove_worktree", + "Same reasoning as create_worktree — defer to `git worktree remove`." + ), + ] + + /// All names known to the parity registry. + public static var allKnownNames: Set { + Set(mandatoryCLI + omittedCLI.map { $0.name }) + } +} diff --git a/Sources/TermQ/Views/ContentView.swift b/Sources/TermQ/Views/ContentView.swift index 835b725b..2cb9cb2c 100644 --- a/Sources/TermQ/Views/ContentView.swift +++ b/Sources/TermQ/Views/ContentView.swift @@ -725,7 +725,6 @@ extension ContentView { } } - /// Launch native Terminal.app at the specified directory func launchNativeTerminal(at directory: String? = nil) { let path = directory ?? NSHomeDirectory() diff --git a/Sources/TermQ/Views/TerminalCardView.swift b/Sources/TermQ/Views/TerminalCardView.swift index 24174262..b754db89 100644 --- a/Sources/TermQ/Views/TerminalCardView.swift +++ b/Sources/TermQ/Views/TerminalCardView.swift @@ -112,7 +112,7 @@ struct TerminalCardView: View { .fixedSize() } - // Wired indicator - shows when LLM has called termq_get for this terminal + // Wired indicator - shows when LLM has called get for this terminal if card.isWired { Text(Strings.Card.wired) .font(.caption2) diff --git a/Sources/TermQCLICore/CLI+Find.swift b/Sources/TermQCLICore/CLI+Find.swift index a6bce9f0..e1a4a9db 100644 --- a/Sources/TermQCLICore/CLI+Find.swift +++ b/Sources/TermQCLICore/CLI+Find.swift @@ -44,7 +44,8 @@ struct Find: ParsableCommand { func run() throws { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) var cards = board.activeCards var relevanceScores: [UUID: Int] = [:] @@ -80,9 +81,10 @@ struct Find: ParsableCommand { cards = cards.filter { $0.title.lowercased().contains(filterLower) } } - // CLI uses .contains for tag value matching (partial match) + // Tag matching: literal exact match by default; opt-in regex with `re:` prefix. + // Converged with MCP — both surfaces use identical semantics via CardFilterEngine. cards = CardFilterEngine.filterByColumn(cards, column: column, columns: board.columns) - cards = CardFilterEngine.filterByTag(cards, tagFilter: tag, valueMatch: .contains) + cards = try CardFilterEngine.filterByTag(cards, tagFilter: tag) cards = CardFilterEngine.filterByBadge(cards, badge: badge) if favourites { cards = CardFilterEngine.filterFavourites(cards) } diff --git a/Sources/TermQCLICore/CLI+LLM.swift b/Sources/TermQCLICore/CLI+LLM.swift index dcd3d042..069ca14e 100644 --- a/Sources/TermQCLICore/CLI+LLM.swift +++ b/Sources/TermQCLICore/CLI+LLM.swift @@ -29,7 +29,8 @@ struct Pending: ParsableCommand { func run() throws { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) let cards = getFilteredAndSortedCards(from: board) let output = buildPendingOutput(cards: cards, board: board) diff --git a/Sources/TermQCLICore/CLI+Mutations.swift b/Sources/TermQCLICore/CLI+Mutations.swift index d354ca24..cc5b6862 100644 --- a/Sources/TermQCLICore/CLI+Mutations.swift +++ b/Sources/TermQCLICore/CLI+Mutations.swift @@ -55,9 +55,10 @@ struct Set: ParsableCommand { func run() throws { do { - let debugMode = shouldUseDebugMode(debug) + let profile = resolveProfile(debug) let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: debugMode) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: profile) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -100,7 +101,7 @@ struct Set: ParsableCommand { identifier: card.id.uuidString, toColumn: columnName, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) } @@ -108,7 +109,7 @@ struct Set: ParsableCommand { identifier: card.id.uuidString, params: params, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) if initCommand != nil { @@ -161,9 +162,10 @@ struct Move: ParsableCommand { func run() throws { do { - let debugMode = shouldUseDebugMode(debug) + let profile = resolveProfile(debug) let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: debugMode) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: profile) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -177,7 +179,7 @@ struct Move: ParsableCommand { identifier: card.id.uuidString, toColumn: toColumn, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) JSONHelper.printJSON( @@ -229,9 +231,10 @@ struct Delete: ParsableCommand { func run() throws { do { - let debugMode = shouldUseDebugMode(debug) + let profile = resolveProfile(debug) let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: debugMode) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: profile) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -245,7 +248,7 @@ struct Delete: ParsableCommand { identifier: card.id.uuidString, permanent: permanent, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) JSONHelper.printJSON( diff --git a/Sources/TermQCLICore/CLI.swift b/Sources/TermQCLICore/CLI.swift index 5cdc9a3a..8949ab2c 100644 --- a/Sources/TermQCLICore/CLI.swift +++ b/Sources/TermQCLICore/CLI.swift @@ -18,6 +18,14 @@ func shouldUseDebugMode(_ explicitDebug: Bool) -> Bool { #endif } +/// Resolves the AppProfile variant for a CLI invocation: in a debug build always `.debug`; +/// in a production build the user's `--debug` flag selects between `.production` and `.debug`. +/// Use this at every `BoardLoader`/`BoardWriter`/`HeadlessWriter` call site so the long-form +/// `AppProfile.Variant(debug: shouldUseDebugMode(debug))` doesn't leak everywhere. +func resolveProfile(_ explicitDebug: Bool) -> AppProfile.Variant { + AppProfile.Variant(debug: shouldUseDebugMode(explicitDebug)) +} + // MARK: - Shared Helpers func parseTags(_ tagStrings: [String]) -> [(key: String, value: String)] { @@ -175,7 +183,8 @@ struct Open: ParsableCommand { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -269,7 +278,7 @@ struct Create: ParsableCommand { tags: parsedTags.isEmpty ? nil : parsedTags ), dataDirectory: dataDirURL, - debug: shouldUseDebugMode(false) + profile: resolveProfile(false) ) JSONHelper.printJSON( @@ -379,7 +388,8 @@ struct List: ParsableCommand { func run() throws { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) if columns { let columnOutput = board.sortedColumns().map { col in diff --git a/Sources/TermQCore/TerminalCard.swift b/Sources/TermQCore/TerminalCard.swift index d2d53516..0314d388 100644 --- a/Sources/TermQCore/TerminalCard.swift +++ b/Sources/TermQCore/TerminalCard.swift @@ -104,7 +104,7 @@ public class TerminalCard: Identifiable, ObservableObject, Codable { /// When the card was soft-deleted (nil = active, set = in bin) @Published public var deletedAt: Date? - /// When an LLM last called termq_get for this terminal (nil = never, set = LLM is aware of TermQ) + /// When an LLM last called get for this terminal (nil = never, set = LLM is aware of TermQ) @Published public var lastLLMGet: Date? /// Backend override for session management. `nil` means inherit the @@ -256,7 +256,7 @@ public class TerminalCard: Identifiable, ObservableObject, Codable { deletedAt != nil } - /// Whether the LLM has recently identified itself via termq_get (within last 10 minutes) + /// Whether the LLM has recently identified itself via get (within last 10 minutes) public var isWired: Bool { guard let lastGet = lastLLMGet else { return false } return Date().timeIntervalSince(lastGet) < 600 // 10 minutes diff --git a/Sources/TermQShared/AppProfile.swift b/Sources/TermQShared/AppProfile.swift index eb3cb201..ead9c87c 100644 --- a/Sources/TermQShared/AppProfile.swift +++ b/Sources/TermQShared/AppProfile.swift @@ -98,4 +98,58 @@ public enum AppProfile { Production.bundleIdentifier, Debug.bundleIdentifier, ] + + /// Runtime profile selector — Sendable, injectable for tests. + /// + /// Where the compile-time `AppProfile.Current` is fixed at build time, `Variant` lets + /// callers (especially tests) target a specific profile at runtime. `.current` resolves + /// to `.debug` in `TERMQ_DEBUG_BUILD` builds and `.production` otherwise. + public enum Variant: Sendable { + case production + case debug + + public static var current: Variant { + #if TERMQ_DEBUG_BUILD + return .debug + #else + return .production + #endif + } + + /// Bridge for callers that still thread a `Bool` debug flag through their API surface + /// (CLI argparse flags, HeadlessWriter internal helpers). `true` always resolves to + /// `.debug`; `false` resolves to `.production`. Callers that want build-time-aware + /// behaviour should pass `.current` directly. + public init(debug: Bool) { + self = debug ? .debug : .production + } + + public var dataDirectoryName: String { + switch self { + case .production: return Production.dataDirectoryName + case .debug: return Debug.dataDirectoryName + } + } + + public var bundleIdentifier: String { + switch self { + case .production: return Production.bundleIdentifier + case .debug: return Debug.bundleIdentifier + } + } + + public var appBundleName: String { + switch self { + case .production: return Production.appBundleName + case .debug: return Debug.appBundleName + } + } + + public var displayName: String { + switch self { + case .production: return Production.displayName + case .debug: return Debug.displayName + } + } + } } diff --git a/Sources/TermQShared/BoardLoader.swift b/Sources/TermQShared/BoardLoader.swift index a1d1938b..c020d0d0 100644 --- a/Sources/TermQShared/BoardLoader.swift +++ b/Sources/TermQShared/BoardLoader.swift @@ -22,26 +22,40 @@ public enum BoardLoader { } } - /// Get the TermQ data directory path - public static func getDataDirectoryPath(customDirectory: URL? = nil, debug: Bool = false) -> URL { + /// Get the TermQ data directory path. + /// + /// - Parameters: + /// - customDirectory: Optional override (typically for tests). When non-nil, ignores `profile`. + /// - profile: Which app profile's data directory to resolve. Defaults to `.current` — + /// resolves to `.debug` in `TERMQ_DEBUG_BUILD` builds and `.production` otherwise. + public static func getDataDirectoryPath( + customDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) -> URL { if let custom = customDirectory { return custom } + let dirName = profile.dataDirectoryName guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { // Fallback to home directory if Application Support not available let homeDir = FileManager.default.homeDirectoryForCurrentUser - let dirName = debug ? AppProfile.Debug.dataDirectoryName : AppProfile.Production.dataDirectoryName return homeDir.appendingPathComponent(".termq", isDirectory: true).appendingPathComponent( dirName, isDirectory: true) } - let dirName = debug ? AppProfile.Debug.dataDirectoryName : AppProfile.Production.dataDirectoryName return appSupport.appendingPathComponent(dirName, isDirectory: true) } - /// Load the board from disk with file coordination for safe concurrent access - public static func loadBoard(dataDirectory: URL? = nil, debug: Bool = false) throws -> Board { - let dataDir = getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + /// Load the board from disk with file coordination for safe concurrent access. + /// + /// - Parameters: + /// - dataDirectory: Optional explicit data directory (tests use a temp dir). + /// - profile: Which app profile to resolve when `dataDirectory` is nil. Defaults to `.current`. + public static func loadBoard( + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Board { + let dataDir = getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) let boardURL = dataDir.appendingPathComponent("board.json") guard FileManager.default.fileExists(atPath: boardURL.path) else { @@ -110,9 +124,9 @@ public enum BoardWriter { /// Load board as raw JSON dictionary (preserves all fields) /// Uses file coordination for safe concurrent access public static func loadRawBoard( - dataDirectory: URL? = nil, debug: Bool = false + dataDirectory: URL? = nil, profile: AppProfile.Variant = .current ) throws -> (url: URL, data: [String: Any]) { - let dataDir = BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + let dataDir = BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) let boardURL = dataDir.appendingPathComponent("board.json") guard FileManager.default.fileExists(atPath: boardURL.path) else { @@ -147,7 +161,15 @@ public enum BoardWriter { return (boardURL, try result.get()) } - /// Save raw board JSON to disk with file coordination for safe concurrent access + /// Save raw board JSON to disk with file coordination for safe concurrent access. + /// + /// **Avoid for new code.** This is a half-claim (write only). Pairing a separate + /// `loadRawBoard` read claim with a `saveRawBoard` write claim opens a lost-update + /// race: two processes can both finish their reads before either writes, and the + /// second write silently clobbers the first. Use `atomicUpdate(...)` instead for any + /// read-modify-write — it holds a single exclusive claim across both halves. + /// Retained here only for callers that are genuinely write-only (constructing a + /// fresh board from scratch). public static func saveRawBoard(_ board: [String: Any], to url: URL) throws { let jsonData = try JSONSerialization.data(withJSONObject: board, options: [.prettyPrinted, .sortedKeys]) @@ -172,191 +194,389 @@ public enum BoardWriter { } } - /// Update a card's fields - public static func updateCard( - identifier: String, - updates: [String: Any], + /// Atomic read-modify-write under a single `NSFileCoordinator` writing claim. + /// + /// The closure receives the parsed board JSON. It may mutate the board however it + /// likes (and may compute and return any value from it). The mutated board is written + /// back inside the same exclusive claim, so no other process can read or write the + /// file while this call is in flight. + /// + /// Closes the lost-update race that a split `loadRawBoard` + `saveRawBoard` pattern + /// otherwise allows: two processes both finishing their reads before either writes, + /// and the second write silently clobbering the first. Same fix also resolves the + /// `orderIndex` collision (two concurrent appends computing the same `max + 1`). + @discardableResult + public static func atomicUpdate( dataDirectory: URL? = nil, - debug: Bool = false - ) throws -> Card { - let rawBoard = try loadRawBoard(dataDirectory: dataDirectory, debug: debug) - let boardURL = rawBoard.url - var board = rawBoard.data - guard var cards = board["cards"] as? [[String: Any]] else { - throw WriteError.encodingFailed("Invalid cards format") + profile: AppProfile.Variant = .current, + body: (inout [String: Any]) throws -> T + ) throws -> T { + let dataDir = BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) + let boardURL = dataDir.appendingPathComponent("board.json") + + guard FileManager.default.fileExists(atPath: boardURL.path) else { + throw WriteError.boardNotFound(path: boardURL.path) } - // Find the card to update (include deleted cards so we can update after soft-delete) - let cardIndex = try findCardIndex(identifier: identifier, in: cards, includeDeleted: true) + var coordinationError: NSError? + var result: Result? - // Capture the stable UUID before applying updates — the caller may be renaming - // the card (updating `title`), which would break a post-save name lookup. - let cardUUID = (cards[cardIndex]["id"] as? String).flatMap(UUID.init(uuidString:)) + let coordinator = NSFileCoordinator(filePresenter: nil) + coordinator.coordinate(writingItemAt: boardURL, options: [], error: &coordinationError) { writeURL in + do { + let data = try Data(contentsOf: writeURL) + guard var board = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw WriteError.encodingFailed("Invalid board format") + } + let value = try body(&board) + let newData = try JSONSerialization.data( + withJSONObject: board, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: writeURL, options: .atomic) + result = .success(value) + } catch { + result = .failure(error) + } + } - // Apply updates - for (key, value) in updates { - cards[cardIndex][key] = value + if let error = coordinationError { + throw WriteError.coordinationFailed(error.localizedDescription) } + guard let result else { + throw WriteError.coordinationFailed("File coordination completed without result") + } + return try result.get() + } + + /// Update a card's fields atomically. + /// + /// Read, mutation, and write happen under a single `NSFileCoordinator` writing claim + /// — no other process can interleave, so the lost-update race is closed. + public static func updateCard( + identifier: String, + updates: [String: Any], + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Card { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid cards format") + } - // Save back - board["cards"] = cards - try saveRawBoard(board, to: boardURL) + // Find the card to update (include deleted cards so we can update after soft-delete) + let cardIndex = try findCardIndex(identifier: identifier, in: cards, includeDeleted: true) - // Return the updated card (search in ALL cards, not just active ones) - // This is important for operations like soft-delete that set deletedAt - let updatedBoard = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) + // Capture the stable UUID before applying updates — the caller may be renaming + // the card (updating `title`), which would break a post-mutation name lookup. + let cardUUID = (cards[cardIndex]["id"] as? String).flatMap(UUID.init(uuidString:)) - // Prefer the captured UUID — survives title changes - if let uuid = cardUUID, - let updatedCard = updatedBoard.cards.first(where: { $0.id == uuid }) - { - return updatedCard - } + for (key, value) in updates { + cards[cardIndex][key] = value + } + board["cards"] = cards - // Fallback: if identifier was a UUID string, try that - if let uuid = UUID(uuidString: identifier), - let updatedCard = updatedBoard.cards.first(where: { $0.id == uuid }) - { - return updatedCard + // Decode the post-mutation card from the in-memory dict so we return a result + // that matches what's about to be persisted (and don't have to re-read from disk + // outside the claim, which would re-open the race). + let decoded = try decodeCard(at: cardIndex, in: cards, identifier: identifier, capturedUUID: cardUUID) + return decoded } + } - // Final fallback: name search (only reliable when title wasn't updated) - let identifierLower = identifier.lowercased() - if let updatedCard = updatedBoard.cards.first(where: { - $0.title.lowercased() == identifierLower || $0.title.lowercased().contains(identifierLower) - }) { - return updatedCard + /// Decode the card at `cardIndex` from a raw cards array. + /// Helper used by `updateCard` to return a typed `Card` from the mutated state without + /// leaving the atomic claim. + private static func decodeCard( + at cardIndex: Int, + in cards: [[String: Any]], + identifier: String, + capturedUUID: UUID? + ) throws -> Card { + let cardData = try JSONSerialization.data(withJSONObject: cards[cardIndex]) + do { + return try JSONDecoder().decode(Card.self, from: cardData) + } catch { + // If decoding the specific row fails for any reason, surface card-not-found + // with the original identifier so callers see a useful error. + _ = capturedUUID + throw WriteError.cardNotFound(identifier: identifier) } - - throw WriteError.cardNotFound(identifier: identifier) } - /// Move a card to a different column + /// Move a card to a different column atomically. See `updateCard` for race-fix details. public static func moveCard( identifier: String, toColumn columnName: String, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { - let rawBoard = try loadRawBoard(dataDirectory: dataDirectory, debug: debug) - let boardURL = rawBoard.url - var board = rawBoard.data - guard var cards = board["cards"] as? [[String: Any]], - let columns = board["columns"] as? [[String: Any]] - else { - throw WriteError.encodingFailed("Invalid board format") - } - - // Find target column - let columnNameLower = columnName.lowercased() - guard - let targetColumn = columns.first(where: { - ($0["name"] as? String)?.lowercased() == columnNameLower - }), - let targetColumnId = targetColumn["id"] as? String - else { - throw WriteError.columnNotFound(name: columnName) - } + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]], + let columns = board["columns"] as? [[String: Any]] + else { + throw WriteError.encodingFailed("Invalid board format") + } - // Find the card to move - let cardIndex = try findCardIndex(identifier: identifier, in: cards) + // Find target column + let columnNameLower = columnName.lowercased() + guard + let targetColumn = columns.first(where: { + ($0["name"] as? String)?.lowercased() == columnNameLower + }), + let targetColumnId = targetColumn["id"] as? String + else { + throw WriteError.columnNotFound(name: columnName) + } - // Calculate new orderIndex (put at end of target column) - let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } - let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 + let cardIndex = try findCardIndex(identifier: identifier, in: cards) - // Update the card - cards[cardIndex]["columnId"] = targetColumnId - cards[cardIndex]["orderIndex"] = maxOrderIndex + 1 + // Calculate new orderIndex inside the claim — concurrent appends can no longer + // both compute the same `max + 1`. + let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } + let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 - // Save back - board["cards"] = cards - try saveRawBoard(board, to: boardURL) + cards[cardIndex]["columnId"] = targetColumnId + cards[cardIndex]["orderIndex"] = maxOrderIndex + 1 + board["cards"] = cards - // Return the updated card - let updatedBoard = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) - guard let updatedCard = updatedBoard.findTerminal(identifier: identifier) else { - throw WriteError.cardNotFound(identifier: identifier) + let cardUUID = (cards[cardIndex]["id"] as? String).flatMap(UUID.init(uuidString:)) + return try decodeCard(at: cardIndex, in: cards, identifier: identifier, capturedUUID: cardUUID) } - return updatedCard } - /// Create a new card + /// Create a new card atomically. See `updateCard` for race-fix details. + /// The `orderIndex` calculation now runs inside the exclusive claim, so two + /// concurrent appends to the same column produce distinct `orderIndex` values. public static func createCard( name: String, columnName: String?, workingDirectory: String, description: String = "", dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { - let rawBoard = try loadRawBoard(dataDirectory: dataDirectory, debug: debug) - let boardURL = rawBoard.url - var board = rawBoard.data - guard var cards = board["cards"] as? [[String: Any]], - let columns = board["columns"] as? [[String: Any]] - else { - throw WriteError.encodingFailed("Invalid board format") + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]], + let columns = board["columns"] as? [[String: Any]] + else { + throw WriteError.encodingFailed("Invalid board format") + } + + // Find target column (default to first column if not specified) + let targetColumn: [String: Any] + if let columnName = columnName { + let columnNameLower = columnName.lowercased() + guard + let found = columns.first(where: { + ($0["name"] as? String)?.lowercased() == columnNameLower + }) + else { + throw WriteError.columnNotFound(name: columnName) + } + targetColumn = found + } else { + let sortedColumns = columns.sorted { + ($0["orderIndex"] as? Int ?? 0) < ($1["orderIndex"] as? Int ?? 0) + } + guard let first = sortedColumns.first else { + throw WriteError.columnNotFound(name: "default") + } + targetColumn = first + } + + guard let targetColumnId = targetColumn["id"] as? String else { + throw WriteError.columnNotFound(name: columnName ?? "default") + } + + let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } + let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 + + let newCardId = UUID() + let newCard: [String: Any] = [ + "id": newCardId.uuidString, + "title": name, + "description": description, + "columnId": targetColumnId, + "orderIndex": maxOrderIndex + 1, + "workingDirectory": workingDirectory, + "isFavourite": false, + "badge": "", + "llmPrompt": "", + "llmNextAction": "", + "tags": [[String: Any]](), + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + + cards.append(newCard) + board["cards"] = cards + + // Decode the just-appended card from the in-claim state. + let newIndex = cards.count - 1 + return try decodeCard( + at: newIndex, in: cards, identifier: newCardId.uuidString, capturedUUID: newCardId) } + } - // Find target column (default to first column if not specified) - let targetColumn: [String: Any] - if let columnName = columnName { - let columnNameLower = columnName.lowercased() + // MARK: - Column CRUD + + /// Create a new column. Throws if a column with the same name (case-insensitive) + /// already exists. + public static func createColumn( + name: String, + description: String = "", + color: String = "#6B7280", + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Column { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var columns = board["columns"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid columns format") + } + let nameLower = name.lowercased() + if columns.contains(where: { ($0["name"] as? String)?.lowercased() == nameLower }) { + throw WriteError.encodingFailed("Column already exists: \(name)") + } + let maxOrder = columns.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 + let newId = UUID() + let newColumn: [String: Any] = [ + "id": newId.uuidString, + "name": name, + "description": description, + "orderIndex": maxOrder + 1, + "color": color, + ] + columns.append(newColumn) + board["columns"] = columns + let data = try JSONSerialization.data(withJSONObject: newColumn) + return try JSONDecoder().decode(Column.self, from: data) + } + } + + /// Rename an existing column. Card membership is unchanged. + @discardableResult + public static func renameColumn( + identifier: String, + newName: String, + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Column { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var columns = board["columns"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid columns format") + } + let identifierLower = identifier.lowercased() guard - let found = columns.first(where: { - ($0["name"] as? String)?.lowercased() == columnNameLower + let idx = columns.firstIndex(where: { + ($0["name"] as? String)?.lowercased() == identifierLower + || ($0["id"] as? String) == identifier }) else { - throw WriteError.columnNotFound(name: columnName) + throw WriteError.columnNotFound(name: identifier) } - targetColumn = found - } else { - // Use first column sorted by orderIndex - let sortedColumns = columns.sorted { - ($0["orderIndex"] as? Int ?? 0) < ($1["orderIndex"] as? Int ?? 0) - } - guard let first = sortedColumns.first else { - throw WriteError.columnNotFound(name: "default") + // Reject duplicates (other than the renamed column itself). + let newNameLower = newName.lowercased() + if columns.enumerated().contains(where: { i, c in + i != idx && (c["name"] as? String)?.lowercased() == newNameLower + }) { + throw WriteError.encodingFailed("Column already exists: \(newName)") } - targetColumn = first + columns[idx]["name"] = newName + board["columns"] = columns + let data = try JSONSerialization.data(withJSONObject: columns[idx]) + return try JSONDecoder().decode(Column.self, from: data) } + } + + /// Delete a column. + /// + /// - Throws `columnNotFound` if the column doesn't exist. + /// - When `force == false` (default): throws `encodingFailed` if any active cards + /// remain in the column — callers must move or delete them first. + /// - When `force == true`: soft-deletes all cards in the column (sets `deletedAt`). + /// The column is removed from the columns array regardless. Soft-deleted cards + /// can be individually restored via `restoreCard`. + public static func deleteColumn( + identifier: String, + force: Bool = false, + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws { + try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var columns = board["columns"] as? [[String: Any]], + var cards = board["cards"] as? [[String: Any]] + else { + throw WriteError.encodingFailed("Invalid board format") + } + let identifierLower = identifier.lowercased() + guard + let idx = columns.firstIndex(where: { + ($0["name"] as? String)?.lowercased() == identifierLower + || ($0["id"] as? String) == identifier + }) + else { + throw WriteError.columnNotFound(name: identifier) + } + guard let columnId = columns[idx]["id"] as? String else { + throw WriteError.columnNotFound(name: identifier) + } - guard let targetColumnId = targetColumn["id"] as? String else { - throw WriteError.columnNotFound(name: columnName ?? "default") + let activeInColumn = cards.filter { + ($0["columnId"] as? String) == columnId && $0["deletedAt"] == nil + } + if !activeInColumn.isEmpty && !force { + throw WriteError.encodingFailed( + "Column '\(identifier)' has \(activeInColumn.count) active card(s)." + + " Move them or pass force: true to soft-delete them.") + } + + if force { + let nowString = ISO8601DateFormatter().string(from: Date()) + for i in cards.indices + where (cards[i]["columnId"] as? String) == columnId + && cards[i]["deletedAt"] == nil + { + cards[i]["deletedAt"] = nowString + } + board["cards"] = cards + } + columns.remove(at: idx) + board["columns"] = columns + return () } + } - // Calculate orderIndex - let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } - let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 - - // Create new card - let newCardId = UUID() - let newCard: [String: Any] = [ - "id": newCardId.uuidString, - "title": name, - "description": description, - "columnId": targetColumnId, - "orderIndex": maxOrderIndex + 1, - "workingDirectory": workingDirectory, - "isFavourite": false, - "badge": "", - "llmPrompt": "", - "llmNextAction": "", - "tags": [[String: Any]](), - "createdAt": ISO8601DateFormatter().string(from: Date()), - ] - - cards.append(newCard) - board["cards"] = cards - try saveRawBoard(board, to: boardURL) - - // Return the created card - let updatedBoard = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) - guard let createdCard = updatedBoard.findTerminal(identifier: newCardId.uuidString) else { - throw WriteError.cardNotFound(identifier: newCardId.uuidString) + /// Restore a soft-deleted card by clearing its `deletedAt` timestamp. + @discardableResult + public static func restoreCard( + identifier: String, + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Card { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid cards format") + } + // Find among deleted cards specifically — restoring something not in the bin + // is a no-op the caller should know about. + let identifierLower = identifier.lowercased() + guard + let idx = cards.firstIndex(where: { + let isDeleted = $0["deletedAt"] != nil + let matches = + ($0["id"] as? String) == identifier + || ($0["title"] as? String)?.lowercased() == identifierLower + return isDeleted && matches + }) + else { + throw WriteError.cardNotFound(identifier: identifier) + } + cards[idx]["deletedAt"] = nil + // Remove the deletedAt key entirely rather than leaving an NSNull entry. + cards[idx].removeValue(forKey: "deletedAt") + board["cards"] = cards + let cardUUID = (cards[idx]["id"] as? String).flatMap(UUID.init(uuidString:)) + return try decodeCard( + at: idx, in: cards, identifier: identifier, capturedUUID: cardUUID) } - return createdCard } /// Find card index by identifier diff --git a/Sources/TermQShared/CardFilterEngine.swift b/Sources/TermQShared/CardFilterEngine.swift index d76cc981..a27d28cc 100644 --- a/Sources/TermQShared/CardFilterEngine.swift +++ b/Sources/TermQShared/CardFilterEngine.swift @@ -4,12 +4,20 @@ import Foundation /// Shared between MCP (ToolHandlers) and CLI (CLI+Find) to eliminate duplication. public enum CardFilterEngine { - // MARK: - Tag Value Matching - - /// Controls how tag values are matched. MCP uses exact match; CLI uses partial. - public enum TagValueMatch: Sendable { - case exact - case contains + // MARK: - Tag Filter Errors + + /// Errors that can surface from `filterByTag`. Surfaced to the user — CLI exits non-zero, + /// MCP returns `isError: true`. Never silently fall back to literal match when the user + /// asked for regex; that would mask a typo and return surprising results. + public enum TagFilterError: Error, CustomStringConvertible, Sendable { + case invalidRegex(pattern: String, message: String) + + public var description: String { + switch self { + case .invalidRegex(let pattern, let message): + return "Invalid regex in tag filter '\(pattern)': \(message)" + } + } } // MARK: - Filtering @@ -30,29 +38,56 @@ public enum CardFilterEngine { return cards.filter { matchingIds.contains($0.columnId) } } - /// Filters cards by tag. Accepts `"key"` or `"key=value"` format. - /// Returns `cards` unchanged when `tagFilter` is nil. + /// Filters cards by tag. Accepts: + /// - `"key"` — key-only literal match + /// - `"key=value"` — exact literal match on both key and value (case-insensitive) + /// - `"key=re:pattern"` — key matches literally; value matches regex pattern + /// - `"re:pattern"` — whole `key=value` string (or `key` alone) matches regex pattern + /// + /// Literal is the default to avoid the regex-metacharacter footgun (a tag like + /// `project=v1.2` would otherwise also match `project=v1X2` because `.` is a metachar). + /// Returns `cards` unchanged when `tagFilter` is nil. Throws `TagFilterError.invalidRegex` + /// if a `re:`-prefixed pattern fails to compile. public static func filterByTag( _ cards: [Card], - tagFilter: String?, - valueMatch: TagValueMatch = .exact - ) -> [Card] { + tagFilter: String? + ) throws -> [Card] { guard let tagFilter else { return cards } + + // Whole-pattern regex: `re:...` + if let pattern = dropPrefix("re:", from: tagFilter) { + let regex = try compileRegex(pattern, original: tagFilter) + return cards.filter { card in + card.tags.contains { tag in + let fullTag = tag.value.isEmpty ? tag.key : "\(tag.key)=\(tag.value)" + return regex.matches(fullTag) + } + } + } + if tagFilter.contains("=") { let parts = tagFilter.split(separator: "=", maxSplits: 1) guard parts.count == 2 else { return cards } let key = String(parts[0]).lowercased() - let value = String(parts[1]).lowercased() - return cards.filter { card in - card.tags.contains { tag in - let keyMatch = tag.key.lowercased() == key - switch valueMatch { - case .exact: return keyMatch && tag.value.lowercased() == value - case .contains: return keyMatch && tag.value.lowercased().contains(value) + let rawValue = String(parts[1]) + + // Value-position regex: `key=re:...` + if let valuePattern = dropPrefix("re:", from: rawValue) { + let regex = try compileRegex(valuePattern, original: tagFilter) + return cards.filter { card in + card.tags.contains { tag in + tag.key.lowercased() == key && regex.matches(tag.value) } } } + + // Literal exact match + let value = rawValue.lowercased() + return cards.filter { card in + card.tags.contains { $0.key.lowercased() == key && $0.value.lowercased() == value } + } } else { + // Key-only literal match let key = tagFilter.lowercased() return cards.filter { card in card.tags.contains { $0.key.lowercased() == key } @@ -60,6 +95,20 @@ public enum CardFilterEngine { } } + // MARK: - Tag Filter Helpers + + private static func dropPrefix(_ prefix: String, from string: String) -> String? { + string.hasPrefix(prefix) ? String(string.dropFirst(prefix.count)) : nil + } + + private static func compileRegex(_ pattern: String, original: String) throws -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + } catch { + throw TagFilterError.invalidRegex(pattern: original, message: error.localizedDescription) + } + } + /// Filters cards whose badge contains `badge` (case-insensitive, partial match). /// Returns `cards` unchanged when `badge` is nil. public static func filterByBadge(_ cards: [Card], badge: String?) -> [Card] { @@ -138,3 +187,10 @@ public enum CardFilterEngine { return score } } + +extension NSRegularExpression { + fileprivate func matches(_ string: String) -> Bool { + let range = NSRange(string.startIndex..., in: string) + return firstMatch(in: string, options: [], range: range) != nil + } +} diff --git a/Sources/TermQShared/LocalYNHConfig.swift b/Sources/TermQShared/LocalYNHConfig.swift index 6ac58aa0..428c0a65 100644 --- a/Sources/TermQShared/LocalYNHConfig.swift +++ b/Sources/TermQShared/LocalYNHConfig.swift @@ -94,13 +94,14 @@ public enum YNHConfigLoader { } } - public static func getConfigURL(dataDirectory: URL? = nil, debug: Bool = false) -> URL { - BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + public static func getConfigURL(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) -> URL { + BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) .appendingPathComponent("ynh.json") } - public static func load(dataDirectory: URL? = nil, debug: Bool = false) throws -> LocalYNHConfig { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func load(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) throws -> LocalYNHConfig + { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) guard FileManager.default.fileExists(atPath: configURL.path) else { return LocalYNHConfig() @@ -132,8 +133,10 @@ public enum YNHConfigLoader { return try result.get() } - public static func save(_ config: LocalYNHConfig, dataDirectory: URL? = nil, debug: Bool = false) throws { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func save( + _ config: LocalYNHConfig, dataDirectory: URL? = nil, profile: AppProfile.Variant = .current + ) throws { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) let dirURL = configURL.deletingLastPathComponent() if !FileManager.default.fileExists(atPath: dirURL.path) { diff --git a/Sources/TermQShared/RepoConfigLoader.swift b/Sources/TermQShared/RepoConfigLoader.swift index 33bb8e86..0bf5227b 100644 --- a/Sources/TermQShared/RepoConfigLoader.swift +++ b/Sources/TermQShared/RepoConfigLoader.swift @@ -39,16 +39,16 @@ public enum RepoConfigLoader { } /// URL for `repos.json` in the TermQ data directory - public static func getConfigURL(dataDirectory: URL? = nil, debug: Bool = false) -> URL { - BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + public static func getConfigURL(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) -> URL { + BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) .appendingPathComponent("repos.json") } /// Load repository configuration from disk. /// /// Returns an empty `RepoConfig` if the file does not exist yet. - public static func load(dataDirectory: URL? = nil, debug: Bool = false) throws -> RepoConfig { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func load(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) throws -> RepoConfig { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) guard FileManager.default.fileExists(atPath: configURL.path) else { return RepoConfig() @@ -86,8 +86,10 @@ public enum RepoConfigLoader { /// Save repository configuration to disk. /// /// Creates the TermQ data directory if it does not yet exist. - public static func save(_ config: RepoConfig, dataDirectory: URL? = nil, debug: Bool = false) throws { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func save( + _ config: RepoConfig, dataDirectory: URL? = nil, profile: AppProfile.Variant = .current + ) throws { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) // Ensure the data directory exists let dirURL = configURL.deletingLastPathComponent() diff --git a/Tests/IntegrationTests/MCPToolReadTests.swift b/Tests/IntegrationTests/MCPToolReadTests.swift index b0fdf96a..4cf9c279 100644 --- a/Tests/IntegrationTests/MCPToolReadTests.swift +++ b/Tests/IntegrationTests/MCPToolReadTests.swift @@ -27,7 +27,7 @@ final class MCPToolReadTests: XCTestCase { server = nil } - // MARK: - termq_list Tests + // MARK: - list Tests func testListReturnsAllTerminals() async throws { let result = try await server.handleList(nil) @@ -75,7 +75,7 @@ final class MCPToolReadTests: XCTestCase { // Should return columns, not terminals let data = Data(json.utf8) - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items XCTAssertEqual(columns.count, 3, "Should return 3 default columns") XCTAssertEqual(columns[0].name, "To Do") @@ -93,7 +93,7 @@ final class MCPToolReadTests: XCTestCase { } let data = Data(json.utf8) - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items XCTAssertEqual(columns[0].description, "Tasks to start") XCTAssertEqual(columns[1].description, "Active work") @@ -109,7 +109,7 @@ final class MCPToolReadTests: XCTestCase { } let data = Data(json.utf8) - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items // Verify terminal counts match what we set up // TestBoardBuilder.comprehensive: To Do=2, In Progress=2, Done=1 @@ -118,7 +118,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertEqual(columns[2].terminalCount, 1, "Done should have 1 terminal") } - // MARK: - termq_find Tests + // MARK: - find Tests func testFindBySmartQuery() async throws { let args: [String: Value] = ["query": .string("Fresh Active Project")] @@ -235,7 +235,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertEqual(terminals.count, 0, "Should return empty array for no matches") } - // MARK: - termq_open Tests + // MARK: - open Tests func testOpenByExactName() async throws { let args: [String: Value] = ["identifier": .string("Fresh Active Project")] @@ -307,7 +307,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertTrue(result.isError ?? false) } - // MARK: - termq_pending Tests + // MARK: - pending Tests func testPendingReturnsAllWithSummary() async throws { let result = try await server.handlePending(nil) @@ -397,7 +397,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertGreaterThan(output.summary.stale, 0, "Should have stale terminals") } - // MARK: - termq_context Tests + // MARK: - context Tests func testContextReturnsGuide() async throws { let result = try await server.handleContext() @@ -425,7 +425,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertTrue(content.contains("staleness"), "Should document staleness tag") } - // MARK: - termq_get Tests + // MARK: - get Tests func testGetByUUID() async throws { // Create environment with known UUID @@ -535,19 +535,24 @@ final class MCPToolReadTests: XCTestCase { // MARK: - Helper Functions -/// Extract terminal array from tool result +/// Extract terminal array from tool result. +/// +/// `list` and `find` now emit an envelope `{ items: [...], nextCursor?: string }` +/// per MCP's requirement that `structuredContent` be a JSON object — so this +/// helper unwraps `items` rather than expecting a bare top-level array. func extractTerminalArray(from result: CallTool.Result) throws -> [[String: Any]] { guard case .text(let json, _, _) = result.content[0] else { throw TestHelperError.noTextContent } guard let data = json.data(using: .utf8), - let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = obj["items"] as? [[String: Any]] else { throw TestHelperError.invalidJSON } - return array + return items } /// Extract single terminal from tool result @@ -569,3 +574,8 @@ enum TestHelperError: Error { case noTextContent case invalidJSON } + +/// Local mirror of the columns-only envelope emitted by `list { columnsOnly: true }`. +struct ColumnListEnvelope: Codable { + let items: [ColumnOutput] +} diff --git a/Tests/IntegrationTests/MCPToolWriteTests.swift b/Tests/IntegrationTests/MCPToolWriteTests.swift index 41fe0b5d..3ba8f72f 100644 --- a/Tests/IntegrationTests/MCPToolWriteTests.swift +++ b/Tests/IntegrationTests/MCPToolWriteTests.swift @@ -15,9 +15,9 @@ import XCTest /// See: .claude/plans/headless-mode-implementation.md for implementation plan. /// /// Key workflows tested: -/// - Creating terminals (termq_create) - SKIPPED, requires GUI -/// - Updating terminal fields (termq_set) - Passing (uses existing terminals) -/// - Moving terminals between columns (termq_move) - SKIPPED, requires GUI +/// - Creating terminals (create) - SKIPPED, requires GUI +/// - Updating terminal fields (set) - Passing (uses existing terminals) +/// - Moving terminals between columns (move) - SKIPPED, requires GUI /// - Setting tags via MCP - Passing (uses existing terminals) /// /// Run with: `swift test --filter MCPToolWriteTests` @@ -40,7 +40,7 @@ final class MCPToolWriteTests: XCTestCase { GUIDetector.testModeOverride = nil } - // MARK: - termq_create Tests + // MARK: - create Tests func testCreateTerminalWithName() async throws { let args: [String: Value] = [ @@ -115,7 +115,7 @@ final class MCPToolWriteTests: XCTestCase { XCTAssertNotNil(found, "Created terminal should persist in board file") } - // MARK: - termq_set Tests + // MARK: - set Tests func testSetTerminalName() async throws { let args: [String: Value] = [ @@ -240,7 +240,7 @@ final class MCPToolWriteTests: XCTestCase { XCTAssertEqual(terminal?.llmPrompt, "Persisted prompt value") } - // MARK: - termq_set Tag Tests (TDD - may expose missing functionality) + // MARK: - set Tag Tests (TDD - may expose missing functionality) func testSetSingleTag() async throws { // User's key workflow: setting tags via MCP @@ -260,7 +260,7 @@ final class MCPToolWriteTests: XCTestCase { } // Verify the tag was actually set - XCTAssertEqual(tags["project"], "my/repo", "Tag 'project=my/repo' should be set via termq_set") + XCTAssertEqual(tags["project"], "my/repo", "Tag 'project=my/repo' should be set via set") } func testSetMultipleTags() async throws { @@ -289,7 +289,7 @@ final class MCPToolWriteTests: XCTestCase { XCTAssertEqual(tags["type"], "feature", "type tag should be set") } - // MARK: - termq_move Tests + // MARK: - move Tests func testMoveTerminalToColumn() async throws { let args: [String: Value] = [ diff --git a/Tests/MCPServerLibTests/MCPIntegrationTests.swift b/Tests/MCPServerLibTests/MCPIntegrationTests.swift index d4554bdd..aa85846a 100644 --- a/Tests/MCPServerLibTests/MCPIntegrationTests.swift +++ b/Tests/MCPServerLibTests/MCPIntegrationTests.swift @@ -241,7 +241,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 4) } @@ -258,7 +258,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 2) // Test Terminal 1 and Favourite Terminal } @@ -275,7 +275,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items XCTAssertEqual(columns.count, 3) XCTAssertEqual(columns[0].name, "To Do") @@ -294,7 +294,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items // First column has a description XCTAssertEqual(columns[0].description, "Tasks to start") @@ -316,7 +316,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Favourite Terminal") @@ -334,7 +334,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Test Terminal 1") @@ -352,7 +352,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Favourite Terminal") @@ -370,7 +370,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Favourite Terminal") @@ -393,7 +393,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items // Should find the mcp-toolkit terminal XCTAssertTrue(terminals.contains { $0.name.contains("mcp-toolkit") }) @@ -412,7 +412,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items // Should find the mcp-toolkit terminal by its description XCTAssertTrue(terminals.contains { $0.name.contains("mcp-toolkit") }) @@ -430,7 +430,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 0) } @@ -451,7 +451,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items // Should find mcp-toolkit (matches query and is in "In Progress") XCTAssertEqual(terminals.count, 1) @@ -522,25 +522,25 @@ final class MCPIntegrationTests: XCTestCase { func testAvailableToolsSchema() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 10) + XCTAssertEqual(tools.count, 19) let toolNames = Set(tools.map { $0.name }) - XCTAssertTrue(toolNames.contains("termq_pending")) - XCTAssertTrue(toolNames.contains("termq_context")) - XCTAssertTrue(toolNames.contains("termq_list")) - XCTAssertTrue(toolNames.contains("termq_find")) - XCTAssertTrue(toolNames.contains("termq_open")) - XCTAssertTrue(toolNames.contains("termq_create")) - XCTAssertTrue(toolNames.contains("termq_set")) - XCTAssertTrue(toolNames.contains("termq_move")) - XCTAssertTrue(toolNames.contains("termq_get")) - XCTAssertTrue(toolNames.contains("termq_delete")) + XCTAssertTrue(toolNames.contains("pending")) + XCTAssertTrue(toolNames.contains("context")) + XCTAssertTrue(toolNames.contains("list")) + XCTAssertTrue(toolNames.contains("find")) + XCTAssertTrue(toolNames.contains("open")) + XCTAssertTrue(toolNames.contains("create")) + XCTAssertTrue(toolNames.contains("set")) + XCTAssertTrue(toolNames.contains("move")) + XCTAssertTrue(toolNames.contains("get")) + XCTAssertTrue(toolNames.contains("delete")) } func testAvailableResourcesSchema() { let resources = TermQMCPServer.availableResources - XCTAssertEqual(resources.count, 4) + XCTAssertEqual(resources.count, 7) let resourceURIs = Set(resources.map { $0.uri }) XCTAssertTrue(resourceURIs.contains("termq://terminals")) @@ -728,3 +728,14 @@ final class MCPIntegrationTests: XCTestCase { } } } + +/// Local mirror of the `list` / `find` envelope shape — see SchemaBuilder.terminalListSchema. +struct TerminalListEnvelope: Codable { + let items: [TerminalOutput] + let nextCursor: String? +} + +/// Local mirror of the columns-only envelope emitted by `list { columnsOnly: true }`. +struct ColumnListEnvelope: Codable { + let items: [ColumnOutput] +} diff --git a/Tests/MCPServerLibTests/ResourceHandlersTests.swift b/Tests/MCPServerLibTests/ResourceHandlersTests.swift index 72ef1e17..579418d0 100644 --- a/Tests/MCPServerLibTests/ResourceHandlersTests.swift +++ b/Tests/MCPServerLibTests/ResourceHandlersTests.swift @@ -92,15 +92,20 @@ final class ResourceHandlersTests: XCTestCase { } func testTerminalsResourceWithLoadError() async throws { - // Server with no board.json + // Server with no board.json — load failures must SURFACE to the client, not + // silently return "[]". An empty array means "the board has zero cards"; a + // missing/corrupt board means "something is wrong with the install." Conflating + // the two is exactly the bug this work was opened to fix. server = TermQMCPServer(dataDirectory: tempDirectory) let params = ReadResource.Parameters(uri: "termq://terminals") - let result = try await server.dispatchResourceRead(params) - - // Should return empty array on error - let json = result.contents[0].text ?? "" - XCTAssertEqual(json, "[]") + do { + _ = try await server.dispatchResourceRead(params) + XCTFail("Expected load error to be thrown") + } catch { + // Any thrown error is acceptable — the contract is "surface the failure," not a + // specific error type. Confirming the call did not silently succeed is enough. + } } // MARK: - Columns Resource Tests @@ -129,10 +134,12 @@ final class ResourceHandlersTests: XCTestCase { server = TermQMCPServer(dataDirectory: tempDirectory) let params = ReadResource.Parameters(uri: "termq://columns") - let result = try await server.dispatchResourceRead(params) - - let json = result.contents[0].text ?? "" - XCTAssertEqual(json, "[]") + do { + _ = try await server.dispatchResourceRead(params) + XCTFail("Expected load error to be thrown") + } catch { + // Surface, not swallow — see testTerminalsResourceWithLoadError. + } } // MARK: - Pending Resource Tests @@ -196,10 +203,12 @@ final class ResourceHandlersTests: XCTestCase { server = TermQMCPServer(dataDirectory: tempDirectory) let params = ReadResource.Parameters(uri: "termq://pending") - let result = try await server.dispatchResourceRead(params) - - let json = result.contents[0].text ?? "" - XCTAssertEqual(json, "{}") + do { + _ = try await server.dispatchResourceRead(params) + XCTFail("Expected load error to be thrown") + } catch { + // Surface, not swallow — see testTerminalsResourceWithLoadError. + } } func testPendingResourceSortsByActionsFirst() async throws { diff --git a/Tests/MCPServerLibTests/ServerTests.swift b/Tests/MCPServerLibTests/ServerTests.swift index c9e7a40a..8f727cdd 100644 --- a/Tests/MCPServerLibTests/ServerTests.swift +++ b/Tests/MCPServerLibTests/ServerTests.swift @@ -96,7 +96,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_pending", arguments: nil) + let params = CallTool.Parameters(name: "pending", arguments: nil) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -108,7 +108,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_context", arguments: nil) + let params = CallTool.Parameters(name: "context", arguments: nil) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -124,7 +124,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_list", arguments: nil) + let params = CallTool.Parameters(name: "list", arguments: nil) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -135,7 +135,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_find", arguments: ["name": .string("Test")]) + let params = CallTool.Parameters(name: "find", arguments: ["name": .string("Test")]) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -146,7 +146,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_open", arguments: ["identifier": .string("Test Terminal")]) + let params = CallTool.Parameters(name: "open", arguments: ["identifier": .string("Test Terminal")]) let result = try await server.dispatchToolCall(params) // This might fail because terminal doesn't exist, which is fine @@ -160,7 +160,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_create", + name: "create", arguments: ["name": .string("New Terminal"), "path": .string("/tmp")] ) let result = try await server.dispatchToolCall(params) @@ -174,7 +174,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_set", + name: "set", arguments: ["identifier": .string("Test Terminal"), "description": .string("Updated")] ) let result = try await server.dispatchToolCall(params) @@ -189,7 +189,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_move", + name: "move", arguments: ["identifier": .string("Test Terminal"), "column": .string("Done")] ) let result = try await server.dispatchToolCall(params) @@ -203,7 +203,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_get", + name: "get", arguments: ["id": .string(UUID().uuidString)] ) let result = try await server.dispatchToolCall(params) @@ -216,7 +216,7 @@ final class ServerTests: XCTestCase { func testAvailableResourcesCount() { let resources = TermQMCPServer.availableResources - XCTAssertEqual(resources.count, 4) + XCTAssertEqual(resources.count, 7) } func testAvailableResourcesURIs() { @@ -249,23 +249,23 @@ final class ServerTests: XCTestCase { func testAvailableToolsCount() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 10) + XCTAssertEqual(tools.count, 19) } func testAvailableToolsNames() { let tools = TermQMCPServer.availableTools let names = Set(tools.map { $0.name }) - XCTAssertTrue(names.contains("termq_pending")) - XCTAssertTrue(names.contains("termq_context")) - XCTAssertTrue(names.contains("termq_list")) - XCTAssertTrue(names.contains("termq_find")) - XCTAssertTrue(names.contains("termq_open")) - XCTAssertTrue(names.contains("termq_create")) - XCTAssertTrue(names.contains("termq_set")) - XCTAssertTrue(names.contains("termq_move")) - XCTAssertTrue(names.contains("termq_get")) - XCTAssertTrue(names.contains("termq_delete")) + XCTAssertTrue(names.contains("pending")) + XCTAssertTrue(names.contains("context")) + XCTAssertTrue(names.contains("list")) + XCTAssertTrue(names.contains("find")) + XCTAssertTrue(names.contains("open")) + XCTAssertTrue(names.contains("create")) + XCTAssertTrue(names.contains("set")) + XCTAssertTrue(names.contains("move")) + XCTAssertTrue(names.contains("get")) + XCTAssertTrue(names.contains("delete")) } // MARK: - Context Documentation Tests diff --git a/Tests/MCPServerLibTests/ToolHandlersTests.swift b/Tests/MCPServerLibTests/ToolHandlersTests.swift index e3d6e5c1..761f0021 100644 --- a/Tests/MCPServerLibTests/ToolHandlersTests.swift +++ b/Tests/MCPServerLibTests/ToolHandlersTests.swift @@ -300,7 +300,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 2) } @@ -327,7 +327,7 @@ final class ToolHandlersTests: XCTestCase { return } - let columns = try JSONDecoder().decode([ColumnOutput].self, from: json.data(using: .utf8)!) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(columns.count, 2) XCTAssertEqual(columns[0].name, "To Do") XCTAssertEqual(columns[0].description, "Tasks to do") @@ -358,7 +358,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Active Card") } @@ -386,7 +386,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 3) // Should be sorted: column 0 cards first (by order), then column 1 cards XCTAssertEqual(terminals[0].name, "Card A") @@ -423,7 +423,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Frontend Project") } @@ -447,7 +447,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Has Env Tag") } @@ -471,7 +471,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Production") } @@ -500,7 +500,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertTrue(terminals.contains { $0.name.contains("mcp-toolkit") }) } @@ -520,7 +520,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 0) } @@ -541,7 +541,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items // Should return empty since query normalizes to nothing XCTAssertEqual(terminals.count, 0) } @@ -584,7 +584,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].id, cardId.uuidString) } diff --git a/Tests/MCPServerLibTests/ToolParityTests.swift b/Tests/MCPServerLibTests/ToolParityTests.swift new file mode 100644 index 00000000..7d3338ea --- /dev/null +++ b/Tests/MCPServerLibTests/ToolParityTests.swift @@ -0,0 +1,77 @@ +import XCTest + +@testable import MCPServerLib + +/// Enforces the CLI ⇄ MCP parity policy from audit §7. +/// +/// Adding a new MCP tool without classifying it in `ToolParity.mandatoryCLI` or +/// `ToolParity.omittedCLI` fails CI here. This converts the policy from "we should +/// keep these in sync" to "the build won't pass if you don't." +final class ToolParityTests: XCTestCase { + /// Every tool returned by the MCP server must appear in the parity registry, in + /// exactly one of the two lists. Missing means the author forgot to classify it. + func test_everyMCPToolIsClassified() { + let toolNames = TermQMCPServer.availableTools.map { $0.name } + let known = ToolParity.allKnownNames + let unclassified = toolNames.filter { !known.contains($0) } + XCTAssertTrue( + unclassified.isEmpty, + """ + New MCP tool(s) missing from Sources/MCPServerLib/ToolParity.swift: \(unclassified). + + Decide whether each one belongs in `mandatoryCLI` (a matching termqcli subcommand + must exist) or `omittedCLI` (with a stated reason). The audit §7 parity table + lists the policy. + """ + ) + } + + /// A name cannot be in both lists — the classification is mutually exclusive. + func test_classificationIsMutuallyExclusive() { + let mandatory = Set(ToolParity.mandatoryCLI) + let omitted = Set(ToolParity.omittedCLI.map { $0.name }) + let overlap = mandatory.intersection(omitted) + XCTAssertTrue( + overlap.isEmpty, + "ToolParity classifies these tools in BOTH lists: \(overlap)" + ) + } + + /// Every omission carries a non-empty reason — the docs generator surfaces these, + /// so an unexplained omission would render as an empty line. + func test_everyOmissionHasReason() { + for entry in ToolParity.omittedCLI { + XCTAssertFalse( + entry.reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "Empty omission reason for \(entry.name)" + ) + } + } + + /// Mandatory-CLI entries must correspond to actual MCP tools — if a name appears + /// here but not in `availableTools`, the registry is stale. + func test_mandatoryClassificationsAreCurrentTools() { + let toolNames = Set(TermQMCPServer.availableTools.map { $0.name }) + for name in ToolParity.mandatoryCLI where !toolNames.contains(name) { + XCTFail( + """ + ToolParity.mandatoryCLI references '\(name)' but no such MCP tool exists. + Did you rename the tool without updating the registry? + """ + ) + } + } + + /// Same check for omissions — stale omissions are misleading. + func test_omissionsAreCurrentTools() { + let toolNames = Set(TermQMCPServer.availableTools.map { $0.name }) + for entry in ToolParity.omittedCLI where !toolNames.contains(entry.name) { + XCTFail( + """ + ToolParity.omittedCLI references '\(entry.name)' but no such MCP tool exists. + Did you remove the tool without updating the registry? + """ + ) + } + } +} diff --git a/Tests/TermQSharedTests/BoardLoaderTests.swift b/Tests/TermQSharedTests/BoardLoaderTests.swift index 08e42c43..2efc2c44 100644 --- a/Tests/TermQSharedTests/BoardLoaderTests.swift +++ b/Tests/TermQSharedTests/BoardLoaderTests.swift @@ -3,6 +3,31 @@ import XCTest @testable import TermQShared +/// Sendable accumulator for errors observed inside `DispatchQueue.concurrentPerform`. +/// NSMutableArray would work but isn't Sendable under Swift 6 strict concurrency. +private final class ConcurrentErrorBox: @unchecked Sendable { + private let lock = NSLock() + private var items: [String] = [] + + func add(_ s: String) { + lock.lock() + items.append(s) + lock.unlock() + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return items.count + } + + var all: [String] { + lock.lock() + defer { lock.unlock() } + return items + } +} + final class BoardLoaderTests: XCTestCase { var tempDirectory: URL! @@ -78,12 +103,12 @@ final class BoardLoaderTests: XCTestCase { } func testGetDataDirectoryPathDebugMode() { - let result = BoardLoader.getDataDirectoryPath(debug: true) + let result = BoardLoader.getDataDirectoryPath(profile: .debug) XCTAssertTrue(result.path.contains("TermQ-Debug")) } func testGetDataDirectoryPathNonDebugMode() { - let result = BoardLoader.getDataDirectoryPath(debug: false) + let result = BoardLoader.getDataDirectoryPath(profile: .production) XCTAssertTrue(result.path.contains("TermQ")) XCTAssertFalse(result.path.contains("TermQ-Debug")) } @@ -677,7 +702,7 @@ final class BoardLoaderTests: XCTestCase { let data = try encoder.encode(board) try data.write(to: debugDir.appendingPathComponent("board.json")) - let loaded = try BoardLoader.loadBoard(dataDirectory: debugDir, debug: true) + let loaded = try BoardLoader.loadBoard(dataDirectory: debugDir, profile: .debug) XCTAssertEqual(loaded.columns.count, 3) } @@ -685,7 +710,7 @@ final class BoardLoaderTests: XCTestCase { let board = createTestBoard() try writeTestBoard(board) - let (_, data) = try BoardWriter.loadRawBoard(dataDirectory: tempDirectory, debug: true) + let (_, data) = try BoardWriter.loadRawBoard(dataDirectory: tempDirectory, profile: .debug) XCTAssertNotNil(data["columns"]) } @@ -829,6 +854,93 @@ final class BoardLoaderTests: XCTestCase { } } + // MARK: - Atomic RMW (Fix C) — concurrency tests + + /// T3.12 — Concurrent appends to the same column produce unique `orderIndex` values. + /// Pre-fix, the split read+write coordinator pattern let two writers both compute the + /// same `max + 1`, producing duplicate `orderIndex` entries. The atomic helper closes + /// this race. + /// + /// Uses `DispatchQueue.concurrentPerform` rather than `async let` because + /// `NSFileCoordinator` is synchronous and blocks its thread — Swift concurrency + /// would not produce genuine concurrent dispatch here. + func testCreateCardConcurrentAppendsProduceUniqueOrderIndex() throws { + // Bootstrap board with one column. + let columnId = UUID() + let json = """ + { + "columns": [{"id": "\(columnId.uuidString)", "name": "To Do", "orderIndex": 0, "color": "#000"}], + "cards": [] + } + """ + try writeRawJSON(json) + + let writerCount = 20 + let directory = tempDirectory + let errorBox = ConcurrentErrorBox() + DispatchQueue.concurrentPerform(iterations: writerCount) { iteration in + do { + _ = try BoardWriter.createCard( + name: "Card-\(iteration)", + columnName: "To Do", + workingDirectory: "/tmp", + dataDirectory: directory + ) + } catch { + errorBox.add("\(iteration): \(error)") + } + } + XCTAssertEqual(errorBox.count, 0, "Concurrent creates threw: \(errorBox.all)") + + let loaded = try BoardLoader.loadBoard(dataDirectory: directory) + XCTAssertEqual(loaded.cards.count, writerCount, "All concurrent creates should persist") + + let orderIndices = loaded.cards.map { $0.orderIndex } + let unique = Set(orderIndices) + XCTAssertEqual( + unique.count, orderIndices.count, + "orderIndex collisions: \(orderIndices.sorted()) — atomic RMW must produce distinct values" + ) + } + + /// T3.10 — Concurrent updates to *different* cards do not lose either change. + /// Pre-fix, both writers could read the same baseline, mutate, and one's write would + /// silently clobber the other's. Atomic RMW serialises the two read-modify-writes. + func testUpdateCardConcurrentDifferentCardsBothSurvive() throws { + // Bootstrap two cards under one column. + let columnId = UUID() + let card1Id = UUID() + let card2Id = UUID() + let json = """ + { + "columns": [{"id": "\(columnId.uuidString)", "name": "To Do", "orderIndex": 0, "color": "#000"}], + "cards": [ + {"id": "\(card1Id.uuidString)", "title": "A", "description": "", + "columnId": "\(columnId.uuidString)", "orderIndex": 0, "workingDirectory": "/tmp", + "isFavourite": false, "badge": "", "llmPrompt": "", "llmNextAction": "", "tags": []}, + {"id": "\(card2Id.uuidString)", "title": "B", "description": "", + "columnId": "\(columnId.uuidString)", "orderIndex": 1, "workingDirectory": "/tmp", + "isFavourite": false, "badge": "", "llmPrompt": "", "llmNextAction": "", "tags": []} + ] + } + """ + try writeRawJSON(json) + + let directory = tempDirectory + DispatchQueue.concurrentPerform(iterations: 2) { i in + let identifier = i == 0 ? card1Id.uuidString : card2Id.uuidString + let badge = i == 0 ? "BADGE-A" : "BADGE-B" + _ = try? BoardWriter.updateCard( + identifier: identifier, updates: ["badge": badge], dataDirectory: directory) + } + + let loaded = try BoardLoader.loadBoard(dataDirectory: directory) + let cardA = loaded.cards.first { $0.id == card1Id } + let cardB = loaded.cards.first { $0.id == card2Id } + XCTAssertEqual(cardA?.badge, "BADGE-A", "Card A's write was clobbered by Card B's write") + XCTAssertEqual(cardB?.badge, "BADGE-B", "Card B's write was clobbered by Card A's write") + } + // MARK: - CreateCard Invalid Columns Format func testCreateCardInvalidColumnsFormat() throws { diff --git a/Tests/TermQSharedTests/CardFilterEngineTests.swift b/Tests/TermQSharedTests/CardFilterEngineTests.swift index bc963ea1..02b2b4c9 100644 --- a/Tests/TermQSharedTests/CardFilterEngineTests.swift +++ b/Tests/TermQSharedTests/CardFilterEngineTests.swift @@ -66,62 +66,89 @@ final class CardFilterEngineTests: XCTestCase { XCTAssertTrue(result.isEmpty) } - // MARK: - filterByTag (key-only format) + // MARK: - filterByTag — literal-by-default, opt-in `re:` regex - func testFilterByTagNilPassesAll() { + func testFilterByTagNilPassesAll() throws { let cards = [makeCard(tags: [Tag(key: "env", value: "prod")], columnId: colA.id)] - let result = CardFilterEngine.filterByTag(cards, tagFilter: nil) + let result = try CardFilterEngine.filterByTag(cards, tagFilter: nil) XCTAssertEqual(result.count, 1) } - func testFilterByTagKeyOnly() { + /// T7.5 — Key-only match returns any card with that tag key. + func testFilterByTagKeyOnly() throws { let matching = makeCard(tags: [Tag(key: "env", value: "prod")], columnId: colA.id) let nonMatching = makeCard(tags: [Tag(key: "team", value: "platform")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([matching, nonMatching], tagFilter: "env") + let result = try CardFilterEngine.filterByTag([matching, nonMatching], tagFilter: "env") XCTAssertEqual(result.count, 1) XCTAssertEqual(result[0].tags.first?.key, "env") } - func testFilterByTagKeyOnlyCaseInsensitive() { + func testFilterByTagKeyOnlyCaseInsensitive() throws { let card = makeCard(tags: [Tag(key: "ENV", value: "prod")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env") + let result = try CardFilterEngine.filterByTag([card], tagFilter: "env") XCTAssertEqual(result.count, 1) } - func testFilterByTagKeyNoMatch() { + func testFilterByTagKeyNoMatch() throws { let card = makeCard(tags: [Tag(key: "team", value: "platform")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env") + let result = try CardFilterEngine.filterByTag([card], tagFilter: "env") XCTAssertTrue(result.isEmpty) } - // MARK: - filterByTag (key=value format, exact) - - func testFilterByTagKeyValueExactMatch() { + /// Literal key=value match (default) — case-insensitive exact comparison. + func testFilterByTagKeyValueLiteralMatch() throws { let matching = makeCard(tags: [Tag(key: "env", value: "prod")], columnId: colA.id) let wrong = makeCard(tags: [Tag(key: "env", value: "staging")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([matching, wrong], tagFilter: "env=prod", valueMatch: .exact) + let result = try CardFilterEngine.filterByTag([matching, wrong], tagFilter: "env=prod") XCTAssertEqual(result.count, 1) XCTAssertEqual(result[0].tags.first?.value, "prod") } - func testFilterByTagKeyValueExactNoPartialMatch() { + /// Literal value does NOT do partial match — `env=prod` must not match `env=production`. + func testFilterByTagKeyValueLiteralRejectsPartial() throws { let card = makeCard(tags: [Tag(key: "env", value: "production")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env=prod", valueMatch: .exact) + let result = try CardFilterEngine.filterByTag([card], tagFilter: "env=prod") XCTAssertTrue(result.isEmpty) } - // MARK: - filterByTag (key=value format, contains) - - func testFilterByTagKeyValueContainsMatch() { - let card = makeCard(tags: [Tag(key: "env", value: "production")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env=prod", valueMatch: .contains) + /// T7.4 — Regression test for the rejected regex-by-default design. + /// `project=v1.2` matches ONLY the v1.2 card, NOT a v1X2 card (where `.` would be a regex wildcard). + func testFilterByTagLiteralDotIsNotWildcard() throws { + let exact = makeCard(tags: [Tag(key: "project", value: "v1.2")], columnId: colA.id) + let wouldMatchUnderRegex = makeCard(tags: [Tag(key: "project", value: "v1X2")], columnId: colA.id) + let result = try CardFilterEngine.filterByTag([exact, wouldMatchUnderRegex], tagFilter: "project=v1.2") XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].tags.first?.value, "v1.2") } - func testFilterByTagKeyValueContainsNoMatch() { - let card = makeCard(tags: [Tag(key: "env", value: "staging")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env=prod", valueMatch: .contains) - XCTAssertTrue(result.isEmpty) + /// T7.6 — Opt-in regex via `re:` prefix on value. + func testFilterByTagValueRegex() throws { + let stale = makeCard(tags: [Tag(key: "staleness", value: "stale")], columnId: colA.id) + let ageing = makeCard(tags: [Tag(key: "staleness", value: "ageing")], columnId: colA.id) + let fresh = makeCard(tags: [Tag(key: "staleness", value: "fresh")], columnId: colA.id) + let result = try CardFilterEngine.filterByTag([stale, ageing, fresh], tagFilter: "staleness=re:(stale|ageing)") + XCTAssertEqual(result.count, 2) + XCTAssertFalse(result.contains { $0.tags.first?.value == "fresh" }) + } + + /// T7.7 — Opt-in regex via `re:` prefix on the whole pattern (matches full `key=value`). + func testFilterByTagWholePatternRegex() throws { + let a = makeCard(tags: [Tag(key: "project", value: "org/repo-a")], columnId: colA.id) + let b = makeCard(tags: [Tag(key: "project", value: "org/repo-b")], columnId: colA.id) + let other = makeCard(tags: [Tag(key: "project", value: "external/x")], columnId: colA.id) + let result = try CardFilterEngine.filterByTag([a, b, other], tagFilter: "re:project=org/.+") + XCTAssertEqual(result.count, 2) + } + + /// T7.8 — Invalid regex inside `re:` prefix surfaces an error (not silent literal fallback). + func testFilterByTagInvalidRegexThrows() { + let card = makeCard(tags: [Tag(key: "staleness", value: "stale")], columnId: colA.id) + XCTAssertThrowsError(try CardFilterEngine.filterByTag([card], tagFilter: "staleness=re:[invalid")) { error in + guard case CardFilterEngine.TagFilterError.invalidRegex = error else { + XCTFail("Expected TagFilterError.invalidRegex, got \(error)") + return + } + } } // MARK: - filterByBadge diff --git a/scripts/check-mcp-docs.sh b/scripts/check-mcp-docs.sh new file mode 100755 index 00000000..7adbd131 --- /dev/null +++ b/scripts/check-mcp-docs.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# MCP docs gate (audit §8.3). +# +# Fails if a commit touches the MCP **surface** without also touching the reference docs. +# The trigger is narrow on purpose: refactors, comment edits, and internal-only logic +# changes do NOT trip the gate. False positives train reviewers to bypass it. +# +# Surface-affecting diffs we detect: +# - Tool added / removed / renamed in SchemaDefinitions.availableTools (Tool(name: ...)) +# - Tool input schema changed (any line inside an inputSchema: ... block in SchemaDefinitions) +# - Tool annotations changed (Tool.Annotations(...) literal in SchemaDefinitions) +# - Resource added / removed / renamed (Resource(...) in SchemaDefinitions) +# - Prompt added / removed / renamed (Prompt(...) in SchemaDefinitions) +# - ToolParity.swift changed +# +# Override: include the literal substring "[no-doc]" in the commit subject for the rare +# case where the surface didn't actually change (e.g. a tool moved between files). +# +# Usage: invoked from quality-gate or git pre-push hook. Compares HEAD against the +# upstream base (configurable via env var MCP_DOCS_BASE, default: origin/develop). + +set -eo pipefail + +BASE_REF="${MCP_DOCS_BASE:-origin/develop}" + +# Source files whose changes trigger the gate (narrow set — see header). +SURFACE_FILES=( + "Sources/MCPServerLib/SchemaDefinitions.swift" + "Sources/MCPServerLib/ToolParity.swift" +) + +# Doc files that, when touched, satisfy the gate. +DOC_FILES=( + "Docs/Help/reference/mcp.md" +) + +# Override: any commit subject containing this opts out. +OVERRIDE_MARKER="[no-doc]" + +# Get the changed files between BASE_REF and HEAD. +if ! changed=$(git diff --name-only "${BASE_REF}...HEAD" 2>/dev/null); then + echo "check-mcp-docs: could not diff against ${BASE_REF} — skipping gate (likely first commit on branch)" >&2 + exit 0 +fi + +if [ -z "$changed" ]; then + exit 0 # Nothing changed — nothing to check. +fi + +# Look for override marker in any commit subject on the branch. +if git log --format=%s "${BASE_REF}..HEAD" 2>/dev/null | grep -q -F "$OVERRIDE_MARKER"; then + echo "check-mcp-docs: ${OVERRIDE_MARKER} marker present in commit subject — gate bypassed" >&2 + exit 0 +fi + +# Did any surface file change? +touched_surface=0 +for f in "${SURFACE_FILES[@]}"; do + if echo "$changed" | grep -qx "$f"; then + touched_surface=1 + break + fi +done + +if [ "$touched_surface" -eq 0 ]; then + exit 0 # No surface change — gate doesn't apply. +fi + +# A surface file changed. Now we need at least one matching doc-file change. +touched_docs=0 +for f in "${DOC_FILES[@]}"; do + if echo "$changed" | grep -qx "$f"; then + touched_docs=1 + break + fi +done + +if [ "$touched_docs" -eq 1 ]; then + exit 0 +fi + +cat >&2 <