From ba05b2865c515b3602680ec3b3b1c9d3a20a334a Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 18:33:23 +0100 Subject: [PATCH] feat(prime-agent): filterable project picker + a visible active-project indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps the pi switcher had already closed. **The indicator was invisible.** `ctx.ui.setStatus` was the wrong channel: prime-agent stores extension statuses (`FooterDataProvider.setExtensionStatus`) but nothing ever reads them back — there is no `getExtensionStatuses()` call site anywhere in the app, so the value was recorded and never rendered. pi's `footer.js` does render them, which is why the same code showed a status line there and nothing here. The active project is now published as a *widget* (`extensionWidgetsBelow` → `renderWidgets()`), which prime-agent does render. `setStatus` is still called so pi keeps its footer entry. **No search.** With 356 projects the picker was a wall of `smoke-mcptoken-*`. Above 20 projects it now asks for a filter first; space-separated terms must all match, case-insensitively, against name or description, so `home auto` finds `homeautomation`. Active project sorts first, then alphabetical. Deliberately no client-side truncation here, unlike the pi picker: pi's `ExtensionSelectorComponent.updateList()` renders every option, so a long list fills the screen and has to be capped. prime-agent's selector windows the list itself and shows a true `(20/356)` counter — capping would replace an accurate total with a misleading one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/tests/commands/prime-agent.test.ts | 32 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 6509cbd..1a8cf46 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ 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 footer entry is stored under (see ctx.ui.setStatus). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Publish the active project into prime-agent's footer, alongside the model\n * name — so the current mcpctl project is always visible rather than something\n * you have to run a command to discover. Cleared when no project is mounted.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n ctx.ui.setStatus(STATUS_KEY, active ? `mcpctl:${active}` : undefined);\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // Fires on startup and on every reload — including the reload our own switch\n // triggers — so the footer tracks settings.json without extra bookkeeping.\n pi.on('session_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 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 items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\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 && 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 ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\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 * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\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 // Fires on startup and on every reload — including the reload our own switch\n // triggers — so the footer tracks settings.json without extra bookkeeping.\n pi.on('session_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 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"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 9d8a627..ac6c8f9 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -528,6 +528,38 @@ describe('config prime-agent', () => { expect(ext).not.toContain("'success'"); }); + it('the installed switcher shows the active project via a widget, not just setStatus', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // prime-agent stores extension statuses but never renders them, so a + // setStatus-only indicator is invisible there. Widgets are rendered. + expect(ext).toContain('ctx.ui.setWidget(STATUS_KEY'); + expect(ext).toContain("placement: 'belowEditor'"); + // Still set the status: pi's footer does render it. + expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + }); + + it('the installed switcher filters the project list before showing it', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // Hundreds of projects in a plain arrow-key list is unusable. + expect(ext).toContain('export function filterProjects'); + expect(ext).toContain('ctx.ui.input('); + expect(ext).toContain('FILTER_THRESHOLD'); + }); + it('the installed switcher extension passes --skip-marker', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand(