From a9fcd83ed883135066c36dd8141081515972ca96 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:20:36 +0100 Subject: [PATCH] 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"] +}