From be2a5cb1892d96cf4c4d31d2098fa3fb1cdfad88 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 21:03:53 +0100 Subject: [PATCH 01/22] =?UTF-8?q?feat(opencode):=20native=20opencode=20int?= =?UTF-8?q?egration=20=E2=80=94=20/mcpctl=20switcher,=20live=20project=20s?= =?UTF-8?q?witching,=20footer=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills sync target, so an mcpctl project can be switched from inside opencode's TUI and the active one is visible at a glance. Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into the host's config. opencode exposes an HTTP API for its own MCP registry (`POST /mcp`), so the project is mounted through the running app: - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a mode-0644 opencode.json users paste into bug reports; - switching projects takes effect on the next turn, with no restart. Inside opencode: /mcpctl filterable project picker; switches live /mcpctl-status active project, mount state, gateway URL /mcpctl-skills re-sync this project's skills plus a `mcpctl:` indicator in the prompt footer, next to the model name and one line above the token counter. Design notes: - the MCP server is registered under a constant name, so tools keep a stable `mcpctl_*` prefix and opencode's per-request tool resolution shows the new project's tools by itself — no "your old tool names are dead" message to the model, unlike the pi extension; - an unchanged mount is never re-registered: mcp.add rebuilds the connection and mcplocal binds a gated project's unlocked state to that connection's mcp-session-id, so re-adding would re-lock a project begin_session had just opened; - the server plugin does not mount during setup — setup runs before the server accepts connections and mcp.add calls back into it, which hangs opencode on a blank screen before the TUI draws; - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token minting, state and skills stay in one place; - no usable credential aborts non-zero with the state file untouched, so a failed switch leaves the previous project working rather than swapping it for a mount that 401s. `skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG aware) with the same shared-tree semantics as pi and prime-agent. The credential plumbing shared with `config prime-agent` is lifted to one place and parameterised by agent rather than copied. The plugin sources are embedded in the CLI (generated, freshness-tested) so an installed binary with no source tree can provision them, and are typechecked against the real @opencode-ai/plugin types. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 42 + completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 17 +- docs/opencode-extension.md | 186 ++++ eslint.config.js | 2 +- package.json | 8 +- pnpm-lock.yaml | 947 +++++++++++++++++- scripts/generate-opencode-extension.ts | 62 ++ src/cli/src/commands/config.ts | 405 ++++++-- src/cli/src/commands/skills.ts | 63 +- src/cli/src/config/opencode-extension.ts | 27 + src/cli/src/utils/opencode-settings.ts | 204 ++++ .../tests/commands/config-opencode.test.ts | 210 ++++ .../config/opencode-extension-embed.test.ts | 74 ++ .../config/opencode-project-order.test.ts | 67 ++ src/cli/tests/utils/opencode-settings.test.ts | 169 ++++ src/cli/vitest.config.ts | 7 + src/opencode-ext/mcpctl-opencode-tui.tsx | 298 ++++++ src/opencode-ext/mcpctl-opencode.ts | 165 +++ src/opencode-ext/tsconfig.json | 28 + 20 files changed, 2858 insertions(+), 128 deletions(-) create mode 100644 docs/opencode-extension.md create mode 100644 scripts/generate-opencode-extension.ts create mode 100644 src/cli/src/config/opencode-extension.ts create mode 100644 src/cli/src/utils/opencode-settings.ts create mode 100644 src/cli/tests/commands/config-opencode.test.ts create mode 100644 src/cli/tests/config/opencode-extension-embed.test.ts create mode 100644 src/cli/tests/config/opencode-project-order.test.ts create mode 100644 src/cli/tests/utils/opencode-settings.test.ts create mode 100644 src/opencode-ext/mcpctl-opencode-tui.tsx create mode 100644 src/opencode-ext/mcpctl-opencode.ts create mode 100644 src/opencode-ext/tsconfig.json diff --git a/README.md b/README.md index 3f7f839..60fa274 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,48 @@ Inside pi: See [docs/pi-extension.md](docs/pi-extension.md) for full details. +### Connect opencode + +[opencode](https://opencode.ai) supports MCP natively *and* exposes an API for +its own MCP registry, so mcpctl mounts the project through the running app — +which means you can **switch projects from the TUI without restarting**. + +```bash +mcpctl config opencode --project monitoring +``` + +This mints (or reuses) the project's gateway token into +`~/.mcpctl/opencode-state.json` (0600 — the token never lands in +`opencode.json`), installs a server plugin into +`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into +`~/.config/opencode/mcpctl/`, registers the latter in +`~/.config/opencode/tui.json`, and syncs skills into +`~/.config/opencode/skill/`. + +Inside opencode: + +- `/mcpctl` — **switch project** from a filterable picker; takes effect on the + next turn, no restart +- `/mcpctl-status` — active project, mount state, gateway URL +- `/mcpctl-skills` — re-sync this project's skills +- the active project shows as `mcpctl:` in the prompt footer, next to + the model name + +Skip individual steps as needed: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +``` + +Re-sync skills on their own with +`mcpctl skills sync --agent opencode --project monitoring`. + +See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8dbfce5..f9719fc 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -133,6 +133,9 @@ _mcpctl() { prime-agent-generate) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; + opencode) + COMPREPLY=($(compgen -W "-p --project --gateway-url --token --opencode-dir --skip-skills --skip-plugin --skip-marker --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index b4b8da1..92b6c96 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -267,7 +267,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' @@ -277,6 +277,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a pi -d 'Install the pi extension + sync skills (native, no MCP client, no Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a opencode -d 'Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -324,6 +325,16 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' +# config opencode options +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l token -d 'mcpctl project bearer token to use (skips auto-minting)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l opencode-dir -d 'Override opencode\'s config dir (default: ~/.config/opencode)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-plugin -d 'Do not (re)install or register the opencode plugins' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Print what would change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -525,11 +536,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, or pi' -x +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, pi, or opencode' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by session-start hooks)' diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md new file mode 100644 index 0000000..5a5430d --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,186 @@ +# mcpctl × opencode — project switching from the TUI + +## Motivation + +`mcpctl config claude` and `mcpctl config prime-agent` both work the same way: +write the project's MCP server into the host's config file, then restart the +host so it picks the change up. That is fine when you pick a project once, and +tiresome when you switch between `homeautomation`, `sre` and `docmost` all day. + +[opencode](https://opencode.ai) can do better, because it exposes two things +the other hosts do not: + +- an **HTTP API for its own MCP registry** (`POST /mcp`, + `/mcp/{name}/disconnect`) — so a mount can be re-pointed while the app runs; +- a **TUI plugin API** (`tui.json`) with slash commands, dialogs and UI slots — + so the switcher can be a first-class part of the interface rather than a + shell command you run in another terminal. + +So the opencode integration does not write an MCP entry into `opencode.json` at +all. It ships two plugins, and switching projects takes effect on the next turn +with no restart. + +## How it works + +``` +~/.mcpctl/opencode-state.json (0600) + { project, gatewayUrl, tokens: { : } } + │ + │ read by both plugins + ▼ +~/.config/opencode/plugin/mcpctl.ts server plugin ─┐ +~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤ + │ client.mcp.add({ + │ name: "mcpctl", + ▼ type: "remote", url, headers }) + opencode's MCP registry + │ + ▼ + https:///projects//mcp +``` + +### The state file, not `opencode.json` + +Two reasons the project does not live in opencode's own config: + +1. **The token.** The gateway needs `Authorization: Bearer `. + `opencode.json` is a mode-0644 file people paste into bug reports; + `~/.mcpctl/opencode-state.json` is 0600, like every other mcpctl credential. +2. **The restart.** A config file is read at startup. Re-pointing the mount + through the running server's MCP API is what makes `/mcpctl` instant. + +Tokens are kept **per project**, so switching back to a project you have +already used needs no new mint — and a failed mint for project B cannot cost +you the credential for project A. + +### The server plugin (`plugin/mcpctl.ts`) + +Auto-discovered by opencode. It mounts the active project's gateway under the +fixed MCP server name `mcpctl`, on the first server event and again before +every user turn. + +Two details matter: + +- **The name is constant.** Tools keep a stable `mcpctl_*` prefix across + switches, and because opencode re-resolves the tool list per request, the + model simply sees the new project's tools on its next turn. (Contrast pi, + where the extension has to *tell* the model its old tool names are dead.) +- **It does not re-register an unchanged mount.** `mcp.add` rebuilds the + connection, and mcplocal binds a gated project's unlocked state to that + connection's `mcp-session-id` — re-adding every turn would re-lock a project + you had just opened with `begin_session`. + +It also exists so that **headless** runs (`opencode run …`), which load no TUI +plugins at all, still get the project's tools. + +> The mount is deliberately *not* performed during plugin setup. Setup runs +> before opencode's server accepts connections, and `client.mcp.add` calls back +> into that same server — awaiting it there hangs the app on a blank screen +> before the TUI ever draws. + +### The TUI plugin (`mcpctl/mcpctl-tui.tsx`) + +Registered in `~/.config/opencode/tui.json` (opencode does not auto-discover +TUI plugins). It adds: + +| Command | What it does | +|---------|--------------| +| `/mcpctl` | Filterable project picker; switches live | +| `/mcpctl-status` | Active project, mount state, gateway URL | +| `/mcpctl-skills` | Re-sync this project's skills | + +and a `mcpctl:` indicator in the prompt footer, next to the model name +and one line above the token counter. + +Switching delegates to the CLI — +`mcpctl config opencode --project X --skip-plugin --skip-marker` — so token +minting, state and skills stay in one place and the plugin stays a UI shell. +`--skip-plugin` avoids rewriting the very file opencode has already loaded; +`--skip-marker` stops a switch from silently re-scoping whichever repository +opencode happened to be started in. + +The picker needs no pre-filter prompt (unlike the pi and prime-agent +switchers): opencode's select dialog filters as you type, so the plugin only +has to order the list — active project first, then alphabetical. + +The indicator is published through `api.kv`, which is a reactive store: writing +it re-renders the slot with no signal plumbing, and it persists across sessions +so the label is right on the first frame. + +#### Why the footer and not the status bar + +opencode exposes UI slots, not arbitrary layout. In the footer region the +options are: + +| Slot | Result | +|------|--------| +| `session_prompt_right` / `home_prompt_right` | **used** — renders on the prompt's bottom line, right of the model name, directly above the token counter | +| `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | +| `app_bottom` | costs a whole extra terminal row | + +There is no slot on the status-bar line itself. On the home screen the prompt +box is narrow, so a long project name wraps onto a second line; in a session +(where the prompt is full width) it always fits on one. + +### Skills + +opencode implements the Agent Skills standard and loads `SKILL.md` trees from +`~/.config/opencode/skill/` (XDG-aware). `mcpctl skills sync --agent opencode` +syncs there, with the same shared-tree semantics as `--agent pi` and +`--agent prime-agent`: a flat tree with per-project ownership, its own state +file (`~/.mcpctl/skills-state-opencode.json`), no SessionStart hooks, no +`postInstall`, and untracked skill directories are never clobbered. + +## Usage + +```bash +mcpctl config opencode --project monitoring +``` + +That mints (or reuses) the project token, writes the 0600 state file, installs +both plugins, registers the TUI plugin in `tui.json`, and runs an initial +skills sync. Start opencode and the project's tools are there. + +Skip individual steps: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +mcpctl config opencode --project monitoring --opencode-dir /path # non-default opencode config dir +``` + +## Failure semantics + +- **No usable credential → the switch fails.** The command exits non-zero and + leaves the state file untouched, so the previously active project keeps + working rather than being replaced by a mount that 401s. `/mcpctl` reads that + exit code and reports the switch as failed instead of claiming success over a + project with no tools. +- **Gateway unreachable → no mcpctl tools.** Never a failed startup: the server + plugin swallows mount errors. +- **Corrupt `tui.json` → refuses to write.** One syntax error must not silently + drop every other TUI plugin you installed. + +## Files + +| Path | Purpose | +|------|---------| +| `src/opencode-ext/mcpctl-opencode.ts` | Server plugin source | +| `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin source | +| `src/cli/src/config/opencode-extension.ts` | Generated embed of both (do not edit) | +| `scripts/generate-opencode-extension.ts` | Regenerates that embed | +| `src/cli/src/utils/opencode-settings.ts` | Install / register / state helpers | + +The plugin sources are **embedded** in the CLI so `mcpctl config opencode` +works from an installed binary with no source tree. After editing either +source, re-run: + +```bash +npx tsx scripts/generate-opencode-extension.ts +``` + +A test fails if you forget. They are typechecked against the real +`@opencode-ai/plugin` types via `pnpm typecheck:opencode-ext`. diff --git a/eslint.config.js b/eslint.config.js index 53f1093..b247282 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/package.json b/package.json index de493e0..5d8ab67 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build && pnpm run typecheck:pi-ext", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -34,6 +34,7 @@ "mcpd:deploy-dry": "bash deploy.sh --dry-run", "mcpd:logs": "bash logs.sh", "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", + "typecheck:opencode-ext": "tsc -p src/opencode-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { @@ -44,6 +45,10 @@ "devDependencies": { "@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-coding-agent": "0.84.1", + "@opencode-ai/plugin": "1.18.15", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -51,6 +56,7 @@ "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "rimraf": "^6.1.3", + "solid-js": "1.9.12", "tsx": "^4.21.0", "typebox": "1.3.11", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6c99d..86a79db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,18 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.1 version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@opencode-ai/plugin': + specifier: 1.18.15 + version: 1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)) + '@opentui/core': + specifier: 0.4.5 + version: 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': + specifier: 0.4.5 + version: 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': + specifier: 0.4.5 + version: 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -35,6 +47,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + solid-js: + specifier: 1.9.12 + version: 1.9.12 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -184,7 +199,7 @@ importers: version: link:../shared '@modelcontextprotocol/sdk': specifier: ^1.0.0 - version: 1.26.0(zod@3.25.76) + version: 1.26.0(zod@4.1.8) fastify: specifier: ^5.0.0 version: 5.7.4 @@ -280,10 +295,18 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -408,10 +431,18 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} @@ -420,36 +451,98 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -463,6 +556,29 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -475,6 +591,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -483,14 +611,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -1276,6 +1416,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@next/env@16.2.5': resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} @@ -1385,6 +1555,23 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@opencode-ai/plugin@1.18.15': + resolution: {integrity: sha512-AY10RtFbzLkf951dbLkSGJdBeddeS6ojbjvqCpWnINedqlj2cK/GF91xWjWnlHpqQZ4GABLPCuoSJx8mGmKvCw==} + peerDependencies: + '@opentui/core': '>=0.4.5' + '@opentui/keymap': '>=0.4.5' + '@opentui/solid': '>=0.4.5' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.18.15': + resolution: {integrity: sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -1491,6 +1678,73 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/keymap@0.4.5': + resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==} + peerDependencies: + '@opentui/react': 0.4.5 + '@opentui/solid': 0.4.5 + react: '>=19.2.0' + solid-js: 1.9.12 + peerDependenciesMeta: + '@opentui/react': + optional: true + '@opentui/solid': + optional: true + react: + optional: true + solid-js: + optional: true + + '@opentui/solid@0.4.5': + resolution: {integrity: sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==} + peerDependencies: + solid-js: 1.9.12 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2198,6 +2452,23 @@ packages: react-native-b4a: optional: true + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2313,6 +2584,11 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bun-ffi-structs@0.2.4: + resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} + peerDependencies: + typescript: ^5 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2583,6 +2859,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + docker-modem@5.0.6: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} @@ -2617,6 +2897,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} @@ -2641,6 +2924,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2795,6 +3086,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2853,10 +3148,20 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2981,6 +3286,11 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -3022,6 +3332,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + helmet@7.2.0: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} @@ -3049,6 +3363,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3125,6 +3442,10 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink@6.8.0: resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} engines: {node: '>=20'} @@ -3159,6 +3480,10 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3287,6 +3612,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3322,6 +3650,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3399,6 +3730,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3451,6 +3786,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3514,6 +3854,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -3545,6 +3889,10 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -3581,6 +3929,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3645,6 +4003,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3762,10 +4124,18 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3778,6 +4148,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3790,6 +4164,9 @@ packages: resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} engines: {node: ^20.17.0 || >=22.9.0} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3804,6 +4181,10 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3816,6 +4197,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3853,6 +4241,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -3924,6 +4316,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4017,9 +4412,17 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4072,6 +4475,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4113,6 +4519,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4188,6 +4604,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4304,6 +4723,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4387,6 +4810,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4492,6 +4919,11 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true validate-npm-package-license@3.0.4: @@ -4591,6 +5023,14 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4724,17 +5164,29 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + snapshots: '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -4981,8 +5433,34 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5011,6 +5489,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -5019,8 +5509,34 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5028,6 +5544,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5037,12 +5560,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -5054,6 +5612,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5064,6 +5644,28 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -5072,6 +5674,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5084,11 +5692,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} @@ -5819,6 +6444,29 @@ snapshots: zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color + optional: true + + '@modelcontextprotocol/sdk@1.26.0(zod@4.1.8)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.0 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.1.8 + zod-to-json-schema: 3.25.1(zod@4.1.8) + transitivePeerDependencies: + - supports-color '@monaco-editor/loader@1.7.0': dependencies: @@ -5831,6 +6479,24 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@next/env@16.2.5': {} '@next/swc-darwin-arm64@16.2.5': @@ -5975,6 +6641,21 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 + '@opencode-ai/plugin@1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@opencode-ai/sdk': 1.18.15 + effect: 4.0.0-beta.83 + zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + + '@opencode-ai/sdk@1.18.15': + dependencies: + cross-spawn: 7.0.6 + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -6094,6 +6775,76 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.5 + solid-js: 1.9.12 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.12 + transitivePeerDependencies: + - supports-color + - typescript + - web-tree-sitter + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -6426,7 +7177,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -6459,7 +7210,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.2 + minimatch: 10.2.5 '@types/aria-query@5.0.4': {} @@ -6817,6 +7568,30 @@ snapshots: b4a@1.8.0: {} + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.0): + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.12 + + babel-preset-solid@1.9.12(@babel/core@7.28.0)(solid-js@1.9.12): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.12 + balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -6942,6 +7717,10 @@ snapshots: buildcheck@0.0.7: optional: true + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bytes@3.1.2: {} c12@3.1.0: @@ -7161,6 +7940,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -7209,6 +7990,19 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + effect@4.0.0-beta.83: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 + yaml: 2.9.0 + electron-to-chromium@1.5.344: {} emoji-regex@10.6.0: {} @@ -7228,6 +8022,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + + entities@7.0.1: {} + entities@8.0.0: {} env-paths@2.2.1: {} @@ -7442,6 +8240,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -7519,12 +8321,22 @@ snapshots: transitivePeerDependencies: - supports-color + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + + find-my-way-ts@0.1.6: {} + find-my-way@9.4.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.0.0 + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7676,6 +8488,13 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -7711,6 +8530,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + helmet@7.2.0: {} highlight.js@10.7.3: {} @@ -7733,6 +8556,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7805,6 +8630,8 @@ snapshots: ini@6.0.0: {} + ini@7.0.0: {} + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 @@ -7857,13 +8684,17 @@ snapshots: ipaddr.js@2.3.0: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -7972,6 +8803,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-nice@1.1.4: {} @@ -8005,6 +8838,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8065,6 +8900,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8124,6 +8964,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -8166,6 +9008,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@8.0.7: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -8200,6 +9046,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -8230,6 +9078,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mute-stream@2.0.0: {} nan@2.25.0: @@ -8282,6 +9148,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -8415,10 +9286,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -8430,6 +9309,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pacote@21.5.1: @@ -8460,6 +9341,10 @@ snapshots: just-diff: 6.0.2 just-diff-apply: 5.5.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -8470,12 +9355,21 @@ snapshots: patch-console@2.0.0: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -8519,6 +9413,10 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -8600,6 +9498,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -8680,8 +9580,17 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@4.1.8: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: dependencies: onetime: 5.1.2 @@ -8755,6 +9664,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -8795,6 +9706,12 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -8918,6 +9835,12 @@ snapshots: ip-address: 10.0.1 smart-buffer: 4.2.0 + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -8998,7 +9921,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 string-width@8.2.0: @@ -9035,6 +9958,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} tagged-tag@1.0.0: {} @@ -9140,6 +10065,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -9227,6 +10154,8 @@ snapshots: uuid@10.0.0: {} + uuid@14.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -9299,6 +10228,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -9405,4 +10336,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.1.8): + dependencies: + zod: 4.1.8 + zod@3.25.76: {} + + zod@4.1.8: {} diff --git a/scripts/generate-opencode-extension.ts b/scripts/generate-opencode-extension.ts new file mode 100644 index 0000000..09d409d --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two + * opencode plugin sources as string constants — mirroring how the pi extension + * (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher + * (`prime-agent-extension.ts`) are embedded. + * + * Embedding matters: `mcpctl config opencode` must work from an installed + * binary that has no access to the source tree. The installed plugin files are + * this exact embedded source, so what the CLI ships is always what opencode + * runs. + * + * Regenerate after editing the plugin sources: + * npx tsx scripts/generate-opencode-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'opencode-ext'); + +const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); +const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); + +function embed(src) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from + * \`src/opencode-ext/mcpctl-opencode.ts\` and + * \`src/opencode-ext/mcpctl-opencode-tui.tsx\`. + * + * \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered \`plugin/\` dir, + * where its basename is what shows up in \`opencode plugin list\`; + * - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from + * \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)}; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out); +console.log('wrote src/cli/src/config/opencode-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 676a5e7..a57e359 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,16 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + opencodeConfigDir, + opencodeStatePath, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../utils/opencode-settings.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** @@ -44,6 +54,9 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; */ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; +/** Same, for the tokens `config opencode` mints. */ +const OPENCODE_TOKEN_PREFIX = 'opencode'; + interface McpConfig { mcpServers: Record }>; } @@ -76,6 +89,118 @@ export function createConfigCommand(deps?: Partial, apiDeps?: const config = new Command('config').description('Manage mcpctl configuration'); + // ── shared credential plumbing ───────────────────────────────────────────── + // `config prime-agent` and `config opencode` both provision an mcptoken for + // the project they mount and retire the one they replace. The rules are + // identical; only the token *name* differs, so the agent label is a + // parameter rather than a second copy of this logic. + + interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } + + /** The project's tokens, or null when the API can't be consulted. */ + async function listProjectTokens(project: string): Promise { + if (!skillsClient) return null; + try { + const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); + return Array.isArray(list) ? list as ProjectToken[] : null; + } catch { + return null; + } + } + + /** + * Is the credential we already have for this project still usable? + * + * A key being *present* proves nothing — a revoked or expired token would + * short-circuit provisioning and leave the agent silently unable to reach + * the gateway while the command reported success. mcptokens are only ever + * shown once, so we compare the stored token's 16-char `tokenPrefix` + * against the project's *active* tokens instead of sending the secret. + * + * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some + * other kind), or an unreachable API all mean "keep what's there" rather + * than minting a duplicate on every run. + */ + async function hasUsableCredential(project: string, key: string | null): Promise { + if (key === null) return false; + if (!skillsClient || !isMcpctlToken(key)) return true; + const tokens = await listProjectTokens(project); + if (tokens === null) return true; // can't check → don't churn credentials + const prefix = mcpTokenPrefixOf(key); + const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); + if (!live) { + log(`Stored credential for '${project}' is no longer active — minting a replacement`); + } + return live; + } + + /** + * Retire the token this install used to hold, now that `keepToken` has + * replaced it on disk. + * + * Scoped to that one credential on purpose. Sweeping every `` token + * for the project would revoke the one a *different* install is using — + * another machine, or this machine when the run targeted a custom output + * path. Anything else that looks orphaned is reported, not deleted: an + * unnecessary token costs nothing, a revoked one costs a broken install. + * Best-effort throughout, and only ever called once the replacement is + * safely stored. + */ + async function retireSupersededToken( + agent: string, + project: string, + staleKey: string | null, + keepToken: string, + ): Promise { + if (!skillsClient) return; + const keepPrefix = mcpTokenPrefixOf(keepToken); + const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; + if (stalePrefix === keepPrefix) return; + const tokens = await listProjectTokens(project); + if (tokens === null) return; + + const orphans: string[] = []; + for (const t of tokens) { + if (typeof t.id !== 'string' || t.status !== 'active') continue; + if (t.tokenPrefix === keepPrefix) continue; + // Only ever consider tokens minted for this purpose. + const name = t.name ?? ''; + if (name !== agent && !name.startsWith(`${agent}-`)) continue; + if (t.tokenPrefix === stalePrefix) { + try { + await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); + log(`Revoked the superseded '${name}' token for '${project}'`); + } catch { /* best-effort */ } + } else { + orphans.push(name); + } + } + if (orphans.length > 0) { + log(`Note: '${project}' still has other ${agent} token(s): ${orphans.join(', ')}. ` + + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); + } + } + + /** + * Mint a fresh mcptoken for `project`. + * + * A unique `-` name every time: `McpToken` is unique on + * (name, projectId) and revoke is a soft delete, so a fixed name could only + * ever be minted once per project. + */ + async function mintProjectToken(agent: string, project: string): Promise { + if (!skillsClient) return null; + const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { + name: `${agent}-${stamp}`, + projectName: project, + ttl: 'never', + description: `mcpctl proxy MCP credential for ${agent} (${new Date().toISOString()})`, + }); + return typeof minted?.token === 'string' && minted.token.length > 0 ? minted.token : null; + } + + config .command('view') .description('Show current configuration') @@ -299,7 +424,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // Only when we actually replaced something: `--token` with a fresh // auth.json must stay entirely offline, as documented. if (staleKey !== null) { - await retireSupersededToken(opts.project, staleKey, opts.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, opts.token); } } else if (await hasUsableCredential(opts.project, staleKey)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); @@ -309,18 +434,12 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // (name, projectId) and revoke is a soft delete, so reusing a fixed // name would collide with the revoked row forever. Retire the old // tokens only *after* the replacement is safely on disk. - const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; - const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`, - projectName: opts.project, - ttl: 'never', - description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, - }); - if (typeof minted?.token === 'string' && minted.token.length > 0) { - await writePrimeAgentAuth(opts.project, minted.token, authPath); + const minted = await mintProjectToken(PRIME_AGENT_TOKEN_PREFIX, opts.project); + if (minted !== null) { + await writePrimeAgentAuth(opts.project, minted, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; - await retireSupersededToken(opts.project, staleKey, minted.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted); } else { log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } @@ -417,86 +536,6 @@ export function createConfigCommand(deps?: Partial, apiDeps?: void cmd; } - /** - * Is the credential already in auth.json still usable? - * - * A key being *present* proves nothing — a revoked or expired token would - * short-circuit provisioning and leave prime-agent silently unable to reach - * the gateway while the command reported success. mcptokens are only ever - * shown once, so we compare the stored token's 16-char `tokenPrefix` - * against the project's *active* tokens instead of sending the secret. - * - * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some - * other kind), or an unreachable API all mean "keep what's there" rather - * than minting a duplicate on every run. - */ - async function hasUsableCredential(project: string, key: string | null): Promise { - if (key === null) return false; - if (!skillsClient || !isMcpctlToken(key)) return true; - const tokens = await listProjectTokens(project); - if (tokens === null) return true; // can't check → don't churn credentials - const prefix = mcpTokenPrefixOf(key); - const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); - if (!live) { - log(`Stored credential for '${project}' is no longer active — minting a replacement`); - } - return live; - } - - /** - * Retire the token this auth.json used to hold, now that `keepToken` has - * replaced it on disk. - * - * Scoped to that one credential on purpose. Sweeping every `prime-agent` - * token for the project would revoke the one a *different* auth.json is - * using — another machine, or this machine when the run targeted a custom - * `--output`. Anything else that looks orphaned is reported, not deleted: - * an unnecessary token costs nothing, a revoked one costs a broken install. - * Best-effort throughout, and only ever called once the replacement is - * safely stored. - */ - async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise { - if (!skillsClient) return; - const keepPrefix = mcpTokenPrefixOf(keepToken); - const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; - if (stalePrefix === keepPrefix) return; - const tokens = await listProjectTokens(project); - if (tokens === null) return; - - const orphans: string[] = []; - for (const t of tokens) { - if (typeof t.id !== 'string' || t.status !== 'active') continue; - if (t.tokenPrefix === keepPrefix) continue; - // Only ever consider tokens minted for this purpose. - const name = t.name ?? ''; - if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue; - if (t.tokenPrefix === stalePrefix) { - try { - await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); - log(`Revoked the superseded '${name}' token for '${project}'`); - } catch { /* best-effort */ } - } else { - orphans.push(name); - } - } - if (orphans.length > 0) { - log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. ` - + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); - } - } - - interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } - - /** The project's tokens, or null when the API can't be consulted. */ - async function listProjectTokens(project: string): Promise { - if (!skillsClient) return null; - try { - const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); - return Array.isArray(list) ? list as ProjectToken[] : null; - } catch { - return null; - } - } } registerClaudeCommand('claude', false); @@ -590,6 +629,190 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + // ── opencode: install the plugins + provision the gateway credential ── + // + // Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into + // the host's own config: opencode gets a server plugin that mounts the gateway + // through the running server's MCP API, so the bearer token stays in a 0600 + // state file and switching projects needs no restart. See + // `utils/opencode-settings.ts` for the reasoning. + config + .command('opencode') + .description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills') + .option('-p, --project ', 'Project name to make active') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to use (skips auto-minting)') + .option('--opencode-dir ', 'Override opencode\'s config dir (default: ~/.config/opencode)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-plugin', 'Do not (re)install or register the opencode plugins') + .option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { + project?: string; + gatewayUrl: string; + token?: string; + opencodeDir?: string; + skipSkills?: boolean; + skipPlugin?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for mcpctl config opencode'); + process.exitCode = 1; + return; + } + const project = opts.project; + const configDir = opts.opencodeDir !== undefined ? resolve(opts.opencodeDir) : opencodeConfigDir(); + const paths = withOpencodeDir(configDir); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + // Isolate the state file under a custom --opencode-dir so tests (and + // side-by-side installs) never write the real ~/.mcpctl/opencode-state.json. + const statePath = opts.opencodeDir !== undefined + ? join(configDir, 'mcpctl-state.json') + : opencodeStatePath(); + + if (opts.dryRun === true) { + log(JSON.stringify({ + opencode: { + serverPlugin: opts.skipPlugin === true ? '' : paths.serverPluginPath(), + tuiPlugin: opts.skipPlugin === true ? '' : paths.tuiPluginPath(), + tuiJson: opts.skipPlugin === true ? '' : paths.tuiJsonPath(), + statePath, + mcpUrl: `${gatewayBase}/projects/${encodeURIComponent(project)}/mcp`, + skillsDir: opts.skipSkills === true ? '' : paths.skillsDir(), + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'install plugins + register in tui.json + write 0600 state (project, gateway, bearer token) + sync skills', + }, null, 2)); + return; + } + + // 1. Provision the bearer credential the gateway needs. + // + // This runs FIRST and is fatal on failure: the state file is what the + // plugins mount from, so writing a project we have no usable token for + // would swap a working mount for a 401. Keeping the old state means the + // previously active project keeps working, and the `/mcpctl` switcher + // (which reads this command's exit code) reports the switch as failed + // instead of leaving the user staring at a project with no tools. + const priorState = await readOpencodeState(statePath); + const staleKey = storedToken(priorState, project); + let token: string | null = null; + try { + if (opts.token !== undefined && opts.token !== '') { + token = opts.token; + } else if (await hasUsableCredential(project, staleKey)) { + token = staleKey; + log(`Bearer credential for '${project}' already present in ${statePath}`); + } else { + token = await mintProjectToken(OPENCODE_TOKEN_PREFIX, project); + if (token === null) { + log(skillsClient + ? `Error: no token returned minting for '${project}'; pass --token to supply one` + : 'Error: no API client available to mint a project token — pass --token '); + } + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (token === null || token === '') { + process.exitCode = 1; + log(`Aborted: leaving ${statePath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Write the state file (0600) — the single source of truth both + // plugins read for "which project, which gateway, which token". + try { + await writeOpencodeState({ project, gatewayUrl: gatewayBase, token }, statePath); + log(`Active project '${project}' → ${statePath} (${gatewayBase}/projects/${encodeURIComponent(project)}/mcp)`); + } catch (err: unknown) { + log(`Error: could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + // Only once the replacement is safely on disk. + if (token !== staleKey) { + await retireSupersededToken(OPENCODE_TOKEN_PREFIX, project, staleKey, token); + } + + // 3. Install + register the plugins (skippable: the `/mcpctl` switcher + // re-runs this command in-process and must not rewrite the very file + // opencode has already loaded). + if (opts.skipPlugin !== true) { + try { + const written = await installOpencodePlugins(configDir); + log('Installed opencode plugins:'); + for (const w of written) log(` ${w}`); + } catch (err: unknown) { + log(`Error: could not install opencode plugins: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + try { + const { added } = await registerOpencodeTuiPlugin(paths.tuiJsonPath(), paths.tuiPluginPath()); + log(added + ? `Registered the TUI plugin in ${paths.tuiJsonPath()}` + : `TUI plugin already registered in ${paths.tuiJsonPath()}`); + } catch (err: unknown) { + // Non-fatal: without tui.json there is no /mcpctl command or footer + // indicator, but the server plugin still mounts the project's tools. + log(`Warning: could not update ${paths.tuiJsonPath()}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 4. Write the .mcpctl-project marker (same semantics as prime-agent: + // an explicit -p is authoritative, $HOME is never scoped, and the + // switcher opts out so it cannot re-scope an unrelated repo). + try { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${project}')` + : `Wrote ${markerPath}`); + } + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 5. Sync skills into opencode's skill dir (skippable). Best-effort: the + // mount is what determines whether the switch succeeded. + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runSkillsSync( + { project, target: 'opencode', installRoot: paths.skillsDir() }, + { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + log(total > 0 + ? `Skills synced to ${paths.skillsDir()} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)` + : 'Skills: no changes (already up to date)'); + } catch (err: unknown) { + log(`Warning: skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent opencode` separately)'); + } + } + + if (opts.skipPlugin !== true) { + log(''); + log('Next: restart opencode (or start a new session). Use /mcpctl to switch projects'); + log('without restarting; the active project shows in the prompt footer.'); + } + }); + config.addCommand(createConfigSetupCommand({ configDeps })); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 89f95e6..33708a9 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -33,6 +33,21 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { opencodeSkillsDir } from '../utils/opencode-settings.js'; + +/** + * Every agent whose skill tree `mcpctl skills sync --agent` can write. + * + * `claude` is the only one with Claude-specific behaviour (SessionStart hooks, + * postInstall, mcpServers auto-attach); every other target is a shared flat + * tree with per-project ownership. + */ +export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode'] as const; +export type SyncTarget = (typeof SYNC_TARGETS)[number]; + +export function isSyncTarget(value: string): value is SyncTarget { + return (SYNC_TARGETS as readonly string[]).includes(value); +} /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -103,8 +118,10 @@ export interface SyncOpts { * configuring a second project never deletes the * first project's skills, and pre-existing * (untracked) skill dirs are preserved. + * 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in + * ~/.pi/agent/skills and opencode's `skill` dir. */ - target?: 'claude' | 'prime-agent' | 'pi'; + target?: SyncTarget; } export interface SyncResult { @@ -192,32 +209,23 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise (s.scope === 'global' ? null : (projectName ?? null)); - const statePath = opts.statePath ?? (isPrimeAgent - ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') - : isPi - ? join(homeDir, '.mcpctl', 'skills-state-pi.json') - : defaultStatePath()); + const statePath = opts.statePath + ?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`)); const state = await loadState(statePath); // Which project last wrote this state file, captured before step 7 overwrites // it. Skills tracked by a CLI that predates ownership recording carry no // `project` field; this is the only evidence of who installed them. const priorSyncProject = state.lastSyncProject; - const installRoot = opts.installRoot ?? (isPrimeAgent - ? join(homeDir, '.prime', 'agent', 'skills') - : isPi - ? join(homeDir, '.pi', 'agent', 'skills') - : join(homeDir, '.claude', 'skills')); + const installRoot = opts.installRoot ?? agentInstallRoot(target, homeDir); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -566,17 +574,20 @@ export interface SkillsCommandDeps { * "claude" → ~/.claude/skills * "prime-agent" → ~/.prime/agent/skills * "pi" → ~/.pi/agent/skills - * Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree. + * "opencode" → ~/.config/opencode/skill (XDG-aware) + * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. */ -export function agentInstallRoot(agent: string | undefined): string { +export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { switch (agent) { case 'prime-agent': - return join(homedir(), '.prime', 'agent', 'skills'); + return join(homeDir, '.prime', 'agent', 'skills'); case 'pi': - return join(homedir(), '.pi', 'agent', 'skills'); + return join(homeDir, '.pi', 'agent', 'skills'); + case 'opencode': + return opencodeSkillsDir(process.env, homeDir); case 'claude': default: - return join(homedir(), '.claude', 'skills'); + return join(homeDir, '.claude', 'skills'); } } @@ -589,9 +600,9 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)') + .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') - .option('--agent ', 'Sync target: claude (default), prime-agent, or pi', 'claude') + .option('--agent ', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') @@ -609,8 +620,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { // Validate --agent so an unknown value fails loudly instead of silently // running the default (Claude) sync. const agent = opts.agent ?? 'claude'; - if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') { - warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`); + if (!isSyncTarget(agent)) { + warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`); process.exitCode = 1; return; } @@ -622,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - target: agent as 'claude' | 'prime-agent' | 'pi', + target: agent, installRoot: agentInstallRoot(agent), }, { client, log, warn }, diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts new file mode 100644 index 0000000..4f60f81 --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,27 @@ +/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-opencode-extension.ts` from + * `src/opencode-ext/mcpctl-opencode.ts` and + * `src/opencode-ext/mcpctl-opencode-tui.tsx`. + * + * `mcpctl config opencode` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered `plugin/` dir, + * where its basename is what shows up in `opencode plugin list`; + * - the TUI plugin lands beside it in `mcpctl/`, referenced by path from + * `tui.json`, and must keep a `.tsx` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n const Indicator = (): JSX.Element => (\n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/src/utils/opencode-settings.ts b/src/cli/src/utils/opencode-settings.ts new file mode 100644 index 0000000..13f2d76 --- /dev/null +++ b/src/cli/src/utils/opencode-settings.ts @@ -0,0 +1,204 @@ +/** + * Wiring helpers for the opencode integration. + * + * `mcpctl config opencode --project X`: + * 1. writes the embedded plugin sources into opencode's config dir + * (`plugin/mcpctl.ts` — auto-discovered server plugin — and + * `mcpctl/mcpctl-tui.tsx` — the `/mcpctl` switcher + footer indicator), + * 2. registers the TUI plugin in `tui.json` (opencode does not auto-discover + * TUI plugins; they have to be listed), + * 3. writes `~/.mcpctl/opencode-state.json` (0600) with the active project, + * the gateway URL and the per-project bearer tokens. + * + * WHY THE STATE FILE AND NOT `opencode.json`: + * opencode's own `mcp` block is a fine way to mount a *static* server, but + * this integration needs two things it cannot give. The gateway wants an + * `Authorization` header, and opencode.json is a mode-0644 file users paste + * into bug reports; and switching projects has to work without restarting + * opencode, which a config file cannot do. The plugins read the state file + * and mount through opencode's live MCP API instead, so `opencode.json` is + * never touched at all. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. + */ +import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_SOURCE, +} from '../config/opencode-extension.js'; + +/** + * opencode's config directory. + * + * opencode is XDG-aware, so an install with `XDG_CONFIG_HOME` set keeps its + * config somewhere other than `~/.config`. Reading the same variable is what + * stops us provisioning a directory opencode will never look at. + */ +export function opencodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const xdg = env['XDG_CONFIG_HOME']; + const base = xdg !== undefined && xdg !== '' ? xdg : join(homeDir, '.config'); + return join(base, 'opencode'); +} + +/** Resolve every path `config opencode` writes, under an explicit config dir. */ +export function withOpencodeDir(base: string): { + serverPluginPath: () => string; + tuiPluginDir: () => string; + tuiPluginPath: () => string; + tuiJsonPath: () => string; + skillsDir: () => string; +} { + return { + serverPluginPath: () => join(base, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME), + tuiPluginDir: () => join(base, 'mcpctl'), + tuiPluginPath: () => join(base, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME), + tuiJsonPath: () => join(base, 'tui.json'), + skillsDir: () => join(base, 'skill'), + }; +} + +/** Where `mcpctl skills sync --agent opencode` installs skill bundles. */ +export function opencodeSkillsDir(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return withOpencodeDir(opencodeConfigDir(env, homeDir)).skillsDir(); +} + +/** Path of the state file both plugins read. */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'opencode-state.json'); +} + +/** + * Write the embedded plugin sources into opencode's config dir. + * + * This is the production path — it needs no source tree, so it works from an + * installed binary. Returns the files written. + */ +export async function installOpencodePlugins(base: string): Promise { + const paths = withOpencodeDir(base); + const written: string[] = []; + for (const [path, source] of [ + [paths.serverPluginPath(), OPENCODE_SERVER_PLUGIN_SOURCE], + [paths.tuiPluginPath(), OPENCODE_TUI_PLUGIN_SOURCE], + ] as const) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf-8'); + written.push(path); + } + return written; +} + +interface TuiConfig { + $schema?: string; + plugin?: unknown[]; + [k: string]: unknown; +} + +/** + * Load tui.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it. One syntax + * error must not silently drop every other TUI plugin the user installed. + */ +async function readTuiConfig(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; + try { + const parsed = JSON.parse(raw) as TuiConfig; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error( + `${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Register the TUI plugin in `tui.json`. + * + * Idempotent, and a merge rather than a rewrite: any other TUI plugin the user + * has installed keeps working. Returns whether anything changed, so a no-op run + * doesn't reformat a hand-maintained file. + */ +export async function registerOpencodeTuiPlugin(tuiJsonPath: string, pluginPath: string): Promise<{ added: boolean }> { + const config = await readTuiConfig(tuiJsonPath); + const plugins = Array.isArray(config.plugin) ? [...config.plugin] : []; + if (plugins.some((p) => p === pluginPath)) return { added: false }; + + // Drop any stale entry pointing at an older install location of *our* plugin + // (e.g. a rename between releases): leaving it behind makes opencode fail to + // load a file that no longer exists on every start. + const kept = plugins.filter((p) => !(typeof p === 'string' && p.endsWith(`/${OPENCODE_TUI_PLUGIN_FILENAME}`))); + kept.push(pluginPath); + + config.$schema = typeof config.$schema === 'string' ? config.$schema : 'https://opencode.ai/tui.json'; + config.plugin = kept; + await writeJsonAtomic(tuiJsonPath, config); + return { added: true }; +} + +export interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +/** Read the state file; missing or corrupt yields an empty state. */ +export async function readOpencodeState(path: string = opencodeStatePath()): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Set the active project (and optionally its bearer token) in the state file. + * + * Tokens for other projects are preserved so switching back to a project + * already provisioned on this machine needs no new mint — and so a mint failure + * for project B cannot cost you the credential for project A. + * + * Written 0600: this file holds bearer tokens. `writeFile`'s mode only applies + * when the file is created, so we chmod after writing too. + */ +export async function writeOpencodeState( + update: { project: string; gatewayUrl: string; token?: string }, + path: string = opencodeStatePath(), +): Promise { + const current = await readOpencodeState(path); + const tokens = { ...(current.tokens ?? {}) }; + if (update.token !== undefined && update.token !== '') tokens[update.project] = update.token; + const next: OpencodeState = { project: update.project, gatewayUrl: update.gatewayUrl, tokens }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(path, 0o600); + } catch { + /* best-effort: a credential written is better than one refused */ + } + return path; +} + +/** The token currently stored for `project`, if any. */ +export function storedToken(state: OpencodeState, project: string): string | null { + const token = state.tokens?.[project]; + return typeof token === 'string' && token !== '' ? token : null; +} diff --git a/src/cli/tests/commands/config-opencode.test.ts b/src/cli/tests/commands/config-opencode.test.ts new file mode 100644 index 0000000..42d2685 --- /dev/null +++ b/src/cli/tests/commands/config-opencode.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +interface ClientCalls { posts: Array<{ path: string; body?: unknown }> } + +/** + * @param tokens what `GET /api/v1/mcptokens` reports as existing for a project + */ +function mockClient(calls: ClientCalls, tokens: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.startsWith('/api/v1/mcptokens')) return tokens; + if (path.endsWith('/skills/visible')) return []; + return {}; + }), + post: vi.fn(async (path: string, body?: unknown) => { + calls.posts.push({ path, body }); + if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' }; + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + let output: string[]; + let tmpDir: string; + let ocDir: string; + let calls: ClientCalls; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const statePath = (): string => join(ocDir, 'mcpctl-state.json'); + + /** Pre-existing state, as a machine that has already run this command has. */ + function seedState(value: unknown): void { + mkdirSync(ocDir, { recursive: true }); + writeFileSync(statePath(), JSON.stringify(value)); + } + + function command(client: ApiClient) { + return createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + } + + beforeEach(() => { + output = []; + calls = { posts: [] }; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-')); + ocDir = join(tmpDir, 'opencode'); + }); + afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + + it('requires a project', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(existsSync(statePath())).toBe(false); + }); + + it('installs both plugins, registers the TUI one and writes the state file', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'docmost', '--opencode-dir', ocDir, + '--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true); + expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true); + + const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8')); + expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]); + + const state = JSON.parse(readFileSync(statePath(), 'utf-8')); + expect(state).toEqual({ + project: 'docmost', + gatewayUrl: 'https://gw.example', + tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' }, + }); + }); + + it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/', + '--token', 't', '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example'); + }); + + it('--token never mints', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0); + }); + + it('mints a uniquely-named opencode token when none is stored', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens'); + expect(mint).toBeDefined(); + // A fixed name could only ever be minted once per project: McpToken is + // unique on (name, projectId) and revoke is a soft delete. + expect((mint?.body as { name: string }).name).toMatch(/^opencode-/); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000'); + }); + + it('reuses a stored token that the server still reports as active', async () => { + // 16-char prefix is what the server exposes; the secret is never re-sent. + const stored = 'mcpctl_pat_STORED000000000'; + seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } }); + const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'active', tokenPrefix: stored.slice(0, 16) }]); + await command(client).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0); + expect(output.join('\n')).toContain('already present'); + }); + + it('re-mints when the stored token has been revoked server-side', async () => { + const stored = 'mcpctl_pat_REVOKED00000000'; + seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } }); + const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'revoked', tokenPrefix: stored.slice(0, 16) }]); + await command(client).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(1); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000'); + }); + + it('leaves the previous project mounted when no credential can be provisioned', async () => { + // A switch with no usable credential is a FAILURE: exiting 0 here would + // have the /mcpctl switcher report success over a project with no tools. + seedState({ project: 'old', gatewayUrl: 'https://gw', tokens: { old: 'tok-old' } }); + const client = { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), // mint returns no token + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + const prevExit = process.exitCode; + await command(client).parseAsync( + ['opencode', '--project', 'new', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('old'); + expect(output.join('\n')).toContain('Aborted'); + }); + + it('--skip-plugin updates state without touching the loaded plugin files', async () => { + // This is the path the /mcpctl switcher takes: rewriting the very file + // opencode has already loaded buys nothing. + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-plugin', '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false); + expect(existsSync(join(ocDir, 'tui.json'))).toBe(false); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('p'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--dry-run'], { from: 'user' }); + const plan = JSON.parse(output.join('\n')); + expect(plan.opencode.mcpUrl).toContain('/projects/p/mcp'); + expect(plan.opencode.statePath).toBe(statePath()); + expect(existsSync(ocDir)).toBe(false); + expect(calls.posts).toHaveLength(0); + }); + + it('--skip-marker leaves the working directory unscoped', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(workDir, '.mcpctl-project'))).toBe(false); + } finally { + process.chdir(cwd); + } + }); + + it('writes a .mcpctl-project marker by default', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work2'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', '--skip-skills'], + { from: 'user' }); + expect(readFileSync(join(workDir, '.mcpctl-project'), 'utf-8')).toContain('p'); + } finally { + process.chdir(cwd); + } + }); +}); diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts new file mode 100644 index 0000000..422ac8c --- /dev/null +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_SOURCE, + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +/** + * `mcpctl config opencode` installs the *embedded* copy of the plugins, not the + * files in src/opencode-ext/. Editing the sources without re-running the + * generator therefore ships stale code to users while the repo looks correct — + * and the embedded copy is the one thing no typecheck covers. Same guarantee + * the completions check gives. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'opencode-ext'); + +describe('embedded opencode plugins', () => { + it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { + expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8')); + expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8')); + }); + + it('install under names opencode can actually load', () => { + // The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is + // referenced by path from tui.json and must stay .tsx for its JSX to be + // transpiled. + expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); + expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); + }); + + it('are self-contained — the installed files have no mcpctl imports to resolve', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).not.toMatch(/from '@mcpctl\//); + expect(src).not.toMatch(/from '\.\.\//); + } + }); + + it('keep the JSX pragma the TUI plugin needs to render its indicator', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true); + }); + + it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("const SERVER_NAME = 'mcpctl'"); + } + }); + + it('agree on the state file both read', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')"); + } + }); + + it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/); + expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/); + }); + + it('sync skills into opencode’s own tree, never Claude’s', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); + }); + + it('switch without rewriting the plugin file opencode has already loaded', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); + }); +}); diff --git a/src/cli/tests/config/opencode-project-order.test.ts b/src/cli/tests/config/opencode-project-order.test.ts new file mode 100644 index 0000000..87aec71 --- /dev/null +++ b/src/cli/tests/config/opencode-project-order.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { orderProjects, indicatorLabel } from '../../../opencode-ext/mcpctl-opencode-tui.js'; + +/** + * opencode's select dialog filters as you type, so the picker only has to get + * the *order* right — the active project first, because "the one I am on" is + * the most likely pick and real installs run to hundreds of projects (smoke-test + * leftovers included). + */ +const PROJECTS = [ + { name: 'smoke-proj-none-mohimh46' }, + { name: 'homeautomation', description: 'house' }, + { name: 'docmost' }, + { name: 'copy-homeautomation' }, + { name: 'labctl' }, + { name: 'sre' }, +]; + +describe('orderProjects', () => { + it('puts the active project first, then sorts alphabetically', () => { + expect(orderProjects(PROJECTS, 'labctl').map((p) => p.name)).toEqual([ + 'labctl', + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('sorts alphabetically when nothing is active', () => { + expect(orderProjects(PROJECTS, null).map((p) => p.name)).toEqual([ + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'labctl', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('never drops or duplicates a project', () => { + expect(orderProjects(PROJECTS, 'sre')).toHaveLength(PROJECTS.length); + expect(orderProjects(PROJECTS, 'not-a-project')).toHaveLength(PROJECTS.length); + }); + + it('does not mutate the caller’s list', () => { + const input = [...PROJECTS]; + orderProjects(input, 'sre'); + expect(input.map((p) => p.name)).toEqual(PROJECTS.map((p) => p.name)); + }); + + it('keeps descriptions, which the dialog shows under each row', () => { + expect(orderProjects(PROJECTS, null).find((p) => p.name === 'homeautomation')?.description).toBe('house'); + }); +}); + +describe('indicatorLabel', () => { + it('names the active project', () => { + expect(indicatorLabel('docmost')).toBe('mcpctl:docmost'); + }); + + it('says so when there is none, rather than rendering a bare prefix', () => { + expect(indicatorLabel(null)).toBe('mcpctl:none'); + expect(indicatorLabel('')).toBe('mcpctl:none'); + }); +}); diff --git a/src/cli/tests/utils/opencode-settings.test.ts b/src/cli/tests/utils/opencode-settings.test.ts new file mode 100644 index 0000000..1dc6893 --- /dev/null +++ b/src/cli/tests/utils/opencode-settings.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + opencodeConfigDir, + opencodeSkillsDir, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../../src/utils/opencode-settings.js'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +describe('opencodeConfigDir', () => { + it('defaults to ~/.config/opencode', () => { + expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('honours XDG_CONFIG_HOME — provisioning ~/.config would be invisible to opencode', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode'); + }); + + it('ignores an empty XDG_CONFIG_HOME rather than resolving against ""', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '' }, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('puts skills where opencode looks for them', () => { + expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill'); + }); +}); + +describe('installOpencodePlugins', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => { + const written = await installOpencodePlugins(dir); + const paths = withOpencodeDir(dir); + expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]); + expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true); + expect(existsSync(join(dir, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME))).toBe(true); + }); + + it('keeps the .tsx extension — opencode transpiles the TUI plugin by extension', async () => { + await installOpencodePlugins(dir); + expect(withOpencodeDir(dir).tuiPluginPath().endsWith('.tsx')).toBe(true); + }); + + it('is idempotent (re-running overwrites in place)', async () => { + await installOpencodePlugins(dir); + const first = readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8'); + await installOpencodePlugins(dir); + expect(readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8')).toBe(first); + }); +}); + +describe('registerOpencodeTuiPlugin', () => { + let dir: string; + let tuiJson: string; + const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx'; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); + tuiJson = join(dir, 'tui.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates tui.json with the plugin and a $schema', async () => { + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual([pluginPath]); + expect(parsed.$schema).toBe('https://opencode.ai/tui.json'); + }); + + it('is idempotent and leaves the file untouched on a no-op run', async () => { + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + const before = readFileSync(tuiJson, 'utf-8'); + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: false }); + expect(readFileSync(tuiJson, 'utf-8')).toBe(before); + }); + + it('preserves other TUI plugins and unrelated keys', async () => { + writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual(['opencode-tui-utils', pluginPath]); + expect(parsed.theme).toBe('nord'); + }); + + it('drops a stale entry for an older install location of our own plugin', async () => { + // Left behind, opencode fails to load a file that no longer exists on + // every start. + writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/place/mcpctl-tui.tsx', 'other-plugin'] })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + expect(JSON.parse(readFileSync(tuiJson, 'utf-8')).plugin).toEqual(['other-plugin', pluginPath]); + }); + + it('refuses to overwrite a corrupt tui.json instead of dropping the user’s plugins', async () => { + writeFileSync(tuiJson, '{ this is not json'); + await expect(registerOpencodeTuiPlugin(tuiJson, pluginPath)).rejects.toThrow(/not valid JSON/); + expect(readFileSync(tuiJson, 'utf-8')).toBe('{ this is not json'); + }); + + it('treats an empty file as a fresh start', async () => { + writeFileSync(tuiJson, ' \n'); + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + }); +}); + +describe('opencode state file', () => { + let dir: string; + let statePath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); + statePath = join(dir, 'nested', 'opencode-state.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates the directory and writes project + gateway + token', async () => { + await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'mcpctl_pat_a' }, statePath); + expect(await readOpencodeState(statePath)).toEqual({ + project: 'docmost', + gatewayUrl: 'https://gw', + tokens: { docmost: 'mcpctl_pat_a' }, + }); + }); + + it('is written 0600 — it holds bearer tokens', async () => { + await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + }); + + it('keeps other projects’ tokens, so switching back needs no new mint', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.project).toBe('b'); + expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' }); + }); + + it('switching without a new token leaves the stored one alone', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.gatewayUrl).toBe('https://gw2'); + expect(storedToken(state, 'a')).toBe('tok-a'); + }); + + it('reads a missing or corrupt state as empty rather than throwing', async () => { + expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({}); + mkdirSync(join(dir, 'x'), { recursive: true }); + writeFileSync(join(dir, 'x', 's.json'), 'not json'); + expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({}); + }); + + it('storedToken ignores an empty or missing entry', () => { + expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull(); + expect(storedToken({}, 'a')).toBeNull(); + expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x'); + }); +}); diff --git a/src/cli/vitest.config.ts b/src/cli/vitest.config.ts index 1cbc8c8..beac373 100644 --- a/src/cli/vitest.config.ts +++ b/src/cli/vitest.config.ts @@ -1,6 +1,13 @@ import { defineProject } from 'vitest/config'; export default defineProject({ + // The opencode TUI plugin is a .tsx that ships as source; its pure helpers are + // unit-tested by importing that file directly, so the test run needs the same + // JSX runtime opencode transpiles it with. + esbuild: { + jsx: 'automatic', + jsxImportSource: '@opentui/solid', + }, test: { name: 'cli', include: ['tests/**/*.test.ts'], diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx new file mode 100644 index 0000000..bd067f7 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -0,0 +1,298 @@ +/** @jsxImportSource @opentui/solid */ +/** + * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in + * `~/.config/opencode/tui.json`. + * + * What it adds to opencode: + * - `/mcpctl` — pick the active project from a filterable dialog + * - `/mcpctl-status` — what is mounted, from where, as which user + * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill + * - a `mcpctl:` indicator in the prompt footer, next to the model + * name and the token counter + * + * The switch itself is delegated to the `mcpctl` CLI (the same binary that + * installed this file), so token minting, state and skills stay in one place + * and this stays a UI shell. Once the CLI has rewritten the state file, the + * mount is re-pointed live through opencode's own MCP API — no restart, unlike + * every config-file-based integration. + * + * Only Node builtins + opencode's plugin API are imported. + */ +import type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'; +import type { JSX } from '@opentui/solid'; +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name the mount lives under — must match the server plugin. */ +const SERVER_NAME = 'mcpctl'; + +/** + * kv key holding the label the footer renders. + * + * kv is a reactive store, so writing it here re-renders the slot with no + * signal plumbing of our own; it also survives across sessions, so the label is + * correct on the very first frame instead of after the state file is read. + */ +const KV_LABEL = 'mcpctl.project'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +interface ProjectInfo { + name: string; + description?: string; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +/** + * Run the `mcpctl` CLI and resolve its stdout. + * + * execFile, not a shell: project names come from the server and would otherwise + * need quoting, and a shell buys nothing here. + */ +function mcpctl(args: string[], timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || err.message).trim() || String(err))); + else resolve(stdout); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000); + const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>; + return parsed + .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string') + .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined })); +} + +/** + * Active project first, then alphabetical. + * + * opencode's select dialog does its own fuzzy filtering as you type, so unlike + * the pi and prime-agent switchers this needs no pre-filter prompt — only a + * sensible starting order, since the most likely pick is "the one I am on". + * + * Exported so the ordering is unit-tested rather than eyeballed through a TUI. + */ +export function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] { + return [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); +} + +/** The footer label for a project (or the absence of one). */ +export function indicatorLabel(project: string | null): string { + return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none'; +} + +const tui = async (api: TuiPluginApi): Promise => { + /** Re-read the state file and publish the footer label. */ + async function refreshIndicator(): Promise { + const state = await readState(); + const project = state.project ?? null; + api.kv.set(KV_LABEL, indicatorLabel(project)); + return project !== null && project !== '' ? project : null; + } + + /** + * Point the live MCP mount at `project`. + * + * Registering under the same name every time keeps the tool prefix stable + * (`mcpctl_*`), and because opencode re-resolves tools per request the model + * simply sees the new project's tools on its next turn. + */ + async function mount(project: string, state: OpencodeState): Promise { + const gatewayUrl = state.gatewayUrl; + if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `'); + const token = state.tokens?.[project] ?? ''; + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await api.client.mcp.add({ + name: SERVER_NAME, + config: { + type: 'remote', + url: projectUrl(gatewayUrl, project), + headers, + enabled: true, + timeout: 120_000, + }, + }); + } + + async function switchTo(project: string): Promise { + api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' }); + try { + // The CLI mints/reuses the project token, rewrites the state file and + // syncs skills. --skip-plugin leaves this very file alone (rewriting a + // loaded plugin mid-session buys nothing); --skip-marker stops us + // silently re-scoping whatever repo opencode was started in, which + // Claude Code's own skills sync would then pick up. + await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']); + } catch (err) { + api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' }); + return; + } + try { + await mount(project, await readState()); + } catch (err) { + // The state file is already updated, so a restart would recover — say so + // rather than reporting a success the tools do not back up. + api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' }); + await refreshIndicator(); + return; + } + await refreshIndicator(); + api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' }); + } + + api.keymap.registerLayer({ + commands: [ + { + name: 'mcpctl.switch', + title: 'mcpctl: switch project', + description: 'Mount another mcpctl project’s MCP servers and skills', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl', + async run(): Promise { + const active = await refreshIndicator(); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' }); + return; + } + if (projects.length === 0) { + api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' }); + return; + } + const DialogSelect = api.ui.DialogSelect; + api.ui.dialog.replace(() => ( + ({ + title: p.name, + value: p.name, + description: p.description, + }))} + onSelect={(option): void => { + api.ui.dialog.clear(); + const picked = option.value; + if (typeof picked !== 'string') return; + if (picked === active) { + api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' }); + return; + } + void switchTo(picked); + }} + /> + )); + }, + }, + { + name: 'mcpctl.status', + title: 'mcpctl: status', + description: 'Show the active mcpctl project and its MCP mount', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-status', + async run(): Promise { + const state = await readState(); + const project = await refreshIndicator(); + let mcpStatus = 'unknown'; + try { + const res = await api.client.mcp.status(); + mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted'; + } catch { + mcpStatus = 'unavailable'; + } + const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a'; + api.ui.toast({ + message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`, + variant: mcpStatus === 'connected' ? 'success' : 'warning', + duration: 12_000, + }); + }, + }, + { + name: 'mcpctl.skills', + title: 'mcpctl: sync skills', + description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-skills', + async run(): Promise { + const project = await refreshIndicator(); + if (project === null) { + api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' }); + return; + } + try { + await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']); + api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' }); + } catch (err) { + api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' }); + } + }, + }, + ], + }); + + // The indicator. `session_prompt_right` and `home_prompt_right` are the only + // slots in the footer cluster opencode exposes to plugins: they render on the + // prompt's bottom line, immediately right of the model name and directly + // above the token counter. (`home_footer` would sit on the counter's line but + // *replaces* the cwd/version footer rather than adding to it, and + // `app_bottom` costs a whole extra terminal row.) + const Indicator = (): JSX.Element => ( + {api.kv.get(KV_LABEL, indicatorLabel(null))} + ); + api.slots.register({ + order: 100, + slots: { + session_prompt_right: () => , + home_prompt_right: () => , + }, + }); + + await refreshIndicator(); +}; + +function errText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export default { + id: 'mcpctl', + tui, +} satisfies TuiPluginModule & { id: string }; diff --git a/src/opencode-ext/mcpctl-opencode.ts b/src/opencode-ext/mcpctl-opencode.ts new file mode 100644 index 0000000..7fa6558 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -0,0 +1,165 @@ +/** + * mcpctl opencode server plugin — mounts the active project's MCP gateway. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it. + * + * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json: + * 1. The gateway needs an `Authorization: Bearer ` header. Putting + * it in opencode.json means a secret in a mode-0644 config file that users + * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest + * of mcpctl's credentials. + * 2. Switching projects has to work *without restarting opencode*. The server + * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can + * be re-pointed live — a config file can't do that. + * + * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that + * headless runs (`opencode run ...`), which load no TUI plugins at all, still + * get the active project's tools. + * + * Only Node builtins + the plugin API are imported, so the installed file needs + * no dependencies of its own. + */ +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name we mount under. Constant on purpose — see `mount`. */ +const SERVER_NAME = 'mcpctl'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +/** Proxy MCP URL for a project on the gateway. */ +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +const server: Plugin = async ({ client }) => { + /** + * The (url, token) this process last registered. + * + * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal + * binds a gated project's unlocked state to the `mcp-session-id` of that + * connection. Re-adding an unchanged config every turn would therefore drop + * the gate open by `begin_session` and re-lock the project mid-conversation. + * So we only call `add` when the target actually changed — or when the mount + * is not connected, where reconnecting is the whole point. + */ + let mounted: string | null = null; + + /** + * Mount (or re-point) the active project. + * + * The MCP server is always registered under the same name, so tools keep the + * stable `mcpctl_*` prefix across switches and the model never sees a tool + * namespace vanish mid-conversation. opencode resolves the tool list per + * request, so a re-point is picked up on the next turn with no restart and no + * "the tools you were told about are gone" announcement to the model. + */ + async function mount(): Promise { + const state = await readState(); + const project = state.project; + const gatewayUrl = state.gatewayUrl; + if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return; + const token = state.tokens?.[project] ?? ''; + const url = projectUrl(gatewayUrl, project); + const target = `${url}\u0000${token}`; + + if (mounted === target && (await isConnected())) return; + + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await client.mcp.add({ + body: { + name: SERVER_NAME, + config: { + type: 'remote', + url, + headers, + enabled: true, + timeout: 120_000, + }, + }, + }); + mounted = target; + } + + /** Is our mount currently up? Unknown/unreachable counts as "not connected". */ + async function isConnected(): Promise { + try { + const res = await client.mcp.status(); + return res.data?.[SERVER_NAME]?.status === 'connected'; + } catch { + return false; + } + } + + /** + * `mount`, serialised and never throwing. + * + * Serialised because the two callers below can overlap — the event stream is + * chatty and a message can land while a mount is still connecting — and two + * concurrent `mcp.add` calls would race to register the same name. + * + * Never throwing because an unreachable gateway must degrade to "no mcpctl + * tools", not to "opencode fails to start". + */ + let inflight: Promise | null = null; + function ensureMounted(): Promise { + inflight ??= mount() + .catch(() => { /* best-effort */ }) + .finally(() => { inflight = null; }); + return inflight; + } + + // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is + // accepting connections, and `client.mcp.add` calls back into that same + // server — awaiting it at this point hangs opencode on a blank screen before + // the TUI ever draws. Both hooks below fire only once the server is live. + return { + /** + * First contact: mount as soon as the server is up, so a session that never + * sends a message still shows the project's tools (and the sidebar shows + * the mount as connected). + */ + event: async (): Promise => { + await ensureMounted(); + }, + + /** + * Re-assert the mount before every user turn. + * + * `mcpctl config opencode --project X` (run from a shell, or by the TUI + * switcher in a *different* opencode window) rewrites the state file + * underneath us. Re-reading here is what makes an external switch take + * effect on the next message instead of on the next restart. When nothing + * changed this is a state-file read and a status call — `mount` will not + * re-register a mount that is already pointing at the right place. + */ + 'chat.message': async (): Promise => { + await ensureMounted(); + }, + }; +}; + +export default { + id: 'mcpctl', + server, +} satisfies PluginModule & { id: string }; diff --git a/src/opencode-ext/tsconfig.json b/src/opencode-ext/tsconfig.json new file mode 100644 index 0000000..542bb09 --- /dev/null +++ b/src/opencode-ext/tsconfig.json @@ -0,0 +1,28 @@ +{ + "//": [ + "The opencode plugins are shipped as source (embedded in the CLI, then", + "written into ~/.config/opencode/) and are therefore never compiled by the", + "CLI's own build. Without this project they would be typechecked by nothing.", + "", + "They are checked against the REAL @opencode-ai/plugin types (a dev", + "dependency, pinned to the opencode release they target) rather than a", + "hand-written shim, because a shim drifting from the published API is the", + "exact failure mode this guards against — see src/pi-ext/tsconfig.json." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-opencode.ts", "mcpctl-opencode-tui.tsx"] +} From 99f881dd6708821e6aa6977abc973bc54a4201ec Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:02:52 +0100 Subject: [PATCH 02/22] feat(opencode): leader keybind, explicit unmount on switch, non-wrapping indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements taken from reading the sibling opencode branches (feat/opencode-extension-abhishek in particular): - `m` opens the project picker. Switching is the repeated action and typing `/mcpctl` every time is friction; the other two commands stay palette-only. - A switch disconnects before re-adding. `mcp.add` under the same name does re-point the tools on its own, but leaves it to opencode whether the previous client is closed — and an abandoned one keeps its `mcp-session-id` alive on mcplocal, which is exactly what holds a gated project open. Best-effort, so a first mount still works. - The footer label renders `wrapMode="none" truncate`. The home prompt row is narrow enough that the default wrap broke `mcpctl:homeautomation` across two lines mid-word; clipping the tail of a long name reads far better. Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is now one line, and a disconnect-then-add switch still lands — the model called `mcpctl_begin_session` and listed the new project's tools. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 4 ++-- docs/opencode-extension.md | 19 ++++++++++++---- src/cli/src/config/opencode-extension.ts | 2 +- .../config/opencode-extension-embed.test.ts | 16 ++++++++++++++ src/opencode-ext/mcpctl-opencode-tui.tsx | 22 ++++++++++++++++++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 60fa274..43fcd22 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,8 @@ This mints (or reuses) the project's gateway token into Inside opencode: -- `/mcpctl` — **switch project** from a filterable picker; takes effect on the - next turn, no restart +- `/mcpctl` (or `m`) — **switch project** from a filterable picker; + takes effect on the next turn, no restart - `/mcpctl-status` — active project, mount state, gateway URL - `/mcpctl-skills` — re-sync this project's skills - the active project shows as `mcpctl:` in the prompt footer, next to diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md index 5a5430d..0690a8b 100644 --- a/docs/opencode-extension.md +++ b/docs/opencode-extension.md @@ -85,10 +85,13 @@ TUI plugins). It adds: | Command | What it does | |---------|--------------| -| `/mcpctl` | Filterable project picker; switches live | +| `/mcpctl` (or `m`) | Filterable project picker; switches live | | `/mcpctl-status` | Active project, mount state, gateway URL | | `/mcpctl-skills` | Re-sync this project's skills | +Switching is the thing you do repeatedly, so it gets a chord as well as a slash +command; the other two stay palette-only. + and a `mcpctl:` indicator in the prompt footer, next to the model name and one line above the token counter. @@ -103,6 +106,12 @@ The picker needs no pre-filter prompt (unlike the pi and prime-agent switchers): opencode's select dialog filters as you type, so the plugin only has to order the list — active project first, then alphabetical. +A switch **disconnects before re-adding**. `mcp.add` under the same name does +re-point the tools on its own, but leaves it to opencode whether the previous +client is closed, and an abandoned one keeps its `mcp-session-id` alive on +mcplocal — the very thing that holds a gated project open. Best-effort: on a +first mount there is nothing to disconnect. + The indicator is published through `api.kv`, which is a reactive store: writing it re-renders the slot with no signal plumbing, and it persists across sessions so the label is right on the first frame. @@ -118,9 +127,11 @@ options are: | `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | | `app_bottom` | costs a whole extra terminal row | -There is no slot on the status-bar line itself. On the home screen the prompt -box is narrow, so a long project name wraps onto a second line; in a session -(where the prompt is full width) it always fits on one. +There is no slot on the status-bar line itself. The home prompt box is narrow +enough that the default wrap breaks `mcpctl:homeautomation` across two lines +mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of +a long name reads better than a two-line footer. In a session the prompt is full +width and it always fits. ### Skills diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts index 4f60f81..fbc2f5d 100644 --- a/src/cli/src/config/opencode-extension.ts +++ b/src/cli/src/config/opencode-extension.ts @@ -24,4 +24,4 @@ export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; -export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n const Indicator = (): JSX.Element => (\n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts index 422ac8c..b772315 100644 --- a/src/cli/tests/config/opencode-extension-embed.test.ts +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -67,6 +67,22 @@ describe('embedded opencode plugins', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); }); + it('bind the switcher to a chord as well as a slash command', () => { + // Switching is the repeated action; typing /mcpctl every time is friction. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: 'm'"); + }); + + it('tear the outgoing mount down before re-pointing it', () => { + // An abandoned client keeps its mcp-session-id — and a gated project's + // unlocked state — alive on mcplocal. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })'); + }); + + it('clip rather than wrap the footer label on the narrow home prompt', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"'); + }); + it('switch without rewriting the plugin file opencode has already loaded', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx index bd067f7..40d38e3 100644 --- a/src/opencode-ext/mcpctl-opencode-tui.tsx +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -134,6 +134,16 @@ const tui = async (api: TuiPluginApi): Promise => { const token = state.tokens?.[project] ?? ''; const headers: Record = {}; if (token !== '') headers['Authorization'] = `Bearer ${token}`; + // Tear the outgoing mount down explicitly. `mcp.add` under the same name + // does re-point the tools, but leaves it to opencode whether the previous + // client is closed — and an abandoned one keeps its `mcp-session-id` alive + // on mcplocal, which is what holds a gated project open. Best-effort: on a + // first mount there is nothing to disconnect. + try { + await api.client.mcp.disconnect({ name: SERVER_NAME }); + } catch { + /* not mounted yet */ + } await api.client.mcp.add({ name: SERVER_NAME, config: { @@ -266,6 +276,11 @@ const tui = async (api: TuiPluginApi): Promise => { }, }, ], + // Switching is the thing you do repeatedly, so it gets a chord as well as + // `/mcpctl`. The other two commands stay palette-only — they are occasional. + bindings: [ + { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' }, + ], }); // The indicator. `session_prompt_right` and `home_prompt_right` are the only @@ -274,8 +289,13 @@ const tui = async (api: TuiPluginApi): Promise => { // above the token counter. (`home_footer` would sit on the counter's line but // *replaces* the cwd/version footer rather than adding to it, and // `app_bottom` costs a whole extra terminal row.) + // wrapMode="none" + truncate: the home prompt row is narrow, and the default + // wrap breaks "mcpctl:homeautomation" across two lines mid-word. Clipping the + // tail of a long name reads far better than a two-line footer. const Indicator = (): JSX.Element => ( - {api.kv.get(KV_LABEL, indicatorLabel(null))} + + {api.kv.get(KV_LABEL, indicatorLabel(null))} + ); api.slots.register({ order: 100, From c5ea39e959897f79ddbb6168e8d17d7a471e6253 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:39:36 +0100 Subject: [PATCH 03/22] =?UTF-8?q?feat(claude):=20claude-vllm=20=E2=80=94?= =?UTF-8?q?=20run=20Claude=20Code=20against=20the=20homelab=20LLM=20gatewa?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already serves the Anthropic Messages API on /v1/messages — verified with a real completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is the whole integration. `claude-vllm` exists only to stop you pasting four exports each time. It reuses what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then opencode's config, first hit wins — taking the base URL and the credential from the same source so one gateway's URL is never paired with another's key. Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss: - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the background and summarisation calls ask the gateway for a real Haiku it does not serve and every one 404s; - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow, because Claude Code assumes 200k for models it has no table for — and deepseek-v4-* is 393k, so it would auto-compact at half capacity. On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves /api/v1/llms (management) and its adapters call out to providers for gating, prompt selection and agent chat; nothing serves /v1/messages. Routing Claude through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own (per-project gating of model calls, prompt audit, budgets), which is a mcpd endpoint rather than a wrapper script. Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against deepseek on the homelab, with the unknown-model context warning gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 19 +++++ docs/claude-vllm.md | 92 +++++++++++++++++++++++ nfpm.yaml | 4 + stack/claude-vllm | 178 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 docs/claude-vllm.md create mode 100755 stack/claude-vllm diff --git a/README.md b/README.md index 43fcd22..8be6684 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,25 @@ Re-sync skills on their own with See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. +### Run Claude Code on the homelab LLM + +The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic +Messages API, so Claude Code can talk to it directly — no bridge: + +```bash +claude-vllm # reuse the provider/model/key pi or prime-agent already uses +claude-vllm --model deepseek-v4-max +claude-vllm --list # show what it would use +claude-vllm -- -p "summarise this repo" # args after -- go to claude +``` + +It discovers the endpoint, credential, model and context window from +`~/.pi/agent`, `~/.prime/agent` or opencode's config (first hit wins) and sets +the `ANTHROPIC_*` environment Claude Code needs. + +See [docs/claude-vllm.md](docs/claude-vllm.md), including why routing this +through mcpctl would add surface without adding capability. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/docs/claude-vllm.md b/docs/claude-vllm.md new file mode 100644 index 0000000..72c3446 --- /dev/null +++ b/docs/claude-vllm.md @@ -0,0 +1,92 @@ +# `claude-vllm` — Claude Code on the homelab LLM + +## The short version + +```bash +claude-vllm # default provider + model +claude-vllm --model deepseek-v4-max +claude-vllm --list # what would it use? +claude-vllm --print-env # the exports, without running claude +claude-vllm -- -p "summarise this repo" # anything after -- goes to claude +``` + +## Why no bridge is needed + +The homelab gateway at `llm.ad.itaz.eu` is **LiteLLM in front of vLLM**, and +LiteLLM already serves the Anthropic Messages API: + +```console +$ curl -s https://llm.ad.itaz.eu/v1/messages -X POST \ + -H 'x-api-key: ' -H 'anthropic-version: 2023-06-01' \ + -d '{"model":"deepseek-v4-fast","max_tokens":32, + "messages":[{"role":"user","content":"say only: ok"}]}' +{"type":"message","role":"assistant","model":"deepseek-v4-fast", + "content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn", …} +``` + +That is the exact protocol Claude Code speaks. So pointing `ANTHROPIC_BASE_URL` +at the gateway is the whole integration — no translation layer, no proxy, no +mcpctl in the request path. + +### Why not route it through mcpctl + +mcpctl's LLM layer is a **client**, not a server: `mcpd` exposes +`/api/v1/llms` (management) and the adapters in +`src/mcpd/src/services/llm/adapters/` *call out* to Anthropic/OpenAI for gating, +prompt selection and agent chat. Nothing in mcpd or mcplocal serves +`/v1/messages`. A `claude-mcpctl` would therefore mean adding an +Anthropic-shaped passthrough to mcpd that re-wraps what LiteLLM already does — +new surface, new failure mode, no new capability. + +It would only be worth it if you wanted mcpctl *in* the LLM path for its own +sake: per-project gating of model calls, audit of prompts, or budget +enforcement. Those are real features, but they are a passthrough endpoint in +mcpd, not a wrapper script. + +## What it discovers, and from where + +`claude-vllm` exists only so you don't paste four exports each time. It reuses +whatever you already configured for another agent — first hit wins: + +| Order | Source | Supplies | +|-------|--------|----------| +| 1 | environment (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) | anything already set is respected | +| 2 | `--provider` / `--model` flags | provider, model | +| 3 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context window · `auth.json` → key | +| 4 | `~/.prime/agent` | same shape | +| 5 | `~/.config/opencode/opencode.jsonc` | `provider..options.{baseURL,apiKey}` | + +The base URL and the credential always come from the *same* source, so one +gateway's URL is never paired with another's key. + +## What it sets, and why each one + +| Variable | Reason | +|----------|--------| +| `ANTHROPIC_BASE_URL` | The gateway. A stored OpenAI-style `…/v1` base has the suffix stripped — Claude Code appends `/v1/messages` itself, and `/v1/v1/messages` 404s. | +| `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_API_KEY` | The gateway takes either; which one Claude Code sends has changed between releases. | +| `ANTHROPIC_MODEL` | The model to drive the session. | +| `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Background/summarisation calls otherwise ask for a real Haiku the gateway does not serve, and every one 404s. | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code assumes 200k for models it has no table for. `deepseek-v4-*` is 393k, so without this it auto-compacts at half capacity. Taken from `contextWindow` in the provider config. | +| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Beta headers the gateway does not implement make it reject otherwise-fine requests. | +| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | No telemetry about a non-Anthropic endpoint. | + +## Expected noise + +``` +⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth + source is set and takes precedence over your claude.ai login +``` + +Unavoidable and harmless: it fires for *any* non-claude.ai auth, including +`ANTHROPIC_AUTH_TOKEN` alone (verified). It means your claude.ai org connectors +are not loaded for this session — which is the point of running against the +homelab. + +## Verified + +```console +$ claude-vllm --model deepseek-v4-fast -- -p "Reply with exactly: PARITY-OK" +claude-vllm: itaz · deepseek-v4-fast · https://llm.ad.itaz.eu +PARITY-OK +``` diff --git a/nfpm.yaml b/nfpm.yaml index 047ecc8..3280d1c 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -16,6 +16,10 @@ contents: dst: /usr/bin/mcpctl-local file_info: mode: 0755 + - src: ./stack/claude-vllm + dst: /usr/bin/claude-vllm + file_info: + mode: 0755 - src: ./deploy/mcplocal.service dst: /usr/lib/systemd/user/mcplocal.service file_info: diff --git a/stack/claude-vllm b/stack/claude-vllm new file mode 100755 index 0000000..17942f8 --- /dev/null +++ b/stack/claude-vllm @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# claude-vllm — run Claude Code against the homelab LLM gateway instead of api.anthropic.com. +# +# The gateway (LiteLLM in front of vLLM) already speaks the Anthropic Messages +# API on /v1/messages, so no bridge or translation layer is needed — Claude Code +# talks to it directly once ANTHROPIC_BASE_URL points there. This script exists +# only to find the endpoint, credential and model you have already configured +# for another agent, instead of making you paste four exports every time. +# +# Discovery order (first hit wins, per field): +# 1. environment already set (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL) +# 2. flags (--provider, --model) +# 3. ~/.pi/agent settings.json + models.json + auth.json +# 4. ~/.prime/agent settings.json + models.json + auth.json +# 5. ~/.config/opencode/opencode.jsonc (provider..options.{baseURL,apiKey}) +# +# Usage: +# claude-vllm # default provider + model, then exec claude +# claude-vllm --model deepseek-v4-max +# claude-vllm --provider itaz --model deepseek-v4-fast -- -p "summarise this repo" +# claude-vllm --list # show discoverable providers/models and exit +# claude-vllm --print-env # print the exports and exit (don't run claude) +set -euo pipefail + +PROVIDER="" +MODEL="" +LIST=0 +PRINT_ENV=0 +CLAUDE_ARGS=() + +while [ $# -gt 0 ]; do + case "$1" in + --provider) PROVIDER="${2:?--provider needs a value}"; shift 2 ;; + --model) MODEL="${2:?--model needs a value}"; shift 2 ;; + --list) LIST=1; shift ;; + --print-env) PRINT_ENV=1; shift ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --) shift; CLAUDE_ARGS+=("$@"); break ;; + *) CLAUDE_ARGS+=("$1"); shift ;; + esac +done + +command -v jq >/dev/null || { echo "claude-vllm: jq is required" >&2; exit 1; } + +PI_HOME="${PI_AGENT_HOME:-$HOME/.pi/agent}" +PRIME_HOME="${PRIME_AGENT_HOME:-$HOME/.prime/agent}" +OC_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.jsonc" + +# jq tolerates the // comments opencode.jsonc may contain only after we strip +# them; harmless for strict JSON. +read_json() { [ -f "$1" ] && sed 's://[^"]*$::' "$1" | jq -c . 2>/dev/null || echo '{}'; } + +# ── discover ───────────────────────────────────────────────────────────────── +# Each agent home is tried in turn; the first one that yields a base URL wins, +# and the credential is taken from that same home so we never pair one gateway's +# URL with another's key. +discover_from_agent_home() { + local home="$1" settings models auth provider base key model + settings=$(read_json "$home/settings.json") + models=$(read_json "$home/models.json") + auth=$(read_json "$home/auth.json") + + provider="$PROVIDER" + [ -n "$provider" ] || provider=$(jq -r '.defaultProvider // empty' <<<"$settings") + [ -n "$provider" ] || return 1 + + base=$(jq -r --arg p "$provider" '.providers[$p].baseUrl // empty' <<<"$models") + [ -n "$base" ] || return 1 + # `apiKey` in models.json names an env var; the secret itself lives in auth.json. + key=$(jq -r --arg p "$provider" '.[$p].key // empty' <<<"$auth") + + model="$MODEL" + [ -n "$model" ] || model=$(jq -r '.defaultModel // empty' <<<"$settings") + + FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key"; FOUND_MODEL="$model" + FOUND_MODELS=$(jq -r --arg p "$provider" '.providers[$p].models[]?.id' <<<"$models") + FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$model" \ + '.providers[$p].models[]? | select(.id==$m) | .contextWindow // empty' <<<"$models") + FOUND_SOURCE="$home" + return 0 +} + +discover_from_opencode() { + local cfg provider base key + cfg=$(read_json "$OC_CONFIG") + provider="$PROVIDER" + if [ -z "$provider" ]; then + provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") + fi + [ -n "$provider" ] || return 1 + base=$(jq -r --arg p "$provider" '.provider[$p].options.baseURL // empty' <<<"$cfg") + [ -n "$base" ] || return 1 + key=$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg") + + FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key" + FOUND_MODEL="${MODEL:-$(jq -r '(.model // "") | split("/")[1] // empty' <<<"$cfg")}" + FOUND_MODELS=$(jq -r --arg p "$provider" '.provider[$p].models | keys[]?' <<<"$cfg") + FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$FOUND_MODEL" \ + '.provider[$p].models[$m].limit.context // empty' <<<"$cfg") + FOUND_SOURCE="$OC_CONFIG" + return 0 +} + +FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" +discover_from_agent_home "$PI_HOME" \ + || discover_from_agent_home "$PRIME_HOME" \ + || discover_from_opencode \ + || true + +BASE="${ANTHROPIC_BASE_URL:-$FOUND_BASE}" +KEY="${ANTHROPIC_AUTH_TOKEN:-${ANTHROPIC_API_KEY:-$FOUND_KEY}}" +MODEL_ID="${MODEL:-${ANTHROPIC_MODEL:-$FOUND_MODEL}}" + +if [ "$LIST" = 1 ]; then + echo "provider: ${FOUND_PROVIDER:-} (from ${FOUND_SOURCE:-nowhere})" + echo "endpoint: ${BASE:-}" + echo "credential: $([ -n "$KEY" ] && echo "found (${KEY:0:10}…)" || echo "")" + echo "default model: ${MODEL_ID:-}${FOUND_CONTEXT:+ (context ${FOUND_CONTEXT})}" + echo "models:" + [ -n "$FOUND_MODELS" ] && printf ' %s\n' $FOUND_MODELS || echo " " + exit 0 +fi + +if [ -z "$BASE" ]; then + echo "claude-vllm: no LLM endpoint found." >&2 + echo " Looked in $PI_HOME, $PRIME_HOME and $OC_CONFIG." >&2 + echo " Set ANTHROPIC_BASE_URL, or configure a provider in one of those." >&2 + exit 1 +fi +if [ -z "$KEY" ]; then + echo "claude-vllm: found $BASE but no credential for '${FOUND_PROVIDER}'." >&2 + echo " Set ANTHROPIC_AUTH_TOKEN, or add the key to ${FOUND_SOURCE}/auth.json." >&2 + exit 1 +fi + +# Claude Code appends /v1/messages itself, so the stored provider baseUrl's +# trailing /v1 (an OpenAI-style base) has to come off or requests go to +# /v1/v1/messages. +BASE="${BASE%/}"; BASE="${BASE%/v1}" + +export ANTHROPIC_BASE_URL="$BASE" +# Both forms: the gateway accepts either, and which one Claude Code sends has +# changed between releases (Authorization: Bearer vs x-api-key). +export ANTHROPIC_AUTH_TOKEN="$KEY" +export ANTHROPIC_API_KEY="$KEY" +[ -n "$MODEL_ID" ] && export ANTHROPIC_MODEL="$MODEL_ID" +# Without a substitute, the background/summarisation calls ask the gateway for a +# real Haiku it does not serve, and every one of them 404s. +export ANTHROPIC_SMALL_FAST_MODEL="${ANTHROPIC_SMALL_FAST_MODEL:-${CLAUDE_VLLM_FAST_MODEL:-$MODEL_ID}}" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="${ANTHROPIC_DEFAULT_HAIKU_MODEL:-$ANTHROPIC_SMALL_FAST_MODEL}" +# Claude Code only knows the context window of models it ships a table for, and +# assumes 200k for anything else — so a 393k model would auto-compact at half +# its capacity. Tell it the real number when the provider config states one. +if [ -n "${FOUND_CONTEXT:-}" ] && [ -z "${CLAUDE_CODE_MAX_CONTEXT_TOKENS:-}" ]; then + export CLAUDE_CODE_MAX_CONTEXT_TOKENS="$FOUND_CONTEXT" +fi +# Beta headers the gateway does not implement make it reject otherwise fine +# requests. +export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS="${CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS:-1}" +# Stops Claude Code phoning home about a non-Anthropic endpoint. +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1}" + +if [ "$PRINT_ENV" = 1 ]; then + for v in ANTHROPIC_BASE_URL ANTHROPIC_MODEL ANTHROPIC_SMALL_FAST_MODEL \ + ANTHROPIC_DEFAULT_HAIKU_MODEL CLAUDE_CODE_MAX_CONTEXT_TOKENS \ + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; do + [ -n "${!v:-}" ] || continue + printf 'export %s=%q\n' "$v" "${!v}" + done + # Never printed: the credential. Use --list to confirm one was found. + echo 'export ANTHROPIC_AUTH_TOKEN= # and ANTHROPIC_API_KEY' + exit 0 +fi + +command -v claude >/dev/null || { echo "claude-vllm: claude is not on PATH" >&2; exit 1; } +echo "claude-vllm: ${FOUND_PROVIDER:-custom} · ${ANTHROPIC_MODEL:-} · $ANTHROPIC_BASE_URL" >&2 +exec claude "${CLAUDE_ARGS[@]}" From d7055a0953b99697025691694d3af30bc4d4f8a9 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 18:51:31 +0100 Subject: [PATCH 04/22] fix(claude): one constant `mcpctl` MCP entry instead of one per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config claude` named the `.mcp.json` entry after the project, and the file is merged rather than rewritten — so configuring a second project left the first one mounted alongside it. Every project you had ever configured stayed connected, with duplicate tool names and nothing saying which was active. The entry is now always `mcpctl`, and switching rewrites what sits behind that name. Claude Code can reconnect an existing MCP server from inside a session, so a switch lands without restarting the app, and the tool prefix stays stable across switches. Entries an older CLI wrote are retired on the next run — recognised by the pairing that makes retiring them safe: our command, named after the very project it bridges to. A hand-configured server is never touched. The shaping lives in config/claude-mcp.ts as pure functions so the merge, migration and active-project detection are unit-tested rather than inferred from a command's side effects. Also brings two parity gaps in line with `config opencode` / `config prime-agent`: --dry-run, and --skip-marker for when the caller must not re-scope the directory it runs in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 4 + src/cli/src/commands/config.ts | 101 +++++++++++------- src/cli/src/config/claude-mcp.ts | 133 ++++++++++++++++++++++++ src/cli/tests/commands/claude.test.ts | 64 +++++++++--- src/cli/tests/config/claude-mcp.test.ts | 110 ++++++++++++++++++++ 6 files changed, 363 insertions(+), 53 deletions(-) create mode 100644 src/cli/src/config/claude-mcp.ts create mode 100644 src/cli/tests/config/claude-mcp.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index f9719fc..6e00b52 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -119,10 +119,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 92b6c96..38665a4 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -290,6 +290,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d ' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' @@ -297,6 +299,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l ou complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index a57e359..2f78a35 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,12 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + MCPCTL_SERVER_NAME, + mergeMcpctlServers, + activeProjectIn, + type McpJson, +} from '../config/claude-mcp.js'; import { opencodeConfigDir, opencodeStatePath, @@ -57,8 +63,18 @@ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; /** Same, for the tokens `config opencode` mints. */ const OPENCODE_TOKEN_PREFIX = 'opencode'; -interface McpConfig { - mcpServers: Record }>; +/** + * Read an existing `.mcp.json`. A missing or unparseable file yields null, and + * the caller starts fresh — the same behaviour as before, kept because a + * half-written file must not stop you re-provisioning. + */ +function readMcpJson(path: string): McpJson | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as McpJson; + } catch { + return null; + } } export interface ConfigCommandDeps { @@ -261,63 +277,70 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => { + .option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; dryRun?: boolean }) => { if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; return; } - const servers: McpConfig['mcpServers'] = {}; - if (opts.project) { - servers[opts.project] = { - command: 'mcpctl', - args: ['mcp', '-p', opts.project], - }; - } - if (opts.inspect) { - servers['mcpctl-inspect'] = { - command: 'mcpctl', - args: ['console', '--stdin-mcp'], - }; - } + const outputPath = resolve(opts.output); + const existing = readMcpJson(outputPath); + const { config: finalConfig, retired } = mergeMcpctlServers(existing, { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), + }); - if (opts.stdout) { - log(JSON.stringify({ mcpServers: servers }, null, 2)); + if (opts.stdout === true) { + log(JSON.stringify(finalConfig, null, 2)); return; } - const outputPath = resolve(opts.output); - let finalConfig: McpConfig = { mcpServers: servers }; - - // Always merge with existing .mcp.json — never overwrite other servers - if (existsSync(outputPath)) { - try { - const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig; - finalConfig = { - mcpServers: { - ...existing.mcpServers, - ...servers, - }, - }; - } catch { - // If existing file is invalid, start fresh - } + if (opts.dryRun === true) { + log(JSON.stringify({ + claude: { + output: outputPath, + previousProject: activeProjectIn(existing), + server: MCPCTL_SERVER_NAME, + entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '', + retiredLegacyEntries: retired, + marker: opts.skipMarker === true || opts.project === undefined + ? '' + : join(dirname(outputPath), '.mcpctl-project'), + skills: opts.skipSkills === true ? '' : 'sync + SessionStart hook', + }, + action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook', + }, null, 2)); + return; } writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); const serverCount = Object.keys(finalConfig.mcpServers).length; log(`Wrote ${outputPath} (${serverCount} server(s))`); + if (retired.length > 0) { + // Before the constant name, every project you configured stayed + // mounted alongside the new one. + log(`Retired legacy per-project entr${retired.length === 1 ? 'y' : 'ies'}: ${retired.join(', ')}`); + } + if (opts.project !== undefined) { + log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`); + } // PR-5: write project marker, run initial skills sync, install // SessionStart hook. Skipped when --inspect-only or --skip-skills. if (opts.project && !opts.skipSkills) { const projectDir = dirname(outputPath); - try { - const markerPath = await writeProjectMarker(projectDir, opts.project); - log(`Wrote ${markerPath}`); - } catch (err: unknown) { - log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else { + try { + const markerPath = await writeProjectMarker(projectDir, opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } } if (skillsClient) { diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts new file mode 100644 index 0000000..6feaa0f --- /dev/null +++ b/src/cli/src/config/claude-mcp.ts @@ -0,0 +1,133 @@ +/** + * `.mcp.json` shaping for `mcpctl config claude`. + * + * WHY A CONSTANT SERVER NAME + * + * The entry used to be named after the project (`homeautomation`, + * `docmost`, …). Because `.mcp.json` is *merged* rather than rewritten, running + * `config claude` for a second project left the first one mounted too: every + * project you had ever configured stayed connected, with duplicate tool names + * and no way to tell which one was "active". + * + * The entry is now always called `mcpctl`, and switching projects rewrites what + * is behind that name. Claude Code can reconnect an existing MCP server from + * inside a session (`/mcp`), so a switch takes effect without restarting the + * app — and the tool prefix (`mcpctl__*`) stays stable across switches, so the + * model never sees a tool namespace disappear. + * + * Legacy project-named entries this CLI wrote are retired on the next run; see + * `isLegacyMcpctlEntry` for what counts as ours. + */ + +/** The one MCP server name mcpctl owns in `.mcp.json`. */ +export const MCPCTL_SERVER_NAME = 'mcpctl'; + +/** Name of the optional traffic-inspection server (`--inspect`). */ +export const MCPCTL_INSPECT_SERVER_NAME = 'mcpctl-inspect'; + +export interface McpServerEntry { + command?: string; + args?: string[]; + url?: string; + env?: Record; + [key: string]: unknown; +} + +export interface McpJson { + mcpServers: Record; + [key: string]: unknown; +} + +/** The stdio-bridge entry that mounts `project`. */ +export function mcpctlStdioServer(project: string): McpServerEntry { + return { command: 'mcpctl', args: ['mcp', '-p', project] }; +} + +/** The `--inspect` traffic monitor entry. */ +export function mcpctlInspectServer(): McpServerEntry { + return { command: 'mcpctl', args: ['console', '--stdin-mcp'] }; +} + +/** + * The project an entry bridges to, or null if it is not an mcpctl stdio bridge. + * + * Reads it straight out of `args` rather than a bookkeeping key, so nothing + * non-standard is written into a file Claude Code owns. + */ +export function projectOfEntry(entry: unknown): string | null { + if (entry === null || typeof entry !== 'object') return null; + const rec = entry as McpServerEntry; + if (rec.command !== 'mcpctl' || !Array.isArray(rec.args)) return null; + const args = rec.args; + if (args[0] !== 'mcp') return null; + const flag = args.indexOf('-p') >= 0 ? args.indexOf('-p') : args.indexOf('--project'); + if (flag < 0) return null; + const project = args[flag + 1]; + return typeof project === 'string' && project !== '' ? project : null; +} + +/** + * Is `name` an entry an older mcpctl wrote — i.e. named after the very project + * its command bridges to? + * + * That pairing is what makes retiring it safe. A server someone configured by + * hand would have to be named exactly after the project it bridges to *and* run + * our command to be mistaken for one of ours, at which point it is functionally + * the same entry anyway. + */ +export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean { + if (name === MCPCTL_SERVER_NAME) return false; + return projectOfEntry(entry) === name; +} + +/** The project currently mounted by `.mcp.json`, preferring the canonical entry. */ +export function activeProjectIn(config: Pick | null | undefined): string | null { + const servers = config?.mcpServers; + if (!servers) return null; + const canonical = projectOfEntry(servers[MCPCTL_SERVER_NAME]); + if (canonical !== null) return canonical; + for (const [name, entry] of Object.entries(servers)) { + if (isLegacyMcpctlEntry(name, entry)) return name; + } + return null; +} + +export interface MergeResult { + config: McpJson; + /** Legacy project-named entries dropped by this merge. */ + retired: string[]; +} + +/** + * Merge mcpctl's entries into an existing `.mcp.json`. + * + * Every server the user configured is preserved; only our own legacy + * project-named entries are dropped, and only once the canonical entry replaces + * them. Passing no project leaves any existing mount alone (`--inspect` on its + * own must not unmount the project you are working in). + */ +export function mergeMcpctlServers( + existing: Partial | null | undefined, + opts: { project?: string; inspect?: boolean }, +): MergeResult { + const servers: Record = { ...(existing?.mcpServers ?? {}) }; + const retired: string[] = []; + + if (opts.project !== undefined && opts.project !== '') { + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(opts.project); + } + if (opts.inspect === true) { + servers[MCPCTL_INSPECT_SERVER_NAME] = mcpctlInspectServer(); + } + + // Preserve any sibling top-level keys the file carried. + const rest = { ...(existing ?? {}) } as Partial; + delete rest.mcpServers; + return { config: { ...rest, mcpServers: servers }, retired }; +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9eb0053..4ebc6fd 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -46,7 +46,7 @@ describe('config claude', () => { expect(client.get).not.toHaveBeenCalled(); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['homeautomation']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'], }); @@ -61,7 +61,7 @@ describe('config claude', () => { await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' }); const parsed = JSON.parse(output[0]); - expect(parsed.mcpServers['myproj']).toEqual({ + expect(parsed.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'myproj'], }); @@ -81,7 +81,7 @@ describe('config claude', () => { const written = JSON.parse(readFileSync(outPath, 'utf-8')); expect(written.mcpServers['existing--server']).toBeDefined(); - expect(written.mcpServers['proj-1']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'proj-1'], }); @@ -113,7 +113,7 @@ describe('config claude', () => { await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['ha']).toBeDefined(); + expect(written.mcpServers['mcpctl']).toBeDefined(); expect(written.mcpServers['mcpctl-inspect']).toBeDefined(); expect(output.join('\n')).toContain('2 server(s)'); }); @@ -127,21 +127,61 @@ describe('config claude', () => { await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['proj-1']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'proj-1'], }); }); - it('uses project name as the server key', async () => { + it('uses one constant server key, whatever the project is called', async () => { + // The key used to be the project name, so `config claude` for a second + // project left the first one mounted too — every project ever configured + // stayed connected, with duplicate tool names. const outPath = join(tmpDir, '.mcp.json'); - const cmd = createConfigCommand( - { configDeps: { configDir: tmpDir }, log }, - ); - await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' }); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath, '--skip-skills'], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']); + expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']); + expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'my-fancy-project']); + }); + + it('switching projects replaces the mount instead of stacking a second one', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'first', '-o', outPath, '--skip-skills'], { from: 'user' }); + await cmd.parseAsync(['claude', '--project', 'second', '-o', outPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']); + expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'second']); + }); + + it('retires a legacy project-named entry left by an older CLI', async () => { + const outPath = join(tmpDir, '.mcp.json'); + writeFileSync(outPath, JSON.stringify({ + mcpServers: { + homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }, + 'my-own-server': { command: 'echo', args: [] }, + }, + })); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'docmost', '-o', outPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(Object.keys(written.mcpServers).sort()).toEqual(['mcpctl', 'my-own-server']); + expect(output.join('\n')).toContain('Retired legacy per-project entry: homeautomation'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'p', '-o', outPath, '--dry-run'], { from: 'user' }); + + const plan = JSON.parse(output.join('\n')); + expect(plan.claude.server).toBe('mcpctl'); + expect(plan.claude.entry.args).toEqual(['mcp', '-p', 'p']); + expect(existsSync(outPath)).toBe(false); }); }); diff --git a/src/cli/tests/config/claude-mcp.test.ts b/src/cli/tests/config/claude-mcp.test.ts new file mode 100644 index 0000000..45a1a0a --- /dev/null +++ b/src/cli/tests/config/claude-mcp.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { + MCPCTL_SERVER_NAME, + mergeMcpctlServers, + projectOfEntry, + isLegacyMcpctlEntry, + activeProjectIn, +} from '../../src/config/claude-mcp.js'; + +const bridge = (project: string): Record => ({ + command: 'mcpctl', + args: ['mcp', '-p', project], +}); + +describe('projectOfEntry', () => { + it('reads the project out of the bridge args', () => { + expect(projectOfEntry(bridge('docmost'))).toBe('docmost'); + expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '--project', 'sre'] })).toBe('sre'); + }); + + it('ignores anything that is not our stdio bridge', () => { + expect(projectOfEntry({ command: 'echo', args: ['mcp', '-p', 'x'] })).toBeNull(); + expect(projectOfEntry({ command: 'mcpctl', args: ['console', '--stdin-mcp'] })).toBeNull(); + expect(projectOfEntry({ type: 'remote', url: 'https://x/projects/y/mcp' })).toBeNull(); + expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '-p'] })).toBeNull(); + expect(projectOfEntry(null)).toBeNull(); + expect(projectOfEntry('nope')).toBeNull(); + }); +}); + +describe('isLegacyMcpctlEntry', () => { + it('recognises an entry named after the very project it bridges to', () => { + expect(isLegacyMcpctlEntry('docmost', bridge('docmost'))).toBe(true); + }); + + it('never claims the canonical entry', () => { + expect(isLegacyMcpctlEntry(MCPCTL_SERVER_NAME, bridge('docmost'))).toBe(false); + }); + + it('leaves a hand-configured server alone', () => { + // Same name, different command — someone else's server. + expect(isLegacyMcpctlEntry('docmost', { command: 'docker', args: ['run', 'docmost'] })).toBe(false); + // Our command, but the name does not match the project: not something this + // CLI ever wrote, so it is the user's to keep. + expect(isLegacyMcpctlEntry('my-shortcut', bridge('docmost'))).toBe(false); + }); +}); + +describe('activeProjectIn', () => { + it('prefers the canonical entry', () => { + expect(activeProjectIn({ mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre') } })).toBe('sre'); + }); + + it('falls back to a legacy entry so pre-migration installs still report', () => { + expect(activeProjectIn({ mcpServers: { docmost: bridge('docmost') } })).toBe('docmost'); + }); + + it('is null when nothing of ours is mounted', () => { + expect(activeProjectIn({ mcpServers: { other: { command: 'echo' } } })).toBeNull(); + expect(activeProjectIn(null)).toBeNull(); + expect(activeProjectIn({ mcpServers: {} })).toBeNull(); + }); +}); + +describe('mergeMcpctlServers', () => { + it('writes one constant entry regardless of project', () => { + const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('my-fancy-project')); + }); + + it('re-points rather than stacking on a second project', () => { + const first = mergeMcpctlServers(null, { project: 'a' }).config; + const { config } = mergeMcpctlServers(first, { project: 'b' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('b')); + }); + + it('retires legacy per-project entries and reports them', () => { + const existing = { mcpServers: { homeautomation: bridge('homeautomation'), sre: bridge('sre') } }; + const { config, retired } = mergeMcpctlServers(existing, { project: 'docmost' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(retired.sort()).toEqual(['homeautomation', 'sre']); + }); + + it('preserves servers the user configured, and other top-level keys', () => { + const existing = { + mcpServers: { 'my-own': { command: 'echo', args: [] } }, + someOtherKey: { keep: true }, + }; + const { config, retired } = mergeMcpctlServers(existing, { project: 'p' }); + expect(config.mcpServers['my-own']).toEqual({ command: 'echo', args: [] }); + expect(config['someOtherKey']).toEqual({ keep: true }); + expect(retired).toEqual([]); + }); + + it('--inspect alone does not unmount the project you are working in', () => { + const existing = mergeMcpctlServers(null, { project: 'p' }).config; + const { config, retired } = mergeMcpctlServers(existing, { inspect: true }); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('p')); + expect(config.mcpServers['mcpctl-inspect']).toEqual({ command: 'mcpctl', args: ['console', '--stdin-mcp'] }); + expect(retired).toEqual([]); + }); + + it('does not mutate the config it was handed', () => { + const existing = { mcpServers: { homeautomation: bridge('homeautomation') } }; + mergeMcpctlServers(existing, { project: 'docmost' }); + expect(Object.keys(existing.mcpServers)).toEqual(['homeautomation']); + }); +}); From b3a062ce28ebe2baf4860697725f1ad255f4b920 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:06:06 +0100 Subject: [PATCH 05/22] feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code had neither of the things opencode, pi and prime-agent all have: a visible active project, and a way to change it from inside a session. It has no plugin API that can draw a widget or open a picker, but it does run a command for its status line and it does load slash commands — which is enough for both. - `mcpctl statusline` prints the active project (from .mcp.json, falling back to a .mcpctl-project marker) and is wired into settings.json. It reads the directory out of the JSON Claude Code pipes in, so it follows /cwd rather than reporting wherever the binary was launched. Prints nothing when no project is active: an empty line beats "none" on every unrelated repo. - `/mcpctl [project]` switches and reminds you to reconnect from /mcp. allowed-tools is scoped to the four exact mcpctl invocations it needs. Three things found by running it rather than reasoning about it: - Claude Code REWRITES settings.json against its own schema and strips unknown keys from `statusLine` — our `_mcpctl_managed` marker came back gone, so ownership is now determined by the command string. (Hooks keep their marker; statusLine does not.) A composed line like `my-prompt && mcpctl statusline` is deliberately not claimed. - Every `!`-prefixed block in a slash command is permission-checked against allowed-tools. Omitting `statusline` failed the whole command before the model saw anything. A test now asserts every pre-executed command is covered. - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn that auth may not work; claude-vllm now sets only the former and clears an inherited API key. Also fixes a pre-existing test-isolation bug this work would have made worse: `config claude` wrote into the developer's real ~/.claude when the suite ran, which is how an untagged duplicate of the skills-sync SessionStart hook got there. Both the hook installer and the new UI installers now honour CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first, isolation second), `config claude` gains --claude-dir for parity with --pi-dir and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical. Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost` switches and the line updates to `mcpctl:docmost` in the same session. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- completions/mcpctl.bash | 9 +- completions/mcpctl.fish | 11 +- src/cli/src/commands/config.ts | 43 +++++- src/cli/src/commands/statusline.ts | 103 +++++++++++++ src/cli/src/index.ts | 2 + src/cli/src/utils/claude-ui.ts | 201 ++++++++++++++++++++++++++ src/cli/src/utils/sessionhook.ts | 7 +- src/cli/tests/commands/claude.test.ts | 15 ++ src/cli/tests/utils/claude-ui.test.ts | 133 +++++++++++++++++ stack/claude-vllm | 12 +- 10 files changed, 525 insertions(+), 11 deletions(-) create mode 100644 src/cli/src/commands/statusline.ts create mode 100644 src/cli/src/utils/claude-ui.ts create mode 100644 src/cli/tests/utils/claude-ui.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 6e00b52..d83ba09 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" + local commands="status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -94,6 +94,9 @@ _mcpctl() { status) COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur")) return ;; + statusline) + COMPREPLY=($(compgen -W "-d --directory --prefix -h --help" -- "$cur")) + return ;; login) COMPREPLY=($(compgen -W "--mcpd-url -h --help" -- "$cur")) return ;; @@ -119,10 +122,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 38665a4..0144057 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate +set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server favourites # Disable file completions by default @@ -221,6 +221,7 @@ end # Top-level commands (without --project) complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show mcpctl status and connectivity' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a statusline -d 'Print the active mcpctl project (for Claude Code\'s statusLine setting)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out and remove stored credentials' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' @@ -291,6 +292,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Incl complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options @@ -300,6 +303,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options @@ -599,6 +604,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from rotate; and not __fish_seen_s # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x +# statusline options +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -s d -l directory -d 'Directory to resolve the project for (default: from stdin, then cwd)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -l prefix -d 'Text before the project name' -x + # login options complete -c mcpctl -n "__fish_seen_subcommand_from login" -l mcpd-url -d 'mcpd URL to authenticate against' -x diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 2f78a35..d37c541 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,12 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + installStatusLine, + installSlashCommand, + claudeConfigDir, + STATUSLINE_COMMAND, +} from '../utils/claude-ui.js'; import { MCPCTL_SERVER_NAME, mergeMcpctlServers, @@ -278,8 +284,17 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') .option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file') + .option('--skip-ui', 'Do not install the status line or the /mcpctl slash command') + .option('--claude-dir ', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)') .option('--dry-run', 'Print what would change without writing or syncing') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; dryRun?: boolean }) => { + .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => { + // Resolve Claude's config dir once: an explicit --claude-dir wins, then + // $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather + // than letting each helper default) is what keeps the test suite off the + // developer's real ~/.claude. + const claudeDir = opts.claudeDir !== undefined ? resolve(opts.claudeDir) : claudeConfigDir(); + const claudeSettings = join(claudeDir, 'settings.json'); + const claudeCommand = join(claudeDir, 'commands', 'mcpctl.md'); if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; @@ -310,6 +325,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: ? '' : join(dirname(outputPath), '.mcpctl-project'), skills: opts.skipSkills === true ? '' : 'sync + SessionStart hook', + statusLine: opts.skipUi === true ? '' : `${claudeSettings} (${STATUSLINE_COMMAND})`, + slashCommand: opts.skipUi === true ? '' : claudeCommand, }, action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook', }, null, 2)); @@ -359,12 +376,34 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } try { - const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet'); + const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet', claudeSettings); log(updated ? `Installed SessionStart hook in ${settingsPath}` : `SessionStart hook already up to date in ${settingsPath}`); } catch (err: unknown) { log(`Warning: failed to install SessionStart hook: ${err instanceof Error ? err.message : String(err)}`); } } + + // The UI bits are independent of --skip-skills: they are how you see + // and change the project, not how skills get there. + if (opts.project !== undefined && opts.skipUi !== true) { + try { + const outcome = await installStatusLine(claudeSettings); + if (outcome.status === 'installed') log(`Installed the active-project status line in ${claudeSettings}`); + else if (outcome.status === 'already') log('Status line already up to date'); + else { + // Never clobber a status line someone built. + log(`Left your existing status line alone (${outcome.command}).`); + log(` To show the project too, append: $(${STATUSLINE_COMMAND})`); + } + } catch (err: unknown) { + log(`Warning: failed to install the status line: ${err instanceof Error ? err.message : String(err)}`); + } + try { + log(`Installed the /mcpctl switcher: ${await installSlashCommand(claudeCommand)}`); + } catch (err: unknown) { + log(`Warning: failed to install the /mcpctl command: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { // Commander shows empty-description commands but they won't clutter help output diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts new file mode 100644 index 0000000..a3d7c29 --- /dev/null +++ b/src/cli/src/commands/statusline.ts @@ -0,0 +1,103 @@ +import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { activeProjectIn, type McpJson } from '../config/claude-mcp.js'; +import { findProjectMarker } from '../utils/project-marker.js'; + +/** + * `mcpctl statusline` — print the active mcpctl project, for Claude Code's + * `statusLine` setting. + * + * Claude Code has no plugin API that can draw into its UI, but it does run a + * command for the status line and render whatever that prints. This is that + * command: it is what gives Claude Code the same at-a-glance "which project am + * I in" that opencode gets from a footer slot and pi/prime-agent from + * `setStatus`. + * + * Claude Code pipes a JSON blob in on stdin (session id, model, workspace). We + * only need the directory — the project is whatever `.mcp.json` there mounts, + * falling back to a `.mcpctl-project` marker up the tree so a checkout that is + * scoped but not yet wired still reports. + * + * Prints nothing at all when no project is active: an empty status line is + * better than one that says "none" on every unrelated repo you open. + */ + +interface StatusLineInput { + workspace?: { current_dir?: string; project_dir?: string }; + cwd?: string; +} + +/** Read Claude Code's stdin payload. Absent or unparseable → no directory hint. */ +async function readStdinJson(): Promise { + if (process.stdin.isTTY === true) return {}; + const chunks: Buffer[] = []; + try { + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (raw.length === 0) return {}; + return JSON.parse(raw) as StatusLineInput; + } catch { + return {}; + } +} + +/** + * The directory whose project we should report. + * + * Claude Code's `current_dir` moves with `/cwd`, so it beats the process cwd + * (which is wherever the Claude Code binary was launched, often unrelated). + */ +export function resolveDirectory(input: StatusLineInput, fallback: string): string { + return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; +} + +/** The project `.mcp.json` in `dir` mounts, or null. */ +export function projectFromMcpJson(dir: string): string | null { + try { + const parsed = JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; + return activeProjectIn(parsed); + } catch { + return null; + } +} + +/** Format for the status line. Empty string means "render nothing". */ +export function formatStatus(project: string | null, prefix: string): string { + return project !== null && project !== '' ? `${prefix}${project}` : ''; +} + +export interface StatuslineDeps { + log: (line: string) => void; + cwd: () => string; + homeDir: () => string; +} + +export function createStatuslineCommand(deps?: Partial): Command { + const log = deps?.log ?? ((line: string): void => { process.stdout.write(line); }); + const cwd = deps?.cwd ?? ((): string => process.cwd()); + const homeDir = deps?.homeDir ?? homedir; + + return new Command('statusline') + .description('Print the active mcpctl project (for Claude Code\'s statusLine setting)') + .option('-d, --directory ', 'Directory to resolve the project for (default: from stdin, then cwd)') + .option('--prefix ', 'Text before the project name', 'mcpctl:') + .action(async (opts: { directory?: string; prefix: string }) => { + const input = opts.directory !== undefined ? {} : await readStdinJson(); + const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); + + let project = projectFromMcpJson(dir); + if (project === null) { + // Not wired here (or wired above this directory) — the marker is the + // other thing `config claude` writes, and skills sync already trusts it. + const marker = await findProjectMarker(dir, homeDir()).catch(() => null); + project = marker?.project ?? null; + } + + const line = formatStatus(project, opts.prefix); + // No trailing newline: Claude Code renders the output as one line, and a + // stray newline shows up as a blank second row. + if (line !== '') log(line); + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 7227068..47c2070 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -26,6 +26,7 @@ import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; import { createReviewCommand } from './commands/review.js'; import { createSkillsCommand } from './commands/skills.js'; +import { createStatuslineCommand } from './commands/statusline.js'; import { createPasswdCommand } from './commands/passwd.js'; import { createErrorsCommand } from './commands/errors.js'; import { ApiClient, ApiError } from './api-client.js'; @@ -44,6 +45,7 @@ export function createProgram(): Command { .option('-p, --project ', 'Target project for project commands'); program.addCommand(createStatusCommand()); + program.addCommand(createStatuslineCommand()); program.addCommand(createLoginCommand()); program.addCommand(createLogoutCommand()); diff --git a/src/cli/src/utils/claude-ui.ts b/src/cli/src/utils/claude-ui.ts new file mode 100644 index 0000000..e0a05d9 --- /dev/null +++ b/src/cli/src/utils/claude-ui.ts @@ -0,0 +1,201 @@ +/** + * The two pieces of Claude Code UI `mcpctl config claude` wires up: + * + * - a **status line** showing the active project, so Claude Code gets the + * same at-a-glance indicator opencode has in its footer and pi/prime-agent + * get from `setStatus`; + * - a **`/mcpctl` slash command** to switch projects from inside a session. + * + * Claude Code has no plugin API that can draw its own widget or open a picker, + * so neither is as native as the opencode switcher. The status line is a + * command Claude Code runs and renders; the slash command is a prompt file that + * drives the model through `mcpctl` CLI calls. That is the whole extension + * surface Claude Code offers, and it is enough for both jobs. + */ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { MCPCTL_SERVER_NAME } from '../config/claude-mcp.js'; + +/** Same marker the SessionStart hook installer uses to recognise its own rows. */ +export const MARKER_KEY = '_mcpctl_managed'; + +/** The command Claude Code runs to render the status line. */ +export const STATUSLINE_COMMAND = 'mcpctl statusline'; + +/** + * Claude Code's config directory. + * + * `CLAUDE_CONFIG_DIR` is Claude Code's own override, so honouring it is correct + * behaviour first and test isolation second — without it, anything that + * provisions Claude writes into the developer's real ~/.claude when the test + * suite runs. + */ +export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const override = env['CLAUDE_CONFIG_DIR']; + return override !== undefined && override !== '' ? override : join(homeDir, '.claude'); +} + +export function claudeSettingsPath(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return join(claudeConfigDir(env, homeDir), 'settings.json'); +} + +export function claudeCommandPath(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return join(claudeConfigDir(env, homeDir), 'commands', 'mcpctl.md'); +} + +interface StatusLine { + type?: string; + command?: string; + [k: string]: unknown; +} + +interface Settings { + statusLine?: StatusLine; + [k: string]: unknown; +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + // Same heuristic as the hook installer: strip line comments so a file an + // editor added notes to still parses. + return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as Settings; + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } +} + +async function writeSettings(path: string, settings: Settings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Is this status line ours? + * + * The `_mcpctl_managed` marker alone is not enough: Claude Code rewrites + * settings.json against its own schema (on theme change, for instance) and + * **strips unknown keys from `statusLine`** — verified in a live session, where + * our tagged entry came back as a bare `{type, command}`. Hooks keep their + * marker; statusLine does not. Relying on the tag would mean reporting our own + * status line as somebody else's forever, and never upgrading the command. + * + * So the command string is the real evidence. It must *be* an `mcpctl + * statusline` invocation, not merely contain one — someone who composed ours + * into a bigger line (`my-prompt && mcpctl statusline`) owns that line, and we + * must not overwrite it. + */ +export function isOurStatusLine(current: StatusLine | null | undefined): boolean { + if (current === null || current === undefined) return false; + if (current[MARKER_KEY] === true) return true; + const command = current.command; + return typeof command === 'string' && /^\s*(\S*\/)?mcpctl\s+statusline(\s|$)/.test(command); +} + +export type StatusLineOutcome = + | { status: 'installed' } + | { status: 'already' } + | { status: 'foreign'; command: string }; + +/** + * Install the status line — but never over one the user already has. + * + * A status line is a single slot, so installing ours on top of a custom one + * silently deletes work someone put effort into. When we find a foreign one we + * leave it and report it, so the caller can print the one-line snippet to add + * instead. Ours is tagged, so re-running is idempotent and an upgrade of the + * command string still lands. + */ +export async function installStatusLine( + settingsPath: string = claudeSettingsPath(), + command: string = STATUSLINE_COMMAND, +): Promise { + const settings = await readSettings(settingsPath); + const current = settings.statusLine; + + if (current !== undefined && current !== null) { + if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '') }; + if (current.command === command) return { status: 'already' }; + } + + settings.statusLine = { type: 'command', command, [MARKER_KEY]: true }; + await writeSettings(settingsPath, settings); + return { status: 'installed' }; +} + +/** Remove our status line, leaving a foreign one alone. */ +export async function removeStatusLine(settingsPath: string = claudeSettingsPath()): Promise { + const settings = await readSettings(settingsPath); + if (!isOurStatusLine(settings.statusLine)) return false; + delete settings.statusLine; + await writeSettings(settingsPath, settings); + return true; +} + +/** + * The `/mcpctl` slash command. + * + * Claude Code slash commands are prompt files, not code — so unlike opencode's + * picker this drives the model through CLI calls. `allowed-tools` is scoped to + * the exact `mcpctl` invocations it needs, so accepting the command does not + * hand it a general shell. + * + * Every `!`-prefixed block below is pre-executed by Claude Code and checked + * against that same list — including `statusline`, which is easy to forget + * because it is context-gathering rather than an action. Omitting one fails the + * whole command with a permission error before the model sees anything. + * + * `--skip-marker` matters here for the same reason it does in the opencode + * switcher: the session's directory is whatever you happened to open, and + * re-scoping it would silently change which skills sync into it. + */ +export const MCPCTL_SLASH_COMMAND = `--- +description: Switch the active mcpctl project (MCP servers + skills) +allowed-tools: Bash(mcpctl statusline:*), Bash(mcpctl get projects:*), Bash(mcpctl config claude:*), Bash(mcpctl skills sync:*) +--- + +# Switch the active mcpctl project + +The user wants to change which mcpctl project this session is connected to. +There is exactly one mcpctl MCP server, named \`${MCPCTL_SERVER_NAME}\`; switching +projects changes what sits behind that name. + +Requested project (may be empty): $ARGUMENTS + +## Steps + +1. Show the current project and the available ones: + + !\`mcpctl statusline --prefix 'current: ' --directory .\` + !\`mcpctl get projects -o json\` + +2. If \$ARGUMENTS names a project, use it. Otherwise list the projects + compactly (name — description) and ask which one. Do not guess. + +3. Switch, keeping this directory's scope unchanged: + + \`mcpctl config claude --project --skip-marker\` + +4. Report the switch as "now on ". Do not describe the project as + the server — the server is always \`${MCPCTL_SERVER_NAME}\`, only what sits + behind it changed. + +5. Tell the user, in one short line, that they must now **reconnect the + \`${MCPCTL_SERVER_NAME}\` server from \`/mcp\`** for the new project's tools to + load. The config on disk is already correct; the running session still holds + the old connection until it is reconnected. + +Keep the whole exchange to a few lines. This is a switcher, not a report. +`; + +/** Write the `/mcpctl` slash command into Claude Code's user commands dir. */ +export async function installSlashCommand(path: string = claudeCommandPath()): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8'); + return path; +} diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts index 49a9479..f0f6cf4 100644 --- a/src/cli/src/utils/sessionhook.ts +++ b/src/cli/src/utils/sessionhook.ts @@ -39,7 +39,12 @@ interface Settings { } function defaultSettingsPath(): string { - return join(homedir(), '.claude', 'settings.json'); + // CLAUDE_CONFIG_DIR is Claude Code's own override. Honouring it also stops + // the test suite writing a hook into the developer's real ~/.claude — which + // is how an untagged duplicate of this very hook got there in the first place. + const override = process.env['CLAUDE_CONFIG_DIR']; + const base = override !== undefined && override !== '' ? override : join(homedir(), '.claude'); + return join(base, 'settings.json'); } async function readSettings(path: string): Promise { diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 4ebc6fd..9a079f7 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -21,14 +21,29 @@ describe('config claude', () => { let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); + /** + * Claude Code's config dir, redirected per test. + * + * Without this the suite writes a SessionStart hook, a status line and a + * slash command into the developer's real ~/.claude — which is exactly how an + * untagged duplicate of the skills-sync hook ended up there. + */ + let claudeDir: string; + let priorClaudeConfigDir: string | undefined; + beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-')); + claudeDir = join(tmpDir, 'claude-home'); + priorClaudeConfigDir = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + if (priorClaudeConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = priorClaudeConfigDir; }); it('generates .mcp.json with mcpctl mcp bridge entry', async () => { diff --git a/src/cli/tests/utils/claude-ui.test.ts b/src/cli/tests/utils/claude-ui.test.ts new file mode 100644 index 0000000..9f56e10 --- /dev/null +++ b/src/cli/tests/utils/claude-ui.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + installStatusLine, + removeStatusLine, + installSlashCommand, + MCPCTL_SLASH_COMMAND, + STATUSLINE_COMMAND, + MARKER_KEY, +} from '../../src/utils/claude-ui.js'; + +describe('installStatusLine', () => { + let dir: string; + let settings: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-ui-')); + settings = join(dir, 'settings.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('installs into a missing settings file', async () => { + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + const parsed = JSON.parse(readFileSync(settings, 'utf-8')); + expect(parsed.statusLine).toEqual({ type: 'command', command: STATUSLINE_COMMAND, [MARKER_KEY]: true }); + }); + + it('is idempotent', async () => { + await installStatusLine(settings); + const before = readFileSync(settings, 'utf-8'); + expect(await installStatusLine(settings)).toEqual({ status: 'already' }); + expect(readFileSync(settings, 'utf-8')).toBe(before); + }); + + it('upgrades its own entry when the command changes', async () => { + await installStatusLine(settings, 'mcpctl statusline --prefix old:'); + expect(await installStatusLine(settings, STATUSLINE_COMMAND)).toEqual({ status: 'installed' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe(STATUSLINE_COMMAND); + }); + + it('still recognises its own line after Claude Code strips the marker', async () => { + // Claude Code rewrites settings.json against its own schema and drops + // unknown keys from statusLine — verified live. Without matching on the + // command we would call our own line foreign forever. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'already' }); + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline --prefix p:' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + }); + + it('does not claim a line that merely composes ours into a bigger one', async () => { + // That line is the user's work, even though our command appears in it. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-prompt && mcpctl statusline' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-prompt && mcpctl statusline' }); + }); + + it('never clobbers a status line the user built', async () => { + // A status line is a single slot; overwriting one silently deletes work. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-fancy-prompt' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-fancy-prompt' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('my-fancy-prompt'); + }); + + it('preserves every other setting', async () => { + writeFileSync(settings, JSON.stringify({ permissions: { allow: ['Bash'] }, hooks: { SessionStart: [] } })); + await installStatusLine(settings); + const parsed = JSON.parse(readFileSync(settings, 'utf-8')); + expect(parsed.permissions).toEqual({ allow: ['Bash'] }); + expect(parsed.hooks).toEqual({ SessionStart: [] }); + }); + + it('tolerates line comments an editor may have added', async () => { + writeFileSync(settings, '{\n // my notes\n "permissions": { "allow": [] }\n}\n'); + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).permissions).toEqual({ allow: [] }); + }); + + it('removes only its own entry', async () => { + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'theirs' } })); + expect(await removeStatusLine(settings)).toBe(false); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('theirs'); + + await installStatusLine(join(dir, 'ours.json')); + expect(await removeStatusLine(join(dir, 'ours.json'))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, 'ours.json'), 'utf-8')).statusLine).toBeUndefined(); + }); +}); + +describe('the /mcpctl slash command', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-cmd-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('is written where Claude Code looks for user commands', async () => { + const path = join(dir, 'commands', 'mcpctl.md'); + expect(await installSlashCommand(path)).toBe(path); + expect(existsSync(path)).toBe(true); + }); + + it('scopes allowed-tools to mcpctl, not a general shell', async () => { + // Accepting the command must not hand it arbitrary Bash. + const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? ''; + expect(tools).not.toMatch(/Bash\(\*\)|Bash\)/); + for (const t of tools.split(', ')) expect(t).toMatch(/^Bash\(mcpctl /); + }); + + it('permits every command it pre-executes', () => { + // A `!`-block missing from allowed-tools fails the whole command with a + // permission error before the model sees anything — which is exactly what + // happened live when `statusline` was omitted. + const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? ''; + const permitted = tools.split(', ').map((t) => /^Bash\((.+?):?\*?\)$/.exec(t)?.[1] ?? ''); + const preExecuted = [...MCPCTL_SLASH_COMMAND.matchAll(/!`([^`]+)`/g)].map((m) => m[1] ?? ''); + expect(preExecuted.length).toBeGreaterThan(0); + for (const cmd of preExecuted) { + expect(permitted.some((p) => p !== '' && cmd.startsWith(p)), `"${cmd}" is not covered by allowed-tools`).toBe(true); + } + }); + + it('switches without re-scoping the directory the session opened in', () => { + expect(MCPCTL_SLASH_COMMAND).toContain('--skip-marker'); + }); + + it('tells the user to reconnect, since the running session holds the old connection', () => { + expect(MCPCTL_SLASH_COMMAND).toMatch(/reconnect/i); + expect(MCPCTL_SLASH_COMMAND).toContain('/mcp'); + }); + + it('refers to the one constant server name', () => { + expect(MCPCTL_SLASH_COMMAND).toContain('`mcpctl`'); + }); +}); diff --git a/stack/claude-vllm b/stack/claude-vllm index 17942f8..d7119c0 100755 --- a/stack/claude-vllm +++ b/stack/claude-vllm @@ -139,10 +139,13 @@ fi BASE="${BASE%/}"; BASE="${BASE%/v1}" export ANTHROPIC_BASE_URL="$BASE" -# Both forms: the gateway accepts either, and which one Claude Code sends has -# changed between releases (Authorization: Bearer vs x-api-key). +# ANTHROPIC_AUTH_TOKEN only. Setting ANTHROPIC_API_KEY as well makes Claude Code +# warn that "auth may not work as expected" — it wants exactly one. AUTH_TOKEN +# is the right one for a third-party gateway (sent as `Authorization: Bearer`), +# and an inherited ANTHROPIC_API_KEY would otherwise take precedence over it, so +# clear it for the child process. export ANTHROPIC_AUTH_TOKEN="$KEY" -export ANTHROPIC_API_KEY="$KEY" +unset ANTHROPIC_API_KEY [ -n "$MODEL_ID" ] && export ANTHROPIC_MODEL="$MODEL_ID" # Without a substitute, the background/summarisation calls ask the gateway for a # real Haiku it does not serve, and every one of them 404s. @@ -169,7 +172,8 @@ if [ "$PRINT_ENV" = 1 ]; then printf 'export %s=%q\n' "$v" "${!v}" done # Never printed: the credential. Use --list to confirm one was found. - echo 'export ANTHROPIC_AUTH_TOKEN= # and ANTHROPIC_API_KEY' + echo 'export ANTHROPIC_AUTH_TOKEN=' + echo 'unset ANTHROPIC_API_KEY' exit 0 fi From a9fcd83ed883135066c36dd8141081515972ca96 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:20:36 +0100 Subject: [PATCH 06/22] refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prime-agent switcher existed only as a 275-line string literal inside prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap that let a wrong ctx.ui.select() option shape ship in the pi extension. It now lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig checking it against the real @earendil-works/pi-coding-agent types, eslint coverage and an embed-freshness test, matching pi and opencode. The extraction was verified byte-identical before any edit, so the behaviour shipped today is exactly what was captured. Linting it then found six problems in code nothing had ever checked: object-truthiness null guards, a nullable string conditional and a missing return type. All behaviour-preserving to fix, but exactly the class of thing that ships silently when nothing is looking. Also: - `config pi` gains --dry-run, the last agent without it. - The SessionStart hook installer now drops untagged duplicates of its own exact command — rows left behind before the marker existed, or by a suite that used to write into a real ~/.claude. Invisible in the UI; they just run the sync twice per session. A hook the user wrote is never touched, even one calling `mcpctl skills sync` with different flags. - docs/claude-integration.md and docs/prime-agent-extension.md, the two integrations that had no page. prime-agent deliberately keeps its per-project MCP entry name rather than the constant `mcpctl` claude and opencode now use: its switcher already unmounts the previous project, so it never accumulates entries, and re-keying auth.json from mcp: to mcp:mcpctl would give up per-project token caching and needs a migration. Documented as its own change rather than folded in here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 10 + completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 1 + docs/claude-integration.md | 114 ++++++++ docs/prime-agent-extension.md | 101 +++++++ eslint.config.js | 2 +- package.json | 3 +- scripts/generate-prime-agent-extension.ts | 41 +++ src/cli/src/commands/config.ts | 17 +- src/cli/src/config/prime-agent-extension.ts | 9 +- src/cli/src/utils/sessionhook.ts | 26 ++ .../prime-agent-extension-embed.test.ts | 48 +++ src/cli/tests/utils/sessionhook.test.ts | 45 +++ src/prime-agent-ext/mcpctl-switch.ts | 275 ++++++++++++++++++ src/prime-agent-ext/tsconfig.json | 29 ++ 15 files changed, 716 insertions(+), 7 deletions(-) create mode 100644 docs/claude-integration.md create mode 100644 docs/prime-agent-extension.md create mode 100644 scripts/generate-prime-agent-extension.ts create mode 100644 src/cli/tests/config/prime-agent-extension-embed.test.ts create mode 100644 src/prime-agent-ext/mcpctl-switch.ts create mode 100644 src/prime-agent-ext/tsconfig.json diff --git a/README.md b/README.md index 8be6684..02c1515 100644 --- a/README.md +++ b/README.md @@ -164,12 +164,22 @@ The `/mcpctl` switcher runs with `--skip-extension --skip-marker`, so switching projects from inside prime-agent never re-scopes whichever repository prime-agent happened to be started in. +See [docs/prime-agent-extension.md](docs/prime-agent-extension.md) for full details. + Preview the change without writing anything: ```bash mcpctl config prime-agent --project monitoring --dry-run ``` +Inside Claude Code: + +- `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from + `/mcp` (no restart) +- the active project shows in the status line at the bottom of the screen + +See [docs/claude-integration.md](docs/claude-integration.md) for full details. + ### Connect pi [pi](https://github.com/earendil-works/pi) does not support MCP, but it supports diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index d83ba09..80c161a 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -128,7 +128,7 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) - COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir --dry-run -h --help" -- "$cur")) ;; prime-agent) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 0144057..9918f91 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -313,6 +313,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l extension-dir -d 'So complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l skip-skills -d 'Skip the initial skills sync' complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l settings -d 'pi settings.json path (default: ~/.pi/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l pi-dir -d 'Override the pi agent home (default: ~/.pi/agent)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent options complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' diff --git a/docs/claude-integration.md b/docs/claude-integration.md new file mode 100644 index 0000000..da59b78 --- /dev/null +++ b/docs/claude-integration.md @@ -0,0 +1,114 @@ +# mcpctl × Claude Code + +## What `mcpctl config claude --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| MCP entry | `./.mcp.json` | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | +| Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | +| SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | +| Status line | `~/.claude/settings.json` | the active project, bottom of the screen | +| `/mcpctl` | `~/.claude/commands/mcpctl.md` | switch projects from inside a session | + +`--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything +under `~/.claude`. + +## One server named `mcpctl`, not one per project + +The MCP entry used to be named after the project. Because `.mcp.json` is +*merged*, configuring a second project left the first mounted too — every +project you had ever configured stayed connected, with duplicate tool names and +nothing marking which was active. + +There is now exactly one entry, `mcpctl`, and switching rewrites what sits +behind it: + +```jsonc +{ "mcpServers": { "mcpctl": { "command": "mcpctl", "args": ["mcp", "-p", "docmost"] } } } +``` + +Two things follow. The tool prefix is stable across switches, so the model never +sees a tool namespace disappear mid-conversation. And because Claude Code can +reconnect an existing MCP server from `/mcp`, a switch lands without restarting +the app. + +Entries an older CLI wrote are retired on the next run. They are recognised by +the pairing that makes retiring them safe — *our* command, named after the very +project it bridges to. A hand-configured server is never touched, even one +called `docmost`, unless it also runs `mcpctl mcp -p docmost`, at which point it +is the same entry anyway. + +## Switching: `/mcpctl` + +``` +❯ /mcpctl docmost +● Bash(mcpctl config claude --project docmost --skip-marker) + ⎿ Wrote .mcp.json (1 server(s)) +● Now on docmost. Reconnect the mcpctl server from /mcp for its tools to load. +``` + +With no argument it lists the projects and asks. + +Claude Code slash commands are **prompt files, not code**, so unlike opencode's +picker this drives the model through CLI calls — there is no keyboard picker to +be had. `allowed-tools` is scoped to the four exact `mcpctl` invocations it +needs, so accepting it does not hand the session a general shell. + +> Every `` !`…` `` block in a slash command is permission-checked against +> `allowed-tools` *before* the model runs. Omitting one fails the whole command +> with a permission error and no explanation. A test asserts every pre-executed +> command in ours is covered. + +`--skip-marker` is deliberate: the session's directory is whatever you happened +to open, and re-scoping it would silently change which skills sync into it. + +## The status line + +``` + mcpctl:docmost + ⏵⏵ bypass permissions on · ← for agents +``` + +`mcpctl statusline` resolves the project from `.mcp.json`, falling back to a +`.mcpctl-project` marker up the tree so a checkout that is scoped but not yet +wired still reports. It reads the directory from the JSON Claude Code pipes in, +so it follows `/cwd` rather than reporting wherever the binary was launched, and +prints **nothing** when no project is active — an empty status line beats one +saying "none" on every unrelated repo. + +### It is never installed over yours + +A status line is a single slot, so overwriting a custom one silently deletes +work. When `config claude` finds a foreign one it leaves it and prints the +snippet to add instead: + +``` +Left your existing status line alone (my-fancy-prompt). + To show the project too, append: $(mcpctl statusline) +``` + +> **Ownership is decided by the command string, not a marker.** Claude Code +> rewrites `settings.json` against its own schema and **strips unknown keys from +> `statusLine`** — a tagged entry comes back as a bare `{type, command}`. +> (Hooks keep their marker; `statusLine` does not.) Matching on the command is +> what stops us reporting our own line as foreign forever. A line that merely +> *composes* ours — `my-prompt && mcpctl statusline` — is yours, and is left +> alone. + +## Skills + +`mcpctl skills sync` installs into `~/.claude/skills/`. Claude Code is the only +target that also gets hooks, `postInstall` and `mcpServers` auto-attach; pi, +prime-agent and opencode share the simpler flat-tree semantics. + +The SessionStart hook keeps them current. It carries a `_mcpctl_managed` marker, +and an install now also drops **untagged duplicates of that exact command** — +rows left behind before the marker existed (or by a test suite that used to +write into a real `~/.claude`). They are invisible in the UI and just run the +sync twice per session. A hook you wrote is never touched, even one that also +calls `mcpctl skills sync` with different flags. + +## Running Claude Code on the homelab LLM + +See [claude-vllm.md](claude-vllm.md). diff --git a/docs/prime-agent-extension.md b/docs/prime-agent-extension.md new file mode 100644 index 0000000..3d6b48b --- /dev/null +++ b/docs/prime-agent-extension.md @@ -0,0 +1,101 @@ +# mcpctl × prime-agent + +## What `mcpctl config prime-agent --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| Proxy MCP | `~/.prime/agent/settings.json` | `{ type: "http", url: "/projects/X/mcp" }` | +| Bearer credential | `~/.prime/agent/auth.json` | `mcp:X` → a minted mcpctl PAT | +| Skills | `~/.prime/agent/skills/` | the project's `SKILL.md` bundles | +| `/mcpctl` switcher | `~/.prime/agent/extensions/mcpctl-switch.ts` | switch projects, and the active-project indicator | + +Unlike Claude Code (a stdio bridge, no token) and pi (native tools over +JSON-RPC), prime-agent talks to the **HTTP gateway**, so the switch is really +three things: a credential, a settings entry, and a reload. + +## Credentials are the fragile part + +`config prime-agent` mints an `mcptoken` per project, stores it under +`mcp:`, and retires the one it replaced. Three rules make that safe: + +- **A key being present proves nothing.** A revoked token would short-circuit + provisioning and leave prime-agent unable to reach the gateway while the + command reported success. mcptokens are shown once, so the stored token's + 16-char `tokenPrefix` is compared against the project's *active* tokens rather + than sending the secret. +- **No credential means the switch fails.** Non-zero exit, `settings.json` + untouched, so the previously active project keeps working instead of being + replaced by a mount that 401s — and the `/mcpctl` switcher, which reads that + exit code, reports failure rather than success over a project with no tools. +- **Only the token we replaced is revoked.** Sweeping every `prime-agent` token + for a project would kill the one another machine is using. Anything else that + looks orphaned is reported, not deleted. + +This plumbing is shared with `config opencode`, parameterised by agent rather +than copied. + +## The `/mcpctl` switcher + +`pi.registerCommand('mcpctl', …)` opens a picker, shells out to +`mcpctl config prime-agent --project X --skip-extension --skip-marker`, then +calls `ctx.reload()` — which re-reads `settings.json` and `auth.json` and +rebuilds the MCP map, so the switch lands without restarting the app. + +`--skip-extension` stops it rewriting the very file it is running from; +`--skip-marker` stops it re-scoping whatever repository prime-agent was started +in, which Claude Code's own skills sync would then pick up. + +Above 20 projects it asks for a filter first: prime-agent's selector is an +arrow-key list with no search, and real installs run to hundreds of projects. +(opencode's dialog filters as you type, so its switcher needs no such prompt.) + +## The active-project indicator + +Published with `ctx.ui.setStatus('mcpctl', …)`, which prime-agent renders in its +tray line next to the model name. + +Two quirks worth knowing: + +- prime-agent emits `session_start` **only from `reload()`**, never at startup — + so the indicator is also published on `turn_start`, the earliest moment with a + real UI context bound. +- `resetExtensionUI()` clears extension statuses *after* `session_start`, so the + indicator set there is wiped before anyone sees it. It is re-published on a + short retry schedule to land after that reset. + +> The rendering itself only exists in prime-agent from +> `prime-agent-extension-status.patch` (upstream PR pending). On an unpatched +> build `setStatus` silently does nothing and no indicator appears. + +## The extension is real source now + +It used to exist **only** as a string literal inside +`src/cli/src/config/prime-agent-extension.ts` — so nothing typechecked or linted +it, which is precisely the gap that let a wrong `ctx.ui.select()` option shape +ship in the pi extension. + +It now lives at `src/prime-agent-ext/mcpctl-switch.ts`, checked against the real +`@earendil-works/pi-coding-agent` types: + +```bash +pnpm typecheck:prime-agent-ext +npx tsx scripts/generate-prime-agent-extension.ts # after editing it +``` + +A test fails if the embed goes stale. Extracting it found six lint problems in +code that had never been linted — all null-guard and return-type issues rather +than live bugs, but exactly the class of thing that ships silently when nothing +is looking. + +## What it deliberately does *not* do + +The MCP entry is still **named after the project**, not the constant `mcpctl` +that `config claude` and `config opencode` now use. prime-agent's switcher +already unmounts the previous project, so it never accumulates entries the way +`config claude` did — the bug the constant name fixes does not exist here. + +The remaining difference is tool-prefix stability: switching changes tool names, +so the model can hold stale ones. Moving prime-agent to a constant name would +also re-key `auth.json` from `mcp:` to `mcp:mcpctl`, giving up +per-project token caching and needing a migration. Worth doing, but as its own +change rather than folded into a parity pass. diff --git a/eslint.config.js b/eslint.config.js index b247282..b4e6858 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/prime-agent-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/package.json b/package.json index 5d8ab67..ef00c0f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext && pnpm run typecheck:prime-agent-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -35,6 +35,7 @@ "mcpd:logs": "bash logs.sh", "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", "typecheck:opencode-ext": "tsc -p src/opencode-ext/tsconfig.json", + "typecheck:prime-agent-ext": "tsc -p src/prime-agent-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { diff --git a/scripts/generate-prime-agent-extension.ts b/scripts/generate-prime-agent-extension.ts new file mode 100644 index 0000000..6dcc224 --- /dev/null +++ b/scripts/generate-prime-agent-extension.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/prime-agent-extension.ts`, which embeds the + * prime-agent `/mcpctl` switcher as a string constant — the same arrangement + * the pi and opencode extensions use. + * + * The switcher used to live *only* as that string literal, with no source file + * behind it, so nothing typechecked or linted it. Now `src/prime-agent-ext/` + * holds the real source and this script produces the embed, so the file the CLI + * writes into ~/.prime/agent/extensions/ is always exactly what was checked. + * + * Regenerate after editing the extension source: + * npx tsx scripts/generate-prime-agent-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'prime-agent-ext'); + +const SOURCE = readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8'); + +const out = `/** + * The source of the \`/mcpctl\` project-switcher extension, exported as a string + * so \`mcpctl config prime-agent\` can install it into prime-agent's auto- + * discovered extensions directory (\`~/.prime/agent/extensions/\`). + * + * DO NOT EDIT BY HAND. Generated by + * \`npx tsx scripts/generate-prime-agent-extension.ts\` from + * \`src/prime-agent-ext/mcpctl-switch.ts\` — edit that instead, so the change is + * typechecked before it ships. The installed file is this exact source + * (verbatim), so the extension the CLI writes is always the one that ran. + */ +export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; +export const MCPCTL_SWITCH_EXTENSION = ${JSON.stringify(SOURCE)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'prime-agent-extension.ts'), out); +console.log('wrote src/cli/src/config/prime-agent-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index d37c541..84e0eaf 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -612,7 +612,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--skip-skills', 'Skip the initial skills sync') .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') .option('--pi-dir ', 'Override the pi agent home (default: ~/.pi/agent)') - .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => { + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string; dryRun?: boolean }) => { if (!opts.project) { log('Error: --project is required for mcpctl config pi'); process.exitCode = 1; @@ -628,6 +629,20 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // custom --pi-dir is used (also keeps tests off the real ~/.mcpctl). const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath(); + if (opts.dryRun === true) { + log(JSON.stringify({ + pi: { + settingsPath, + extensionDir: extDest, + statePath, + skillsDir: opts.skipSkills === true ? '' : skillsInstall, + source: opts.extensionDir ?? '', + }, + action: 'install extension files + register in settings.json (extensions + skills) + write active project + sync skills', + }, null, 2)); + return; + } + // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/. // Default: write the embedded sources (works from an installed binary // with no source tree). --extension-dir overrides with a source-tree diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 39f7966..7231a71 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -3,8 +3,11 @@ * so `mcpctl config prime-agent` can install it into prime-agent's auto- * discovered extensions directory (`~/.prime/agent/extensions/`). * - * The installed file is this exact source (verbatim), so the extension shipped - * by the CLI is always the one that runs. + * DO NOT EDIT BY HAND. Generated by + * `npx tsx scripts/generate-prime-agent-extension.ts` from + * `src/prime-agent-ext/mcpctl-switch.ts` — edit that instead, so the change is + * typechecked before it ships. The installed file is this exact source + * (verbatim), so the extension the CLI writes is always the one that ran. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p !== null && typeof p === 'object' && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry !== undefined && entry !== null && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry !== undefined && entry !== null ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description !== undefined && p.description !== '' ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI): void {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (picked === undefined || picked === '') return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts index f0f6cf4..47f9a54 100644 --- a/src/cli/src/utils/sessionhook.ts +++ b/src/cli/src/utils/sessionhook.ts @@ -100,6 +100,32 @@ export async function installManagedSessionHook( } } + // Drop untagged copies of the very command we manage. + // + // Before this installer carried a marker — and, for a long while, whenever + // the test suite ran against the developer's real ~/.claude — an identical + // but untagged row could be left behind. It is invisible in the UI and simply + // runs the sync a second time on every session start. Scoped to an exact + // string match on our own command, so a hook someone wrote themselves (even + // one that also calls `mcpctl skills sync`, but with different flags) is + // never touched. + if (foundEntry) { + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + const kept = group.hooks.filter((e) => e[MARKER_KEY] === true || e.command !== command); + if (kept.length !== group.hooks.length) { + group.hooks = kept; + entryChanged = true; + } + } + // A group we emptied is noise in the file. + const nonEmpty = groups.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0); + if (nonEmpty.length !== groups.length) { + settings.hooks.SessionStart = nonEmpty; + entryChanged = true; + } + } + if (!foundEntry) { groups.push({ hooks: [{ type: 'command', command, [MARKER_KEY]: true }], diff --git a/src/cli/tests/config/prime-agent-extension-embed.test.ts b/src/cli/tests/config/prime-agent-extension-embed.test.ts new file mode 100644 index 0000000..20a5a2c --- /dev/null +++ b/src/cli/tests/config/prime-agent-extension-embed.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + MCPCTL_SWITCH_EXTENSION, + MCPCTL_SWITCH_EXTENSION_FILENAME, +} from '../../src/config/prime-agent-extension.js'; + +/** + * `mcpctl config prime-agent` installs the *embedded* copy of the switcher, not + * the file in src/prime-agent-ext/. Editing the source without re-running the + * generator therefore ships stale code while the repo looks correct. + * + * This matters more here than for the pi and opencode extensions: until the + * source file existed, the switcher was only a string literal, so nothing + * typechecked or linted it at all. The guarantee only holds while the two stay + * in sync. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'prime-agent-ext'); + +describe('embedded prime-agent switcher', () => { + it('matches the source in src/prime-agent-ext (re-run scripts/generate-prime-agent-extension.ts)', () => { + expect(MCPCTL_SWITCH_EXTENSION, 'stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8')); + }); + + it('installs under the name prime-agent auto-discovers', () => { + expect(MCPCTL_SWITCH_EXTENSION_FILENAME).toBe('mcpctl-switch.ts'); + }); + + it('is self-contained — the installed file has no mcpctl imports to resolve', () => { + expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '@mcpctl\//); + expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '\.\.\//); + }); + + it('switches without re-installing itself or re-scoping the launch directory', () => { + // Rewriting the extension file prime-agent has already loaded buys nothing; + // writing a marker would silently re-scope whatever repo it was started in. + expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-extension'"); + expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-marker'"); + }); + + it('publishes the active-project indicator, which is the only visible state', () => { + expect(MCPCTL_SWITCH_EXTENSION).toContain('setStatus'); + expect(MCPCTL_SWITCH_EXTENSION).toContain("'turn_start'"); + }); +}); diff --git a/src/cli/tests/utils/sessionhook.test.ts b/src/cli/tests/utils/sessionhook.test.ts index 052e8b3..26d72ad 100644 --- a/src/cli/tests/utils/sessionhook.test.ts +++ b/src/cli/tests/utils/sessionhook.test.ts @@ -104,3 +104,48 @@ describe('sessionhook', () => { expect(settings.hooks.SessionStart).toHaveLength(1); }); }); + +describe('untagged duplicates of the managed hook', () => { + let tmp2: string; + let settings: string; + beforeEach(async () => { + tmp2 = await mkdtemp(join(tmpdir(), 'mcpctl-hook-dupe-')); + settings = join(tmp2, 'settings.json'); + }); + afterEach(async () => { await rm(tmp2, { recursive: true, force: true }); }); + + it('removes an identical row left behind before the marker existed', async () => { + // Exactly the shape found in a real ~/.claude: one tagged row, one not. + // Invisible in the UI; it just runs the sync twice every session. + await writeFile(settings, JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet' }] }, + { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }] }, + ], + }, + })); + const { updated } = await installManagedSessionHook('mcpctl skills sync --quiet', settings); + expect(updated).toBe(true); + + const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { + hooks: { SessionStart: Array<{ hooks: Array> }> }; + }; + const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks); + expect(rows).toEqual([{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }]); + }); + + it('leaves a hook the user wrote alone, even one that also calls mcpctl', async () => { + await writeFile(settings, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'mcpctl skills sync --project mine' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', settings); + const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { + hooks: { SessionStart: Array<{ hooks: Array<{ command: string }> }> }; + }; + const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks).map((r) => r.command); + expect(rows).toContain('mcpctl skills sync --project mine'); + }); +}); diff --git a/src/prime-agent-ext/mcpctl-switch.ts b/src/prime-agent-ext/mcpctl-switch.ts new file mode 100644 index 0000000..9239412 --- /dev/null +++ b/src/prime-agent-ext/mcpctl-switch.ts @@ -0,0 +1,275 @@ +/** + * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/. + * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy + * MCP + skills) from inside prime-agent, then reloads the session. + * + * It shells out to the `mcpctl` CLI (same binary that wrote the config) to + * list projects and apply the switch, then asks the running TUI to reload so + * the new project's MCP servers, credentials and skills take effect without an + * app restart. Keeping the logic in the CLI means this UI shell stays in + * lock-step with the machinery in the mcpctl repo. + */ +import { exec } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const AGENT_DIR = join(homedir(), '.prime', 'agent'); + +interface ProjectInfo { + name: string; + description?: string; +} + +function mcpctl(...args: string[]): Promise { + const quoted = args.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(' '); + return new Promise((resolve, reject) => { + exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || String(err)).trim() || String(err))); + else resolve(stdout || ''); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl('get', 'projects', '-o', 'json'); + const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>; + return parsed.filter((p) => p !== null && typeof p === 'object' && typeof p.name === 'string').map((p) => ({ + name: p.name as string, + description: p.description, + })); +} + +/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */ +async function credentialedProjects(): Promise> { + const out = new Set(); + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8'); + const parsed = JSON.parse(raw) as Record; + for (const [k, v] of Object.entries(parsed)) { + if (!k.startsWith('mcp:')) continue; + const key = v?.key; + if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4)); + } + } catch { + // no auth.json (or unreadable) — nothing to adopt + } + return out; +} + +/** + * The single *active* mcpctl project. Entries this CLI wrote carry an + * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an + * untagged entry also counts when its URL is the canonical + * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl + * PAT. A hand-configured server has no such credential and is never mistaken + * for the active project. + */ +async function activeProject(): Promise { + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8'); + const settings = JSON.parse(raw) as { mcpServers?: Record> }; + if (!settings.mcpServers) return null; + const names = Object.keys(settings.mcpServers); + for (const name of names) { + const entry = settings.mcpServers[name]; + if (entry !== undefined && entry !== null && entry['mcpctlManaged'] === true) return name; + } + const credentialed = await credentialedProjects(); + for (const name of names) { + const entry = settings.mcpServers[name]; + const url = entry !== undefined && entry !== null ? entry['url'] : undefined; + if (typeof url !== 'string' || !credentialed.has(name)) continue; + if (url.replace(/\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name; + } + return null; + } catch { + return null; + } +} + +/** Key our indicator is stored under (both the widget and the footer status). */ +const STATUS_KEY = 'mcpctl'; + +interface StatusCapableContext { + hasUI?: boolean; + ui: { setStatus(key: string, text: string | undefined): void }; +} + +/** + * Show the active project in the UI, so it is visible at a glance instead of + * something you run a command to discover. + * + * Published via `setStatus`, which both hosts render next to the model name: + * pi in its footer, prime-agent in the tray line built by + * `getTrayLocationLabel()`. + * + * NOTE: prime-agent only grew that rendering in + * `prime-agent-extension-status.patch` (upstream PR pending) — before it, + * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this + * call silently did nothing. An unpatched build shows no indicator; a widget + * would render there but scrolls away with the transcript, so it is not a + * substitute for a status line. + */ +async function publishStatus(ctx: StatusCapableContext): Promise { + // Before the TUI binds its UI context the runtime hands extensions a no-op + // one, where every setter silently discards. Publishing then would cache a + // label that never rendered. + if (ctx.hasUI === false) return; + let active: string | null = null; + try { + active = await activeProject(); + } catch { + active = null; + } + // Deliberately not skipped when the value is unchanged: prime-agent clears + // extension statuses on reset (see the retries in session_start), so a cached + // "nothing changed" short-circuit would leave the indicator permanently blank. + ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined); +} + +/** Above this many projects, offer a filter before opening the list. */ +const FILTER_THRESHOLD = 20; + +/** + * Order and filter the project list for the picker. + * + * Active project first (most likely pick), then alphabetical. Terms are + * space-separated and ALL must match as case-insensitive substrings against + * the name or description, so `home auto` finds `homeautomation`. A blank + * query keeps everything. + */ +export function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] { + const ordered = [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); + const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0); + if (terms.length === 0) return ordered; + return ordered.filter((p) => { + const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase(); + return terms.every((t) => haystack.includes(t)); + }); +} + +/** + * Choose a project, asking for a filter first when the list is long. + * + * The host's selector is a plain arrow-key list with no search, so filtering + * has to happen before the list is handed over. Real installs run to hundreds + * of projects (smoke-test leftovers included), where scrolling is hopeless. + */ +async function pickProject( + ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } }, + projects: ProjectInfo[], + active: string | null, +): Promise { + let candidates = filterProjects(projects, '', active); + if (candidates.length > FILTER_THRESHOLD) { + const query = await ctx.ui.input( + `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`, + 'e.g. home auto', + ); + if (query === undefined) return undefined; // cancelled + candidates = filterProjects(projects, query, active); + if (candidates.length === 0) { + ctx.ui.notify(`No project matches '${query}'`, 'warning'); + return undefined; + } + } + + // No client-side cap: prime-agent's selector windows long lists itself and + // shows a true "(20/356)" counter, so truncating here would only replace an + // accurate total with a misleading one. + const items = candidates.map((p) => (p.description !== undefined && p.description !== '' ? `${p.name} — ${p.description}` : p.name)); + const picked = await ctx.ui.select( + (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project') + + ` (${String(candidates.length)})`, + items, + ); + if (picked === undefined) return undefined; + return picked.split(' — ')[0]?.trim(); +} + +export default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI): void { + // prime-agent emits `session_start` ONLY from reload() — never at startup — + // so this alone would leave the indicator blank until the first switch. + // `turn_start` fires on every user turn with a real UI context bound, which + // is the earliest reliable moment; publishStatus is a no-op when the label + // has not changed, so calling it per turn costs nothing. + pi.on('session_start', async (_event, ctx) => { + await publishStatus(ctx); + // prime-agent wipes extension state shortly after startup: + // resetExtensionUI() calls clearExtensionStatuses() (and + // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the + // connection-state-snapshot handler, both of which land *after* + // session_start. The indicator set above is therefore cleared before it is + // ever seen. Re-publish a few times to land after that reset; setStatus is + // idempotent, so an unnecessary retry costs one re-render. + for (const delay of [1_000, 3_000, 6_000]) { + setTimeout(() => { void publishStatus(ctx); }, delay); + } + }); + pi.on('turn_start', async (_event, ctx) => { + await publishStatus(ctx); + }); + + pi.registerCommand('mcpctl', { + description: 'Switch the active mcpctl project (proxy MCP + skills) and reload', + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify('/mcpctl needs an interactive session', 'error'); + return; + } + // Running the command is itself proof of a real UI, and the "already on + // X" path below returns without reloading — so publish here too. + await publishStatus(ctx); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error'); + return; + } + if (projects.length === 0) { + ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info'); + return; + } + + const active = await activeProject(); + const picked = await pickProject(ctx, projects, active); + if (picked === undefined || picked === '') return; + + const name = picked; + if (name === active) { + ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info'); + return; + } + + ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info'); + try { + // Mint the project token (if needed), write settings.json + auth.json, + // and sync skills. --skip-extension stops re-installing this very file; + // --skip-marker stops us writing a .mcpctl-project into whatever + // directory prime-agent was launched from, which would silently + // re-scope that repo for Claude Code's own skills sync. + await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker'); + } catch (err) { + ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error'); + return; + } + + // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP + // integration map from scratch, so the old project's gateway is dropped + // and the new one mounted without restarting the app. + await ctx.reload(); + // reload re-emits session_start, which refreshes the footer — but this + // command's context outlives that, so set it here too rather than relying + // on ordering. + await publishStatus(ctx); + ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info'); + }, + }); +} diff --git a/src/prime-agent-ext/tsconfig.json b/src/prime-agent-ext/tsconfig.json new file mode 100644 index 0000000..215ecbd --- /dev/null +++ b/src/prime-agent-ext/tsconfig.json @@ -0,0 +1,29 @@ +{ + "//": [ + "The prime-agent `/mcpctl` switcher is shipped as source (embedded in the", + "CLI, then written into ~/.prime/agent/extensions/) and is therefore never", + "compiled by the CLI's own build.", + "", + "Until this project existed it lived only as a string literal inside", + "src/cli/src/config/prime-agent-extension.ts, which means nothing typechecked", + "it at all — the same gap that let a `ctx.ui.select()` call with the wrong", + "option shape ship in the pi extension (see src/pi-ext/tsconfig.json).", + "", + "It is checked against the REAL @earendil-works/pi-coding-agent types, the", + "ExtensionAPI prime-agent implements, rather than a hand-written shim." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-switch.ts"] +} From b7c0de2bf00d785b93dd909366f35648bd8fe36d Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:59:48 +0100 Subject: [PATCH 07/22] feat(claude): register the MCP server in user scope by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in every checkout you opened — and in a repo that commits `.mcp.json` (this one does) it dirtied the working tree. Every other integration is already global: pi, prime-agent and opencode each have one active project, wired once. Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in every directory and window. That is now the default. `--scope project`, or an explicit `-o/--output`, keeps the old per-directory file for a repo that wants its own pinned project. `--inspect` stays project-scope — it is a debugging server you turn on for one checkout. Details worth knowing: - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside it. Verified against a live Claude Code run with an isolated config dir. - `.claude.json` also holds onboarding state, caches and a per-project map that Claude Code rewrites while running, so this merges into the document and writes through a temp file + rename. - User scope writes no `.mcpctl-project` marker: it scopes nothing to a directory, and a marker beside `.claude.json` would sit in $HOME and scope every repo under it. - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that pinned itself wins), then user scope, then the marker. Scope selection reads Commander's option source rather than process.argv — argv is the test runner's command line when the command is driven in-process, which the suite caught immediately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 5 ++ completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 6 +- docs/claude-integration.md | 38 ++++++++++- src/cli/src/commands/config.ts | 97 ++++++++++++++++++++++----- src/cli/src/commands/statusline.ts | 15 ++++- src/cli/src/config/claude-mcp.ts | 76 ++++++++++++++++++++- src/cli/tests/commands/claude.test.ts | 84 ++++++++++++++++++++++- 8 files changed, 299 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 02c1515..b76b6d0 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ Preview the change without writing anything: mcpctl config prime-agent --project monitoring --dry-run ``` +`config claude` registers in **user scope** by default — one `mcpctl` MCP server +in `~/.claude.json` that applies in every directory and window, so you wire it +once rather than per checkout. Use `--scope project` (or `-o`) for a repo that +wants its own pinned project. + Inside Claude Code: - `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 80c161a..a045a22 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -122,10 +122,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir --dry-run -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9918f91..e79dc3b 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -287,7 +287,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config view" -s o -l output -d 'ou # config claude options complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' @@ -298,7 +299,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Prin # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' diff --git a/docs/claude-integration.md b/docs/claude-integration.md index da59b78..d0163b5 100644 --- a/docs/claude-integration.md +++ b/docs/claude-integration.md @@ -4,7 +4,7 @@ | Piece | Where | Purpose | |-------|-------|---------| -| MCP entry | `./.mcp.json` | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| MCP entry | `~/.claude.json` (**user scope, every directory**) | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | | Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | | Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | | SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | @@ -14,6 +14,37 @@ `--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything under `~/.claude`. +## User scope by default: wire it once, not per checkout + +Claude Code has two MCP scopes: **project** (`./.mcp.json`, that directory only, +and usually committed — so writing to it dirties the repo) and **user** +(`mcpServers` in `.claude.json`, every directory and every window). + +mcpctl now defaults to **user** scope. One active project everywhere is how the +pi, prime-agent and opencode integrations already behave; per-directory wiring +meant re-running `config claude` in every checkout you opened. Switching the +project is then one command from anywhere. + +```bash +mcpctl config claude --project homeautomation # applies everywhere +mcpctl config claude --project docmost --scope project # just this repo +mcpctl config claude --project docmost -o .mcp.json # same thing; -o implies project scope +``` + +User scope writes **no `.mcpctl-project` marker** — it scopes nothing to a +directory, and a marker beside `.claude.json` would sit in `$HOME` and scope +every repo under it. `--inspect` stays project-scope: it is a debugging server +you turn on for one checkout. + +> `.claude.json` also holds onboarding state, caches and a per-project map that +> Claude Code rewrites while running, so mcpctl merges into it and writes +> through a temp file + rename. A truncated write there costs far more than a +> stale MCP entry. +> +> Note the path asymmetry: with `CLAUDE_CONFIG_DIR` set the file is +> `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` — +> *beside* `~/.claude/`, not inside it. + ## One server named `mcpctl`, not one per project The MCP entry used to be named after the project. Because `.mcp.json` is @@ -70,8 +101,9 @@ to open, and re-scoping it would silently change which skills sync into it. ⏵⏵ bypass permissions on · ← for agents ``` -`mcpctl statusline` resolves the project from `.mcp.json`, falling back to a -`.mcpctl-project` marker up the tree so a checkout that is scoped but not yet +`mcpctl statusline` resolves the project from a directory-scoped `.mcp.json` +first (a repo that pinned itself wins), then the user-scope entry in +`.claude.json`, then a `.mcpctl-project` marker up the tree so a checkout that is scoped but not yet wired still reports. It reads the directory from the JSON Claude Code pipes in, so it follows `/cwd` rather than reporting wherever the binary was launched, and prints **nothing** when no project is active — an empty status line beats one diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 84e0eaf..8623513 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; @@ -43,8 +43,12 @@ import { import { MCPCTL_SERVER_NAME, mergeMcpctlServers, + mergeUserScopeServer, + userScopeProject, activeProjectIn, + claudeJsonPath, type McpJson, + type ClaudeJson, } from '../config/claude-mcp.js'; import { opencodeConfigDir, @@ -74,6 +78,23 @@ const OPENCODE_TOKEN_PREFIX = 'opencode'; * the caller starts fresh — the same behaviour as before, kept because a * half-written file must not stop you re-provisioning. */ +function readJsonFile(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return null; + } +} + +/** Write JSON through a temp file + rename, so a crash cannot truncate it. */ +function writeJsonAtomicSync(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + renameSync(tmp, path); +} + function readMcpJson(path: string): McpJson | null { if (!existsSync(path)) return null; try { @@ -279,7 +300,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .command(name) .description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook') .option('-p, --project ', 'Project name') - .option('-o, --output ', 'Output file path', '.mcp.json') + .option('--scope ', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user') + .option('-o, --output ', 'Project-scope output file path (implies --scope project)', '.mcp.json') .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') @@ -287,7 +309,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--skip-ui', 'Do not install the status line or the /mcpctl slash command') .option('--claude-dir ', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)') .option('--dry-run', 'Print what would change without writing or syncing') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => { + .action(async (opts: { project?: string; scope: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }, command: Command) => { // Resolve Claude's config dir once: an explicit --claude-dir wins, then // $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather // than letting each helper default) is what keeps the test suite off the @@ -301,12 +323,41 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - const outputPath = resolve(opts.output); - const existing = readMcpJson(outputPath); - const { config: finalConfig, retired } = mergeMcpctlServers(existing, { - ...(opts.project !== undefined ? { project: opts.project } : {}), - ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), - }); + // An explicit --output only makes sense for the per-directory file, so + // it selects project scope on its own — no need to pass both. + // Commander's source tracking, not process.argv: the latter is the test + // runner's command line when the command is driven in-process. + const explicitOutput = command.getOptionValueSource('output') === 'cli'; + const scope = explicitOutput ? 'project' : opts.scope; + if (scope !== 'user' && scope !== 'project') { + log(`Error: unknown --scope '${scope}' (expected 'user' or 'project')`); + process.exitCode = 1; + return; + } + const userScope = scope === 'user'; + + const outputPath = userScope ? claudeJsonPath() : resolve(opts.output); + const existing = userScope + ? (readJsonFile(outputPath) ?? {}) + : readMcpJson(outputPath); + + // `--inspect` is a project-scope idea (a debugging server you turn on + // for one checkout), so it stays on .mcp.json even in user scope. + let finalConfig: McpJson | ClaudeJson; + let retired: string[]; + if (userScope) { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for user scope (--scope project for an --inspect-only .mcp.json)'); + process.exitCode = 1; + return; + } + ({ config: finalConfig, retired } = mergeUserScopeServer(existing as ClaudeJson, opts.project)); + } else { + ({ config: finalConfig, retired } = mergeMcpctlServers(existing as McpJson, { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), + })); + } if (opts.stdout === true) { log(JSON.stringify(finalConfig, null, 2)); @@ -316,10 +367,13 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (opts.dryRun === true) { log(JSON.stringify({ claude: { + scope, output: outputPath, - previousProject: activeProjectIn(existing), + previousProject: userScope + ? userScopeProject(existing as ClaudeJson) + : activeProjectIn(existing as McpJson), server: MCPCTL_SERVER_NAME, - entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '', + entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '', retiredLegacyEntries: retired, marker: opts.skipMarker === true || opts.project === undefined ? '' @@ -333,9 +387,14 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); - const serverCount = Object.keys(finalConfig.mcpServers).length; - log(`Wrote ${outputPath} (${serverCount} server(s))`); + // Atomic: `.claude.json` also holds Claude Code's onboarding state and + // per-project map, and Claude Code rewrites it while running — a + // truncated write there costs far more than a stale MCP entry. + writeJsonAtomicSync(outputPath, finalConfig); + const serverCount = Object.keys(finalConfig.mcpServers ?? {}).length; + log(userScope + ? `Registered '${MCPCTL_SERVER_NAME}' for every directory in ${outputPath}` + : `Wrote ${outputPath} (${String(serverCount)} server(s))`); if (retired.length > 0) { // Before the constant name, every project you configured stayed // mounted alongside the new one. @@ -344,12 +403,20 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (opts.project !== undefined) { log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`); } + if (userScope) { + // The whole point of user scope: you do this once, not per checkout. + log('This applies in every directory — no need to re-run it per repo.'); + } // PR-5: write project marker, run initial skills sync, install // SessionStart hook. Skipped when --inspect-only or --skip-skills. if (opts.project && !opts.skipSkills) { const projectDir = dirname(outputPath); - if (opts.skipMarker === true) { + if (userScope) { + // User scope deliberately scopes nothing to a directory; writing a + // marker into $HOME would silently scope every repo under it. + log('Skipped .mcpctl-project marker (user scope is not directory-specific)'); + } else if (opts.skipMarker === true) { log('Skipped .mcpctl-project marker (--skip-marker)'); } else { try { diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts index a3d7c29..7f22ede 100644 --- a/src/cli/src/commands/statusline.ts +++ b/src/cli/src/commands/statusline.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { activeProjectIn, type McpJson } from '../config/claude-mcp.js'; +import { activeProjectIn, claudeJsonPath, userScopeProject, type McpJson, type ClaudeJson } from '../config/claude-mcp.js'; import { findProjectMarker } from '../utils/project-marker.js'; /** @@ -53,6 +53,15 @@ export function resolveDirectory(input: StatusLineInput, fallback: string): stri return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; } +/** The project Claude Code's user-scope config mounts, or null. */ +export function projectFromUserScope(path: string): string | null { + try { + return userScopeProject(JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson); + } catch { + return null; + } +} + /** The project `.mcp.json` in `dir` mounts, or null. */ export function projectFromMcpJson(dir: string): string | null { try { @@ -87,7 +96,9 @@ export function createStatuslineCommand(deps?: Partial): Command const input = opts.directory !== undefined ? {} : await readStdinJson(); const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); - let project = projectFromMcpJson(dir); + // Directory-scoped wiring wins: a repo with its own .mcp.json entry has + // deliberately pinned itself, and that beats the global default. + let project = projectFromMcpJson(dir) ?? projectFromUserScope(claudeJsonPath()); if (project === null) { // Not wired here (or wired above this directory) — the marker is the // other thing `config claude` writes, and skills sync already trusts it. diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts index 6feaa0f..d426b26 100644 --- a/src/cli/src/config/claude-mcp.ts +++ b/src/cli/src/config/claude-mcp.ts @@ -1,3 +1,6 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + /** * `.mcp.json` shaping for `mcpctl config claude`. * @@ -19,7 +22,24 @@ * `isLegacyMcpctlEntry` for what counts as ours. */ -/** The one MCP server name mcpctl owns in `.mcp.json`. */ +/** + * WHERE THE ENTRY LIVES + * + * Claude Code has two MCP scopes: + * - **project** — `./.mcp.json`, which applies only in that directory (and is + * usually committed, so writing to it dirties the repo); + * - **user** — `mcpServers` in `.claude.json`, which applies in every + * directory and every window. + * + * mcpctl defaults to **user** scope, because one active project everywhere is + * how the pi, prime-agent and opencode integrations already behave — and + * because per-directory wiring means re-running `config claude` in every + * checkout you open. `--scope project` (or an explicit `--output`) keeps the + * old per-directory file for a repo that genuinely wants its own pinned + * project. + */ + +/** The one MCP server name mcpctl owns. */ export const MCPCTL_SERVER_NAME = 'mcpctl'; /** Name of the optional traffic-inspection server (`--inspect`). */ @@ -131,3 +151,57 @@ export function mergeMcpctlServers( delete rest.mcpServers; return { config: { ...rest, mcpServers: servers }, retired }; } + + +/** + * Path of Claude Code's user-scope config. + * + * NOTE the asymmetry: with `CLAUDE_CONFIG_DIR` set the file is + * `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` + * — *beside* `~/.claude/`, not inside it. Verified against a live Claude Code + * run with an isolated config dir. + */ +export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: string): string { + const override = env['CLAUDE_CONFIG_DIR']; + const home = homeDir ?? homedir(); + return override !== undefined && override !== '' + ? join(override, '.claude.json') + : join(home, '.claude.json'); +} + +/** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */ +export interface ClaudeJson { + mcpServers?: Record; + [key: string]: unknown; +} + +/** + * Set the user-scope entry, returning the new document and any legacy + * project-named entries retired from it. + * + * `.claude.json` also holds onboarding state, caches and a per-project map that + * Claude Code rewrites constantly — so this merges into the document it was + * given and never reconstructs it. + */ +export function mergeUserScopeServer( + existing: ClaudeJson | null | undefined, + project: string, +): { config: ClaudeJson; retired: string[] } { + const doc: ClaudeJson = { ...(existing ?? {}) }; + const servers: Record = { ...(doc.mcpServers ?? {}) }; + const retired: string[] = []; + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(project); + doc.mcpServers = servers; + return { config: doc, retired }; +} + +/** The project the user-scope entry mounts, or null. */ +export function userScopeProject(doc: ClaudeJson | null | undefined): string | null { + return activeProjectIn({ mcpServers: doc?.mcpServers ?? {} }); +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9a079f7..9f43bd6 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -278,3 +278,85 @@ describe('config impersonate', () => { expect(output.join('\n')).toContain('No impersonation session to quit'); }); }); + +describe('config claude — user scope', () => { + let output: string[]; + let tmpDir: string; + let claudeDir: string; + let prior: string | undefined; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const claudeJson = (): string => join(claudeDir, '.claude.json'); + + beforeEach(() => { + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-user-')); + claudeDir = join(tmpDir, 'claude-home'); + mkdirSync(claudeDir, { recursive: true }); + prior = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (prior === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = prior; + }); + + const cmd = () => createConfigCommand({ configDeps: {}, log }); + + it('registers in .claude.json by default, not a per-directory .mcp.json', async () => { + // The whole point: wire it once, not in every checkout you open. + await cmd().parseAsync(['claude', '--project', 'homeautomation', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.mcpServers.mcpctl).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }); + expect(output.join('\n')).toContain('every directory'); + }); + + it('preserves everything else in .claude.json', async () => { + // That file also holds onboarding state, caches and the per-project map. + writeFileSync(claudeJson(), JSON.stringify({ + numStartups: 42, + mcpServers: { 'taskmaster-ai': { type: 'stdio', command: 'task-master-ai' } }, + projects: { '/some/repo': { allowedTools: [] } }, + })); + await cmd().parseAsync(['claude', '--project', 'p', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.numStartups).toBe(42); + expect(parsed.projects).toEqual({ '/some/repo': { allowedTools: [] } }); + expect(parsed.mcpServers['taskmaster-ai']).toBeDefined(); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'p']); + }); + + it('switching re-points the one entry', async () => { + await cmd().parseAsync(['claude', '--project', 'a', '--skip-skills', '--skip-ui'], { from: 'user' }); + await cmd().parseAsync(['claude', '--project', 'b', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(Object.keys(parsed.mcpServers)).toEqual(['mcpctl']); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'b']); + }); + + it('writes no .mcpctl-project marker — user scope is not directory-specific', async () => { + // A marker beside .claude.json would sit in $HOME and scope every repo under it. + const cwd = process.cwd(); + process.chdir(tmpDir); + try { + await cmd().parseAsync(['claude', '--project', 'p', '--skip-ui'], { from: 'user' }); + expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false); + expect(output.join('\n')).toContain('not directory-specific'); + } finally { process.chdir(cwd); } + }); + + it('an explicit --output still means the per-directory file', async () => { + const outPath = join(tmpDir, '.mcp.json'); + await cmd().parseAsync(['claude', '--project', 'p', '-o', outPath, '--skip-skills', '--skip-ui'], { from: 'user' }); + expect(existsSync(outPath)).toBe(true); + expect(existsSync(claudeJson())).toBe(false); + }); + + it('rejects an unknown scope instead of silently picking one', async () => { + const prevExit = process.exitCode; + await cmd().parseAsync(['claude', '--project', 'p', '--scope', 'global', '--skip-skills'], { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(output.join('\n')).toContain("unknown --scope 'global'"); + }); +}); From 80fa8a3c7efa5a1f9a47f9a54d07a17d0f5efd9d Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:07:07 +0100 Subject: [PATCH 08/22] feat(claude-vllm): own config file instead of reading opencode's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-vllm read `~/.config/opencode/opencode.jsonc` directly. Same shape is useful; sharing the actual file is not — a credential rotation in opencode would silently change what Claude Code authenticates with, and it couples two tools' configs for no reason. It now has its own `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc`, shaped like opencode's (`provider..options.{baseURL,apiKey}` plus a `models` map), and takes priority. The pi and prime-agent homes stay as a fallback so the command works before any config exists; reading opencode's file is dropped. No key is stored in the tool. `apiKey` may be a literal in the 0600 file, `${ENV_VAR}`, or a bare env var NAME (the form pi's models.json already uses), so the secret can live in the environment instead of on disk. `--init` reads it from stdin when `--api-key` is omitted — keeping it out of shell history and out of the process table, where an argument is visible to every user via `ps`. `--list` prints at most a 10-character prefix. `--init` records the context window for every model it can see, not just the active one: recording only the default meant `--model something-else` silently fell back to Claude Code's assumed 200k on a 393k model. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 10 ++-- docs/claude-vllm.md | 55 ++++++++++++++++--- stack/claude-vllm | 125 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 163 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b76b6d0..16ead01 100644 --- a/README.md +++ b/README.md @@ -258,15 +258,17 @@ The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic Messages API, so Claude Code can talk to it directly — no bridge: ```bash -claude-vllm # reuse the provider/model/key pi or prime-agent already uses +claude-vllm --init # write ~/.config/mcpctl/claude-vllm.jsonc (0600) +claude-vllm # use it claude-vllm --model deepseek-v4-max claude-vllm --list # show what it would use claude-vllm -- -p "summarise this repo" # args after -- go to claude ``` -It discovers the endpoint, credential, model and context window from -`~/.pi/agent`, `~/.prime/agent` or opencode's config (first hit wins) and sets -the `ANTHROPIC_*` environment Claude Code needs. +It reads its own config (shaped like `opencode.jsonc`, but a separate file), +falling back to `~/.pi/agent` or `~/.prime/agent` so it works before you have +written one. The key may be a literal in the 0600 file, `${ENV_VAR}`, or an env +var name — it is never stored in the tool. See [docs/claude-vllm.md](docs/claude-vllm.md), including why routing this through mcpctl would add surface without adding capability. diff --git a/docs/claude-vllm.md b/docs/claude-vllm.md index 72c3446..30ee014 100644 --- a/docs/claude-vllm.md +++ b/docs/claude-vllm.md @@ -43,21 +43,60 @@ sake: per-project gating of model calls, audit of prompts, or budget enforcement. Those are real features, but they are a passthrough endpoint in mcpd, not a wrapper script. -## What it discovers, and from where +## Its own config -`claude-vllm` exists only so you don't paste four exports each time. It reuses -whatever you already configured for another agent — first hit wins: +```bash +claude-vllm --init # prompts for the key, writes 0600 +claude-vllm --init --api-key '${MY_KEY}' # keep the secret in the environment instead +claude-vllm --init --base-url https://other-gateway/v1 +``` + +Written to `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc` (`~/.config/…`), shaped +like `opencode.jsonc` — a `provider` map with `options.baseURL` / +`options.apiKey` and a `models` map: + +```jsonc +{ + "model": "itaz/deepseek-v4-think", + "provider": { + "itaz": { + "options": { "baseURL": "https://llm.ad.itaz.eu/v1", "apiKey": "${MY_LLM_KEY}" }, + "models": { "deepseek-v4-fast": { "limit": { "context": 393216 } } } + } + } +} +``` + +**Same shape as opencode's config, but a separate file.** Reading opencode's own +config would mean a credential rotation there silently changing what Claude Code +authenticates with, and would couple two tools' configs for no reason. + +### Keys are never stored in the tool + +`apiKey` may be a literal (in a 0600 file), `${ENV_VAR}`, or a bare env var +NAME — so the secret can live in your environment or a password manager instead +of on disk. `--init` reads the key from **stdin** when `--api-key` is omitted, +keeping it out of shell history and out of the process table, where `--api-key` +would be visible to every user via `ps`. `--list` never prints more than a +10-character prefix. + +### Discovery order | Order | Source | Supplies | |-------|--------|----------| | 1 | environment (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) | anything already set is respected | | 2 | `--provider` / `--model` flags | provider, model | -| 3 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context window · `auth.json` → key | -| 4 | `~/.prime/agent` | same shape | -| 5 | `~/.config/opencode/opencode.jsonc` | `provider..options.{baseURL,apiKey}` | +| 3 | `~/.config/mcpctl/claude-vllm.jsonc` | its own config | +| 4 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context windows · `auth.json` → key | +| 5 | `~/.prime/agent` | same shape | -The base URL and the credential always come from the *same* source, so one -gateway's URL is never paired with another's key. +The pi/prime homes remain a convenience fallback so `claude-vllm` works before +you have written a config at all. The base URL and the credential always come +from the *same* source, so one gateway's URL is never paired with another's key. + +`--init` records the context window for **every** model it can see, not just the +active one — otherwise `--model something-else` silently falls back to Claude +Code's assumed 200k. ## What it sets, and why each one diff --git a/stack/claude-vllm b/stack/claude-vllm index d7119c0..aebb04e 100755 --- a/stack/claude-vllm +++ b/stack/claude-vllm @@ -10,22 +10,37 @@ # Discovery order (first hit wins, per field): # 1. environment already set (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL) # 2. flags (--provider, --model) -# 3. ~/.pi/agent settings.json + models.json + auth.json -# 4. ~/.prime/agent settings.json + models.json + auth.json -# 5. ~/.config/opencode/opencode.jsonc (provider..options.{baseURL,apiKey}) +# 3. its own config: $XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc (~/.config/...) +# 4. ~/.pi/agent settings.json + models.json + auth.json +# 5. ~/.prime/agent settings.json + models.json + auth.json +# +# The config file is shaped like opencode's — a `provider` map with +# `options.baseURL` / `options.apiKey` and a `models` map — but it is OUR file. +# Reading opencode's own config would mean a credential rotation there silently +# changing what Claude Code authenticates with, and would tie two tools' configs +# together for no reason. The pi/prime homes remain as a convenience fallback so +# `claude-vllm` works before you have written a config at all. +# +# NO KEY IS EVER STORED IN THIS SCRIPT. `apiKey` may be a literal (in a 0600 +# config), or `\${ENV_VAR}` / a bare env var NAME, so the secret can live in your +# environment or a password manager instead of on disk. # # Usage: # claude-vllm # default provider + model, then exec claude # claude-vllm --model deepseek-v4-max # claude-vllm --provider itaz --model deepseek-v4-fast -- -p "summarise this repo" -# claude-vllm --list # show discoverable providers/models and exit +# claude-vllm --list # show what it would use (never prints the key) # claude-vllm --print-env # print the exports and exit (don't run claude) +# claude-vllm --init --api-key ... # write the config file (0600); reads stdin if omitted set -euo pipefail PROVIDER="" MODEL="" LIST=0 +INIT=0 PRINT_ENV=0 +API_KEY_ARG="" +BASE_URL_ARG="" CLAUDE_ARGS=() while [ $# -gt 0 ]; do @@ -33,6 +48,9 @@ while [ $# -gt 0 ]; do --provider) PROVIDER="${2:?--provider needs a value}"; shift 2 ;; --model) MODEL="${2:?--model needs a value}"; shift 2 ;; --list) LIST=1; shift ;; + --init) INIT=1; shift ;; + --api-key) API_KEY_ARG="${2:?--api-key needs a value}"; shift 2 ;; + --base-url) BASE_URL_ARG="${2:?--base-url needs a value}"; shift 2 ;; --print-env) PRINT_ENV=1; shift ;; -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; --) shift; CLAUDE_ARGS+=("$@"); break ;; @@ -44,12 +62,27 @@ command -v jq >/dev/null || { echo "claude-vllm: jq is required" >&2; exit 1; } PI_HOME="${PI_AGENT_HOME:-$HOME/.pi/agent}" PRIME_HOME="${PRIME_AGENT_HOME:-$HOME/.prime/agent}" -OC_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.jsonc" +CONFIG="${CLAUDE_VLLM_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/mcpctl/claude-vllm.jsonc}" # jq tolerates the // comments opencode.jsonc may contain only after we strip # them; harmless for strict JSON. read_json() { [ -f "$1" ] && sed 's://[^"]*$::' "$1" | jq -c . 2>/dev/null || echo '{}'; } +# Resolve an apiKey field that may be a literal, \${ENV_VAR}, or a bare env var +# NAME (the form pi's models.json uses). Keeping the indirection means the +# secret can live in the environment rather than on disk. +resolve_key() { + local raw="$1" + [ -n "$raw" ] || return 0 + case "$raw" in + '${'*'}') local n="${raw#\$\{}"; n="${n%\}}"; printf '%s' "${!n:-}" ;; + # A bare ALL_CAPS token that names a set variable is an env var reference, + # not a key: no real API key looks like that. + [A-Z_][A-Z0-9_]*) if [ -n "${!raw:-}" ]; then printf '%s' "${!raw}"; else printf '%s' "$raw"; fi ;; + *) printf '%s' "$raw" ;; + esac +} + # ── discover ───────────────────────────────────────────────────────────────── # Each agent home is tried in turn; the first one that yields a base URL wins, # and the credential is taken from that same home so we never pair one gateway's @@ -74,45 +107,107 @@ discover_from_agent_home() { FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key"; FOUND_MODEL="$model" FOUND_MODELS=$(jq -r --arg p "$provider" '.providers[$p].models[]?.id' <<<"$models") + # model -> context for ALL models, so --init records every limit rather than + # only the active one (Claude Code assumes 200k for anything it lacks). + FOUND_MODEL_LIMITS=$(jq -c --arg p "$provider" \ + '[.providers[$p].models[]? | select(.contextWindow) | {(.id): {limit:{context:.contextWindow}}}] | add // {}' <<<"$models") FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$model" \ '.providers[$p].models[]? | select(.id==$m) | .contextWindow // empty' <<<"$models") FOUND_SOURCE="$home" return 0 } -discover_from_opencode() { +# Our own config, shaped like opencode's but deliberately a separate file. +discover_from_config() { local cfg provider base key - cfg=$(read_json "$OC_CONFIG") + [ -f "$CONFIG" ] || return 1 + cfg=$(read_json "$CONFIG") provider="$PROVIDER" - if [ -z "$provider" ]; then - provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") - fi + [ -n "$provider" ] || provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") + [ -n "$provider" ] || provider=$(jq -r '.provider | keys[0] // empty' <<<"$cfg") [ -n "$provider" ] || return 1 + base=$(jq -r --arg p "$provider" '.provider[$p].options.baseURL // empty' <<<"$cfg") [ -n "$base" ] || return 1 - key=$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg") + key=$(resolve_key "$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg")") FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key" FOUND_MODEL="${MODEL:-$(jq -r '(.model // "") | split("/")[1] // empty' <<<"$cfg")}" FOUND_MODELS=$(jq -r --arg p "$provider" '.provider[$p].models | keys[]?' <<<"$cfg") + FOUND_MODEL_LIMITS=$(jq -c --arg p "$provider" '.provider[$p].models // {}' <<<"$cfg") FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$FOUND_MODEL" \ '.provider[$p].models[$m].limit.context // empty' <<<"$cfg") - FOUND_SOURCE="$OC_CONFIG" + FOUND_SOURCE="$CONFIG" return 0 } -FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" -discover_from_agent_home "$PI_HOME" \ +FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_MODEL_LIMITS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" +discover_from_config \ + || discover_from_agent_home "$PI_HOME" \ || discover_from_agent_home "$PRIME_HOME" \ - || discover_from_opencode \ || true +# ── --init: write the config file ──────────────────────────────────────────── +if [ "$INIT" = 1 ]; then + init_base="${BASE_URL_ARG:-${FOUND_BASE:-}}" + if [ -z "$init_base" ]; then + echo "claude-vllm --init: no endpoint known. Pass --base-url https://your-gateway/v1" >&2 + exit 1 + fi + init_key="$API_KEY_ARG" + if [ -z "$init_key" ]; then + # Read from stdin so the key never lands in shell history or the process + # table (where --api-key is visible to every user via `ps`). + if [ -t 0 ]; then + printf 'API key (input hidden, or pass ${ENV_VAR} to keep it out of the file): ' >&2 + read -rs init_key; echo >&2 + else + read -r init_key || true + fi + fi + [ -n "$init_key" ] || { echo "claude-vllm --init: no API key given" >&2; exit 1; } + + init_provider="${PROVIDER:-${FOUND_PROVIDER:-default}}" + init_model="${MODEL:-${FOUND_MODEL:-}}" + # Every model's limit, not just the active one — switching with --model must + # not silently drop back to Claude Code's assumed 200k. + init_models_json=$( + if [ -n "$FOUND_MODELS" ]; then + known="${FOUND_MODEL_LIMITS:-{\}}" + for m in $FOUND_MODELS; do jq -n --arg m "$m" '{($m): {}}'; done \ + | jq -s --argjson known "$known" 'add // {} | . * $known' + else + jq -n --arg m "$init_model" 'if $m == "" then {} else {($m): {}} end' + fi + ) + + mkdir -p "$(dirname "$CONFIG")" + umask 077 + jq -n --arg p "$init_provider" --arg model "$init_model" --arg base "$init_base" \ + --arg key "$init_key" --argjson models "$init_models_json" ' + { + "//": "mcpctl claude-vllm config. Shaped like opencode.jsonc, but its own file. apiKey may be a literal, \"${ENV_VAR}\", or a bare env var NAME.", + model: (if $model == "" then null else "\($p)/\($model)" end), + provider: { ($p): { options: { baseURL: $base, apiKey: $key }, models: $models } } + } | del(..|nulls)' > "$CONFIG.tmp.$$" + mv "$CONFIG.tmp.$$" "$CONFIG" + chmod 600 "$CONFIG" + echo "Wrote $CONFIG (0600)" >&2 + echo " provider: $init_provider endpoint: $init_base model: ${init_model:-}" >&2 + case "$init_key" in + '${'*'}'|[A-Z_][A-Z0-9_]*) echo " apiKey: kept as an environment reference, not a literal" >&2 ;; + *) echo " apiKey: stored in the file — readable only by you" >&2 ;; + esac + exit 0 +fi + BASE="${ANTHROPIC_BASE_URL:-$FOUND_BASE}" KEY="${ANTHROPIC_AUTH_TOKEN:-${ANTHROPIC_API_KEY:-$FOUND_KEY}}" MODEL_ID="${MODEL:-${ANTHROPIC_MODEL:-$FOUND_MODEL}}" if [ "$LIST" = 1 ]; then echo "provider: ${FOUND_PROVIDER:-} (from ${FOUND_SOURCE:-nowhere})" + echo "config: $CONFIG $([ -f "$CONFIG" ] && echo '(present)' || echo '(absent — using fallback; run --init)')" echo "endpoint: ${BASE:-}" echo "credential: $([ -n "$KEY" ] && echo "found (${KEY:0:10}…)" || echo "")" echo "default model: ${MODEL_ID:-}${FOUND_CONTEXT:+ (context ${FOUND_CONTEXT})}" From 292506ec985fb077d5b0e8bb788d77a64079e560 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:07:55 +0100 Subject: [PATCH 09/22] fix(install): rebuild when any packaged input changes, not just src/*.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installlocal.sh decided the package was up to date by looking only for `src/**/*.ts` newer than it. nfpm.yaml also packages stack/claude-vllm, the shell completions and the systemd unit — none of which are TypeScript — so editing any of those installed a stale package that reported success. Caught by claude-vllm's new --init being absent from /usr/bin after a clean install. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- installlocal.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/installlocal.sh b/installlocal.sh index 0418811..a68de85 100755 --- a/installlocal.sh +++ b/installlocal.sh @@ -28,6 +28,14 @@ fi echo "==> Detected package format: $PKG_FORMAT (arch: $NFPM_ARCH)" +# Any packaged input newer than $1. Mirrors nfpm.yaml's `contents:` — keep the +# two in step, or the build silently serves a stale binary again. +newer_inputs() { + find src/ -name '*.ts' -newer "$1" 2>/dev/null | head -1 + find stack/ completions/ deploy/ -type f -newer "$1" 2>/dev/null | head -1 + find nfpm.yaml -newer "$1" 2>/dev/null | head -1 +} + # Find package matching the target architecture # RPM uses x86_64/aarch64, DEB uses amd64/arm64 find_pkg() { @@ -38,8 +46,11 @@ find_pkg() { if [ "$PKG_FORMAT" = "rpm" ]; then PKG_FILE=$(find_pkg "dist/mcpctl-*.rpm") - # Build if no package exists or if source is newer - if [[ -z "$PKG_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$PKG_FILE" 2>/dev/null | head -1) ]]; then + # Build if no package exists or if any PACKAGED input is newer. + # Watching only src/*.ts silently shipped a stale package whenever a + # non-TypeScript artifact changed — stack/claude-vllm, the completions, the + # systemd unit are all in nfpm.yaml's contents and none of them are .ts. + if [[ -z "$PKG_FILE" ]] || [[ $(newer_inputs "$PKG_FILE") ]]; then echo "==> Building RPM..." bash scripts/build-rpm.sh PKG_FILE=$(find_pkg "dist/mcpctl-*.rpm") @@ -52,8 +63,11 @@ if [ "$PKG_FORMAT" = "rpm" ]; then else PKG_FILE=$(find_pkg "dist/mcpctl*.deb") - # Build if no package exists or if source is newer - if [[ -z "$PKG_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$PKG_FILE" 2>/dev/null | head -1) ]]; then + # Build if no package exists or if any PACKAGED input is newer. + # Watching only src/*.ts silently shipped a stale package whenever a + # non-TypeScript artifact changed — stack/claude-vllm, the completions, the + # systemd unit are all in nfpm.yaml's contents and none of them are .ts. + if [[ -z "$PKG_FILE" ]] || [[ $(newer_inputs "$PKG_FILE") ]]; then echo "==> Building DEB..." bash scripts/build-deb.sh PKG_FILE=$(find_pkg "dist/mcpctl*.deb") From cbd3b95d97d0ff0d88306e36c352966bf6dbccee Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 20:47:45 +0100 Subject: [PATCH 10/22] fix(opencode): guard state parsing, lint the .tsx, correct an overstated doc claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from a cross-branch review of the competing opencode implementations, all of which are fair. 1. `readState` type-guards the parsed JSON now. A bare try/catch does not cover it: `JSON.parse('null')` succeeds and returns null, so the catch never fires and the next `state.project` throws a TypeError that takes the plugin down. Verified the crash before fixing; a test pins the guard in the embedded copies. Credit to the competing 'opencode-mine' branch, which had this right. 2. eslint now covers `src/opencode-ext/*.tsx`. The glob was `*.ts` only, so the 300-line TUI plugin — the largest file in the addon — was linted by nothing. It was typechecked, which is why this went unnoticed. Confirmed the rules actually fire on it rather than the file being silently skipped. The 'abhishek' branch was the only entry that got this right. 3. docs/opencode-extension.md overstated the security argument. "The token would sit in a 0644 opencode.json" is not a point against a `type: local` stdio bridge, which needs no token at all because it reads your own credentials. That reason is a consequence of having picked the HTTP gateway, not a justification for it. The docs now lead with the real reason — live re-pointing without a restart — and state the trade honestly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- docs/opencode-extension.md | 25 ++++++++++++++----- eslint.config.js | 2 +- src/cli/src/config/opencode-extension.ts | 4 +-- .../config/opencode-extension-embed.test.ts | 13 ++++++++++ src/opencode-ext/mcpctl-opencode-tui.tsx | 7 +++++- src/opencode-ext/mcpctl-opencode.ts | 7 +++++- 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md index 0690a8b..d8e4c7b 100644 --- a/docs/opencode-extension.md +++ b/docs/opencode-extension.md @@ -41,13 +41,26 @@ with no restart. ### The state file, not `opencode.json` -Two reasons the project does not live in opencode's own config: +1. **The restart.** A config file is read at startup. Re-pointing the mount + through the running server's MCP API is what makes `/mcpctl` instant. This is + the load-bearing reason. +2. **The token.** Having chosen the gateway, we need + `Authorization: Bearer ` somewhere. `opencode.json` is a mode-0644 + file people paste into bug reports; `~/.mcpctl/opencode-state.json` is 0600, + like every other mcpctl credential. -1. **The token.** The gateway needs `Authorization: Bearer `. - `opencode.json` is a mode-0644 file people paste into bug reports; - `~/.mcpctl/opencode-state.json` is 0600, like every other mcpctl credential. -2. **The restart.** A config file is read at startup. Re-pointing the mount - through the running server's MCP API is what makes `/mcpctl` instant. +> **Reason 2 is not an argument for this design over the alternative.** A +> `type: "local"` entry running `mcpctl mcp -p ` — the same stdio +> bridge `config claude` uses — needs no bearer token at all, because the bridge +> reads your own `~/.mcpctl/credentials`. So "no secret in a 0644 file" is not a +> point against that approach; it is just a consequence of having picked the +> HTTP gateway. +> +> The honest trade is: the gateway works against a remote mcpctl with no local +> `mcplocal` daemon, and mounts through an API that can be re-pointed live. The +> stdio bridge is simpler and credential-free, but requires `mcpctl` and a +> reachable mcplocal on the same machine. Both are defensible; this one was +> chosen for the remote case and for the live re-point, not for the token. Tokens are kept **per project**, so switching back to a project you have already used needs no new mint — and a failed mint for project B cannot cost diff --git a/eslint.config.js b/eslint.config.js index b4e6858..946882a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/prime-agent-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/opencode-ext/*.tsx', 'src/prime-agent-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts index fbc2f5d..2e7a55e 100644 --- a/src/cli/src/config/opencode-extension.ts +++ b/src/cli/src/config/opencode-extension.ts @@ -22,6 +22,6 @@ export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; /** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; -export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8'));\n // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns\n // null, so the catch never fires and the next `state.project` throws a\n // TypeError that takes the plugin down. A truncated or hand-edited state\n // file must degrade to \"no project\", never to a broken opencode.\n return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {};\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; -export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8'));\n // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns\n // null, so the catch never fires and the next `state.project` throws a\n // TypeError that takes the plugin down. A truncated or hand-edited state\n // file must degrade to \"no project\", never to a broken opencode.\n return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {};\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts index b772315..4e19a97 100644 --- a/src/cli/tests/config/opencode-extension-embed.test.ts +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -88,3 +88,16 @@ describe('embedded opencode plugins', () => { expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); }); }); + +describe('embedded opencode plugins — state parsing', () => { + it('type-guard the parsed state, not just try/catch', () => { + // `JSON.parse('null')` succeeds and returns null, so a bare try/catch lets + // it through and the next `state.project` throws a TypeError that takes the + // plugin down. A hand-edited or truncated state file must degrade to "no + // project", never to a broken opencode. + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("typeof parsed === 'object' && parsed !== null"); + expect(src).not.toMatch(/return JSON\.parse\(await readFile\([^)]*\)\) as OpencodeState;/); + } + }); +}); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx index 40d38e3..16664c1 100644 --- a/src/opencode-ext/mcpctl-opencode-tui.tsx +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -57,7 +57,12 @@ function statePath(): string { async function readState(): Promise { try { - return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8')); + // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns + // null, so the catch never fires and the next `state.project` throws a + // TypeError that takes the plugin down. A truncated or hand-edited state + // file must degrade to "no project", never to a broken opencode. + return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {}; } catch { return {}; } diff --git a/src/opencode-ext/mcpctl-opencode.ts b/src/opencode-ext/mcpctl-opencode.ts index 7fa6558..2fb4c6d 100644 --- a/src/opencode-ext/mcpctl-opencode.ts +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -40,7 +40,12 @@ function statePath(): string { async function readState(): Promise { try { - return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8')); + // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns + // null, so the catch never fires and the next `state.project` throws a + // TypeError that takes the plugin down. A truncated or hand-edited state + // file must degrade to "no project", never to a broken opencode. + return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {}; } catch { return {}; } From b2547429ca6642bef7ff2b9a084265b812ce8979 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 21:23:33 +0100 Subject: [PATCH 11/22] fix(health): a passing tools/list is `live`, not `healthy` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl get instances` showed all eight servers healthy while the UniFi one had never once reached its controller. The default probe is `tools/list`, which MCP servers answer from a static in-process table — no credentials, no upstream, ~3ms. It cannot fail for any reason the user cares about, so it was reporting `healthy` for every process that managed to start. Split the two passes: healthy — readiness: `tools/call` on `healthCheck.tool`. The upstream answered, so the server can actually do its job. live — liveness: `tools/list` only. Process up, upstream unverified. `live` is now the default for any server without a `healthCheck.tool`. It is not a warning; it is an admission that nothing is watching that server. Probe events name which probe ran and which tool ("Readiness check (list_sites) passed"), so the events log distinguishes the two after the fact. Also: - `healthCheck.tool` is optional now, so the timings can be tuned without inventing a readiness probe. - `create server --health-check-tool/-args/-interval/-timeout/ -failure-threshold`, per the rule that everything applyable is a create flag. Merges over a `--from-template` healthCheck rather than replacing it. - `describe instance` explains a `live` verdict instead of leaving it cryptic. - create.ts held a raw NUL byte in a string literal, which made grep treat the whole file as binary and silently skip it. Escaped as `\0`. Verified against the live fleet: with readiness probes configured, my-grafana went unhealthy (Grafana API 403) and my-node-red degraded (connect timeout to a Tailscale address) — both had read healthy for months. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb --- completions/mcpctl.bash | 2 +- completions/mcpctl.fish | 5 + docs/reliability.md | 60 ++++++ src/cli/src/commands/apply.ts | 3 +- src/cli/src/commands/create.ts | 76 ++++++- src/cli/src/commands/describe.ts | 6 + src/cli/tests/health-check-opts.test.ts | 59 ++++++ src/mcpd/src/services/health-probe.service.ts | 35 +++- src/mcpd/src/validation/template.schema.ts | 8 +- src/mcpd/tests/services/health-probe.test.ts | 65 +++++- .../smoke/health-readiness.smoke.test.ts | 185 ++++++++++++++++++ 11 files changed, 495 insertions(+), 9 deletions(-) create mode 100644 src/cli/tests/health-check-opts.test.ts create mode 100644 src/mcplocal/tests/smoke/health-readiness.smoke.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index a045a22..16a4fe5 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -194,7 +194,7 @@ _mcpctl() { else case "$create_sub" in server) - COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --from-template --env-from-secret --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --health-check-tool --health-check-args --health-check-interval --health-check-timeout --health-check-failure-threshold --from-template --env-from-secret --force -h --help" -- "$cur")) ;; secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index e79dc3b..822767d 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -380,6 +380,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l command -d 'Comm complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l container-port -d 'Container port number' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l replicas -d 'Number of replicas' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env -d 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-tool -d 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-args -d 'Readiness probe: JSON object of arguments for the probe tool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-interval -d 'Readiness probe interval in seconds (default 60)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-timeout -d 'Readiness probe timeout in seconds (default 10)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-failure-threshold -d 'Consecutive failures before the instance is marked unhealthy (default 3)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l from-template -d 'Create from template (name or name:version)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env-from-secret -d 'Map template env vars from a secret' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update if already exists' diff --git a/docs/reliability.md b/docs/reliability.md index 28a1c25..5c1f2c5 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -28,6 +28,66 @@ Note: the gate's prompt-ranking uses the **heavy client provider's own model** it deliberately does *not* force the project's vLLM model onto it (doing so made every selection fail silently when the model wasn't anthropic-servable). +## Instance health: `live` is not `healthy` + +An MCP server answers `tools/list` from a **static, in-process table**. It costs +a few milliseconds, needs no credentials, and reaches no upstream — so it stays +green while the thing the server exists to talk to is unreachable. Treating that +as a health signal is how `mcpctl get instances` showed eight healthy servers +while the UniFi one had never once reached its controller. + +So the probe reports two different passes: + +| Status | Probe | Means | +|---|---|---| +| `healthy` | **readiness** — `tools/call` on `healthCheck.tool` | The upstream answered. The server can do its job. | +| `live` | **liveness** — `tools/list` only | The process is up and speaks MCP. Its upstream is **unverified**. | +| `degraded` | either, failing | Failing, but under `failureThreshold`. | +| `unhealthy` | either, failing | Failed `failureThreshold` times in a row. | + +`live` is the default for any server with no `healthCheck.tool`. It is not a +warning — it is an admission that nothing is watching that server's upstream. + +**Configure a readiness probe on every server.** Pick a read-only tool that +genuinely round-trips to the upstream, and verify it passes before configuring +it — a probe naming a local-only tool (`get_..._version`) or a tool the server +doesn't expose reproduces the same false green it was meant to remove. + +```bash +mcpctl create server unifi-network --health-check-tool list_sites \ + --health-check-interval 60 --health-check-timeout 15 --force +``` + +or declaratively — `healthCheck` round-trips through `get -o yaml | apply -f`: + +```yaml +healthCheck: + tool: list_sites + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 15 + failureThreshold: 3 +``` + +Omit `tool` to keep liveness while still tuning the timings. + +Latency is the tell: a probe answering in single-digit milliseconds is reading a +local table, not crossing a network. The UniFi probe went from 3ms (`tools/list`, +lying) to 1847ms on its first real `list_sites` — login, TLS, controller round +trip — and ~40ms once the session was warm. + +### Two failure modes the probe cannot see for you + +The healthy-looking UniFi server was broken **twice over**, and both are worth +checking first when a readiness probe starts failing: + +1. **Egress.** MCP server pods default to TCP 80/443 only + (`servers-allow-external-egress`). Any upstream on another port — the UniFi + controller on `:8443` — times out on every call. Declare it in Pulumi's + `mcpctl.serverEgressTargets` (name + `/32` + ports); don't widen the blanket rule. +2. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` + address. Config pointing at one connect-timeouts forever. Use LAN IPs. + ## LLM-*essential* operations — failover chain Chat needs *an* LLM but not a *specific* one. Instead of failing when the pinned diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 5360a1a..c41325a 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -5,7 +5,8 @@ import { z } from 'zod'; import type { ApiClient } from '../api-client.js'; const HealthCheckSchema = z.object({ - tool: z.string().min(1), + /** Omit for a liveness-only probe (reports `live`); set it for readiness (`healthy`). */ + tool: z.string().min(1).optional(), arguments: z.record(z.unknown()).default({}), intervalSeconds: z.number().int().min(5).max(3600).default(60), timeoutSeconds: z.number().int().min(1).max(120).default(10), diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 6f64801..fe2efd0 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -42,6 +42,69 @@ export function buildFavouriteIndex( return result; } +export interface HealthCheckOpts { + healthCheckTool?: string; + healthCheckArgs?: string; + healthCheckInterval?: string; + healthCheckTimeout?: string; + healthCheckFailureThreshold?: string; +} + +export interface HealthCheckSpec { + tool?: string; + arguments?: Record; + intervalSeconds?: number; + timeoutSeconds?: number; + failureThreshold?: number; +} + +function parsePositiveInt(flag: string, value: string): number { + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`Invalid ${flag} '${value}'. Expected a positive integer.`); + } + return n; +} + +/** + * Build a server `healthCheck` spec from `--health-check-*` flags, or undefined + * when none were given (so the field is only sent when intended). + * + * Mirrors the `healthCheck:` block accepted by `apply -f`, per the rule that + * everything applyable is also a create flag. + */ +export function buildHealthCheck(opts: HealthCheckOpts): HealthCheckSpec | undefined { + const { healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold } = opts; + const given = [healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold] + .some((v) => v !== undefined); + if (!given) return undefined; + + if (healthCheckArgs !== undefined && healthCheckTool === undefined) { + throw new Error('--health-check-args requires --health-check-tool.'); + } + + const spec: HealthCheckSpec = {}; + if (healthCheckTool !== undefined) spec.tool = healthCheckTool; + if (healthCheckArgs !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(healthCheckArgs); + } catch { + throw new Error(`Invalid --health-check-args: not valid JSON. Expected a JSON object, e.g. '{"site":"default"}'.`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid --health-check-args: expected a JSON object, e.g. '{"site":"default"}'.`); + } + spec.arguments = parsed as Record; + } + if (healthCheckInterval !== undefined) spec.intervalSeconds = parsePositiveInt('--health-check-interval', healthCheckInterval); + if (healthCheckTimeout !== undefined) spec.timeoutSeconds = parsePositiveInt('--health-check-timeout', healthCheckTimeout); + if (healthCheckFailureThreshold !== undefined) { + spec.failureThreshold = parsePositiveInt('--health-check-failure-threshold', healthCheckFailureThreshold); + } + return spec; +} + /** * Parse a `--ttl` value. * @@ -136,6 +199,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--container-port ', 'Container port number') .option('--replicas ', 'Number of replicas') .option('--env ', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, []) + .option('--health-check-tool ', 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")') + .option('--health-check-args ', 'Readiness probe: JSON object of arguments for the probe tool') + .option('--health-check-interval ', 'Readiness probe interval in seconds (default 60)') + .option('--health-check-timeout ', 'Readiness probe timeout in seconds (default 10)') + .option('--health-check-failure-threshold ', 'Consecutive failures before the instance is marked unhealthy (default 3)') .option('--from-template ', 'Create from template (name or name:version)') .option('--env-from-secret ', 'Map template env vars from a secret') .option('--force', 'Update if already exists') @@ -218,6 +286,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.externalUrl) body.externalUrl = opts.externalUrl; if (opts.command.length > 0) body.command = opts.command; if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10); + // Merge over any healthCheck inherited from --from-template so partial + // flags (e.g. only --health-check-interval) tune rather than replace it. + const healthCheck = buildHealthCheck(opts as HealthCheckOpts); + if (healthCheck) { + body.healthCheck = { ...(base.healthCheck as HealthCheckSpec | undefined), ...healthCheck }; + } if (opts.env.length > 0) { // Merge: CLI env entries override template env entries by name const cliEnv = parseServerEnv(opts.env); @@ -898,7 +972,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const buf = await fs.readFile(full); // Reject non-UTF8 — v1 is text-only. const text = buf.toString('utf-8'); - if (text.includes('')) { + if (text.includes('\0')) { throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`); } files[rel] = text; diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index bda4ffb..cd94d69 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -90,6 +90,12 @@ function formatInstanceDetail(instance: Record, inspect?: Recor lines.push('Health:'); lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`); if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`); + if (healthStatus === 'live') { + lines.push(` ${pad('Probe:', 16)}liveness (tools/list) — process is up, but nothing`); + lines.push(` ${pad('', 16)}calls the server's upstream. Configure a readiness`); + lines.push(` ${pad('', 16)}probe to reach 'healthy':`); + lines.push(` ${pad('', 16)} mcpctl edit server ${server?.name ?? ''} → healthCheck.tool`); + } } const metadata = instance.metadata as Record | undefined; diff --git a/src/cli/tests/health-check-opts.test.ts b/src/cli/tests/health-check-opts.test.ts new file mode 100644 index 0000000..0c02c03 --- /dev/null +++ b/src/cli/tests/health-check-opts.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { buildHealthCheck } from '../src/commands/create.js'; + +describe('buildHealthCheck — CLI flags → server healthCheck', () => { + it('returns undefined when no health-check flag is given', () => { + expect(buildHealthCheck({})).toBeUndefined(); + }); + + it('builds a readiness probe from --health-check-tool', () => { + expect(buildHealthCheck({ healthCheckTool: 'list_sites' })).toEqual({ tool: 'list_sites' }); + }); + + it('parses --health-check-args as a JSON object', () => { + expect(buildHealthCheck({ healthCheckTool: 'get_devices', healthCheckArgs: '{"site":"default"}' })) + .toEqual({ tool: 'get_devices', arguments: { site: 'default' } }); + }); + + it('carries the timing flags through', () => { + expect(buildHealthCheck({ + healthCheckTool: 'list_sites', + healthCheckInterval: '120', + healthCheckTimeout: '15', + healthCheckFailureThreshold: '2', + })).toEqual({ + tool: 'list_sites', + intervalSeconds: 120, + timeoutSeconds: 15, + failureThreshold: 2, + }); + }); + + it('allows tuning the liveness probe without a tool', () => { + // No `tool` → the probe stays liveness-only (reports `live`), but the + // interval is still configurable. + expect(buildHealthCheck({ healthCheckInterval: '300' })).toEqual({ intervalSeconds: 300 }); + }); + + it('rejects --health-check-args without a tool', () => { + expect(() => buildHealthCheck({ healthCheckArgs: '{}' })) + .toThrow(/--health-check-args requires --health-check-tool/); + }); + + it('rejects non-JSON args', () => { + expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: 'site=default' })) + .toThrow(/not valid JSON/); + }); + + it('rejects JSON args that are not an object', () => { + expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: '["a"]' })) + .toThrow(/expected a JSON object/); + }); + + it('rejects non-positive-integer timings', () => { + expect(() => buildHealthCheck({ healthCheckInterval: '0' })).toThrow(/--health-check-interval/); + expect(() => buildHealthCheck({ healthCheckTimeout: 'abc' })).toThrow(/--health-check-timeout/); + expect(() => buildHealthCheck({ healthCheckFailureThreshold: '-1' })) + .toThrow(/--health-check-failure-threshold/); + }); +}); diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index 8bd4192..8196f29 100644 --- a/src/mcpd/src/services/health-probe.service.ts +++ b/src/mcpd/src/services/health-probe.service.ts @@ -19,10 +19,24 @@ export const DEFAULT_HEALTH_CHECK: HealthCheckSpec = { failureThreshold: 3, }; +/** + * Which probe produced a result. + * + * - `readiness` — a real `tools/call` against the configured probe tool. It + * traverses the server's upstream dependency (controller API, database, + * remote service), so a pass means the server can actually do its job. + * - `liveness` — `tools/list` only. MCP servers answer that from a static + * in-process table, so it proves the process is up and speaking MCP and + * *nothing else*. A server whose upstream is unreachable still answers it. + */ +export type ProbeKind = 'readiness' | 'liveness'; + export interface ProbeResult { healthy: boolean; latencyMs: number; message: string; + /** Set by probeInstance from the healthCheck spec; probe helpers don't fill it. */ + probe?: ProbeKind; } interface ProbeState { @@ -118,6 +132,7 @@ export class HealthProbeRunner { const failureThreshold = healthCheck.failureThreshold ?? 3; const now = new Date(); const start = Date.now(); + const probeKind: ProbeKind = healthCheck.tool === undefined ? 'liveness' : 'readiness'; let result: ProbeResult; @@ -151,6 +166,8 @@ export class HealthProbeRunner { }; } + result.probe = probeKind; + // Update probe state const state = this.probeStates.get(instance.id) ?? { consecutiveFailures: 0, lastProbeAt: 0 }; state.lastProbeAt = Date.now(); @@ -162,18 +179,28 @@ export class HealthProbeRunner { } this.probeStates.set(instance.id, state); - // Determine health status + // Determine health status. + // + // A passing *liveness* probe reports `live`, not `healthy`. `tools/list` + // is answered from a static in-process table, so it stays green while the + // server's upstream is completely unreachable — which is exactly how a + // UniFi server whose controller port was firewalled off sat at "healthy" + // for months. Only a readiness probe (`tools/call` against a real tool) + // earns `healthy`. `live` means "process up, function unverified". const healthStatus = result.healthy - ? 'healthy' + ? (probeKind === 'readiness' ? 'healthy' : 'live') : state.consecutiveFailures >= failureThreshold ? 'unhealthy' : 'degraded'; // Build event + const probeLabel = probeKind === 'readiness' + ? `Readiness check (${healthCheck.tool})` + : 'Liveness check (tools/list)'; const eventType = result.healthy ? 'Normal' : 'Warning'; const eventMessage = result.healthy - ? `Health check passed (${result.latencyMs}ms)` - : `Health check failed: ${result.message}`; + ? `${probeLabel} passed (${result.latencyMs}ms)` + : `${probeLabel} failed: ${result.message}`; const existingEvents = (instance.events as Array<{ timestamp: string; type: string; message: string }>) ?? []; // Keep last 50 events diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts index 8e4d34c..be1e3a4 100644 --- a/src/mcpd/src/validation/template.schema.ts +++ b/src/mcpd/src/validation/template.schema.ts @@ -8,7 +8,13 @@ const TemplateEnvEntrySchema = z.object({ }); export const HealthCheckSchema = z.object({ - tool: z.string().min(1), + /** + * Readiness probe tool. Omit it to keep the liveness-only default + * (`tools/list`) while still tuning interval/timeout/failureThreshold — + * a liveness pass reports `live`, not `healthy`, because `tools/list` is + * answered in-process and never touches the server's upstream. + */ + tool: z.string().min(1).optional(), arguments: z.record(z.unknown()).default({}), intervalSeconds: z.number().int().min(5).max(3600).default(60), timeoutSeconds: z.number().int().min(1).max(120).default(10), diff --git a/src/mcpd/tests/services/health-probe.test.ts b/src/mcpd/tests/services/health-probe.test.ts index 072bef9..8c33ccb 100644 --- a/src/mcpd/tests/services/health-probe.test.ts +++ b/src/mcpd/tests/services/health-probe.test.ts @@ -123,13 +123,76 @@ describe('HealthProbeRunner', () => { // No exec fallback — liveness goes through mcpProxyService expect(orchestrator.execInContainer).not.toHaveBeenCalled(); expect(mcpProxyService.execute).toHaveBeenCalledWith({ serverId: 'srv-1', method: 'tools/list' }); + // A passing liveness probe is `live`, never `healthy` — `tools/list` is + // answered in-process and proves nothing about the server's upstream. expect(instanceRepo.updateStatus).toHaveBeenCalledWith( 'inst-1', 'RUNNING', - expect.objectContaining({ healthStatus: 'healthy' }), + expect.objectContaining({ healthStatus: 'live' }), ); }); + it('reports `live` (not `healthy`) even when the upstream is dead, and says so in the event', async () => { + // The regression this guards: a UniFi server whose controller port was + // firewalled off sat at "healthy" for months because `tools/list` kept + // answering from the in-process tool table. + const instance = makeInstance(); + const server = makeServer({ healthCheck: null }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + + const result = await runner.probeInstance(instance, server, { intervalSeconds: 0 }); + + expect(result.healthy).toBe(true); + expect(result.probe).toBe('liveness'); + + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('live'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Liveness check (tools/list) passed'); + }); + + it('a passing readiness probe earns `healthy` and names the tool in the event', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'list_sites', intervalSeconds: 0 } as McpServer['healthCheck'], + }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockResolvedValue({ jsonrpc: '2.0', id: 1, result: {} }); + + const result = await runner.probeInstance(instance, server, { tool: 'list_sites' }); + + expect(result.probe).toBe('readiness'); + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('healthy'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Readiness check (list_sites) passed'); + }); + + it('a readiness probe whose tool call fails reports the upstream error, not `live`', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'list_sites', failureThreshold: 1 } as McpServer['healthCheck'], + }); + + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message: 'connect ETIMEDOUT 192.168.1.5:8443' }, + }); + + await runner.probeInstance(instance, server, { tool: 'list_sites', failureThreshold: 1 }); + + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('unhealthy'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Readiness check (list_sites) failed'); + expect(events[events.length - 1]?.message).toContain('ETIMEDOUT'); + }); + it('default liveness probe marks unhealthy when tools/list returns JSON-RPC error', async () => { const instance = makeInstance(); const server = makeServer({ diff --git a/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts b/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts new file mode 100644 index 0000000..82e266c --- /dev/null +++ b/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts @@ -0,0 +1,185 @@ +/** + * Smoke tests: readiness probes actually exercise a server's upstream. + * + * The bug these guard: every instance read `healthy` forever because the + * default probe is `tools/list`, which MCP servers answer from a static + * in-process table. The UniFi server sat green for months while every call to + * its controller timed out (pod egress was capped at 80/443, controller on + * :8443) and while its `controller_type` pointed at the wrong API dialect. + * + * So these tests assert the probe is a real round trip, not a self-report: + * 1. Servers with a `healthCheck.tool` really do reach their upstream when + * that tool is called through the production proxy path. + * 2. A server with no `healthCheck.tool` reports `live`, never `healthy` — + * "process up, function unverified" must not read as "working". + * 3. `tools/list` alone cannot distinguish the two, which is why (2) matters. + * + * Prerequisites: + * - mcplocal running on localhost:3200 + * - mcpd reachable (k8s), servers deployed with readiness probes configured + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +const CONFIG_PATH = join(homedir(), '.mcpctl', 'config.json'); +const CREDS_PATH = join(homedir(), '.mcpctl', 'credentials'); + +function loadConfig(): { mcpdUrl: string; token: string } { + let mcpdUrl = 'http://localhost:3100'; + let token = ''; + try { + if (existsSync(CONFIG_PATH)) { + const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as { mcpdUrl?: string }; + if (cfg.mcpdUrl) mcpdUrl = cfg.mcpdUrl; + } + if (existsSync(CREDS_PATH)) { + const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8')) as { token?: string }; + if (creds.token) token = creds.token; + } + } catch { /* use defaults */ } + return { mcpdUrl, token }; +} + +const { mcpdUrl, token } = loadConfig(); + +function mcpdRequest(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> { + return new Promise((resolve, reject) => { + const url = new URL(path, mcpdUrl); + const transport = url.protocol === 'https:' ? https : http; + + const headers: Record = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (token) headers['Authorization'] = `Bearer ${token}`; + const bodyStr = body !== undefined ? JSON.stringify(body) : undefined; + if (bodyStr) headers['Content-Length'] = String(Buffer.byteLength(bodyStr)); + + const req = transport.request(url, { method, timeout: 60_000, headers, rejectUnauthorized: false }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + try { + resolve({ status: res.statusCode ?? 500, data: raw ? JSON.parse(raw) as T : (undefined as T) }); + } catch { + resolve({ status: res.statusCode ?? 500, data: raw as unknown as T }); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); +} + +interface HealthCheck { + tool?: string; + arguments?: Record; +} + +interface Server { + id: string; + name: string; + healthCheck: HealthCheck | null; +} + +interface Instance { + id: string; + serverId: string; + status: string; + healthStatus: string | null; + server?: { name: string }; +} + +interface ProxyResult { + result?: { tools?: Array<{ name: string }>; isError?: boolean; content?: Array<{ text?: string }> }; + error?: { code: number; message: string }; +} + +let servers: Server[] = []; +let instances: Instance[] = []; + +beforeAll(async () => { + const s = await mcpdRequest('GET', '/api/v1/servers'); + expect(s.status, `GET /api/v1/servers returned ${s.status}`).toBe(200); + servers = s.data; + + const i = await mcpdRequest('GET', '/api/v1/instances'); + expect(i.status, `GET /api/v1/instances returned ${i.status}`).toBe(200); + instances = i.data; +}, 120_000); + +describe('readiness probes reach the upstream', () => { + it('every RUNNING server has a readiness probe configured', () => { + const running = instances.filter((i) => i.status === 'RUNNING'); + expect(running.length, 'no RUNNING instances to check').toBeGreaterThan(0); + + const withoutProbe = running + .map((i) => servers.find((s) => s.id === i.serverId)) + .filter((s): s is Server => s !== undefined) + .filter((s) => s.healthCheck?.tool === undefined) + .map((s) => s.name); + + // A server with no readiness probe can only ever report `live`. That is + // honest, but it means nothing is watching its upstream — so the fleet + // should not accumulate them silently. + expect(withoutProbe, `servers with no healthCheck.tool: ${withoutProbe.join(', ')}`).toEqual([]); + }); + + it('each configured probe tool really answers through the proxy', async () => { + const probed = servers.filter((s) => s.healthCheck?.tool !== undefined); + expect(probed.length, 'no servers have readiness probes').toBeGreaterThan(0); + + const failures: string[] = []; + for (const server of probed) { + const hc = server.healthCheck!; + const res = await mcpdRequest('POST', '/api/v1/mcp/proxy', { + serverId: server.id, + method: 'tools/call', + params: { name: hc.tool, arguments: hc.arguments ?? {} }, + }); + if (res.status !== 200) { + failures.push(`${server.name}/${hc.tool}: HTTP ${res.status}`); + continue; + } + if (res.data.error) { + failures.push(`${server.name}/${hc.tool}: ${res.data.error.message}`); + continue; + } + if (res.data.result?.isError === true) { + failures.push(`${server.name}/${hc.tool}: ${res.data.result.content?.[0]?.text ?? 'isError'}`); + } + } + + // When this fails, the server is genuinely broken — fix the environment + // (credentials, egress, upstream address), never the assertion. + expect(failures, `readiness probe tools failing: ${failures.join(' | ')}`).toEqual([]); + }, 300_000); + + it('a probe tool is a different call from tools/list, and both are reachable', async () => { + const server = servers.find((s) => s.healthCheck?.tool !== undefined); + expect(server, 'need at least one probed server').toBeDefined(); + + const list = await mcpdRequest('POST', '/api/v1/mcp/proxy', { + serverId: server!.id, + method: 'tools/list', + }); + expect(list.status).toBe(200); + const toolNames = (list.data.result?.tools ?? []).map((t) => t.name); + // The probe must name a tool the server actually exposes, otherwise the + // readiness check fails for a bookkeeping reason rather than a real one. + expect(toolNames).toContain(server!.healthCheck!.tool); + }, 120_000); + + it('no RUNNING instance is stuck at an unknown health status', () => { + const stuck = instances + .filter((i) => i.status === 'RUNNING') + .filter((i) => i.healthStatus === null || i.healthStatus === 'unknown') + .map((i) => i.server?.name ?? i.serverId); + expect(stuck, `instances with no health verdict: ${stuck.join(', ')}`).toEqual([]); + }); +}); From 732ca98ccc8c243e277d8efbbebafb960ba8f750 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:54:33 +0100 Subject: [PATCH 12/22] docs(reliability): record the three shapes a failing readiness probe takes Turning readiness probes on took the fleet from 8/8 healthy to three real failures in under a minute, and all three were network shape rather than code: an egress port (UniFi :8443), an ingress hairpin through the Envoy L7 policy (Grafana 403 `Access denied` with a token that worked from a laptop), and a Tailscale address a pod can never reach (Node-RED, since retired). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb --- docs/reliability.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/reliability.md b/docs/reliability.md index 5c1f2c5..30423c8 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -76,17 +76,29 @@ local table, not crossing a network. The UniFi probe went from 3ms (`tools/list` lying) to 1847ms on its first real `list_sites` — login, TLS, controller round trip — and ~40ms once the session was warm. -### Two failure modes the probe cannot see for you +### Where a failing readiness probe usually points -The healthy-looking UniFi server was broken **twice over**, and both are worth -checking first when a readiness probe starts failing: +Turning these probes on for the first time took the fleet from "8/8 healthy" to +three genuine failures in under a minute. All three were network shape, not +code — check these before suspecting the server: -1. **Egress.** MCP server pods default to TCP 80/443 only +1. **Egress port.** MCP server pods default to TCP 80/443 only (`servers-allow-external-egress`). Any upstream on another port — the UniFi controller on `:8443` — times out on every call. Declare it in Pulumi's - `mcpctl.serverEgressTargets` (name + `/32` + ports); don't widen the blanket rule. -2. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` - address. Config pointing at one connect-timeouts forever. Use LAN IPs. + `mcpctl.serverEgressTargets`; don't widen the blanket rule. +2. **Ingress hairpin.** A co-located service reached over its *public* hostname + goes out and back through the per-host Envoy L7 policy, which doesn't + reliably carry the caller's identity and replies with a bare `Access denied`. + Grafana 403'd on every call this way while the identical token succeeded from + a laptop. The tell is the error *shape*: plain text, not the upstream's own + JSON error. Use the ClusterIP (`serverEgressTargets` with `namespace:`). +3. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` + address. Config pointing at one connect-timeouts forever. Use LAN IPs. (This + one turned out to be a retired service, which is its own kind of answer.) + +Also check the *dialect*: UniFi's `controller_type` must be `classic` for a +self-hosted controller (login `/api/login`, no `/proxy/network` prefix). +`unifi_os` sends every request to a path that 404s. ## LLM-*essential* operations — failover chain From a158e49ec24bef57b9a7f2b3888b47038445b579 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 23:53:22 +0100 Subject: [PATCH 13/22] fix(templates): make the shipped templates match reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The templates are what `create server --from-template` builds from and what mcpd seeds on start, so drift there ships broken servers. Nothing ever read these files in a test, and they had rotted badly. - grafana: GRAFANA_URL now defaults to the in-cluster ClusterIP and the description spells out why the public hostname is wrong — reaching a co-located Grafana over its ingress hairpins through the per-host Envoy L7 policy, which drops the caller's identity and returns a bare `Access denied` 403 with a perfectly valid token. That cost a day of looking at the token. - unifi-network: was wrong on every field that mattered. `runtime: python` for an npm package, an env contract (UNIFI_HOST/USERNAME/PASSWORD) the package doesn't read, and no probe. Now UNIFI_TARGETS with the classic-vs-unifi_os distinction and the :8443 egress caveat written down. - docmost, gitea: both carried "health check disabled" comments citing a limitation of the old docker-exec probe, which readiness-via-proxy removed. Both probes verified against the live servers. gitea uses search_repos, not get_me, because get_me needs a `read:user` scope a repo-scoped token lacks. - filesystem: packageName was `@anthropic/filesystem-mcp`, which 404s on npm — the template could never have installed. Points at the real package. - terraform: deleted. `@anthropic/terraform-mcp` 404s too and there is no npm-published replacement to point it at. - node-red: deleted, the service is gone. Two supporting fixes: - The seeder declared no `runtime` field and never wrote the column, so a template asking for the python runner silently seeded as null and got node. - A new templates test reads every shipped file: schema-valid, a runner the orchestrator knows, some way to actually start, unique env names, and a readiness probe (without one an instance can only ever report `live`). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb --- README.md | 2 +- src/db/src/seed/index.ts | 9 ++++ src/mcpd/tests/templates.test.ts | 79 ++++++++++++++++++++++++++++++++ templates/docmost.yaml | 11 ++++- templates/filesystem.yaml | 21 ++++++++- templates/gitea.yaml | 13 +++++- templates/grafana.yaml | 16 ++++++- templates/node-red.yaml | 16 ------- templates/terraform.yaml | 6 --- templates/unifi-network.yaml | 53 +++++++++++++-------- 10 files changed, 178 insertions(+), 48 deletions(-) create mode 100644 src/mcpd/tests/templates.test.ts delete mode 100644 templates/node-red.yaml delete mode 100644 templates/terraform.yaml diff --git a/README.md b/README.md index 16ead01..71600b6 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,7 @@ name: home-automation proxyModel: default servers: - home-assistant - - node-red + - unifi-network ``` Via CLI: diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index e8872e3..6c6dfb5 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -21,6 +21,13 @@ export interface SeedTemplate { version: string; description: string; packageName?: string; + /** + * Package runner: 'node' (npx) or 'python' (uvx). McpTemplate has had this + * column all along, but the upsert below never wrote it, so a template + * declaring `runtime: python` seeded as null and every server created from + * it silently got the node runner. + */ + runtime?: string; dockerImage?: string; transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; repositoryUrl?: string; @@ -45,6 +52,7 @@ export async function seedTemplates( version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, @@ -60,6 +68,7 @@ export async function seedTemplates( version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, diff --git a/src/mcpd/tests/templates.test.ts b/src/mcpd/tests/templates.test.ts new file mode 100644 index 0000000..a597a47 --- /dev/null +++ b/src/mcpd/tests/templates.test.ts @@ -0,0 +1,79 @@ +/** + * The shipped `templates/*.yaml` are seeded into mcpd and are what `mcpctl + * create server --from-template` builds from, so drift there ships broken + * servers. The unifi-network template had drifted on every field that + * mattered — python runtime for an npm package, an env contract + * (UNIFI_HOST/USERNAME/PASSWORD) the package doesn't read, and a comment + * disabling its health check for a reason that had stopped being true — and + * nothing caught it because no test ever read the files. + */ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import { CreateTemplateSchema } from '../src/validation/template.schema.js'; + +const TEMPLATES_DIR = fileURLToPath(new URL('../../../templates', import.meta.url)); + +const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + +interface RawTemplate { + name?: string; + runtime?: string; + packageName?: string; + dockerImage?: string; + externalUrl?: string; + healthCheck?: { tool?: string }; + env?: Array<{ name?: string }>; +} + +function load(file: string): RawTemplate { + return yaml.load(readFileSync(join(TEMPLATES_DIR, file), 'utf-8')) as RawTemplate; +} + +describe('shipped templates', () => { + it('ships at least one template', () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files)('%s validates against CreateTemplateSchema', (file) => { + const parsed = CreateTemplateSchema.safeParse(load(file)); + expect(parsed.success ? null : parsed.error.issues).toBeNull(); + }); + + it.each(files)('%s declares a runner the orchestrator knows', (file) => { + const tpl = load(file); + // `runtime` only means anything for package-based servers, and only + // 'node' (npx) and 'python' (uvx) are wired in buildRuntimeSpawnCmd. + if (tpl.runtime !== undefined) { + expect(['node', 'python']).toContain(tpl.runtime); + } + }); + + it.each(files)('%s says how to actually run the server', (file) => { + const tpl = load(file); + const runnable = tpl.packageName !== undefined + || tpl.dockerImage !== undefined + || tpl.externalUrl !== undefined; + expect(runnable, `${file} has no packageName, dockerImage, or externalUrl`).toBe(true); + }); + + it.each(files)('%s names a readiness probe tool, not a bare liveness probe', (file) => { + const tpl = load(file); + // Without a `tool`, an instance from this template can only ever report + // `live` — nothing would ever check its upstream. See docs/reliability.md. + expect(tpl.healthCheck?.tool, `${file} has no healthCheck.tool`).toBeTruthy(); + }); + + it.each(files)('%s declares uniquely-named env entries', (file) => { + const names = (load(file).env ?? []).map((e) => e.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('has no template for a retired server', () => { + // node-red was retired 2026-08-09: it answered on neither its Tailscale + // nor its LAN address and had no deployment anywhere. + expect(files).not.toContain('node-red.yaml'); + }); +}); diff --git a/templates/docmost.yaml b/templates/docmost.yaml index 10d9df1..e608d1c 100644 --- a/templates/docmost.yaml +++ b/templates/docmost.yaml @@ -4,8 +4,15 @@ description: Docmost MCP server for wiki/documentation page management and searc dockerImage: "mysources.co.uk/michal/docmost-mcp:latest" transport: STDIO repositoryUrl: https://github.com/MrMartiniMo/docmost-mcp -# Health check disabled: STDIO health probe requires packageName (npm-based servers). -# This server uses a custom dockerImage. Probe support for dockerImage STDIO servers is TODO. +healthCheck: + # get_workspace calls the Docmost API, so a pass proves URL + login. The old + # "probe requires packageName" caveat here was true of the long-gone + # docker-exec probe; readiness now goes through the MCP proxy, which works + # the same for image-based STDIO servers. Verified against the live server. + tool: get_workspace + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: DOCMOST_API_URL description: Docmost API URL (e.g. http://100.88.157.6:3000/api) diff --git a/templates/filesystem.yaml b/templates/filesystem.yaml index 4c14e7b..cb0c1c9 100644 --- a/templates/filesystem.yaml +++ b/templates/filesystem.yaml @@ -1,6 +1,23 @@ name: filesystem -version: "1.0.0" +version: "2.0.0" description: Filesystem MCP server for reading and writing files -packageName: "@anthropic/filesystem-mcp" +# Was "@anthropic/filesystem-mcp", which 404s on the npm registry — creating a +# server from this template failed at install. This is the real package. +packageName: "@modelcontextprotocol/server-filesystem" +runtime: node transport: STDIO repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem +healthCheck: + # Zero-arg and proves the server resolved its configured roots, which is the + # only thing it can be misconfigured about. + tool: list_allowed_directories + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 +env: + - name: ALLOWED_DIRECTORIES + description: >- + Space-separated directories the server may access. Passed as the + package's positional arguments; without at least one the server exposes + nothing. + required: true diff --git a/templates/gitea.yaml b/templates/gitea.yaml index 9ded49c..c41dbd9 100644 --- a/templates/gitea.yaml +++ b/templates/gitea.yaml @@ -7,7 +7,18 @@ repositoryUrl: https://gitea.com/gitea/gitea-mcp # No command: the image's entrypoint IS the MCP server. mcpd attaches to PID 1 # stdin/stdout (attach mode) rather than exec-ing a new process. The image is # distroless and has no node/shell, so exec-based STDIO would fail. -# Health check disabled: STDIO health probe requires node in the container. +healthCheck: + # search_repos is a real Gitea API call, deliberately chosen over get_me: + # get_me needs the `read:user` token scope, which a repo-scoped token won't + # have, so it would fail for a bookkeeping reason rather than a real one. + # (The "probe requires node in the container" caveat that used to sit here + # described the old docker-exec probe; readiness goes through the MCP proxy + # now, so a distroless image is fine.) Verified against the live server. + tool: search_repos + arguments: + query: mcpctl + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: GITEA_HOST description: Gitea instance URL (e.g. https://gitea.example.com) diff --git a/templates/grafana.yaml b/templates/grafana.yaml index 2bf6992..f8445b7 100644 --- a/templates/grafana.yaml +++ b/templates/grafana.yaml @@ -1,16 +1,28 @@ name: grafana -version: "1.0.0" +version: "1.1.0" description: Grafana MCP server for dashboards, datasources, and alerts packageName: "@leval/mcp-grafana" +runtime: node transport: STDIO repositoryUrl: https://github.com/levalhq/mcp-grafana healthCheck: + # Hits the Grafana API, so a pass proves URL + token + reachability. A + # liveness probe (tools/list) cannot: it answers from the server's own tool + # table and stays green while every Grafana call 403s. tool: list_datasources arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: GRAFANA_URL - description: Grafana instance URL (e.g. https://grafana.example.com) + description: >- + Grafana base URL. For a Grafana in this cluster use its ClusterIP + (http://grafana..svc.cluster.local:3000) — NOT its public + hostname. Reaching it over the public ingress hairpins the request back + through the per-host Envoy L7 policy, which drops the caller's identity + and answers a bare `Access denied` 403 even when the token is valid. required: true + defaultValue: http://grafana.home-automation.svc.cluster.local:3000 - name: GRAFANA_SERVICE_ACCOUNT_TOKEN description: Grafana service account token (glsa_...) required: true diff --git a/templates/node-red.yaml b/templates/node-red.yaml deleted file mode 100644 index d81b749..0000000 --- a/templates/node-red.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: node-red -version: "1.0.0" -description: Node-RED MCP server for flow management and automation -packageName: "mcp-node-red" -transport: STDIO -repositoryUrl: https://github.com/fx/mcp-node-red -healthCheck: - tool: get_settings - arguments: {} -env: - - name: NODE_RED_URL - description: Node-RED instance URL (e.g. http://nodered.local:1880) - required: true - - name: NODE_RED_TOKEN - description: Node-RED access token (optional if no auth) - required: false diff --git a/templates/terraform.yaml b/templates/terraform.yaml deleted file mode 100644 index 9fd4049..0000000 --- a/templates/terraform.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: terraform -version: "1.0.0" -description: Terraform MCP server for infrastructure documentation and state -packageName: "@anthropic/terraform-mcp" -transport: STDIO -repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/terraform diff --git a/templates/unifi-network.yaml b/templates/unifi-network.yaml index 8d5c14a..242309d 100644 --- a/templates/unifi-network.yaml +++ b/templates/unifi-network.yaml @@ -1,25 +1,42 @@ name: unifi-network -version: "1.0.0" +version: "2.0.0" description: UniFi Network MCP server for managing UniFi network devices, clients, and configuration packageName: "unifi-network-mcp" -runtime: python +runtime: node transport: STDIO repositoryUrl: https://github.com/sirkirby/unifi-mcp -# Health check disabled: STDIO health probe requires packageName (npm-based servers). -# This server uses the Python runner. Probe support for Python runner STDIO servers is TODO. +healthCheck: + # list_sites calls the controller (/api/self/sites), so a pass proves the + # whole path: egress to the controller port, TLS, login, session. The old + # template disabled the probe entirely on the belief that STDIO probes only + # worked for npm packages — that stopped being true once readiness probes + # started going through the MCP proxy, and the gap let this server sit at + # "healthy" for months without ever reaching the controller. + tool: list_sites + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 15 env: - - name: UNIFI_HOST - description: UniFi controller hostname or IP (e.g. unifi.example.com — without https://) + - name: UNIFI_TARGETS + description: >- + JSON array of controllers. One object per controller: + {"id", "base_url", "controller_type", "default_site", "auth": + {"username","password"}, "verify_ssl"}. + + controller_type is "classic" for a self-hosted UniFi Network controller + (login /api/login, no path prefix) or "unifi_os" for a UDM/UniFi OS + console (login /api/auth/login, API under /proxy/network). Choosing the + wrong one sends every request to a path that 404s while the server still + starts cleanly. + + base_url must carry the real controller port — a self-hosted controller + is usually :8443, and :443 on the same host is often an unrelated + service. Note that MCP server pods only egress 80/443 by default, so any + other port needs an explicit NetworkPolicy (Pulumi + `mcpctl.serverEgressTargets`). required: true - - name: UNIFI_USERNAME - description: UniFi local admin username - required: true - - name: UNIFI_PASSWORD - description: UniFi admin password - required: true - - name: UNIFI_NETWORK_PORT - description: UniFi controller port (default 443, use 8443 for standalone UniFi Controller) - required: false - - name: UNIFI_NETWORK_VERIFY_SSL - description: Verify SSL certificate (true/false, default true — set false for self-signed certs) - required: false + defaultValue: >- + [{"id": "home", "base_url": "https://unifi.example.com:8443", + "controller_type": "classic", "default_site": "default", + "auth": {"username": "CHANGE_ME", "password": "CHANGE_ME"}, + "verify_ssl": false}] From be7fabd467881b0e84b731650522863e541fbdce Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 00:15:04 +0100 Subject: [PATCH 14/22] fix(pi-ext): stop importing @earendil-works/pi-ai at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension failed to load outright on older pi installs: Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai' pi doesn't resolve an extension's bare specifiers the ordinary way — it hands jiti a hard-coded alias table built from its own dependencies, and that table differs between pi distributions. `@earendil-works/pi-coding- agent` (0.84.1) aliases both the `@earendil-works/*` and legacy `@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases only the old ones. Neither resolves the other's namespace, so a single import outside the intersection takes the whole extension down: every tool, the /mcpctl command, and the status line, all gone. The only thing we used from pi-ai was `StringEnum`, a six-line wrapper over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical output, so `typebox` — aliased by every published pi — is now the sole bare runtime import. The call site also passes `description` through, which the pi-ai version was silently dropping. Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in the embedded sources that isn't `node:`, relative, or typebox now fails. Verified against both installs with the same active project: 0.73.1 reproduced the error verbatim before the change and loads cleanly after, and 0.84.1 keeps registering the gate tool exactly as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8 --- docs/pi-extension.md | 31 +++++++++++++++++-- src/cli/src/config/pi-extension.ts | 2 +- .../tests/config/pi-extension-embed.test.ts | 28 +++++++++++++++++ src/pi-ext/mcpctl-pi.ts | 28 +++++++++++++++-- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/pi-extension.md b/docs/pi-extension.md index fcf98fc..f10c6f7 100644 --- a/docs/pi-extension.md +++ b/docs/pi-extension.md @@ -101,9 +101,34 @@ src/pi-ext/ mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) ``` -The extension imports only from pi-bundled packages -(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it -loads standalone. +The extension imports only from pi-bundled packages, so it loads standalone. + +### `typebox` is the only bare runtime import + +pi does not let an extension resolve modules the ordinary way: it hands jiti a +hard-coded alias table built from its *own* dependencies, and that table is not +the same across pi distributions. The newer `@earendil-works/pi-coding-agent` +aliases both the `@earendil-works/*` and the legacy `@mariozechner/*` names; +older `@mariozechner/pi-coding-agent` installs (0.73.x and earlier) alias only +the `@mariozechner/*` ones. Neither resolves the other's namespace. + +So an import of anything outside the intersection kills the *whole* extension on +someone else's pi — every tool, the `/mcpctl` command, the status line — with: + +``` +Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai' +``` + +which is exactly what `import { StringEnum } from "@earendil-works/pi-ai"` did. +`typebox` is aliased by every published pi, so it is the only bare specifier +allowed at runtime. Everything else must be a `node:` builtin, a relative path, +an `import type` (erased before jiti resolves anything), or inlined — pi-ai's +`StringEnum` is now a six-line local `stringEnum`. The +`tests/config/pi-extension-embed.test.ts` guard fails the build on a reintroduced +runtime import. + +If a user does hit this error, check `type -a pi`: two installs on `$PATH` is the +usual cause, and the extension has to load under whichever one wins. ## Typechecking diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index 35ccf26..eff525c 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n *\n * RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers\n * through a fixed alias table in its own loader, and that table differs between\n * pi distributions — `@earendil-works/*` exists only in the newer packages,\n * while `@mariozechner/*` installs alias only the old names. `typebox` is the\n * one specifier every published pi aliases, so it is the ONLY runtime import\n * allowed here. Anything else must be `import type` (erased before jiti runs)\n * or inlined — see `stringEnum` below.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\n\n/**\n * `{ type: \"string\", enum: [...] }` rather than a union of literals: Google's\n * API (and other providers that reject anyOf/const) only accept the flat form.\n *\n * Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in\n * `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the\n * whole extension then failed to load. See the import note at the top.\n */\nfunction stringEnum(values: string[], description?: string): TSchema {\n return Type.Unsafe({\n type: \"string\",\n enum: values,\n ...(description ? { description } : {}),\n });\n}\n\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return stringEnum(enumVals as string[], desc);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/cli/tests/config/pi-extension-embed.test.ts b/src/cli/tests/config/pi-extension-embed.test.ts index 5453278..3280d0b 100644 --- a/src/cli/tests/config/pi-extension-embed.test.ts +++ b/src/cli/tests/config/pi-extension-embed.test.ts @@ -29,6 +29,34 @@ describe('embedded pi extension', () => { expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js'); }); + /** + * pi resolves an extension's bare specifiers through a hard-coded alias table + * in its own loader, and that table is not the same across pi distributions: + * `@earendil-works/*` exists only in the newer packages, `@mariozechner/*` + * installs alias only the old names, and neither resolves the other. An + * import of a package outside the intersection makes the whole extension fail + * to load with `Cannot find module` — every tool gone, on someone else's pi. + * + * `typebox` is aliased by every published pi, so it is the only safe bare + * runtime import. Type-only imports are erased before jiti resolves anything, + * so they may name whatever they like. + */ + it('imports nothing at runtime that some pi build cannot resolve', () => { + // `import x from "s"` / `import {..} from "s"` (but not `import type`), + // plus the side-effect form `import "s"`. + const runtimeImport = + /^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm; + const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/; + + for (const name of PI_EXTENSION_FILENAMES) { + const src = PI_EXTENSION_FILES[name] ?? ''; + for (const match of src.matchAll(runtimeImport)) { + const specifier = match[1] ?? match[2] ?? ''; + expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed); + } + } + }); + it('carries the fixes the pi API requires', () => { const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? ''; // ctx.ui.select takes string[] and returns the chosen string. diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index d895720..b42d22d 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -20,9 +20,16 @@ * or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"] * * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude. + * + * RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers + * through a fixed alias table in its own loader, and that table differs between + * pi distributions — `@earendil-works/*` exists only in the newer packages, + * while `@mariozechner/*` installs alias only the old names. `typebox` is the + * one specifier every published pi aliases, so it is the ONLY runtime import + * allowed here. Anything else must be `import type` (erased before jiti runs) + * or inlined — see `stringEnum` below. */ import { Type, type TSchema } from "typebox"; -import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { McpHttpSession, @@ -110,6 +117,23 @@ async function listProjects(mcplocalUrl: string, token?: string): Promise({ + type: "string", + enum: values, + ...(description ? { description } : {}), + }); +} + function convertSchema(inputSchema: unknown): TSchema { if (!inputSchema || typeof inputSchema !== "object") { return Type.Object({}); @@ -147,7 +171,7 @@ function convertProp(raw: unknown): TSchema { const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined; if (enumVals && enumVals.every((v) => typeof v === "string")) { - return StringEnum(enumVals as string[]); + return stringEnum(enumVals as string[], desc); } if (enumVals && enumVals.every((v) => typeof v === "number")) { const literals = enumVals.map((v) => Type.Literal(v)); From 2a7bba11ea6638d717b8f5e2143a50b2a63084d0 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 12:15:46 +0100 Subject: [PATCH 15/22] fix(claude): stop pre-migration .mcp.json residue outranking a project switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl config claude --project X` writes user scope and never rewrites a checkout's `.mcp.json`. The status line, however, preferred that file unconditionally — so a legacy project-named entry an older mcpctl left behind (`homeautomation` -> `mcpctl mcp -p homeautomation`) kept naming the old project for good, and every switch looked like it had done nothing. Reproduced live: with user scope on `sre`, `mcpctl statusline --directory ~/developer/michalzxc/claude/debug` printed `mcpctl:homeautomation` — a project Claude Code also had in `disabledMcpServers` for that directory, so the line named a server that was not even mounted. Rank the sources by how deliberate each one is instead: a canonical `mcpctl` pin, then user scope, then legacy residue, then the marker. A pin is a decision and still wins; residue is not and no longer does. At every step, skip a server Claude Code has switched off for that directory. `config claude` now also warns when the working directory's `.mcp.json` contradicts the switch, naming the file — the two scopes are merged rather than chosen between, so nothing else would tell you. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/claude-integration.md | 49 +++++++-- src/cli/src/commands/config.ts | 48 +++++++++ src/cli/src/commands/statusline.ts | 81 ++++++++++++--- src/cli/src/config/claude-mcp.ts | 57 ++++++++-- src/cli/tests/commands/claude.test.ts | 42 ++++++++ src/cli/tests/commands/statusline.test.ts | 121 ++++++++++++++++++++++ src/cli/tests/config/claude-mcp.test.ts | 47 +++++++++ 7 files changed, 414 insertions(+), 31 deletions(-) create mode 100644 src/cli/tests/commands/statusline.test.ts diff --git a/docs/claude-integration.md b/docs/claude-integration.md index d0163b5..425a65e 100644 --- a/docs/claude-integration.md +++ b/docs/claude-integration.md @@ -101,13 +101,48 @@ to open, and re-scoping it would silently change which skills sync into it. ⏵⏵ bypass permissions on · ← for agents ``` -`mcpctl statusline` resolves the project from a directory-scoped `.mcp.json` -first (a repo that pinned itself wins), then the user-scope entry in -`.claude.json`, then a `.mcpctl-project` marker up the tree so a checkout that is scoped but not yet -wired still reports. It reads the directory from the JSON Claude Code pipes in, -so it follows `/cwd` rather than reporting wherever the binary was launched, and -prints **nothing** when no project is active — an empty status line beats one -saying "none" on every unrelated repo. +`mcpctl statusline` reads the directory from the JSON Claude Code pipes in, so it +follows `/cwd` rather than reporting wherever the binary was launched, and prints +**nothing** when no project is active — an empty status line beats one saying +"none" on every unrelated repo. + +It then takes the project from the most deliberate source that names one: + +1. a canonical `mcpctl` entry in that directory's `.mcp.json` — a repo that + pinned itself wins, and it is the scope Claude Code itself prefers when both + define that server name; +2. the user-scope entry in `.claude.json` — what `config claude --project` + writes, so a switch takes effect everywhere it is not overridden; +3. a **legacy** project-named entry in `.mcp.json` (`homeautomation`, + `docmost`, …), left by an mcpctl older than the constant server name; +4. a `.mcpctl-project` marker up the tree, so a checkout that is scoped but not + yet wired still reports. + +> **Legacy entries rank below user scope on purpose.** They used to outrank it, +> which made switching look broken: a user-scope switch never rewrites a +> checkout's `.mcp.json`, so the leftover kept naming the old project for good. +> A pin is a decision; residue is not. + +A server Claude Code has switched off for that directory (`disabledMcpServers` / +`disabledMcpjsonServers`) is skipped at every step — a disabled server is not +mounted, so naming its project would be a lie. A `.mcp.json` server that is in +neither list is still awaiting its approval prompt and does count, since blanking +the status line on a fresh checkout is the more confusing failure. + +### When a directory contradicts a switch + +Claude Code merges the two scopes rather than picking one, so switching in user +scope cannot clean up what a directory declares. `config claude` says so rather +than reporting plain success: + +``` +Warning: /path/to/repo/.mcp.json still registers 'homeautomation' for this +directory — mounted alongside 'sre', not replaced by it. + Re-run with --scope project to retire it, or delete the entry by hand. +``` + +A canonical entry pinned to another project gets the stronger wording — it +*overrides* the switch in that directory rather than sitting beside it. ### It is never installed over yours diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 8623513..917d551 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -46,6 +46,8 @@ import { mergeUserScopeServer, userScopeProject, activeProjectIn, + canonicalProjectIn, + legacyEntriesIn, claudeJsonPath, type McpJson, type ClaudeJson, @@ -104,11 +106,51 @@ function readMcpJson(path: string): McpJson | null { } } +/** + * Warnings about a `.mcp.json` in `dir` that contradicts a user-scope switch to + * `project`. + * + * Claude Code merges the two scopes rather than picking one, so a + * directory-scoped entry does not go away when you switch globally: + * - a canonical `mcpctl` entry shares the name, and project scope wins — the + * switch has no effect in this directory at all; + * - a legacy project-named entry has a *different* name, so it is simply + * mounted alongside and the old project keeps answering here. + * Either way the user is owed the file path, because nothing else will tell + * them. Exported for tests. + */ +export function shadowWarnings(dir: string, project: string | undefined): string[] { + if (project === undefined || project === '') return []; + const path = join(dir, '.mcp.json'); + const parsed = readMcpJson(path); + if (parsed === null) return []; + + const pinned = canonicalProjectIn(parsed); + if (pinned !== null && pinned !== project) { + return [ + `Warning: ${path} pins '${MCPCTL_SERVER_NAME}' to '${pinned}' for this directory, which overrides the switch here.`, + ` Re-run with --scope project to repoint it, or delete the '${MCPCTL_SERVER_NAME}' entry to follow the user-scope project.`, + ]; + } + + const stale = legacyEntriesIn(parsed).filter((e) => e.project !== project); + if (stale.length > 0) { + const names = stale.map((e) => `'${e.server}'`).join(', '); + return [ + `Warning: ${path} still registers ${names} for this directory — mounted alongside '${project}', not replaced by it.`, + ` Re-run with --scope project to retire ${stale.length === 1 ? 'it' : 'them'}, or delete the ${stale.length === 1 ? 'entry' : 'entries'} by hand.`, + ]; + } + return []; +} + export interface ConfigCommandDeps { configDeps: Partial; log: (...args: string[]) => void; /** API client for the skills sync side-effect of `config claude --project`. Optional so existing call sites work; without it we skip the sync step. */ apiClient?: ApiClient; + /** Working directory to check for a shadowing `.mcp.json`. Injectable so tests need not chdir. */ + cwd?: () => string; } export interface ConfigApiDeps { @@ -124,6 +166,7 @@ const defaultDeps: ConfigCommandDeps = { export function createConfigCommand(deps?: Partial, apiDeps?: ConfigApiDeps): Command { const { configDeps, log } = { ...defaultDeps, ...deps }; + const cwd = deps?.cwd ?? ((): string => process.cwd()); // PR-5: api client used by `mcpctl config claude --project` to run the // initial skills sync after wiring the .mcp.json. Threaded through from // index.ts; falls back to apiDeps.client when not explicitly passed (the @@ -406,6 +449,11 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (userScope) { // The whole point of user scope: you do this once, not per checkout. log('This applies in every directory — no need to re-run it per repo.'); + // ...except where a directory-scoped entry contradicts it. That file + // is never rewritten by a user-scope switch, so staying silent is how + // a switch ends up looking like it did nothing: the status line keeps + // naming the old project, and its server keeps answering here. + for (const line of shadowWarnings(cwd(), opts.project)) log(line); } // PR-5: write project marker, run initial skills sync, install diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts index 7f22ede..9d04ef0 100644 --- a/src/cli/src/commands/statusline.ts +++ b/src/cli/src/commands/statusline.ts @@ -2,7 +2,16 @@ import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { activeProjectIn, claudeJsonPath, userScopeProject, type McpJson, type ClaudeJson } from '../config/claude-mcp.js'; +import { + MCPCTL_SERVER_NAME, + canonicalProjectIn, + claudeJsonPath, + disabledServersFor, + legacyEntriesIn, + userScopeProject, + type McpJson, + type ClaudeJson, +} from '../config/claude-mcp.js'; import { findProjectMarker } from '../utils/project-marker.js'; /** @@ -16,9 +25,10 @@ import { findProjectMarker } from '../utils/project-marker.js'; * `setStatus`. * * Claude Code pipes a JSON blob in on stdin (session id, model, workspace). We - * only need the directory — the project is whatever `.mcp.json` there mounts, - * falling back to a `.mcpctl-project` marker up the tree so a checkout that is - * scoped but not yet wired still reports. + * only need the directory; the project is then resolved from the most + * deliberate source that names one — see the ranking in the action below. + * Whatever it reports has to be a project that is genuinely mounted, so a + * server Claude Code has switched off for that directory is skipped. * * Prints nothing at all when no project is active: an empty status line is * better than one that says "none" on every unrelated repo you open. @@ -53,25 +63,47 @@ export function resolveDirectory(input: StatusLineInput, fallback: string): stri return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; } -/** The project Claude Code's user-scope config mounts, or null. */ -export function projectFromUserScope(path: string): string | null { +/** Claude Code's user-scope config, or null if it is missing or unreadable. */ +export function readClaudeJson(path: string): ClaudeJson | null { try { - return userScopeProject(JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson); + return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson; } catch { return null; } } -/** The project `.mcp.json` in `dir` mounts, or null. */ -export function projectFromMcpJson(dir: string): string | null { +/** The project Claude Code's user-scope config mounts, or null. */ +export function projectFromUserScope(doc: ClaudeJson | null): string | null { + return userScopeProject(doc); +} + +/** The `.mcp.json` in `dir`, or null if there isn't a readable one. */ +export function readDirMcpJson(dir: string): McpJson | null { try { - const parsed = JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; - return activeProjectIn(parsed); + return JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; } catch { return null; } } +/** + * The project the canonical `mcpctl` entry in `dir`'s `.mcp.json` pins, or null + * — skipped when Claude Code has that server switched off for `dir`. + */ +export function projectFromDirPin(mcpJson: McpJson | null, disabled: Set): string | null { + if (disabled.has(MCPCTL_SERVER_NAME)) return null; + return canonicalProjectIn(mcpJson); +} + +/** + * The project a *legacy* project-named entry in `dir`'s `.mcp.json` mounts, or + * null. Disabled entries are skipped, so a leftover the user already turned off + * in `/mcp` stops being reported. + */ +export function projectFromDirLegacy(mcpJson: McpJson | null, disabled: Set): string | null { + return legacyEntriesIn(mcpJson).find((e) => !disabled.has(e.server))?.project ?? null; +} + /** Format for the status line. Empty string means "render nothing". */ export function formatStatus(project: string | null, prefix: string): string { return project !== null && project !== '' ? `${prefix}${project}` : ''; @@ -96,12 +128,29 @@ export function createStatuslineCommand(deps?: Partial): Command const input = opts.directory !== undefined ? {} : await readStdinJson(); const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); - // Directory-scoped wiring wins: a repo with its own .mcp.json entry has - // deliberately pinned itself, and that beats the global default. - let project = projectFromMcpJson(dir) ?? projectFromUserScope(claudeJsonPath()); + const claudeJson = readClaudeJson(claudeJsonPath()); + const mcpJson = readDirMcpJson(dir); + const disabled = disabledServersFor(claudeJson, dir); + + // Ranked by how deliberate each source is, because a switch has to be + // able to win: + // 1. a canonical `mcpctl` entry in this directory's .mcp.json — a + // deliberate pin, and the scope Claude Code itself prefers when both + // define the same server name; + // 2. user scope — what `config claude --project` writes, so switching + // projects must beat anything less deliberate than a pin; + // 3. a *legacy* project-named entry in .mcp.json. This used to outrank + // user scope, which made a switch look like it had done nothing: the + // residue an older mcpctl left in a checkout is not a pin, and never + // gets rewritten by a user-scope switch, so it reported the old + // project forever; + // 4. the .mcpctl-project marker — the other thing `config claude` + // writes, and what skills sync already trusts. + let project = + projectFromDirPin(mcpJson, disabled) + ?? projectFromUserScope(claudeJson) + ?? projectFromDirLegacy(mcpJson, disabled); if (project === null) { - // Not wired here (or wired above this directory) — the marker is the - // other thing `config claude` writes, and skills sync already trusts it. const marker = await findProjectMarker(dir, homeDir()).catch(() => null); project = marker?.project ?? null; } diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts index d426b26..2452e93 100644 --- a/src/cli/src/config/claude-mcp.ts +++ b/src/cli/src/config/claude-mcp.ts @@ -100,16 +100,30 @@ export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean { return projectOfEntry(entry) === name; } +/** The project the canonical `mcpctl` entry mounts, or null if there isn't one. */ +export function canonicalProjectIn(config: Pick | null | undefined): string | null { + return projectOfEntry(config?.mcpServers?.[MCPCTL_SERVER_NAME]); +} + +/** + * Legacy project-named entries still present, in file order. + * + * Kept separate from the canonical entry because the two mean different things + * to a reader: the canonical entry is a deliberate pin, a legacy entry is + * residue from an older mcpctl that nothing has cleaned up yet. Callers that + * rank sources (the status line) must be able to tell them apart. + */ +export function legacyEntriesIn(config: Pick | null | undefined): { server: string; project: string }[] { + const servers = config?.mcpServers; + if (!servers) return []; + return Object.entries(servers) + .filter(([name, entry]) => isLegacyMcpctlEntry(name, entry)) + .map(([name]) => ({ server: name, project: name })); +} + /** The project currently mounted by `.mcp.json`, preferring the canonical entry. */ export function activeProjectIn(config: Pick | null | undefined): string | null { - const servers = config?.mcpServers; - if (!servers) return null; - const canonical = projectOfEntry(servers[MCPCTL_SERVER_NAME]); - if (canonical !== null) return canonical; - for (const [name, entry] of Object.entries(servers)) { - if (isLegacyMcpctlEntry(name, entry)) return name; - } - return null; + return canonicalProjectIn(config) ?? legacyEntriesIn(config)[0]?.project ?? null; } export interface MergeResult { @@ -169,12 +183,39 @@ export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: s : join(home, '.claude.json'); } +/** Per-directory state Claude Code keeps in `.claude.json`'s `projects` map. */ +export interface ClaudeProjectEntry { + /** Servers switched off for this directory, whatever scope they came from. */ + disabledMcpServers?: string[]; + /** `.mcp.json` servers declined at the approval prompt. */ + disabledMcpjsonServers?: string[]; + [key: string]: unknown; +} + /** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */ export interface ClaudeJson { mcpServers?: Record; + projects?: Record; [key: string]: unknown; } +/** + * Server names Claude Code has switched off in `dir`. + * + * A disabled server is not mounted, so naming its project as "active" is a + * plain lie — this is what lets the status line skip one. Only *explicit* + * disables count: a `.mcp.json` server in neither list is pending its approval + * prompt, and treating pending as off would blank the status line on a fresh + * checkout, which is the more confusing failure. + */ +export function disabledServersFor(doc: ClaudeJson | null | undefined, dir: string): Set { + const entry = doc?.projects?.[dir]; + return new Set([ + ...(Array.isArray(entry?.disabledMcpServers) ? entry.disabledMcpServers : []), + ...(Array.isArray(entry?.disabledMcpjsonServers) ? entry.disabledMcpjsonServers : []), + ]); +} + /** * Set the user-scope entry, returning the new document and any legacy * project-named entries retired from it. diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9f43bd6..f29a00b 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -359,4 +359,46 @@ describe('config claude — user scope', () => { process.exitCode = prevExit; expect(output.join('\n')).toContain("unknown --scope 'global'"); }); + + // A user-scope switch never rewrites a directory's .mcp.json, so anything of + // ours left in one keeps answering in that directory. Saying so is the only + // way the user finds out — the switch otherwise reports plain success. + describe('warns when the working directory contradicts the switch', () => { + const switchTo = async (project: string): Promise => { + await createConfigCommand({ configDeps: {}, log, cwd: () => tmpDir }) + .parseAsync(['claude', '--project', project, '--skip-skills', '--skip-ui'], { from: 'user' }); + return output.join('\n'); + }; + + it('names a legacy entry that stays mounted alongside the new project', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] } }, + })); + const out = await switchTo('sre'); + expect(out).toContain(join(tmpDir, '.mcp.json')); + expect(out).toContain("'homeautomation'"); + expect(out).toContain('mounted alongside'); + }); + + it('says a canonical pin overrides the switch in that directory', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'docmost'] } }, + })); + expect(await switchTo('sre')).toContain('overrides the switch here'); + }); + + it('stays quiet when the directory already agrees, or wires nothing of ours', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { + mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'sre'] }, + 'their-server': { command: 'docker', args: ['run', 'x'] }, + }, + })); + expect(await switchTo('sre')).not.toContain('Warning:'); + }); + + it('stays quiet when there is no .mcp.json at all', async () => { + expect(await switchTo('sre')).not.toContain('Warning:'); + }); + }); }); diff --git a/src/cli/tests/commands/statusline.test.ts b/src/cli/tests/commands/statusline.test.ts new file mode 100644 index 0000000..da6b40b --- /dev/null +++ b/src/cli/tests/commands/statusline.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStatuslineCommand } from '../../src/commands/statusline.js'; + +/** + * The status line is what tells you which project you are in, so the property + * under test throughout is: after a switch, does it name the project you + * switched to? + * + * These drive the real command rather than the resolution helpers, because the + * bug they cover was in the *ranking* of sources, not in any one source. + */ + +const bridge = (project: string): Record => ({ + command: 'mcpctl', + args: ['mcp', '-p', project], +}); + +let home: string; +let dir: string; + +/** Claude Code's user-scope config, at the path `claudeJsonPath()` resolves. */ +function writeClaudeJson(doc: unknown): void { + writeFileSync(join(home, '.claude.json'), JSON.stringify(doc)); +} + +function writeMcpJson(doc: unknown): void { + writeFileSync(join(dir, '.mcp.json'), JSON.stringify(doc)); +} + +/** Run `statusline` for `dir` and return exactly what it printed. */ +async function statusline(): Promise { + const out: string[] = []; + const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home }); + await cmd.parseAsync(['--directory', dir], { from: 'user' }); + return out.join(''); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-home-')); + dir = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-dir-')); + // Point claudeJsonPath() at the fake home; the CLI reads $CLAUDE_CONFIG_DIR + // first, which keeps this off the developer's real ~/.claude.json. + process.env['CLAUDE_CONFIG_DIR'] = home; +}); + +afterEach(() => { + delete process.env['CLAUDE_CONFIG_DIR']; + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('mcpctl statusline', () => { + it('reports the user-scope project when the directory wires nothing', async () => { + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('prints nothing at all when no project is active', async () => { + writeClaudeJson({ mcpServers: {} }); + expect(await statusline()).toBe(''); + }); + + it('lets a canonical .mcp.json pin override the user-scope project', async () => { + // Same server name in both scopes: Claude Code prefers project scope, so a + // deliberate pin is genuinely what is mounted here. + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } }); + expect(await statusline()).toBe('mcpctl:docmost'); + }); + + it('does not let a legacy project-named entry outrank a user-scope switch', async () => { + // The regression: an older mcpctl wrote `homeautomation` into a checkout, + // and a user-scope switch never rewrites that file — so the status line + // reported the old project forever and the switch looked like a no-op. + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('still reports a legacy entry when nothing more deliberate names a project', async () => { + writeClaudeJson({ mcpServers: {} }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe('mcpctl:homeautomation'); + }); + + it('skips a directory server Claude Code has switched off', async () => { + // A disabled server is not mounted, so naming its project is a lie. + writeClaudeJson({ + mcpServers: {}, + projects: { [dir]: { disabledMcpServers: ['homeautomation'] } }, + }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe(''); + }); + + it('skips a disabled pin and falls through to the user-scope project', async () => { + writeClaudeJson({ + mcpServers: { mcpctl: bridge('sre') }, + projects: { [dir]: { disabledMcpjsonServers: ['mcpctl'] } }, + }); + writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('falls back to a .mcpctl-project marker when nothing is wired', async () => { + writeClaudeJson({ mcpServers: {} }); + writeFileSync(join(dir, '.mcpctl-project'), 'lab\n'); + expect(await statusline()).toBe('mcpctl:lab'); + }); + + it('honours a custom prefix', async () => { + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + const out: string[] = []; + const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home }); + await cmd.parseAsync(['--directory', dir, '--prefix', 'proj '], { from: 'user' }); + expect(out.join('')).toBe('proj sre'); + }); +}); diff --git a/src/cli/tests/config/claude-mcp.test.ts b/src/cli/tests/config/claude-mcp.test.ts index 45a1a0a..d4541da 100644 --- a/src/cli/tests/config/claude-mcp.test.ts +++ b/src/cli/tests/config/claude-mcp.test.ts @@ -5,6 +5,9 @@ import { projectOfEntry, isLegacyMcpctlEntry, activeProjectIn, + canonicalProjectIn, + legacyEntriesIn, + disabledServersFor, } from '../../src/config/claude-mcp.js'; const bridge = (project: string): Record => ({ @@ -62,6 +65,50 @@ describe('activeProjectIn', () => { }); }); +describe('canonicalProjectIn / legacyEntriesIn', () => { + it('tells a deliberate pin apart from pre-migration residue', () => { + const config = { mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre'), homeautomation: bridge('homeautomation') } }; + expect(canonicalProjectIn(config)).toBe('sre'); + expect(legacyEntriesIn(config)).toEqual([{ server: 'homeautomation', project: 'homeautomation' }]); + }); + + it('reports no canonical entry when only legacy ones are present', () => { + const config = { mcpServers: { docmost: bridge('docmost') } }; + expect(canonicalProjectIn(config)).toBeNull(); + expect(legacyEntriesIn(config)).toEqual([{ server: 'docmost', project: 'docmost' }]); + }); + + it('leaves servers that are not ours out of both', () => { + const config = { mcpServers: { other: { command: 'echo' } } }; + expect(canonicalProjectIn(config)).toBeNull(); + expect(legacyEntriesIn(config)).toEqual([]); + expect(legacyEntriesIn(null)).toEqual([]); + }); +}); + +describe('disabledServersFor', () => { + const doc = { + projects: { + '/repo': { disabledMcpServers: ['homeautomation'], disabledMcpjsonServers: ['mcpctl'] }, + '/other': { disabledMcpServers: ['sre'] }, + }, + }; + + it('unions both of Claude Code\'s disable lists for that directory', () => { + expect([...disabledServersFor(doc, '/repo')].sort()).toEqual(['homeautomation', 'mcpctl']); + }); + + it('is scoped to the directory asked about', () => { + expect([...disabledServersFor(doc, '/other')]).toEqual(['sre']); + expect([...disabledServersFor(doc, '/unknown')]).toEqual([]); + expect([...disabledServersFor(null, '/repo')]).toEqual([]); + }); + + it('survives a malformed entry rather than throwing on the status line', () => { + expect([...disabledServersFor({ projects: { '/repo': { disabledMcpServers: 'nope' } } }, '/repo')]).toEqual([]); + }); +}); + describe('mergeMcpctlServers', () => { it('writes one constant entry regardless of project', () => { const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' }); From dd29f98f82b8ac8b3a62f17bbc0ba97d3370026f Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 16:38:19 +0100 Subject: [PATCH 16/22] build: refuse to package from a branch that is behind main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everyone branches off main and builds from their own branch. A branch that is behind main still builds and installs perfectly — it just packages a binary missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the good one with it. Nothing reports an error; the release succeeds and the feature simply vanishes from the installed CLI. That is what happened today: a build from a stale checkout replaced /usr/bin/mcpctl with one that has no `statusline` command at all, months after the status line landed on main (`mcpctl statusline` -> "unknown command"). `check-main-sync.sh` fetches main, compares, and fails before any work happens, listing the commits the branch is missing and the merge that fixes it. Sourced by build-rpm.sh and build-deb.sh — both are run standalone, so neither can rely on the other having checked. A hard failure rather than a warning: a warning scrolls past in a build log, and the whole point is to stop before the artifact exists. MCPCTL_ALLOW_BEHIND_MAIN=1 is the escape hatch for a deliberate old-tree build; MCPCTL_BASE_BRANCH retargets the comparison. Offline it degrades to the last fetched origin/main, then a local main, saying which it used; outside a git checkout it skips. Verified: passes on this branch (up to date with main); against a synthetic ref one commit ahead, build-rpm.sh aborts with exit 1 before ensure_build_deps and before any compilation; the escape hatch bypasses it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 18 +++++++ scripts/build-deb.sh | 5 ++ scripts/build-rpm.sh | 5 ++ scripts/check-main-sync.sh | 99 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100755 scripts/check-main-sync.sh diff --git a/docs/project-summary.md b/docs/project-summary.md index 95552bd..9ec9bd7 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -884,6 +884,24 @@ All pushed to `mysources.co.uk/michal/` registry. source .env && bash scripts/release.sh ``` +**The build refuses to run from a branch that is behind `main`.** Everyone +branches off main, so a stale branch still builds and installs cleanly — it just +ships a binary missing whatever landed on main meanwhile, and `rpm -U --force` +overwrites the good one with it. That happened on 2026-08-10: a build from a +stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline` +command, months after the status line landed. `scripts/check-main-sync.sh` +(sourced by `build-rpm.sh` and `build-deb.sh`) fetches `main`, compares, and +fails before any work happens, listing the commits you are missing. + +```bash +git merge main # the fix +MCPCTL_ALLOW_BEHIND_MAIN=1 bash scripts/release.sh # deliberate old-tree build +MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another branch +``` + +Offline it falls back to the last fetched `origin/main`, then to a local `main`, +and says which it used; outside a git checkout it skips entirely. + Installs via nfpm: - `/usr/bin/mcpctl` — CLI binary (bun compiled) - `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled) diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index e8b1d07..5d0013d 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Same guard as build-rpm.sh: this script is also run on its own, so it cannot +# rely on that one having checked. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index f2db0f1..2f45a98 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Before anything expensive: a branch behind main packages a binary missing +# whatever landed there, and installing it silently downgrades the machine. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/check-main-sync.sh b/scripts/check-main-sync.sh new file mode 100755 index 0000000..246ada2 --- /dev/null +++ b/scripts/check-main-sync.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Refuse to build a package from a branch that main has already moved past. +# +# WHY +# +# Everyone branches off main and builds from their own branch. A branch that is +# behind main still builds and installs perfectly — it just quietly ships a +# binary missing whatever landed on main in the meantime, and `rpm -U --force` +# overwrites the good one with it. +# +# That is not hypothetical: on 2026-08-10 a build from a stale checkout replaced +# /usr/bin/mcpctl with one that had no `statusline` command at all, months after +# the status line landed on main. Nothing reported an error — the release +# succeeded, the feature just vanished from the installed CLI. +# +# So this is a hard failure rather than a warning. A warning scrolls past in a +# build log; the whole point is to stop before the artifact exists. +# +# ESCAPE HATCH +# +# MCPCTL_ALLOW_BEHIND_MAIN=1 build anyway (deliberate build of an old tree) +# MCPCTL_BASE_BRANCH= compare against something other than main +# +# Skips itself entirely outside a git checkout, so tarball builds still work. + +# Resolve the ref to compare against, echoing it on stdout. Prefers a fresh +# fetch; falls back to whatever is already on disk so an offline build is +# degraded rather than blocked. Returns 1 when there is nothing to compare to. +_main_sync_ref() { + local base="$1" remote="$2" + + if [ -n "$remote" ] && git fetch --quiet "$remote" "$base" 2>/dev/null; then + # FETCH_HEAD rather than refs/remotes//: it is what this fetch + # just wrote, so it cannot be a stale opportunistic update. + echo "FETCH_HEAD" + return 0 + fi + + if [ -n "$remote" ] && git rev-parse --verify --quiet "refs/remotes/$remote/$base" >/dev/null; then + echo " (could not reach $remote — comparing against the last fetched $remote/$base)" >&2 + echo "refs/remotes/$remote/$base" + return 0 + fi + + if git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then + echo " (no reachable remote — comparing against local $base)" >&2 + echo "refs/heads/$base" + return 0 + fi + + return 1 +} + +check_main_sync() { + local base="${MCPCTL_BASE_BRANCH:-main}" + + if ! git rev-parse --git-dir >/dev/null 2>&1; then + return 0 # not a checkout; nothing to be behind + fi + + if [ "${MCPCTL_ALLOW_BEHIND_MAIN:-}" = "1" ]; then + echo "==> Skipping the '$base' sync check (MCPCTL_ALLOW_BEHIND_MAIN=1)" + return 0 + fi + + echo "==> Checking this branch is not behind '$base'..." + + local remote ref + remote="$(git remote | head -1)" + if ! ref="$(_main_sync_ref "$base" "$remote")"; then + echo " (no '$base' branch found anywhere — skipping)" + return 0 + fi + + local behind + behind="$(git rev-list --count "HEAD..$ref" 2>/dev/null || echo 0)" + if [ "$behind" -eq 0 ]; then + echo " up to date with $base" + return 0 + fi + + local branch + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" + echo "" >&2 + echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2 + echo "" >&2 + echo " Building now would package a binary without these, and installing it" >&2 + echo " would overwrite a good one with a version missing them:" >&2 + echo "" >&2 + git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2 + if [ "$behind" -gt 15 ]; then + echo " … and $((behind - 15)) more" >&2 + fi + echo "" >&2 + echo " Fix it: git merge $base # or: git rebase $base" >&2 + echo " Anyway: MCPCTL_ALLOW_BEHIND_MAIN=1 $0" >&2 + echo "" >&2 + return 1 +} From 96e27c871654812738b5c008263c28f198845eaa Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 16:41:26 +0100 Subject: [PATCH 17/22] build: extend the main-sync gate to the image build and the k8s deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same hazard as the package build, with the cluster on the receiving end: a branch behind main builds images missing whatever landed there, and deploy-k8s.sh pins that sha in Pulumi — making the stale build the cluster's source of truth. build-mcpd.sh gets its own call because it is run standalone as well as from deploy-k8s.sh, so neither can rely on the other having checked. `--dry-run` is exempt. It builds and cuts over nothing, and blocking a read-only inspection is exactly what teaches people to export MCPCTL_ALLOW_BEHIND_MAIN=1 permanently — which would disable the gate for the real deploys too. The failure text is now artifact-agnostic ("produce an artifact" / "shipping it"), since one helper now speaks for packages, images and deploys. Verified against a synthetic ref one commit ahead: build-mcpd.sh exits 1 before any docker work, and deploy-k8s.sh exits 1 before the test gate, the pg_dump, the image build and pulumi. Neither the working tree nor HEAD was moved to test this — the ref was built with git commit-tree and deleted afterwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 10 ++++++++-- scripts/build-mcpd.sh | 5 +++++ scripts/check-main-sync.sh | 6 ++++-- scripts/deploy-k8s.sh | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/project-summary.md b/docs/project-summary.md index 9ec9bd7..23e7a75 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -890,8 +890,14 @@ ships a binary missing whatever landed on main meanwhile, and `rpm -U --force` overwrites the good one with it. That happened on 2026-08-10: a build from a stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline` command, months after the status line landed. `scripts/check-main-sync.sh` -(sourced by `build-rpm.sh` and `build-deb.sh`) fetches `main`, compares, and -fails before any work happens, listing the commits you are missing. +fetches `main`, compares, and fails before any work happens, listing the commits +you are missing. It gates every path that produces something others consume: +`build-rpm.sh`, `build-deb.sh`, `build-mcpd.sh` (each is also run standalone, so +none can rely on another having checked) and `deploy-k8s.sh` — where a stale +branch would pin its sha in Pulumi and make it the cluster's source of truth. +`deploy-k8s.sh --dry-run` skips the check: it builds and cuts over nothing, and +blocking a read-only inspection only teaches people to export the escape hatch +permanently, disabling the gate for real deploys too. ```bash git merge main # the fix diff --git a/scripts/build-mcpd.sh b/scripts/build-mcpd.sh index 08a7f3a..99ece3a 100755 --- a/scripts/build-mcpd.sh +++ b/scripts/build-mcpd.sh @@ -16,6 +16,11 @@ if [ -f .env ]; then set -a; source .env; set +a fi +# This pushes an image to the registry, so the same staleness gate as the package +# builds applies. Run standalone as well as from deploy-k8s.sh, hence its own copy. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Push directly to internal address (external proxy has body size limit) REGISTRY="10.0.0.194:3012" IMAGE="mcpd" diff --git a/scripts/check-main-sync.sh b/scripts/check-main-sync.sh index 246ada2..05c0e3f 100755 --- a/scripts/check-main-sync.sh +++ b/scripts/check-main-sync.sh @@ -84,8 +84,10 @@ check_main_sync() { echo "" >&2 echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2 echo "" >&2 - echo " Building now would package a binary without these, and installing it" >&2 - echo " would overwrite a good one with a version missing them:" >&2 + # Deliberately artifact-agnostic: the same helper gates RPM/DEB packages, the + # mcpd image, and the k8s deploy. + echo " Building now would produce an artifact without these, and shipping it" >&2 + echo " would replace a good one with a version missing them:" >&2 echo "" >&2 git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2 if [ "$behind" -gt 15 ]; then diff --git a/scripts/deploy-k8s.sh b/scripts/deploy-k8s.sh index e41efad..eff872a 100755 --- a/scripts/deploy-k8s.sh +++ b/scripts/deploy-k8s.sh @@ -80,6 +80,20 @@ cat < Date: Mon, 10 Aug 2026 16:59:41 +0100 Subject: [PATCH 18/22] test(smoke): give the smoke-aws-docs fixture a readiness probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit health-readiness.smoke.test.ts asserts no RUNNING server lacks a healthCheck.tool. The smoke suite's own shared fixture had none, so the suite reported its own scaffolding as a fleet regression — and because the fixture is in the PROTECTED set of clean-smoke-resources.ts, it is long-lived and failed that assertion on every run. Mirrors the production aws-docs probe (same package): search_documentation with a phrase, 300s interval since the call leaves the cluster. Verified live: with this applied, "every RUNNING server has a readiness probe configured" passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- src/mcplocal/tests/smoke/fixtures/smoke-data.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml b/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml index faf22e9..6a69da1 100644 --- a/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml +++ b/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml @@ -12,6 +12,19 @@ servers: env: - name: FASTMCP_LOG_LEVEL value: "ERROR" + # Mirrors the production `aws-docs` probe. Without it this fixture is a + # RUNNING server with no readiness probe, so it fails the very assertion in + # health-readiness.smoke.test.ts that the fixture exists to support — the + # suite reporting its own scaffolding as a fleet regression. + # `search_documentation` needs a phrase; the 300s interval matches aws-docs, + # since the call leaves the cluster. + healthCheck: + tool: search_documentation + arguments: + search_phrase: "s3 bucket" + timeoutSeconds: 20 + intervalSeconds: 300 + failureThreshold: 3 projects: - name: smoke-data From 822c1bb047dbcab8246f0ba4f9addfd7a98876cf Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 17:15:40 +0100 Subject: [PATCH 19/22] build: fail the release when smoke tests fail, and fix the SSE test that hung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.sh printed `WARNING: Smoke tests failed!` and exited 0. That is how four broken readiness probes shipped unnoticed on 2026-08-10 — the warning scrolled past in the build log and the release reported success. It now exits 1, with `MCPCTL_ALLOW_SMOKE_FAILURE=1` as the escape hatch. The message is explicit that smoke runs LAST, against the installed binary: the package is already published and installed, so the failure reports fleet breakage rather than preventing a bad artifact. Turning the gate on required fixing a latent hang first, or every release would have blocked on it. `security.test.ts > /inspect SSE endpoint …` waited for a response body that by design never ends, so it could only settle via the socket's *inactivity* timeout — and /inspect relays every project's MCP traffic, so during a full smoke run it is never idle. Run alone it passed and looked flaky; run with the suite it failed every time. httpRequest gains `headersOnly`, which resolves on the response headers and hangs up. The assertion only ever needed the status line. Verified: full smoke suite 158/158 (was 157/158 with this test timing out); the gate block lifted verbatim from release.sh exits 1 with a stubbed failing smoke run, and exits 0 reaching subsequent code under MCPCTL_ALLOW_SMOKE_FAILURE=1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019wUmrfkVQR6CKcYKxENq7k --- docs/project-summary.md | 9 ++++++++ scripts/release.sh | 23 +++++++++++++++++-- src/mcplocal/tests/smoke/security.test.ts | 28 ++++++++++++++++++----- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/docs/project-summary.md b/docs/project-summary.md index 23e7a75..5c0a163 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -908,6 +908,15 @@ MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another b Offline it falls back to the last fetched `origin/main`, then to a local `main`, and says which it used; outside a git checkout it skips entirely. +**A failing smoke run fails the release.** It used to print +`WARNING: Smoke tests failed!` and exit 0 — which is exactly how four broken +readiness probes shipped unnoticed (see `docs/reliability.md`): the warning +scrolled past and the release reported success. Note what the gate does and does +not do — smoke runs *last*, against the installed binary, so the package is +already published and installed by the time it fails. It reports the breakage +rather than preventing it, so investigate the fleet rather than assuming the +artifact is bad. Override with `MCPCTL_ALLOW_SMOKE_FAILURE=1`. + Installs via nfpm: - `/usr/bin/mcpctl` — CLI binary (bun compiled) - `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled) diff --git a/scripts/release.sh b/scripts/release.sh index fc6cfc8..16bbdd4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -75,9 +75,28 @@ echo "==> Running smoke tests..." export PATH="$HOME/.npm-global/bin:$PATH" if pnpm test:smoke; then echo "==> Smoke tests passed!" +elif [ "${MCPCTL_ALLOW_SMOKE_FAILURE:-}" = "1" ]; then + echo "==> WARNING: Smoke tests failed, continuing (MCPCTL_ALLOW_SMOKE_FAILURE=1)." else - echo "==> WARNING: Smoke tests failed! Check mcplocal/mcpd are running." - echo " Continuing anyway — deployment is complete, but verify manually." + # This used to print a warning and exit 0. That is how four broken readiness + # probes shipped unnoticed on 2026-08-10: the warning scrolled past in the + # build log and the release reported success. A failing smoke run means + # something in the live fleet is genuinely broken — say so in the exit code. + # + # Note what this does and does not do: smoke runs LAST, against the installed + # binary, so the package is already published and installed by now. Failing + # here reports the breakage, it does not prevent it — investigate, do not + # assume the artifact is bad. + echo "" >&2 + echo "ERROR: smoke tests failed — the release is published and installed, but" >&2 + echo " something in the live fleet is broken. Investigate before relying" >&2 + echo " on this build; do not just re-run." >&2 + echo "" >&2 + echo " Common causes: mcplocal/mcpd not running, a readiness probe pointing at" >&2 + echo " a tool the upstream renamed, or an expired credential." >&2 + echo " Override: MCPCTL_ALLOW_SMOKE_FAILURE=1 $0" >&2 + echo "" >&2 + exit 1 fi echo "" diff --git a/src/mcplocal/tests/smoke/security.test.ts b/src/mcplocal/tests/smoke/security.test.ts index 19a4848..3e7ecbe 100644 --- a/src/mcplocal/tests/smoke/security.test.ts +++ b/src/mcplocal/tests/smoke/security.test.ts @@ -32,6 +32,18 @@ function httpRequest(opts: { headers?: Record; body?: string; timeout?: number; + /** + * Resolve as soon as the response headers arrive, then hang up, instead of + * waiting for the body to end. + * + * Required for a streaming endpoint: SSE responses never end, so the normal + * path can only settle via the socket's *inactivity* timeout — which never + * fires while the stream is busy. `/inspect` relays every project's MCP + * traffic, so during a full smoke run it is never idle, and the request hung + * until vitest killed the test. Alone it looked flaky; under load it failed + * every time. Reading the status does not need the body anyway. + */ + headersOnly?: boolean; }): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { return new Promise((resolve, reject) => { const parsed = new URL(opts.url); @@ -46,6 +58,12 @@ function httpRequest(opts: { timeout: opts.timeout ?? 10_000, }, (res) => { + if (opts.headersOnly === true) { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: '' }); + res.destroy(); + req.destroy(); + return; + } const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { @@ -93,17 +111,15 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => { // /inspect streams ALL MCP traffic (tool calls, arguments, responses) // for ALL projects to any unauthenticated local client + // headersOnly: the stream never ends, and waiting for it to go idle is what + // made this hang whenever other suites were generating traffic. The status + // line is all this assertion needs. const res = await httpRequest({ url: `${MCPLOCAL_URL}/inspect`, method: 'GET', headers: { 'Accept': 'text/event-stream' }, timeout: 3_000, - }).catch((err) => { - // Timeout is expected (SSE keeps connection open) — still means endpoint is accessible - if ((err as Error).message.includes('timed out')) { - return { status: 200, headers: {} as http.IncomingHttpHeaders, body: '' }; - } - throw err; + headersOnly: true, }); // Should be accessible without auth (documenting the vulnerability) From 5a8185d7c92c6e4ad3127fa28e958bd6dc19bf74 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:35:16 +0100 Subject: [PATCH 20/22] fix(mcplocal): stop the 30s proxy timeout killing agent turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl chat ` failed with HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"} while mcpd was answering /healthz in 32ms. The message was wrong in a way that cost real debugging time: mcplocal was reaching mcpd fine and giving up after 30s. journalctl shows the signature plainly — statusCode 503 with responseTime 30003.87 on POST /api/v1/agents/reviewer/chat. This blocks the agentic-teams epic outright. An agent turn is a multi-turn tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path is not a safety net, it is a guaranteed failure for every non-trivial turn. Three defects, all in the same path: 1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is right for CRUD and wrong for chat. Chat, project chat, llm infer and inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI already allowed. mcplocal in the middle was the binding constraint. 2. Timeouts were reported as connection failures. Split UpstreamTimeoutError out of ConnectionError and map it to 504 with an accurate message that says the daemon IS reachable. ConnectionError still means unreachable and still returns 503. Verified nothing else branches on ConnectionError. 3. SSE was buffered. `forward()` reads the whole body through res.text(), so even turns that finished in time arrived as one blob and the CLI's live token output never appeared. Streaming routes now use forwardStream() and pipe the body straight through, preserving content-type and x-accel-buffering (dropping the latter lets intermediaries re-buffer and reintroduces the stall). Also closes the escape that produced the sibling `500 code:23` failure: the body read in forward() was outside the try, so when mcpd had already written SSE headers the raw DOMException reached Fastify unhandled. Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat that outlives the CRUD budget" and "streams SSE through instead of buffering" — were confirmed to FAIL against the pre-fix behaviour and pass after. Three existing mcpd-client tests asserted the old taxonomy and were updated to assert the new one deliberately. Local: build clean, workspace 2375 passed, lint unchanged at 869. NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package rebuild + `systemctl --user restart mcplocal` to take effect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- src/mcplocal/src/http/index.ts | 2 +- src/mcplocal/src/http/mcpd-client.ts | 94 ++++++++- src/mcplocal/src/http/routes/proxy.ts | 131 ++++++++++-- src/mcplocal/src/index.ts | 2 +- src/mcplocal/tests/mcpd-client.test.ts | 18 +- src/mcplocal/tests/proxy-long-running.test.ts | 196 ++++++++++++++++++ 6 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 src/mcplocal/tests/proxy-long-running.test.ts diff --git a/src/mcplocal/src/http/index.ts b/src/mcplocal/src/http/index.ts index 274a655..ffdb7d4 100644 --- a/src/mcplocal/src/http/index.ts +++ b/src/mcplocal/src/http/index.ts @@ -2,7 +2,7 @@ export { createHttpServer } from './server.js'; export type { HttpServerDeps } from './server.js'; export { loadHttpConfig } from './config.js'; export type { HttpConfig } from './config.js'; -export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js'; +export { McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError } from './mcpd-client.js'; export { registerProxyRoutes } from './routes/proxy.js'; export { registerMcpEndpoint } from './mcp-endpoint.js'; export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js'; diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 53511e5..8e755e4 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -20,9 +20,41 @@ export class ConnectionError extends Error { } } +/** + * Thrown when mcpd was reachable but did not finish in time. + * + * Deliberately NOT a ConnectionError. Folding timeouts into "cannot connect" + * is what made this class of failure so expensive to diagnose: mcpd answered + * /healthz in 32ms while the proxy insisted the daemon was down. A timeout and + * an unreachable daemon need different messages and different status codes. + */ +export class UpstreamTimeoutError extends Error { + constructor(readonly url: string, readonly timeoutMs: number) { + super(`mcpd did not respond within ${String(timeoutMs)}ms: ${url}`); + this.name = 'UpstreamTimeoutError'; + } +} + +/** True when `err` is an AbortSignal.timeout() firing. */ +function isTimeout(err: unknown): boolean { + return err instanceof DOMException && err.name === 'TimeoutError'; +} + /** Default timeout for mcpd requests (ms). Prevents indefinite hangs on slow upstream tool calls. */ export const DEFAULT_TIMEOUT_MS = 30_000; +/** + * Budget for routes that are *expected* to run long: agent/project chat and + * raw inference. An agent turn is a multi-turn tool-use loop and legitimately + * runs for minutes, so the 30s default is not a safety net there — it is a + * guaranteed failure. Matches `STREAM_TIMEOUT_MS` in the CLI's chat command + * (src/cli/src/commands/chat.ts), which already allowed 10 minutes; mcplocal + * sitting in the middle with 30s was the binding constraint. + * + * Override with `MCPLOCAL_LONG_TIMEOUT_MS`. + */ +export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT_MS']) || 600_000; + /** * Discovery-class operations (tools/list, resources/list, prompts/list) should not share * the full tool-call timeout budget — a single dead upstream would stall session init for @@ -121,9 +153,7 @@ export class McpdClient { try { res = await fetch(url, init); } catch (err: unknown) { - if (err instanceof DOMException && err.name === 'TimeoutError') { - throw new ConnectionError(this.baseUrl, new Error(`Request timed out after ${this.timeoutMs}ms`)); - } + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); throw new ConnectionError(this.baseUrl, err); } @@ -131,7 +161,18 @@ export class McpdClient { throw new AuthenticationError(); } - const text = await res.text(); + // The body read MUST be inside a try. mcpd writes SSE headers immediately + // on chat routes, so fetch() resolves long before the turn finishes and the + // abort lands here instead — previously escaping as a raw DOMException and + // surfacing to the user as an opaque `500 code:23`. + let text: string; + try { + text = await res.text(); + } catch (err: unknown) { + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + let parsed: unknown; try { parsed = JSON.parse(text); @@ -142,6 +183,51 @@ export class McpdClient { return { status: res.status, body: parsed }; } + /** + * Forward a request and hand back the raw Response, body unread. + * + * `forward()` buffers through `res.text()`, which is fine for CRUD but + * defeats streaming entirely: an SSE chat arrives at the client as one blob + * after the turn ends, so the token-by-token output the CLI draws never + * appears. Streaming routes use this instead and pipe the body straight + * through. + */ + async forwardStream( + method: string, + path: string, + query: string, + body: unknown | undefined, + authOverride?: string, + ): Promise { + const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; + const headers: Record = { + ...this.extraHeaders, + 'Authorization': `Bearer ${authOverride ?? this.token}`, + // Accept both: mcpd picks SSE or JSON based on the request's `stream` flag. + 'Accept': 'text/event-stream, application/json', + }; + + const init: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(this.timeoutMs), + }; + if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + try { + const res = await fetch(url, init); + if (res.status === 401) throw new AuthenticationError(); + return res; + } catch (err: unknown) { + if (err instanceof AuthenticationError) throw err; + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + } + private async request(method: string, path: string, body?: unknown): Promise { const result = await this.forward(method, path, '', body); diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index 985f6e6..d6ff369 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -1,10 +1,62 @@ /** * Catch-all proxy route that forwards /api/v1/* requests to mcpd. */ -import type { FastifyInstance } from 'fastify'; -import { AuthenticationError, ConnectionError } from '../mcpd-client.js'; +import { Readable } from 'node:stream'; + +import type { FastifyInstance, FastifyReply } from 'fastify'; + +import { AuthenticationError, ConnectionError, UpstreamTimeoutError, LONG_RUNNING_TIMEOUT_MS } from '../mcpd-client.js'; import type { McpdClient } from '../mcpd-client.js'; +/** + * Routes that are expected to run long and/or stream. + * + * An agent turn is a multi-turn tool-use loop — minutes, not seconds — so the + * 30s default budget guarantees failure rather than guarding against it. These + * also stream SSE, which must be piped rather than buffered or the client sees + * one blob at the end instead of live output. + */ +const LONG_RUNNING = [ + /^\/api\/v1\/agents\/[^/]+\/chat\b/, + /^\/api\/v1\/projects\/[^/]+\/chat\b/, + /^\/api\/v1\/llms\/[^/]+\/infer\b/, + /^\/api\/v1\/inference-tasks\/[^/]+\/stream\b/, +]; + +function isLongRunning(path: string): boolean { + return LONG_RUNNING.some((re) => re.test(path)); +} + +/** Headers worth preserving from mcpd; everything else is re-derived by Fastify. */ +const PASSTHROUGH_HEADERS = ['content-type', 'cache-control', 'x-accel-buffering']; + +function sendUpstreamError(reply: FastifyReply, err: unknown): FastifyReply | undefined { + if (err instanceof AuthenticationError) { + return reply.code(401).send({ + error: 'unauthorized', + message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', + }); + } + if (err instanceof UpstreamTimeoutError) { + // 504, not 503 — mcpd was reachable, it just did not finish. Reporting this + // as "cannot reach mcpd" sent a previous debugging session chasing a + // network fault while /healthz answered in 32ms. + return reply.code(504).send({ + error: 'upstream_timeout', + message: + `mcpd did not respond within ${String(err.timeoutMs)}ms. The daemon is reachable — the ` + + 'request itself ran long. Raise MCPLOCAL_LONG_TIMEOUT_MS if this is a legitimately slow turn.', + }); + } + if (err instanceof ConnectionError) { + return reply.code(503).send({ + error: 'service_unavailable', + message: 'Cannot reach mcpd daemon. Is it running?', + }); + } + return undefined; +} + export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): void { app.all('/api/v1/*', async (request, reply) => { const path = (request.url.split('?')[0]) ?? '/'; @@ -21,23 +73,74 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v const authHeader = request.headers['authorization'] as string | undefined; const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + if (isLongRunning(path)) { + return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); + } + try { const result = await client.forward(request.method, path, querystring, body, userToken); return reply.code(result.status).send(result.body); } catch (err: unknown) { - if (err instanceof AuthenticationError) { - return reply.code(401).send({ - error: 'unauthorized', - message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', - }); - } - if (err instanceof ConnectionError) { - return reply.code(503).send({ - error: 'service_unavailable', - message: 'Cannot reach mcpd daemon. Is it running?', - }); - } + const handled = sendUpstreamError(reply, err); + if (handled) return handled; throw err; } }); } + +/** + * Pipe a long-running response straight through, headers and all. + * + * Hijacks the reply so Fastify does not try to serialize a stream, then copies + * mcpd's status and content-type before piping. `x-accel-buffering` matters: + * mcpd sets it to `no` so intermediaries don't buffer SSE, and dropping it here + * would reintroduce the exact stall we are fixing. + */ +async function proxyStreaming( + reply: FastifyReply, + client: McpdClient, + method: string, + path: string, + querystring: string, + body: unknown, + userToken: string | undefined, +): Promise { + const longClient = client.withTimeout(LONG_RUNNING_TIMEOUT_MS); + + let res: Response; + try { + res = await longClient.forwardStream(method, path, querystring, body, userToken); + } catch (err: unknown) { + const handled = sendUpstreamError(reply, err); + if (handled) return; + throw err; + } + + const headers: Record = {}; + for (const name of PASSTHROUGH_HEADERS) { + const value = res.headers.get(name); + if (value !== null) headers[name] = value; + } + + reply.hijack(); + reply.raw.writeHead(res.status, headers); + + if (res.body === null) { + reply.raw.end(); + return; + } + + try { + // Node's Readable.fromWeb bridges the fetch ReadableStream onto the socket. + await new Promise((resolve, reject) => { + const upstream = Readable.fromWeb(res.body as Parameters[0]); + upstream.on('error', reject); + reply.raw.on('close', () => { upstream.destroy(); resolve(); }); + upstream.pipe(reply.raw).on('finish', resolve).on('error', reject); + }); + } catch { + // Headers are already on the wire, so there is no status left to change. + // Close the socket; the client surfaces the truncated stream. + if (!reply.raw.writableEnded) reply.raw.end(); + } +} diff --git a/src/mcplocal/src/index.ts b/src/mcplocal/src/index.ts index 489e0ba..dbc01d4 100644 --- a/src/mcplocal/src/index.ts +++ b/src/mcplocal/src/index.ts @@ -11,7 +11,7 @@ export type { MainResult } from './main.js'; export { ProviderRegistry } from './providers/index.js'; export type { LlmProvider, CompletionOptions, CompletionResult, ChatMessage } from './providers/index.js'; export { OpenAiProvider, AnthropicProvider, OllamaProvider, GeminiCliProvider, DeepSeekProvider } from './providers/index.js'; -export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, registerProxyRoutes } from './http/index.js'; +export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError, registerProxyRoutes } from './http/index.js'; export type { HttpConfig, HttpServerDeps } from './http/index.js'; export type { JsonRpcRequest, diff --git a/src/mcplocal/tests/mcpd-client.test.ts b/src/mcplocal/tests/mcpd-client.test.ts index c9a50d4..7dac059 100644 --- a/src/mcplocal/tests/mcpd-client.test.ts +++ b/src/mcplocal/tests/mcpd-client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterAll, afterEach } from 'vitest'; import http from 'node:http'; -import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js'; +import { McpdClient, ConnectionError, UpstreamTimeoutError } from '../src/http/mcpd-client.js'; /** * Create a local HTTP server for testing McpdClient behavior. @@ -85,7 +85,7 @@ describe('McpdClient', () => { // ── Timeout behavior ── - it('times out on slow responses and throws ConnectionError', async () => { + it('times out on slow responses and throws UpstreamTimeoutError', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond — simulates a hanging upstream tool call }); @@ -96,7 +96,7 @@ describe('McpdClient', () => { const start = Date.now(); await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow( - /timed out/, + /did not respond within/, ); const elapsed = Date.now() - start; @@ -105,7 +105,7 @@ describe('McpdClient', () => { expect(elapsed).toBeLessThan(3000); }); - it('timeout error is a ConnectionError with descriptive message', async () => { + it('timeout is NOT a ConnectionError — a slow daemon is not an absent one', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond }); @@ -117,8 +117,12 @@ describe('McpdClient', () => { await client.get('/test'); expect.unreachable('Should have thrown'); } catch (err) { - expect(err).toBeInstanceOf(ConnectionError); - expect((err as Error).message).toContain('Request timed out after 200ms'); + // Reporting a timeout as "cannot connect" is what sent a previous + // debugging session chasing a network fault that did not exist. + expect(err).toBeInstanceOf(UpstreamTimeoutError); + expect(err).not.toBeInstanceOf(ConnectionError); + expect((err as UpstreamTimeoutError).timeoutMs).toBe(200); + expect((err as Error).message).toContain('did not respond within 200ms'); } }); @@ -146,7 +150,7 @@ describe('McpdClient', () => { const derived = client.withHeaders({ 'X-Custom': 'val' }); const start = Date.now(); - await expect(derived.get('/test')).rejects.toThrow(/timed out/); + await expect(derived.get('/test')).rejects.toThrow(/did not respond within/); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(2000); }); diff --git a/src/mcplocal/tests/proxy-long-running.test.ts b/src/mcplocal/tests/proxy-long-running.test.ts new file mode 100644 index 0000000..0c5fd04 --- /dev/null +++ b/src/mcplocal/tests/proxy-long-running.test.ts @@ -0,0 +1,196 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { + McpdClient, + UpstreamTimeoutError, + ConnectionError, + LONG_RUNNING_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, +} from '../src/http/mcpd-client.js'; +import { registerProxyRoutes } from '../src/http/routes/proxy.js'; + +/** + * Regression cover for the 30s proxy timeout that made `mcpctl chat` fail with + * a misleading "Cannot reach mcpd daemon" 503 while mcpd was answering + * /healthz in 32ms. + * + * Three separate defects are pinned here: + * 1. chat routes inherited the 30s CRUD budget, so any turn longer than 30s + * failed — and an agent turn is a tool-use loop that routinely exceeds it; + * 2. a timeout was reported as a connection failure, sending diagnosis after + * a network fault that did not exist; + * 3. SSE was buffered through res.text(), so streaming never reached the + * client even when the turn finished in time. + */ +let app: FastifyInstance | null = null; +let upstream: FastifyInstance | null = null; + +afterEach(async () => { + if (app) { await app.close(); app = null; } + if (upstream) { await upstream.close(); upstream = null; } +}); + +/** A stand-in mcpd. Returns its base URL. */ +async function startUpstream(register: (a: FastifyInstance) => void): Promise { + upstream = Fastify(); + register(upstream); + await upstream.listen({ port: 0, host: '127.0.0.1' }); + const addr = upstream.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + return `http://127.0.0.1:${String(addr.port)}`; +} + +async function startProxy(baseUrl: string, timeoutMs?: number): Promise { + app = Fastify(); + registerProxyRoutes(app, new McpdClient(baseUrl, 'test-token', {}, timeoutMs)); + await app.ready(); + return app; +} + +describe('proxy — long-running route budget', () => { + it('gives chat routes the long budget, not the 30s CRUD default', () => { + // The constants themselves are the contract: a 30s cap on an agent turn is + // a guaranteed failure, not a safety net. + expect(DEFAULT_TIMEOUT_MS).toBe(30_000); + expect(LONG_RUNNING_TIMEOUT_MS).toBeGreaterThanOrEqual(600_000); + }); + + it('does not abort an agent chat that outlives the CRUD budget', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async () => { + // Longer than the (deliberately tiny) CRUD budget below. Before the + // fix this inherited that budget and 503'd. + await new Promise((r) => setTimeout(r, 250)); + return { answer: 'pong' }; + }); + }); + // CRUD budget of 50ms — a chat route must NOT inherit it. + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ answer: 'pong' }); + }); + + it('still applies the short budget to ordinary CRUD routes', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + // Times out — and is now reported honestly as a timeout, not a connection fault. + expect(res.statusCode).toBe(504); + expect(res.json().error).toBe('upstream_timeout'); + }); + + it('reports a timeout as 504, never as "cannot reach mcpd"', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + const body = res.json(); + expect(body.message).toMatch(/did not respond within/); + expect(body.message).not.toMatch(/Cannot reach mcpd/); + expect(body.message).toMatch(/reachable/); + }); + + it('streams SSE through instead of buffering it', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }); + reply.raw.write('data: {"type":"text","delta":"po"}\n\n'); + reply.raw.write('data: {"type":"text","delta":"ng"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi', stream: true }, + }); + + expect(res.statusCode).toBe(200); + // Content-type must survive — a client that gets application/json will not + // parse the event stream. + expect(res.headers['content-type']).toMatch(/text\/event-stream/); + // x-accel-buffering=no must survive too, or intermediaries re-buffer the + // stream and reintroduce the stall. + expect(res.headers['x-accel-buffering']).toBe('no'); + expect(res.body).toContain('"delta":"po"'); + expect(res.body).toContain('"delta":"ng"'); + expect(res.body).toContain('[DONE]'); + }); + + it('relays a non-200 status from a streaming route', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + return reply.code(404).send({ error: 'Agent not found' }); + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/ghost/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(404); + expect(res.body).toContain('Agent not found'); + }); + + it('still reports a genuinely unreachable daemon as 503', async () => { + // Port 1 is reserved and refuses instantly. + const proxy = await startProxy('http://127.0.0.1:1', 500); + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + + expect(res.statusCode).toBe(503); + expect(res.json().error).toBe('service_unavailable'); + }); + + it('propagates 401 from a streaming route so login guidance still fires', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => reply.code(401).send({})); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().message).toMatch(/mcpctl login/); + }); +}); + +describe('error taxonomy', () => { + it('keeps timeout and unreachable as distinct types', () => { + const timeout = new UpstreamTimeoutError('http://mcpd', 30_000); + expect(timeout).not.toBeInstanceOf(ConnectionError); + expect(timeout.timeoutMs).toBe(30_000); + expect(timeout.message).toMatch(/did not respond within 30000ms/); + }); +}); From bbd2195c64a0bcdfd3f41833a670165e31325628 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 22:27:21 +0100 Subject: [PATCH 21/22] test(mcplocal): prove chat SSE streams live through the proxy, not buffered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcpctl chat reviewer` showed nothing until the turn finished, then dumped the whole answer at once. mcpd streams token deltas and the CLI renders them incrementally — the sole buffering point was mcplocal's catch-all /api/v1/* proxy reading the whole SSE body via res.text() before replying. The previous commit (cherry-picked from feat/agentic-teams) pipes the body through instead; this one adds the cover that was missing: - proxy-long-running.test.ts: a progressive-delivery test in which the stand-in mcpd withholds its final frame until the client has observed the first delta through the proxy. A buffering proxy cannot satisfy that ordering — verified: the test fails in 3s (no hang) against a res.text() proxy and passes against the piped one. The existing SSE test used inject(), which collects the whole body and so passes either way. - agent-chat.smoke.test.ts: a live smoke that posts through mcplocal on localhost:3200 (the path `mcpctl chat` actually takes — every other chat smoke uses --direct and bypasses the proxy entirely) and asserts delta frames arrive spread across the generation window, not in one burst at stream end. Uses its own agent: the shared smoke agent pins replies to a single token, too short to tell live streaming from a buffer dump. Also settles the strict-boolean-expressions lint on the auth-header check the streaming split touched. Local: workspace 2546 passed, proxy suite 10/10, smoke file loads + self-skips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP --- src/mcplocal/src/http/routes/proxy.ts | 4 +- src/mcplocal/tests/proxy-long-running.test.ts | 59 ++++++++++++ .../tests/smoke/agent-chat.smoke.test.ts | 95 +++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index d6ff369..9b2bebd 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -71,7 +71,9 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v // Forward the user's auth token to mcpd so RBAC applies per-user. // If no user token is present, mcpd will use its auth hook to reject. const authHeader = request.headers['authorization'] as string | undefined; - const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + const userToken = authHeader !== undefined && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; if (isLongRunning(path)) { return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); diff --git a/src/mcplocal/tests/proxy-long-running.test.ts b/src/mcplocal/tests/proxy-long-running.test.ts index 0c5fd04..a793084 100644 --- a/src/mcplocal/tests/proxy-long-running.test.ts +++ b/src/mcplocal/tests/proxy-long-running.test.ts @@ -1,3 +1,5 @@ +import http from 'node:http'; + import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, afterEach } from 'vitest'; @@ -144,6 +146,63 @@ describe('proxy — long-running route budget', () => { expect(res.body).toContain('[DONE]'); }); + it('delivers each SSE frame while the upstream is still generating', async () => { + // The buffering regression is invisible to the pass-through test above: + // `inject()` collects the whole body, so a proxy that buffers via + // res.text() still passes it. This test proves *progressive* delivery by + // making the upstream withhold its final frame until the client has + // observed the first one. A buffering proxy can never satisfy that + // ordering — the 3s guard resolves the gate so the run fails cleanly + // instead of deadlocking. + let openGate: (seen: boolean) => void = () => {}; + const clientSawFirstFrame = new Promise((r) => { openGate = r; }); + const guard = setTimeout(() => openGate(false), 3_000); + + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' }); + reply.raw.write('data: {"type":"text","delta":"live"}\n\n'); + await clientSawFirstFrame; + reply.raw.write('data: {"type":"final"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + await proxy.listen({ port: 0, host: '127.0.0.1' }); + const addr = proxy.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + + const body = await new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port: addr.port, + path: '/api/v1/agents/reviewer/chat', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, (res) => { + let acc = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + acc += chunk; + if (acc.includes('"delta":"live"')) openGate(true); + }); + res.on('end', () => resolve(acc)); + res.on('error', reject); + }); + req.on('error', reject); + req.end(JSON.stringify({ message: 'hi', stream: true })); + }); + clearTimeout(guard); + + // The ordering proof: the first frame reached the client while the + // upstream was still holding the stream open. + await expect(clientSawFirstFrame).resolves.toBe(true); + expect(body).toContain('"type":"final"'); + expect(body).toContain('[DONE]'); + }); + it('relays a non-200 status from a streaming route', async () => { const base = await startUpstream((a) => { a.post('/api/v1/agents/:name/chat', async (_req, reply) => { diff --git a/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts b/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts index e445511..3261c43 100644 --- a/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts +++ b/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts @@ -18,8 +18,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import https from 'node:https'; import { spawnSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const MCPLOCAL_URL = process.env.MCPLOCAL_URL ?? 'http://localhost:3200'; const LLM_URL = process.env.MCPCTL_SMOKE_LLM_URL; const LLM_MODEL = process.env.MCPCTL_SMOKE_LLM_MODEL ?? 'qwen3-thinking'; const LLM_KEY = process.env.MCPCTL_SMOKE_LLM_KEY; @@ -27,6 +31,10 @@ const SUFFIX = Date.now().toString(36); const SECRET_NAME = `smoke-chat-sec-${SUFFIX}`; const LLM_NAME = `smoke-chat-llm-${SUFFIX}`; const AGENT_NAME = `smoke-chat-agent-${SUFFIX}`; +// Dedicated agent for the streaming-timing test: the shared agent's system +// prompt pins the reply to a single token, which is too short to distinguish +// live streaming from an end-of-turn buffer dump. +const STREAM_AGENT_NAME = `smoke-stream-agent-${SUFFIX}`; interface CliResult { code: number; stdout: string; stderr: string } @@ -99,6 +107,7 @@ describe('agent chat smoke (live LLM)', () => { afterAll(() => { if (!liveLlmConfigured || !mcpdUp) return; run(`delete agent ${AGENT_NAME}`); + run(`delete agent ${STREAM_AGENT_NAME}`); run(`delete llm ${LLM_NAME}`); run(`delete secret ${SECRET_NAME}`); }); @@ -139,6 +148,92 @@ describe('agent chat smoke (live LLM)', () => { expect(result.stderr).toMatch(/thread:\s+c[a-z0-9]+/); }); + it('streams progressively THROUGH mcplocal — frames arrive during generation, not in one burst', async () => { + if (!liveLlmConfigured || !mcpdUp) return; + // The regression this pins: mcplocal's /api/v1/* proxy buffered SSE via + // res.text(), so the CLI showed nothing until the turn finished and then + // dumped the whole answer at once. The --direct tests above bypass + // mcplocal entirely and cannot catch that. This one posts to the local + // proxy (the path `mcpctl chat` actually takes) and asserts frames are + // spread across the generation window: with buffering, everything lands + // within a few ms of stream end. + if (!(await healthz(MCPLOCAL_URL))) { + // eslint-disable-next-line no-console + console.warn(`\n ○ mcplocal streaming smoke: skipped — ${MCPLOCAL_URL}/healthz unreachable.\n`); + return; + } + let token = ''; + try { + const credsPath = join(homedir(), '.mcpctl', 'credentials'); + if (existsSync(credsPath)) { + const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) as { token?: string }; + if (creds.token !== undefined) token = creds.token; + } + } catch { /* unauthenticated — the request will 401 and fail loudly */ } + + run(`delete agent ${STREAM_AGENT_NAME}`); + const agent = run([ + `create agent ${STREAM_AGENT_NAME}`, + `--llm ${LLM_NAME}`, + `--description "mcplocal streaming smoke"`, + `--system-prompt "You are a smoke test. Follow the user's instructions exactly."`, + '--default-temperature 0', + '--default-max-tokens 512', + ].join(' ')); + expect(agent.code, agent.stderr).toBe(0); + + const url = new URL(`${MCPLOCAL_URL.replace(/\/$/, '')}/api/v1/agents/${STREAM_AGENT_NAME}/chat`); + const deltaTimes: number[] = []; + let endTime = 0; + let status = 0; + let raw = ''; + + await new Promise((resolve, reject) => { + const req = http.request({ + hostname: url.hostname, + port: url.port || 80, + path: url.pathname, + method: 'POST', + timeout: 120_000, + headers: { + 'Content-Type': 'application/json', + ...(token !== '' ? { Authorization: `Bearer ${token}` } : {}), + }, + }, (res) => { + status = res.statusCode ?? 0; + res.setEncoding('utf-8'); + let buf = ''; + res.on('data', (chunk: string) => { + raw += chunk; + buf += chunk; + let nl: number; + while ((nl = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, nl); + buf = buf.slice(nl + 2); + if (/"type":"(text|thinking)"/.test(frame)) deltaTimes.push(Date.now()); + } + }); + res.on('end', () => { endTime = Date.now(); resolve(); }); + res.on('error', reject); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('stream timed out')); }); + req.end(JSON.stringify({ + message: 'Count from 1 to 40, one number per line. No other text.', + stream: true, + max_tokens: 400, + })); + }); + + expect(status, raw.slice(0, 500)).toBe(200); + expect(deltaTimes.length).toBeGreaterThanOrEqual(2); + // The buffering signature: every frame lands in the same final burst as + // stream end. Live streaming puts the first delta well before the end — + // a 40-line generation spans seconds; 300ms is a conservative floor. + const firstDelta = deltaTimes[0]!; + expect(endTime - firstDelta).toBeGreaterThanOrEqual(300); + }, 150_000); + it('streaming `mcpctl chat` emits text deltas', () => { if (!liveLlmConfigured || !mcpdUp) return; // Default mode is streaming. Pipe stdout/stderr separately. From ac5dee906ea3b07887b9a3db2dcaa470ea415154 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 10 Aug 2026 22:54:53 +0100 Subject: [PATCH 22/22] fix(cli): don't brick the chat REPL when the first turn fails upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live: turn 1 died with an anthropic 429 before the stream's `final` frame, streamOnce resolved '' as the thread id, the REPL stored it, and every later message sent `threadId: ""` — rejected by mcpd's z.string().min(1) with HTTP 400. Permanently stuck: no turn could succeed again, so no `final` frame could ever repair the id. Two independent layers: - streamOnce now resolves `string | undefined` — undefined when no `final` frame arrived — and the REPL keeps its previous thread state on undefined instead of overwriting it. One-shot mode skips the `(thread: ...)` footer when there is none to report. - chatBody refuses to serialize an empty threadId at all, so even a leaked '' can never reach the wire. Regression cover in chat-thread-brick.test.ts (7 tests), including the full REPL chain: failed turn 1 → turn 2 body carries no threadId key. The assertions are the direct inverse of the old behavior, so they fail pre-fix by construction. CLI suite 726 passed, lint clean, tsc clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP --- src/cli/src/commands/chat.ts | 35 ++++-- .../tests/commands/chat-thread-brick.test.ts | 116 ++++++++++++++++++ 2 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/cli/tests/commands/chat-thread-brick.test.ts diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 628642b..864cf46 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -70,7 +70,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { } /** What the chat is bound to: a named Agent or a Project. */ -interface ChatSubject { +export interface ChatSubject { kind: 'agent' | 'project'; name: string; /** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */ @@ -97,14 +97,17 @@ function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject * `personality` overlay (the project schema rejects unknown fields) and adds * `allowSecrets` when requested. */ -function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record { +export function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record { const o: Record = { ...overrides }; if (subject.kind === 'project') { delete o.personality; if (subject.allowSecrets) o.allowSecrets = true; } const body: Record = { message, ...o }; - if (threadId !== undefined) body.threadId = threadId; + // Guard the empty string, not just undefined: a turn that dies before its + // `final` frame yields no thread id, and sending `threadId: ""` trips mcpd's + // min(1) validation — bricking every later message in the REPL with a 400. + if (threadId !== undefined && threadId !== '') body.threadId = threadId; if (stream === true) body.stream = true; return body; } @@ -205,7 +208,11 @@ async function runOneShot( const bar = installStatusBar(); try { const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar); - process.stderr.write(`\n(thread: ${finalThread})\n`); + if (finalThread !== undefined) { + process.stderr.write(`\n(thread: ${finalThread})\n`); + } else { + process.stderr.write('\n'); + } } finally { bar?.teardown(); } @@ -262,7 +269,9 @@ async function runRepl( const answered = formatAnswered(res.llm, res.model, res.failedOver); if (answered !== '') process.stderr.write(`${styleStats(`(${answered})`)}\n`); } else { - threadId = await streamOnce(deps, subject, line, threadId, overrides, bar); + // A failed turn resolves undefined — keep the previous thread (or + // none) instead of overwriting it, so the next message still works. + threadId = await streamOnce(deps, subject, line, threadId, overrides, bar) ?? threadId; process.stdout.write('\n'); } } catch (err) { @@ -502,15 +511,21 @@ async function chatRequestNonStream( }); } -/** Stream a single chat call. Returns the resolved threadId. */ -async function streamOnce( +/** + * Stream a single chat call. Returns the resolved threadId, or undefined when + * the turn never produced a `final` frame (upstream error, early disconnect). + * Returning undefined — instead of the old '' — lets callers keep their + * previous thread state rather than poisoning the next request with an empty + * id that mcpd's validation rejects. + */ +export async function streamOnce( deps: ChatCommandDeps, subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, bar: StatusBar | null = null, -): Promise { +): Promise { const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`); const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true)); @@ -531,7 +546,7 @@ async function streamOnce( } } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const driver = url.protocol === 'https:' ? https : http; const req = driver.request({ hostname: url.hostname, @@ -552,7 +567,7 @@ async function streamOnce( return; } let buf = ''; - let resolvedThread = threadId ?? ''; + let resolvedThread: string | undefined = threadId; let answered = ''; res.setEncoding('utf-8'); res.on('data', (chunk: string) => { diff --git a/src/cli/tests/commands/chat-thread-brick.test.ts b/src/cli/tests/commands/chat-thread-brick.test.ts new file mode 100644 index 0000000..0e09392 --- /dev/null +++ b/src/cli/tests/commands/chat-thread-brick.test.ts @@ -0,0 +1,116 @@ +/** + * Regression: a failed first turn must not brick the REPL. + * + * Observed live: the first message died upstream (anthropic 429) before the + * stream's `final` frame, streamOnce resolved '' as the thread id, the REPL + * stored it, and every later message sent `threadId: ""` — which mcpd's + * `z.string().min(1)` rejects with HTTP 400. The session was permanently + * stuck: no turn could succeed again, so no `final` frame could ever repair + * the thread id. + * + * The fix has two independent layers, pinned separately below: + * 1. streamOnce resolves `undefined` (not '') when no `final` frame arrived, + * and the REPL keeps its previous thread state on undefined; + * 2. chatBody never serializes an empty threadId, even if one leaks in. + */ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { chatBody, streamOnce } from '../../src/commands/chat.js'; +import type { ChatCommandDeps, ChatSubject } from '../../src/commands/chat.js'; +import type { ApiClient } from '../../src/api-client.js'; + +const subject: ChatSubject = { + kind: 'agent', + name: 'reviewer', + path: 'agents/reviewer', + allowSecrets: false, +}; + +// streamOnce only touches baseUrl + token; the ApiClient is for the +// non-streaming path and never dereferenced here. +function depsFor(baseUrl: string): ChatCommandDeps { + return { client: null as unknown as ApiClient, baseUrl, log: () => {} }; +} + +let server: http.Server | null = null; + +afterEach(async () => { + if (server !== null) { + await new Promise((r) => server!.close(() => r())); + server = null; + } +}); + +/** Serve one SSE response body for any POST, return the base URL. */ +async function serveSse(frames: string[]): Promise { + server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const f of frames) res.write(`data: ${f}\n\n`); + res.end(); + }); + await new Promise((r) => server!.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${String(port)}`; +} + +describe('chatBody — threadId serialization', () => { + it('omits threadId when undefined', () => { + expect(chatBody(subject, 'hi', undefined, {})).not.toHaveProperty('threadId'); + }); + + it('omits threadId when empty — the exact payload that 400s against mcpd', () => { + expect(chatBody(subject, 'hi', '', {})).not.toHaveProperty('threadId'); + }); + + it('includes a real threadId', () => { + expect(chatBody(subject, 'hi', 'cthread123', {})).toHaveProperty('threadId', 'cthread123'); + }); +}); + +describe('streamOnce — thread id after a failed turn', () => { + it('resolves undefined when the stream errors before any final frame', async () => { + const base = await serveSse([ + '{"type":"error","message":"anthropic stream: HTTP 429"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {}); + expect(resolved).toBeUndefined(); + }); + + it('keeps the caller-supplied thread when the turn fails mid-conversation', async () => { + const base = await serveSse([ + '{"type":"error","message":"upstream died"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', 'cexisting1', {}); + expect(resolved).toBe('cexisting1'); + }); + + it('resolves the threadId announced by the final frame', async () => { + const base = await serveSse([ + '{"type":"text","delta":"pong"}', + '{"type":"final","threadId":"cfresh42"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {}); + expect(resolved).toBe('cfresh42'); + }); + + it('REPL chain: failed turn 1 leaves turn 2 sendable (the brick)', async () => { + const base = await serveSse([ + '{"type":"error","message":"anthropic stream: HTTP 429"}', + '[DONE]', + ]); + // Mirrors runRepl's assignment: threadId = streamOnce(...) ?? threadId + let threadId: string | undefined = undefined; + threadId = (await streamOnce(depsFor(base), subject, 'hi', threadId, {})) ?? threadId; + + // Turn 2's body must be valid for mcpd: no threadId key at all. + const body = chatBody(subject, 'hi again', threadId, {}, true); + expect(body).not.toHaveProperty('threadId'); + expect(body).toHaveProperty('message', 'hi again'); + }); +});