From 582f6f185bd16b4c73c4f24a9ed6d2deb965660b Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 09:34:15 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat(cli):=20add=20`mcpctl=20config=20prime?= =?UTF-8?q?-agent`=20=E2=80=94=20proxy=20MCP=20+=20skills=20sync=20for=20p?= =?UTF-8?q?rime-agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror `mcpctl config claude` for prime-agent (which talks to the same mcpctl proxy MCP gateway over HTTP instead of stdio): `mcpctl config prime-agent --project X`: - registers the proxy MCP gateway in ~/.prime/agent/settings.json as mcpServers.X = { type: "http", url: /projects/X/mcp }, merging with any existing servers (e.g. the bundled `sre` project) and preserving all other settings - writes a .mcpctl-project marker so later syncs resolve the project - syncs the project's skills into ~/.prime/agent/skills// as markdown skills (prime-agent auto-discovers them at session start) New `mcpctl skills sync --agent prime-agent` target re-syncs the tree later. - src/cli/src/config/prime-agent.ts: settings.json read/merge/write helpers - src/cli/src/utils/prime-agent-skills.ts: prime-agent sync (reuses installSkillAtomic + skills-state; skips Claude-only hooks/postInstall) - completes config.ts/skills.ts wiring; regenerated shell completions - tests: commands/prime-agent.test.ts + utils/prime-agent-skills.test.ts --- README.md | 31 ++ completions/mcpctl.bash | 10 +- completions/mcpctl.fish | 23 +- src/cli/src/commands/config.ts | 94 ++++++ src/cli/src/commands/skills.ts | 24 +- src/cli/src/config/prime-agent.ts | 97 ++++++ src/cli/src/utils/prime-agent-skills.ts | 287 ++++++++++++++++++ src/cli/tests/commands/prime-agent.test.ts | 147 +++++++++ src/cli/tests/completions.test.ts | 2 +- .../tests/utils/prime-agent-skills.test.ts | 124 ++++++++ 10 files changed, 831 insertions(+), 8 deletions(-) create mode 100644 src/cli/src/config/prime-agent.ts create mode 100644 src/cli/src/utils/prime-agent-skills.ts create mode 100644 src/cli/tests/commands/prime-agent.test.ts create mode 100644 src/cli/tests/utils/prime-agent-skills.test.ts diff --git a/README.md b/README.md index e12537e..db6d3ec 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,37 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re mcpctl console monitoring # Preview what Claude sees ``` +### Connect prime-agent + +Prime-agent (Claude's open-source counterpart) talks to the same proxy MCP +gateway over HTTP rather than stdio. Register a project and sync its skills +into `~/.prime/agent/`: + +```bash +mcpctl config prime-agent --project monitoring +``` + +This: + +1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as + `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` + (merging with any existing servers — the bundled `sre` project is preserved). +2. Writes a `.mcpctl-project` marker so later syncs resolve the project. +3. Syncs the project's skills into `~/.prime/agent/skills//` as markdown + skills (prime-agent auto-discovers them at session start). + +Re-sync later with: + +```bash +mcpctl skills sync --agent prime-agent --project monitoring +``` + +Preview the change without writing anything: + +```bash +mcpctl config prime-agent --project monitoring --dry-run +``` + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b544435..9c6d22b 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 setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -124,6 +124,12 @@ _mcpctl() { claude-generate) COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; + prime-agent) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + ;; + prime-agent-generate) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; @@ -378,7 +384,7 @@ _mcpctl() { else case "$skills_sub" in sync) - COMPREPLY=($(compgen -W "-p --project --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --agent --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) ;; *) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ff85934..972cf97 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -239,7 +239,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Manage Claude Code skill bundles synced from mcpd' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -267,13 +267,15 @@ 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 setup impersonate +set -l config_cmds view set path reset claude claude-generate prime-agent prime-agent-generate 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' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration to defaults' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' +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 + sync skills 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 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' @@ -294,6 +296,20 @@ 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+)' +# 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)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print the settings.json change without writing or syncing' + +# config prime-agent-generate options +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print the settings.json 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' @@ -495,10 +511,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 under ~/.claude/skills/' +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/skills/ or ~/.prime/agent/skills/)' # 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) or prime-agent' -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 SessionStart hook)' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 44330ee..f3bc0cb 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -12,6 +12,12 @@ import type { ApiClient } from '../api-client.js'; import { writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; import { runSkillsSync } from './skills.js'; +import { + registerPrimeAgentMcp, + primeAgentSettingsPath, + DEFAULT_MCPCTL_GATEWAY_URL, +} from '../config/prime-agent.js'; +import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; interface McpConfig { mcpServers: Record }>; @@ -193,9 +199,97 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } } + // prime-agent: register our proxy MCP gateway in prime-agent's settings.json + // + sync the project's skills into prime-agent's skills tree. Mirror of the + // claude command above, but targeting ~/.prime/agent/ instead of .mcp.json. + function registerPrimeAgentCommand(name: string, hidden: boolean): void { + const cmd = config + .command(name) + .description(hidden ? '' : 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)') + .option('-p, --project ', 'Project name') + .option('-o, --output ', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--skip-skills', 'Skip the skills sync step') + .option('--dry-run', 'Print the settings.json change without writing or syncing') + .action(async (opts: { + project?: string; + output?: string; + gatewayUrl: string; + skipSkills?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + const settingsPath = resolve(opts.output ?? primeAgentSettingsPath()); + + if (opts.dryRun === true) { + const dry = JSON.stringify({ + primeAgent: { + settingsPath, + mcpServers: { + [opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` }, + }, + }, + action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + }, null, 2); + log(dry); + return; + } + + try { + const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); + log(reg.created + ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` + : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); + } catch (err: unknown) { + log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // Write the project marker in cwd so later `skills sync` calls resolve scope. + try { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // Sync skills into prime-agent's skills tree (skippable). + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runPrimeAgentSkillsSync( + { project: opts.project }, + { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } + } catch (err: unknown) { + log(`Warning: prime-agent 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 prime-agent` separately)'); + } + } + }); + if (hidden) { + void cmd; + } + } + registerClaudeCommand('claude', false); registerClaudeCommand('claude-generate', true); // backward compat + registerPrimeAgentCommand('prime-agent', false); + registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + config.addCommand(createConfigSetupCommand({ configDeps })); if (apiDeps) { diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 627799d..edc4dbb 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -31,6 +31,7 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -441,11 +442,12 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { console.error(...(args as Parameters)); }; - const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk under ~/.claude/skills/') + .description('Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--agent ', 'Sync target: claude (default) or prime-agent', '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 SessionStart hook)') @@ -453,12 +455,30 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') .action(async (opts: { project?: string; + agent?: string; dryRun?: boolean; force?: boolean; quiet?: boolean; skipPostinstall?: boolean; keepOrphans?: boolean; }) => { + if (opts.agent === 'prime-agent') { + const result = await runPrimeAgentSkillsSync( + { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), + ...(opts.force !== undefined ? { force: opts.force } : {}), + ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), + ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + }, + { client, log, warn }, + ); + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + } + return; + } + const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts new file mode 100644 index 0000000..a4bff7f --- /dev/null +++ b/src/cli/src/config/prime-agent.ts @@ -0,0 +1,97 @@ +/** + * Read/merge/write helpers for prime-agent's own configuration files, used + * by `mcpctl config prime-agent`. + * + * prime-agent keeps two user-editable files under `~/.prime/agent/`: + * - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus + * model/provider preferences. `mcpctl config prime-agent` registers our + * proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`. + * - `auth.json` — per-server bearer tokens keyed as `mcp:`. + * + * We only ever merge the `mcpServers` map, preserving every other key and any + * servers the user has already configured (including non-mcpctl gateways like + * the bundled `sre` project). + */ +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +/** Base URL of the deployed mcpctl HTTP MCP gateway. */ +export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu'; + +/** Resolve the prime-agent settings.json path. */ +export function primeAgentSettingsPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'settings.json'); +} + +/** Proxy MCP URL for a given project on the gateway. */ +export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string { + const base = gatewayUrl.replace(/\/+$/, ''); + return `${base}/projects/${encodeURIComponent(project)}/mcp`; +} + +interface PrimeAgentSettings { + mcpServers?: Record; + [key: string]: unknown; +} + +/** Load prime-agent settings; return an empty object if absent/invalid. */ +export async function loadPrimeAgentSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as PrimeAgentSettings; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +export interface RegisterMcpResult { + settingsPath: string; + created: boolean; // true if the settings file did not previously exist + addedServer: string; + newServer: boolean; // true if the project's MCP entry was not already present + url: string; + totalServers: number; +} + +/** + * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the + * prime-agent settings file, preserving all other fields and servers. + * Returns a summary of what changed. + */ +async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +export async function registerPrimeAgentMcp( + project: string, + settingsPath: string, + gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, +): Promise { + const existed = await pathExists(settingsPath); + const settings = await loadPrimeAgentSettings(settingsPath); + + settings.mcpServers = settings.mcpServers ?? {}; + const url = projectMcpUrl(project, gatewayUrl); + const isNewServer = !Object.prototype.hasOwnProperty.call(settings.mcpServers, project); + settings.mcpServers[project] = { type: 'http', url }; + const totalServers = Object.keys(settings.mcpServers).length; + + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + + return { + settingsPath, + created: !existed, + addedServer: project, + newServer: isNewServer, + url, + totalServers, + }; +} diff --git a/src/cli/src/utils/prime-agent-skills.ts b/src/cli/src/utils/prime-agent-skills.ts new file mode 100644 index 0000000..9663bb4 --- /dev/null +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -0,0 +1,287 @@ +/** + * Prime-agent skill sync for `mcpctl config prime-agent`. + * + * Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`) + * but materialises server-side skills as *markdown* skills for prime-agent + * under `~/.prime/agent/skills//`. + * + * Why a separate module instead of parameterising `runSkillsSync`: + * - prime-agent skills carry no `hooks` (there is no SessionStart hook on + * the prime-agent side) and must never touch `~/.claude/settings.json`, + * so the hooks side-effect in `runSkillsSync` would be wrong here. + * - prime-agent skills have no `postInstall` scripts (server scripts assume + * a Claude-esque shell), so we skip that machinery too. + * + * The on-disk format is deliberately the same as what prime-agent already + * ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary + * `files`). prime-agent auto-discovers these at session start, so once the + * config command has pointed prime-agent at the proxy MCP and synced the + * project's skills, later `mcpctl skills sync --agent prime-agent` calls (or + * the config command itself) keep the tree up to date. + */ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import type { ApiClient } from '../api-client.js'; +import { ApiError } from '../api-client.js'; +import { findProjectMarker } from './project-marker.js'; +import { + loadState, + saveState, + detectModifiedFiles, + type SkillState, +} from './skills-state.js'; +import { + installSkillAtomic, + removeSkillAtomic, +} from './skills-disk.js'; + +/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */ +export function primeAgentSkillsRoot(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'skills'); +} + +/** State bookkeeping lives separately from the Claude skills state. */ +export function primeAgentStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json'); +} + +/** Shape of a server-side visible skill (subset we act on). */ +interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; +} + +/** Full skill body fetched from /api/v1/skills/:id (subset we install). */ +interface FullSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + content: string; + files: Record; +} + +export interface PrimeAgentSyncOpts { + /** Project name; otherwise resolved from the .mcpctl-project marker. */ + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + keepOrphans?: boolean; + /** For tests: override cwd for the marker walk-up. */ + cwd?: string; + /** For tests: override the prime-agent skills root. */ + installRoot?: string; + /** For tests: override the state file path. */ + statePath?: string; + /** For tests: override $HOME used for default paths. */ + homeDir?: string; +} + +export interface PrimeAgentSyncResult { + installed: string[]; + updated: string[]; + skipped: string[]; + removed: string[]; + preserved: string[]; + errors: Array<{ skill: string; error: string }>; + exitCode: 0 | 1 | 2; +} + +export interface PrimeAgentSyncDeps { + client: ApiClient; + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +} + +/** + * Sync the active project's skills into prime-agent's markdown skills tree. + * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 + * disk/state error. + */ +export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise { + const { client, log, warn } = deps; + const result: PrimeAgentSyncResult = { + installed: [], + updated: [], + skipped: [], + removed: [], + preserved: [], + errors: [], + exitCode: 0, + }; + + // 1. Resolve project scope (explicit flag beats the marker walk-up). + let projectName = opts.project; + if (projectName === undefined || projectName === '') { + const marker = await findProjectMarker(opts.cwd ?? process.cwd(), opts.homeDir ?? homedir()); + if (marker) projectName = marker.project; + } + + // 2. Fetch the visible skill list. + let visible: VisibleSkill[]; + try { + if (projectName !== undefined) { + visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); + } else { + visible = await client.get('/api/v1/skills?scope=global'); + } + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 401) { + warn('mcpctl: auth failed — run `mcpctl login`'); + result.exitCode = 1; + return result; + } + if (opts.quiet === true) { + // Fail-open in quiet mode so a hung mcpd never blocks agent startup. + warn(`mcpctl: prime-agent skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 0; + return result; + } + throw err; + } + + // Agent-scoped skills aren't surfaced to a user's prime-agent session. + visible = visible.filter((s) => s.scope !== 'agent'); + + // 3. Load state + resolve install root. + const statePath = opts.statePath ?? primeAgentStatePath(opts.homeDir ?? homedir()); + const state = await loadState(statePath); + const installRoot = opts.installRoot ?? primeAgentSkillsRoot(opts.homeDir ?? homedir()); + + // 4. Diff against last sync. + const visibleByName = new Map(visible.map((s) => [s.name, s])); + const stateNames = Object.keys(state.skills); + + const toFetch: VisibleSkill[] = []; + for (const v of visible) { + const prior = state.skills[v.name]; + if (!prior) { + toFetch.push(v); + continue; + } + if (prior.contentHash === v.contentHash) { + result.skipped.push(v.name); + continue; + } + toFetch.push(v); + } + + // 5. Apply install/update (concurrency limit 5). + const concurrency = 5; + for (let i = 0; i < toFetch.length; i += concurrency) { + const batch = toFetch.slice(i, i + concurrency); + await Promise.all(batch.map((v) => applyOne(v))); + } + + // 6. Orphan removal. + if (opts.keepOrphans !== true) { + for (const name of stateNames) { + if (visibleByName.has(name)) continue; + const prior = state.skills[name]; + if (!prior) continue; + try { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0 && opts.force !== true) { + warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); + result.preserved.push(name); + continue; + } + if (opts.dryRun === true) { + result.removed.push(name); + continue; + } + await removeSkillAtomic(prior.installDir); + delete state.skills[name]; + result.removed.push(name); + } catch (err: unknown) { + result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); + } + } + } + + // 7. Persist state. + state.lastSync = new Date().toISOString(); + if (projectName !== undefined) state.lastSyncProject = projectName; + if (opts.dryRun !== true) { + try { + await saveState(state, statePath); + } catch (err: unknown) { + warn(`mcpctl: failed to persist prime-agent skills state — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 2; + } + } + + // 8. Summary. + const anythingHappened = + result.errors.length > 0 || + result.installed.length > 0 || + result.updated.length > 0 || + result.removed.length > 0; + if (opts.quiet !== true || anythingHappened) { + const parts: string[] = []; + if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); + if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); + if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); + if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); + if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); + if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); + if (parts.length === 0) parts.push('no changes'); + if (opts.quiet !== true) { + log(`mcpctl prime-agent skills sync${projectName !== undefined ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); + } else { + warn(`mcpctl: ${parts.join(', ')}`); + } + } + + return result; + + async function applyOne(v: VisibleSkill): Promise { + try { + const prior = state.skills[v.name]; + const targetDir = prior?.installDir ?? join(installRoot, v.name); + if (prior !== undefined && opts.force !== true) { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0) { + warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); + result.preserved.push(v.name); + return; + } + } + if (opts.dryRun === true) { + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + return; + } + + const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); + const files = await installSkillAtomic(targetDir, { + content: full.content, + ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), + }); + + const newState: SkillState = { + id: v.id, + semver: v.semver, + contentHash: v.contentHash, + scope: v.scope, + installDir: targetDir, + files, + postInstallHash: null, + lastSyncedAt: new Date().toISOString(), + }; + state.skills[v.name] = newState; + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + } catch (err: unknown) { + result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); + } + } +} diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts new file mode 100644 index 0000000..947b87e --- /dev/null +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } 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'; +import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config prime-agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + let prevCwd: string; + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-')); + // config prime-agent writes the .mcpctl-project marker into cwd, so run + // every test from an isolated temp dir to avoid polluting the repo. + prevCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + process.exitCode = 0; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('requires --project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(process.exitCode).toBe(1); + }); + + it('writes proxy MCP entry into prime-agent settings.json', 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', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['homeautomation']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, + }); + expect(output.join('\n')).toContain('homeautomation'); + }); + + it('merges with existing servers and preserves other settings', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + defaultProvider: 'itaz', + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, + }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.defaultProvider).toBe('itaz'); // untouched + expect(written.mcpServers['sre']).toBeDefined(); // preserved + expect(written.mcpServers['proj-1']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, + }); + }); + + it('writes a project marker for later skills sync', 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', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const markerPath = join(tmpDir, '.mcpctl-project'); + expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre'); + }); + + it('--dry-run prints the change without writing', 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', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' }); + + expect(output.join('\n')).toContain('proj-2'); + // No file should have been created. + expect(exceptionSafeRead(settingsPath)).toBeNull(); + }); + + it('does not call the API when --skip-skills is set', 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', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + expect(client.get).not.toHaveBeenCalled(); + }); + + it('backward compat: prime-agent-generate still works', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['proj-1']).toBeDefined(); + }); +}); + +function exceptionSafeRead(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/cli/tests/completions.test.ts b/src/cli/tests/completions.test.ts index 2e303ab..cfbfbf6 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -234,7 +234,7 @@ describe('agent + chat completions', () => { }); it('bash dispatches `create agent` with the correct flags', () => { - const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? ''; + const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? ''; expect(createBlock).toContain('--llm'); expect(createBlock).toContain('--system-prompt'); expect(createBlock).toContain('--default-temperature'); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts new file mode 100644 index 0000000..d696724 --- /dev/null +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js'; +import { loadState } from '../../src/utils/skills-state.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(overrides: Record = {}): ApiClient { + return { + get: vi.fn(async (url: string) => { + if (url.includes('/skills/visible')) { + return overrides['visible'] ?? []; + } + if (url.startsWith('/api/v1/skills/')) { + const id = url.split('/').pop() as string; + const full = (overrides['full'] as Record)?.[id]; + if (!full) throw new Error(`no full skill for ${id}`); + return full; + } + if (url.endsWith('/skills?scope=global')) { + return overrides['visible'] ?? []; + } + throw new Error(`unexpected get: ${url}`); + }), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +const SKILL_MD = `--- +name: sample-skill +description: A test skill synced into prime-agent. +--- + +# Sample Skill + +Body text. +`; + +describe('runPrimeAgentSkillsSync', () => { + let tmpDir: string; + let installRoot: string; + let statePath: string; + const log = (..._a: unknown[]) => {}; + const warn = (..._a: unknown[]) => {}; + + function deps(client: ApiClient) { + return { client, log, warn }; + } + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-pa-sync-')); + installRoot = join(tmpDir, 'skills'); + statePath = join(tmpDir, 'skills-state.json'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs a new markdown skill into the prime-agent skills root', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync( + { project: 'proj', installRoot, statePath }, + deps(client), + ); + + expect(result.installed).toEqual(['sample-skill']); + expect(result.errors).toEqual([]); + + const skillDir = join(installRoot, 'sample-skill'); + expect(existsSync(skillDir)).toBe(true); + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe(SKILL_MD); + + // State persisted so a re-sync is a no-op. + const state = await loadState(statePath); + expect(state.skills['sample-skill'].contentHash).toBe('sha256:h1'); + }); + + it('skips unchanged skills on re-sync', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.skipped).toEqual(['sample-skill']); + expect(result.installed).toEqual([]); + }); + + it('syncs the global set when no project is provided', async () => { + const visible = [ + { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', metadata: {}, scope: 'global' }, + ]; + const full = { + 'skill-2': { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', content: '# global\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + // Isolate cwd so no stray .mcpctl-project marker is discovered. + const empty = join(tmpDir, 'empty'); + mkdirSync(empty, { recursive: true }); + + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client)); + + expect(result.installed).toEqual(['global-skill']); + const getCalls = (client.get as ReturnType).mock.calls.map((c) => String(c[0])); + expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true); + }); +}); From eb1642ab1a494e2ad615c5ca5dfb55d32ab232f4 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 10:22:46 +0100 Subject: [PATCH 2/5] fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension Addresses a review of the `config prime-agent` feature and adds the in-app project switcher. Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent tree must never suffer silent data loss): - config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt JSON instead of swallowing it and rewriting the file (which destroyed every non-mcpServers setting). A project's mcpServers entry is merged (keeping user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth / hasPrimeAgentAuth helpers for auth provisioning. - skills sync: unified the near-verbatim prime-agent copy into runSkillsSync via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now a thin wrapper). Under the prime-agent target it: preserves untracked pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`), records per-project ownership so configuring a second project never deletes the first project's skills, skips Claude-only hooks/postInstall, and keeps the mcpServers auto-attach step. - config.ts: `config prime-agent` now (a) provisions the bearer credential in auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens), (b) writes the .mcpctl-project marker only when none exists up-tree and never from $HOME, and (c) propagates the skills sync exit code so auth failures are reported instead of swallowing them. - skills.ts: `--agent` is validated; an unknown value errors instead of silently running the Claude sync. New feature: `config prime-agent` installs a `/mcpctl` project-switcher extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the prime-agent TUI, applies the switch through the CLI, and reloads the session. Regenerated shell completions. Tests: 538 pass (new coverage for settings corruption, entry merge, auth provisioning, extension install/skip, marker $HOME handling, untracked/cross-project skill preservation, --agent validation). --- README.md | 24 +- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 10 +- src/cli/src/commands/config.ts | 97 +++++- src/cli/src/commands/skills.ts | 122 +++++--- src/cli/src/config/prime-agent-extension.ts | 10 + src/cli/src/config/prime-agent.ts | 138 ++++++--- src/cli/src/utils/prime-agent-skills.ts | 293 ++---------------- src/cli/src/utils/skills-state.ts | 7 + src/cli/tests/commands/prime-agent.test.ts | 127 +++++++- src/cli/tests/commands/skills.test.ts | 56 ++++ .../tests/utils/prime-agent-skills.test.ts | 64 +++- 12 files changed, 584 insertions(+), 368 deletions(-) create mode 100644 src/cli/src/config/prime-agent-extension.ts create mode 100644 src/cli/tests/commands/skills.test.ts diff --git a/README.md b/README.md index db6d3ec..8d781b9 100644 --- a/README.md +++ b/README.md @@ -127,10 +127,18 @@ This: 1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` - (merging with any existing servers — the bundled `sre` project is preserved). -2. Writes a `.mcpctl-project` marker so later syncs resolve the project. -3. Syncs the project's skills into `~/.prime/agent/skills//` as markdown - skills (prime-agent auto-discovers them at session start). + (merging with any existing servers and preserving all other settings). +2. Provisions the project's bearer credential in `~/.prime/agent/auth.json` + (`mcp:monitoring`) — either from `--token `, an existing entry, or an + auto-minted project token. +3. Writes a `.mcpctl-project` marker (only if none exists higher up, and never + from `$HOME`) so later syncs resolve the project. +4. Syncs the project's skills into `~/.prime/agent/skills//` as markdown + skills. The shared tree is ownership-tracked per project: it never deletes + another project's skills or an untracked hand-authored skill. +5. Installs a `/mcpctl` project-switcher extension into + `~/.prime/agent/extensions/` so you can switch mcpctl projects from inside + the prime-agent UI (skip with `--skip-extension`). Re-sync later with: @@ -138,6 +146,14 @@ Re-sync later with: mcpctl skills sync --agent prime-agent --project monitoring ``` +Skip individual steps as needed: + +```bash +mcpctl config prime-agent --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config prime-agent --project monitoring --skip-skills # don't sync skills +mcpctl config prime-agent --project monitoring --skip-extension # don't install /mcpctl switcher +``` + Preview the change without writing anything: ```bash diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 9c6d22b..0bbaf18 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -125,10 +125,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; prime-agent) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) ;; prime-agent-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 972cf97..31867cb 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -274,7 +274,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 reset -d 'Reset configuration to defaults' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' -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 + sync skills 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 -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 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' @@ -300,15 +300,19 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' -complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print the settings.json change without writing or syncing' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' -complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print the settings.json change without writing or syncing' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -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' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index f3bc0cb..586d888 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 } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync } 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'; @@ -9,14 +9,17 @@ import { saveCredentials, loadCredentials } from '../auth/index.js'; import { createConfigSetupCommand } from './config-setup.js'; import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; -import { writeProjectMarker } from '../utils/project-marker.js'; +import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; import { runSkillsSync } from './skills.js'; import { registerPrimeAgentMcp, primeAgentSettingsPath, DEFAULT_MCPCTL_GATEWAY_URL, + writePrimeAgentAuth, + hasPrimeAgentAuth, } from '../config/prime-agent.js'; +import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; interface McpConfig { @@ -205,17 +208,21 @@ export function createConfigCommand(deps?: Partial, apiDeps?: function registerPrimeAgentCommand(name: string, hidden: boolean): void { const cmd = config .command(name) - .description(hidden ? '' : 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)') + .description(hidden ? '' : 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)') .option('-p, --project ', 'Project name') .option('-o, --output ', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)') .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to store in auth.json (skips auto-minting)') .option('--skip-skills', 'Skip the skills sync step') - .option('--dry-run', 'Print the settings.json change without writing or syncing') + .option('--skip-extension', 'Do not install the /mcpctl project-switcher extension') + .option('--dry-run', 'Print what would change without writing or syncing') .action(async (opts: { project?: string; output?: string; gatewayUrl: string; + token?: string; skipSkills?: boolean; + skipExtension?: boolean; dryRun?: boolean; }) => { if (opts.project === undefined || opts.project === '') { @@ -225,21 +232,27 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } const settingsPath = resolve(opts.output ?? primeAgentSettingsPath()); + const agentDir = dirname(settingsPath); + const authPath = join(agentDir, 'auth.json'); + const extPath = join(agentDir, 'extensions', MCPCTL_SWITCH_EXTENSION_FILENAME); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + const url = `${gatewayBase}/projects/${encodeURIComponent(opts.project)}/mcp`; if (opts.dryRun === true) { const dry = JSON.stringify({ primeAgent: { settingsPath, - mcpServers: { - [opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` }, - }, + authPath, + mcpServers: { [opts.project]: { type: 'http', url } }, + extension: opts.skipExtension === true ? '' : extPath, }, - action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + action: 'write settings.json + write auth.json credential + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', }, null, 2); log(dry); return; } + // 1. Register the proxy MCP gateway (merge; never destroy settings). try { const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); log(reg.created @@ -251,15 +264,55 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - // Write the project marker in cwd so later `skills sync` calls resolve scope. + // 2. Provision the bearer credential prime-agent needs for this project. + // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must + // store an mcp: token in auth.json. Use --token if given, + // keep an existing one, otherwise mint it via the API. try { - const markerPath = await writeProjectMarker(process.cwd(), opts.project); - log(`Wrote ${markerPath}`); + if (opts.token !== undefined && opts.token !== '') { + await writePrimeAgentAuth(opts.project, opts.token, authPath); + log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + } else if (await hasPrimeAgentAuth(opts.project, authPath)) { + log(`Bearer credential for '${opts.project}' already present in ${authPath}`); + } else if (skillsClient) { + const tokenName = `prime-agent-${Date.now()}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { + name: tokenName, + 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); + log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + } else { + log(`Warning: no token returned minting for '${opts.project}'; pass --token to supply one`); + } + } else { + log('Warning: no API client available to mint a project token — pass --token to provision auth.json'); + } + } catch (err: unknown) { + log(`Warning: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + + // 3. Write the .mcpctl-project marker so later `skills sync` calls can + // resolve the project. Never clobber an existing marker found by + // walk-up, and never scope $HOME itself. + try { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null) { + log(`Project already scoped by existing marker ${existing.markerPath} ('${existing.project}'); not overwriting`); + } else if (process.cwd() !== homedir()) { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(`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)}`); } - // Sync skills into prime-agent's skills tree (skippable). + // 4. Sync skills into prime-agent's skills tree (skippable). if (opts.skipSkills !== true) { if (skillsClient) { try { @@ -268,16 +321,32 @@ export function createConfigCommand(deps?: Partial, apiDeps?: { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, ); const total = result.installed.length + result.updated.length + result.removed.length; - if (total > 0) { - log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + if (total > 0 || result.errors.length > 0) { + log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); + } + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + log(`Warning: prime-agent skills sync exited with code ${String(result.exitCode)}`); } } catch (err: unknown) { log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; } } else { log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)'); } } + + // 5. Install the /mcpctl project-switcher extension (skippable). + if (opts.skipExtension !== true) { + try { + mkdirSync(dirname(extPath), { recursive: true }); + writeFileSync(extPath, MCPCTL_SWITCH_EXTENSION, 'utf-8'); + log(`Installed /mcpctl switcher extension: ${extPath}`); + } catch (err: unknown) { + log(`Warning: failed to install /mcpctl switcher extension: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { void cmd; diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index edc4dbb..48afdf1 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -10,6 +10,7 @@ import { detectModifiedFiles, type SkillState, defaultStatePath, + pathExists, } from '../utils/skills-state.js'; import { installSkillAtomic, @@ -31,7 +32,6 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; -import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -88,10 +88,22 @@ export interface SyncOpts { keepOrphans?: boolean; /** For tests: override cwd start for the marker walk-up. */ cwd?: string; - /** For tests: override skills install root (default: ~/.claude/skills). */ + /** For tests: override skills install root (default depends on target). */ installRoot?: string; - /** For tests: override state file path. */ + /** For tests: override state file path (default depends on target). */ statePath?: string; + /** Override $HOME used for default paths (tests). */ + homeDir?: string; + /** + * Which agent's skill tree to sync into: + * 'claude' (default) — ~/.claude/skills, with hooks + postInstall. + * 'prime-agent' — ~/.prime/agent/skills; no hooks/postInstall, + * shared flat tree with per-project ownership so + * configuring a second project never deletes the + * first project's skills, and pre-existing + * (untracked) skill dirs are preserved. + */ + target?: 'claude' | 'prime-agent'; } export interface SyncResult { @@ -121,6 +133,8 @@ export interface SyncDeps { */ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { const { client, log, warn } = deps; + const target = opts.target ?? 'claude'; + const homeDir = opts.homeDir ?? homedir(); const result: SyncResult = { installed: [], updated: [], @@ -174,10 +188,17 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise s.scope !== 'agent'); - // 3. Load state. - const statePath = opts.statePath ?? defaultStatePath(); + // 3. Load state. Defaults depend on the sync target: Claude Code gets + // ~/.claude/skills + the shared state file; prime-agent gets its own + // tree + separate state file so the two never collide. + const isPrimeAgent = target === 'prime-agent'; + const statePath = opts.statePath ?? (isPrimeAgent + ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') + : defaultStatePath()); const state = await loadState(statePath); - const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills'); + const installRoot = opts.installRoot ?? (isPrimeAgent + ? join(homeDir, '.prime', 'agent', 'skills') + : join(homeDir, '.claude', 'skills')); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -206,12 +227,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise applyOne(v))); } - // 6. Orphan removal: skills in state but not in server's visible set. + // 6. Orphan removal: skills in state but not in the server's visible set. if (!opts.keepOrphans) { for (const name of stateNames) { if (visibleByName.has(name)) continue; const prior = state.skills[name]; if (!prior) continue; + // prime-agent shares one flat skill tree across projects while + // settings.json accumulates one MCP server per project. Never delete a + // skill that belongs to a *different* project (or the user would lose + // their first project just by configuring a second one). Only remove + // skills this project (or globals) previously installed and that have + // since left the visible set. + if (isPrimeAgent && prior.project !== projectName) continue; try { // Preserve user-modified skills — warn + skip. const modified = await detectModifiedFiles(prior.installDir, prior.files); @@ -225,8 +253,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise so each skill's hooks - // can be cleanly added/updated/removed without trampling other - // skills or user-added hooks. No-op when the field is absent or - // empty. const meta = (full.metadata ?? {}) as SyncedSkillMetadata; - if (meta.hooks && typeof meta.hooks === 'object') { - try { - const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); - if (hookRes.updated) result.hooksApplied.push(v.name); - } catch (err: unknown) { - warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + + // ── hooks (Claude only) ── + // prime-agent has no SessionStart-hook equivalent and must never touch + // ~/.claude/settings.json. Tagged with _mcpctl_source: so + // each skill's hooks can be cleanly added/updated/removed without + // trampling other skills or user-added hooks. No-op when absent. + if (!isPrimeAgent) { + if (meta.hooks && typeof meta.hooks === 'object') { + try { + const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); + if (hookRes.updated) result.hooksApplied.push(v.name); + } catch (err: unknown) { + warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + } + } else if (prior !== undefined) { + // Skill no longer declares hooks but used to — clean up. + try { await removeManagedHooks(v.name); } catch { /* best-effort */ } } - } else if (prior !== undefined) { - // Skill no longer declares hooks but used to — clean up. - try { await removeManagedHooks(v.name); } catch { /* best-effort */ } } // ── mcpServers: auto-attach declared deps to the active project ── @@ -352,7 +400,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 @@ -419,6 +470,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { - if (opts.agent === 'prime-agent') { - const result = await runPrimeAgentSkillsSync( - { - ...(opts.project !== undefined ? { project: opts.project } : {}), - ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), - ...(opts.force !== undefined ? { force: opts.force } : {}), - ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), - ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - }, - { client, log, warn }, - ); - if (result.exitCode !== 0) { - process.exitCode = result.exitCode; - } + // 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') { + warn(`mcpctl: unknown sync target '${agent}' (expected 'claude' or 'prime-agent')`); + process.exitCode = 1; return; } - const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), @@ -487,6 +530,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', }, { client, log, warn }, ); diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts new file mode 100644 index 0000000..6dd48c8 --- /dev/null +++ b/src/cli/src/config/prime-agent-extension.ts @@ -0,0 +1,10 @@ +/** + * 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/`). + * + * The installed file is this exact source (verbatim), so the extension shipped + * 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\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 for (const name of Object.keys(settings.mcpServers)) {\n const url = settings.mcpServers[name]?.url ?? '';\n const m = url.match(/\\/projects\\/([^/]+)\\/mcp$/);\n if (m && m[1] === name) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\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 await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\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 await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts index a4bff7f..5632323 100644 --- a/src/cli/src/config/prime-agent.ts +++ b/src/cli/src/config/prime-agent.ts @@ -8,9 +8,14 @@ * proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`. * - `auth.json` — per-server bearer tokens keyed as `mcp:`. * - * We only ever merge the `mcpServers` map, preserving every other key and any - * servers the user has already configured (including non-mcpctl gateways like - * the bundled `sre` project). + * Safety invariants: + * - We only ever *merge* the `mcpServers` map, preserving every other key + * and any servers the user already configured. + * - If `settings.json` exists but is corrupt, we fail loudly instead of + * swallowing the parse error and rewriting (which would destroy every + * non-mcpServers setting). Untouched corrupt files are never overwritten. + * - A project's existing `mcpServers` entry is merged (user-added fields are + * kept), never replaced wholesale. */ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { join, dirname } from 'node:path'; @@ -24,25 +29,46 @@ export function primeAgentSettingsPath(homeDir: string = homedir()): string { return join(homeDir, '.prime', 'agent', 'settings.json'); } +/** Resolve the prime-agent auth.json path. */ +export function primeAgentAuthPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'auth.json'); +} + +/** Resolve the prime-agent extensions directory (auto-discovered by the app). */ +export function primeAgentExtensionsDir(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'extensions'); +} + /** Proxy MCP URL for a given project on the gateway. */ export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string { const base = gatewayUrl.replace(/\/+$/, ''); return `${base}/projects/${encodeURIComponent(project)}/mcp`; } -interface PrimeAgentSettings { - mcpServers?: Record; +export interface PrimeAgentSettings { + mcpServers?: Record>; [key: string]: unknown; } -/** Load prime-agent settings; return an empty object if absent/invalid. */ +/** + * Load prime-agent settings. + * - Missing file → returns `{}` (a brand-new file about to be created). + * - Unreadable/corrupt → throws, so the caller refuses to overwrite it. + */ export async function loadPrimeAgentSettings(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 raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw) as PrimeAgentSettings; return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { - return {}; + } catch (err: unknown) { + throw new Error(`setting file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); } } @@ -57,9 +83,72 @@ export interface RegisterMcpResult { /** * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the - * prime-agent settings file, preserving all other fields and servers. - * Returns a summary of what changed. + * prime-agent settings file. Preserves all other fields and servers, and + * merges into an existing `mcpServers[project]` entry (keeping any user-added + * keys like `headers`) rather than replacing it wholesale. */ +export async function registerPrimeAgentMcp( + project: string, + settingsPath: string, + gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, +): Promise { + const existed = await pathExists(settingsPath); + const settings = await loadPrimeAgentSettings(settingsPath); + if (settings.mcpServers !== undefined && (typeof settings.mcpServers !== 'object' || settings.mcpServers === null)) { + throw new Error(`invalid mcpServers block in ${settingsPath} — refusing to overwrite it`); + } + + settings.mcpServers = settings.mcpServers ?? {}; + const url = projectMcpUrl(project, gatewayUrl); + const existing = settings.mcpServers[project]; + const newServer = existing === undefined; + // Merge: keep any user-added fields on the project's entry (e.g. headers). + settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url }; + const totalServers = Object.keys(settings.mcpServers).length; + + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + + return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers }; +} + +/** + * Ensure `mcp:` carries `{ type: "api_key", key }` in + * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` + * provider credential, other `mcp:*` servers, etc). + */ +export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { + const current = await loadPrimeAgentAuth(authPath); + current[`mcp:${project}`] = { type: 'api_key', key }; + await mkdir(dirname(authPath), { recursive: true }); + // auth.json is 0600 normally; preserve an existing mode if present. + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', 'utf-8'); +} + +/** Load auth.json; missing/corrupt (non-JSON) treated as a fresh file. */ +async function loadPrimeAgentAuth(path: string): Promise> { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + const parsed = JSON.parse(raw) as Record; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** Does the project already have a credential in auth.json? */ +export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { + try { + const raw = await readFile(authPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + const entry = parsed?.[`mcp:${project}`]; + return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); + } catch { + return false; + } +} + async function pathExists(p: string): Promise { try { await stat(p); @@ -68,30 +157,3 @@ async function pathExists(p: string): Promise { return false; } } - -export async function registerPrimeAgentMcp( - project: string, - settingsPath: string, - gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, -): Promise { - const existed = await pathExists(settingsPath); - const settings = await loadPrimeAgentSettings(settingsPath); - - settings.mcpServers = settings.mcpServers ?? {}; - const url = projectMcpUrl(project, gatewayUrl); - const isNewServer = !Object.prototype.hasOwnProperty.call(settings.mcpServers, project); - settings.mcpServers[project] = { type: 'http', url }; - const totalServers = Object.keys(settings.mcpServers).length; - - await mkdir(dirname(settingsPath), { recursive: true }); - await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - - return { - settingsPath, - created: !existed, - addedServer: project, - newServer: isNewServer, - url, - totalServers, - }; -} diff --git a/src/cli/src/utils/prime-agent-skills.ts b/src/cli/src/utils/prime-agent-skills.ts index 9663bb4..4ff7cc0 100644 --- a/src/cli/src/utils/prime-agent-skills.ts +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -1,287 +1,54 @@ /** - * Prime-agent skill sync for `mcpctl config prime-agent`. + * Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`. * - * Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`) - * but materialises server-side skills as *markdown* skills for prime-agent - * under `~/.prime/agent/skills//`. + * This is a thin convenience wrapper around the shared [`runSkillsSync`] + * implementation in `commands/skills.ts`, invoked with `target: 'prime-agent'`. + * All diffing, atomic install, preservation and orphan logic lives there; this + * module only: + * - resolves the prime-agent install root and state file paths, and + * - exposes a `runPrimeAgentSkillsSync` entry that delegates to the unified + * sync so callers and tests keep a stable, intent-revealing name. * - * Why a separate module instead of parameterising `runSkillsSync`: - * - prime-agent skills carry no `hooks` (there is no SessionStart hook on - * the prime-agent side) and must never touch `~/.claude/settings.json`, - * so the hooks side-effect in `runSkillsSync` would be wrong here. - * - prime-agent skills have no `postInstall` scripts (server scripts assume - * a Claude-esque shell), so we skip that machinery too. - * - * The on-disk format is deliberately the same as what prime-agent already - * ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary - * `files`). prime-agent auto-discovers these at session start, so once the - * config command has pointed prime-agent at the proxy MCP and synced the - * project's skills, later `mcpctl skills sync --agent prime-agent` calls (or - * the config command itself) keep the tree up to date. + * Target-specific behaviour (handled by the shared implementation): + * - installs markdown skills under `~/.prime/agent/skills//` + * - never touches `~/.claude/settings.json` (no hooks / postInstall) + * - still auto-attaches skill-declared `mcpServers` deps to the project + * - records per-project ownership and preserves untracked / cross-project + * skill dirs so a shared, hand-editable tree is never silently wiped */ import { join } from 'node:path'; import { homedir } from 'node:os'; -import type { ApiClient } from '../api-client.js'; -import { ApiError } from '../api-client.js'; -import { findProjectMarker } from './project-marker.js'; -import { - loadState, - saveState, - detectModifiedFiles, - type SkillState, -} from './skills-state.js'; -import { - installSkillAtomic, - removeSkillAtomic, -} from './skills-disk.js'; +import { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js'; /** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */ export function primeAgentSkillsRoot(homeDir: string = homedir()): string { return join(homeDir, '.prime', 'agent', 'skills'); } -/** State bookkeeping lives separately from the Claude skills state. */ +/** prime-agent keeps its own state file so it never collides with Claude's. */ export function primeAgentStatePath(homeDir: string = homedir()): string { return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json'); } -/** Shape of a server-side visible skill (subset we act on). */ -interface VisibleSkill { - id: string; - name: string; - description: string; - semver: string; - contentHash: string; - metadata: unknown; - scope: 'project' | 'global' | 'agent'; -} - -/** Full skill body fetched from /api/v1/skills/:id (subset we install). */ -interface FullSkill { - id: string; - name: string; - description: string; - semver: string; - contentHash: string; - content: string; - files: Record; -} - -export interface PrimeAgentSyncOpts { - /** Project name; otherwise resolved from the .mcpctl-project marker. */ - project?: string; - dryRun?: boolean; - force?: boolean; - quiet?: boolean; - keepOrphans?: boolean; - /** For tests: override cwd for the marker walk-up. */ - cwd?: string; - /** For tests: override the prime-agent skills root. */ - installRoot?: string; - /** For tests: override the state file path. */ - statePath?: string; - /** For tests: override $HOME used for default paths. */ - homeDir?: string; -} - -export interface PrimeAgentSyncResult { - installed: string[]; - updated: string[]; - skipped: string[]; - removed: string[]; - preserved: string[]; - errors: Array<{ skill: string; error: string }>; - exitCode: 0 | 1 | 2; -} - -export interface PrimeAgentSyncDeps { - client: ApiClient; - log: (...args: unknown[]) => void; - warn: (...args: unknown[]) => void; -} +export type PrimeAgentSyncOpts = Pick< + SyncOpts, + 'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir' +>; +export type PrimeAgentSyncResult = SyncResult; +export type PrimeAgentSyncDeps = SyncDeps; /** * Sync the active project's skills into prime-agent's markdown skills tree. * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 * disk/state error. */ -export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise { - const { client, log, warn } = deps; - const result: PrimeAgentSyncResult = { - installed: [], - updated: [], - skipped: [], - removed: [], - preserved: [], - errors: [], - exitCode: 0, - }; - - // 1. Resolve project scope (explicit flag beats the marker walk-up). - let projectName = opts.project; - if (projectName === undefined || projectName === '') { - const marker = await findProjectMarker(opts.cwd ?? process.cwd(), opts.homeDir ?? homedir()); - if (marker) projectName = marker.project; - } - - // 2. Fetch the visible skill list. - let visible: VisibleSkill[]; - try { - if (projectName !== undefined) { - visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); - } else { - visible = await client.get('/api/v1/skills?scope=global'); - } - } catch (err: unknown) { - if (err instanceof ApiError && err.status === 401) { - warn('mcpctl: auth failed — run `mcpctl login`'); - result.exitCode = 1; - return result; - } - if (opts.quiet === true) { - // Fail-open in quiet mode so a hung mcpd never blocks agent startup. - warn(`mcpctl: prime-agent skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); - result.exitCode = 0; - return result; - } - throw err; - } - - // Agent-scoped skills aren't surfaced to a user's prime-agent session. - visible = visible.filter((s) => s.scope !== 'agent'); - - // 3. Load state + resolve install root. - const statePath = opts.statePath ?? primeAgentStatePath(opts.homeDir ?? homedir()); - const state = await loadState(statePath); - const installRoot = opts.installRoot ?? primeAgentSkillsRoot(opts.homeDir ?? homedir()); - - // 4. Diff against last sync. - const visibleByName = new Map(visible.map((s) => [s.name, s])); - const stateNames = Object.keys(state.skills); - - const toFetch: VisibleSkill[] = []; - for (const v of visible) { - const prior = state.skills[v.name]; - if (!prior) { - toFetch.push(v); - continue; - } - if (prior.contentHash === v.contentHash) { - result.skipped.push(v.name); - continue; - } - toFetch.push(v); - } - - // 5. Apply install/update (concurrency limit 5). - const concurrency = 5; - for (let i = 0; i < toFetch.length; i += concurrency) { - const batch = toFetch.slice(i, i + concurrency); - await Promise.all(batch.map((v) => applyOne(v))); - } - - // 6. Orphan removal. - if (opts.keepOrphans !== true) { - for (const name of stateNames) { - if (visibleByName.has(name)) continue; - const prior = state.skills[name]; - if (!prior) continue; - try { - const modified = await detectModifiedFiles(prior.installDir, prior.files); - if (modified.length > 0 && opts.force !== true) { - warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); - result.preserved.push(name); - continue; - } - if (opts.dryRun === true) { - result.removed.push(name); - continue; - } - await removeSkillAtomic(prior.installDir); - delete state.skills[name]; - result.removed.push(name); - } catch (err: unknown) { - result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); - } - } - } - - // 7. Persist state. - state.lastSync = new Date().toISOString(); - if (projectName !== undefined) state.lastSyncProject = projectName; - if (opts.dryRun !== true) { - try { - await saveState(state, statePath); - } catch (err: unknown) { - warn(`mcpctl: failed to persist prime-agent skills state — ${err instanceof Error ? err.message : String(err)}`); - result.exitCode = 2; - } - } - - // 8. Summary. - const anythingHappened = - result.errors.length > 0 || - result.installed.length > 0 || - result.updated.length > 0 || - result.removed.length > 0; - if (opts.quiet !== true || anythingHappened) { - const parts: string[] = []; - if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); - if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); - if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); - if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); - if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); - if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); - if (parts.length === 0) parts.push('no changes'); - if (opts.quiet !== true) { - log(`mcpctl prime-agent skills sync${projectName !== undefined ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); - } else { - warn(`mcpctl: ${parts.join(', ')}`); - } - } - - return result; - - async function applyOne(v: VisibleSkill): Promise { - try { - const prior = state.skills[v.name]; - const targetDir = prior?.installDir ?? join(installRoot, v.name); - if (prior !== undefined && opts.force !== true) { - const modified = await detectModifiedFiles(prior.installDir, prior.files); - if (modified.length > 0) { - warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); - result.preserved.push(v.name); - return; - } - } - if (opts.dryRun === true) { - if (prior) result.updated.push(v.name); - else result.installed.push(v.name); - return; - } - - const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); - const files = await installSkillAtomic(targetDir, { - content: full.content, - ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), - }); - - const newState: SkillState = { - id: v.id, - semver: v.semver, - contentHash: v.contentHash, - scope: v.scope, - installDir: targetDir, - files, - postInstallHash: null, - lastSyncedAt: new Date().toISOString(), - }; - state.skills[v.name] = newState; - if (prior) result.updated.push(v.name); - else result.installed.push(v.name); - } catch (err: unknown) { - result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); - } - } +export async function runPrimeAgentSkillsSync( + opts: PrimeAgentSyncOpts, + deps: PrimeAgentSyncDeps, +): Promise { + return runSkillsSync({ ...opts, target: 'prime-agent' }, deps); } + +// Re-export for callers that prefer to use the shared function directly. +export { runSkillsSync }; diff --git a/src/cli/src/utils/skills-state.ts b/src/cli/src/utils/skills-state.ts index 8f653cd..fe64d5d 100644 --- a/src/cli/src/utils/skills-state.ts +++ b/src/cli/src/utils/skills-state.ts @@ -30,6 +30,13 @@ export interface SkillState { /** sha256 of the postInstall script if any; null if none. */ postInstallHash: string | null; lastSyncedAt: string; + /** + * Owning project name, used by the prime-agent sync to avoid cross-project + * orphan deletion in the shared ~/.prime/agent/skills tree. Globals record + * null; project-scoped skills record the project that installed them. + * Unset for the Claude Code path. + */ + project?: string | null; } export interface SkillsStateFile { diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 947b87e..1f9c7d5 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } 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 { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; import type { ApiClient } from '../../src/api-client.js'; import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; @@ -114,15 +114,16 @@ describe('config prime-agent', () => { expect(exceptionSafeRead(settingsPath)).toBeNull(); }); - it('does not call the API when --skip-skills is set', async () => { + it('does not call the API when --skip-skills and --token are given', 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', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' }); expect(client.get).not.toHaveBeenCalled(); + expect(client.post).not.toHaveBeenCalled(); }); it('backward compat: prime-agent-generate still works', async () => { @@ -136,6 +137,124 @@ describe('config prime-agent', () => { const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(written.mcpServers['proj-1']).toBeDefined(); }); + + it('provisions auth.json by minting a project token', 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', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' })); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' }); + }); + + it('uses --token without calling the API', 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', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' }); + }); + + it('keeps an existing credential and does not re-mint', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } })); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:labctl'].key).toBe('existing'); + }); + + it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', 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 extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts'); + expect(existsSync(extPath)).toBe(true); + expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'"); + + output.length = 0; + const cmd2 = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + expect(output.join('\n')).not.toContain('switcher extension'); + }); + + it('does not write a .mcpctl-project marker when run from $HOME', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const prevCwd = process.cwd(); + process.chdir(homedir()); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + try { + await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + } finally { + process.chdir(prevCwd); + } + expect(output.join('\n')).toContain('Skipped .mcpctl-project marker'); + expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull(); + }); + + it('refuses to overwrite a corrupt settings.json', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, '{ this is not valid json !!!'); + const prevCwd = process.cwd(); + process.chdir(tmpDir); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + try { + await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + } finally { + process.chdir(prevCwd); + } + expect(output.join('\n')).toContain('refusing to overwrite'); + // The corrupt file is untouched. + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!'); + }); + + it('merges a re-configured project entry, preserving user-added fields', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } }, + }, + })); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['ha']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`, + headers: { Authorization: 'Bearer u' }, // user-added field preserved + }); + }); }); function exceptionSafeRead(path: string): string | null { diff --git a/src/cli/tests/commands/skills.test.ts b/src/cli/tests/commands/skills.test.ts new file mode 100644 index 0000000..1556667 --- /dev/null +++ b/src/cli/tests/commands/skills.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createSkillsCommand } from '../../src/commands/skills.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('skills sync --agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-skills-agent-')); + process.exitCode = 0; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + process.exitCode = 0; + }); + + it('defaults to claude and runs the normal sync', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--skip-postinstall'], { from: 'user' }); + // claude path calls the project visible endpoint. + expect(String(client.get.mock.calls[0]?.[0])).toContain('/skills/visible'); + }); + + it('routes --agent prime-agent to the prime-agent target', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'prime-agent'], { from: 'user' }); + // prime-agent path also hits the project visible endpoint, and the summary + // line should mention the target. + expect(output.join('\n')).toContain('prime-agent'); + }); + + it('rejects an unknown --agent value with a non-zero exit', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' }); + expect(process.exitCode).toBe(1); + expect(client.get).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index d696724..a6b56e5 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js'; @@ -121,4 +121,66 @@ describe('runPrimeAgentSkillsSync', () => { const getCalls = (client.get as ReturnType).mock.calls.map((c) => String(c[0])); expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true); }); + + it('preserves an untracked pre-existing skill dir on first sync (no rm -rf)', async () => { + const existing = join(installRoot, 'sample-skill'); + mkdirSync(existing, { recursive: true }); + writeFileSync(join(existing, 'SKILL.md'), '# hand-authored\n', 'utf-8'); + + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: '# server content\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.preserved).toContain('sample-skill'); + expect(result.installed).toEqual([]); + // The hand-authored content is untouched. + expect(readFileSync(join(existing, 'SKILL.md'), 'utf-8')).toBe('# hand-authored\n'); + }); + + it('does not delete another project\'s skills when configuring a second project', async () => { + // Project A installs a skill. + const av = [ + { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# a\n', files: {} } }; + const clientA = mockClient({ visible: av, full: af }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect(existsSync(join(installRoot, 'a-skill'))).toBe(true); + + // Project B syncs with a totally different skill set. + const bv = [ + { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' }, + ]; + const bf = { 'b-1': { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# b\n', files: {} } }; + const clientB = mockClient({ visible: bv, full: bf }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + // B should neither remove A\'s skill nor claim it was removed. + expect(resultB.removed).toEqual([]); + expect(existsSync(join(installRoot, 'a-skill'))).toBe(true); + expect(existsSync(join(installRoot, 'b-skill'))).toBe(true); + }); + + it('removes an orphaned skill that belongs to the same project', async () => { + const v = [ + { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', metadata: {}, scope: 'project' }, + ]; + const f = { 'x-1': { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', content: '# old\n', files: {} } }; + const client1 = mockClient({ visible: v, full: f }); + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client1)); + expect(existsSync(join(installRoot, 'old-skill'))).toBe(true); + + // Next sync for the same project: the skill is gone from the visible set. + const client2 = mockClient({ visible: [], full: {} }); + const result2 = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client2)); + + expect(result2.removed).toContain('old-skill'); + expect(existsSync(join(installRoot, 'old-skill'))).toBe(false); + }); }); From fa7055ac5e860d6b852f83156653a12da8b09f5b Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 11:28:15 +0100 Subject: [PATCH 3/5] fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the second round of `config prime-agent` review (10 findings). auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin: - loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and- rewrite, so one syntax error can no longer destroy the provider API key and every other project's credential. hasPrimeAgentAuth shares that guarantee. - writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead of the default umask — bearer tokens are no longer world-readable on first creation. state ownership (commands/skills.ts) — the ownership model edge cases: - orphan-removal guard now normalises a canonical scope (project name, or null for globals; legacy undefined adopted to current scope) instead of comparing null against undefined, so global-only syncs and pre-PR state can no longer leave stale skills on disk forever. - a same-named skill *tracked* to a different project is preserved (with a warning) rather than silently overwritten in the shared flat tree. - mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning (prime-agent's HTTP gateway must not mutate shared mcpd project attachments); this also makes the earlier dropped-attach concern explicit rather than silent. single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts): - registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes other mcpctl-managed entries, so prime-agent has one *active* mcpctl project while preserving untagged servers (hand-configured sre, websearch, etc). - the /mcpctl extension now reads that tag as the single source of truth for the active project, fixing the false short-circuit / no-op switch. config.ts command: - an explicit -p now updates a differing up-tree .mcpctl-project marker (scope no longer silently reverts on the next sync), no-ops when it matches, and still never scopes $HOME. - a project left with no usable credential now exits non-zero (the /mcpctl extension checks child exit status, so it no longer reports a successful switch after provisioning failed). - skills sync is treated as best-effort: settings+auth determine switch success, so a skills error no longer falsely fails the switch. - token minting now revokes prior active `prime-agent` tokens before creating a fresh one (no more never-expiring token litter / lost-credential duplication). Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit code, single-active dedup preserving untagged sre, cross-project overwrite preservation, and global-orphan removal on global-only sync. --- src/cli/src/commands/config.ts | 62 +++++++++++----- src/cli/src/commands/skills.ts | 38 +++++++++- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/src/config/prime-agent.ts | 72 ++++++++++++++----- src/cli/tests/commands/prime-agent.test.ts | 65 ++++++++++++++++- .../tests/utils/prime-agent-skills.test.ts | 42 +++++++++++ 6 files changed, 238 insertions(+), 43 deletions(-) diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 586d888..864ed35 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -267,17 +267,33 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // 2. Provision the bearer credential prime-agent needs for this project. // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must // store an mcp: token in auth.json. Use --token if given, - // keep an existing one, otherwise mint it via the API. + // keep an existing one, otherwise mint it via the API. A switch with + // no usable credential is a FAILURE (exit != 0) so the /mcpctl + // extension does not report success after leaving a project bare. + let provisioned = false; try { if (opts.token !== undefined && opts.token !== '') { await writePrimeAgentAuth(opts.project, opts.token, authPath); log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + provisioned = true; } else if (await hasPrimeAgentAuth(opts.project, authPath)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); + provisioned = true; } else if (skillsClient) { - const tokenName = `prime-agent-${Date.now()}-${Math.floor(Math.random() * 1e6).toString(36)}`; + // Revoke any prior active `prime-agent` token for this project + // first (tokens are immutable + shown once), so we never litter + // never-expiring tokens on repeated reprovisioning. + const list = await skillsClient + .get | unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project)}`) + .catch(() => []); + const existing = Array.isArray(list) ? list : []; + for (const t of existing) { + if (t.name === 'prime-agent' && t.status === 'active') { + try { await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); } catch { /* best-effort */ } + } + } const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: tokenName, + name: 'prime-agent', projectName: opts.project, ttl: 'never', description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, @@ -285,26 +301,36 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (typeof minted?.token === 'string' && minted.token.length > 0) { await writePrimeAgentAuth(opts.project, minted.token, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + provisioned = true; } else { - log(`Warning: no token returned minting for '${opts.project}'; pass --token to supply one`); + log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } } else { - log('Warning: no API client available to mint a project token — pass --token to provision auth.json'); + log('Error: no API client available to mint a project token — pass --token to provision auth.json'); } } catch (err: unknown) { - log(`Warning: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (!provisioned) { + process.exitCode = 1; } // 3. Write the .mcpctl-project marker so later `skills sync` calls can - // resolve the project. Never clobber an existing marker found by - // walk-up, and never scope $HOME itself. + // resolve the project. An explicit -p is authoritative: it updates a + // differing up-tree marker (so the scope doesn't silently revert on + // the next sync), is a no-op when it already matches, and never + // scopes $HOME itself. try { - const existing = await findProjectMarker(process.cwd(), homedir()); - if (existing !== null) { - log(`Project already scoped by existing marker ${existing.markerPath} ('${existing.project}'); not overwriting`); - } else if (process.cwd() !== homedir()) { - const markerPath = await writeProjectMarker(process.cwd(), opts.project); - log(`Wrote ${markerPath}`); + if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === opts.project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.project}')` + : `Wrote ${markerPath}`); + } } else { log('Skipped .mcpctl-project marker (running from $HOME)'); } @@ -313,6 +339,9 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } // 4. Sync skills into prime-agent's skills tree (skippable). + // Best-effort: settings + auth (steps 1–2) determine whether the + // switch succeeded. A skills error is reported but must not flip the + // /mcpctl switch to "failed" when MCP access is already provisioned. if (opts.skipSkills !== true) { if (skillsClient) { try { @@ -324,13 +353,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (total > 0 || result.errors.length > 0) { log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); } - if (result.exitCode !== 0) { - process.exitCode = result.exitCode; - log(`Warning: prime-agent skills sync exited with code ${String(result.exitCode)}`); - } } catch (err: unknown) { log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; } } else { log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)'); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 48afdf1..f5ac265 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -192,6 +192,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 && projectName) { + if (isPrimeAgent) { + // prime-agent talks to the gateway over HTTP; auto-attaching a + // skill's declared server deps would mutate the *shared* mcpd project + // attachments (and a /mcpctl switch would re-trigger it). Deliberately + // never attach for prime-agent, but say so instead of being silent. + if (mcpServerDeps.length > 0) { + warn(`mcpctl: skill '${v.name}' declares mcpServers but prime-agent sync does not attach project servers; skipping attach`); + } + } else if (mcpServerDeps.length > 0 && projectName) { try { const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn); for (const srv of att.attached) { @@ -470,7 +502,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise {\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\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 for (const name of Object.keys(settings.mcpServers)) {\n const url = settings.mcpServers[name]?.url ?? '';\n const m = url.match(/\\/projects\\/([^/]+)\\/mcp$/);\n if (m && m[1] === name) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\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 await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\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 await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\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/**\n * The single *active* mcpctl project is the mcpServers entry that carries the\n * `mcpctlManaged: true` tag (written by `config prime-agent`). Untagged entries\n * (e.g. a hand-configured `sre`, websearch) are never treated as the active\n * mcpctl project, avoiding false short-circuits.\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 for (const name of Object.keys(settings.mcpServers)) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\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 await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\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 await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts index 5632323..1889cb3 100644 --- a/src/cli/src/config/prime-agent.ts +++ b/src/cli/src/config/prime-agent.ts @@ -79,6 +79,8 @@ export interface RegisterMcpResult { newServer: boolean; // true if the project's MCP entry was not already present url: string; totalServers: number; + /** Previously-managed mcpctl project entries removed so `addedServer` is the sole active one. */ + removed: string[]; } /** @@ -102,51 +104,83 @@ export async function registerPrimeAgentMcp( const url = projectMcpUrl(project, gatewayUrl); const existing = settings.mcpServers[project]; const newServer = existing === undefined; - // Merge: keep any user-added fields on the project's entry (e.g. headers). - settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url }; + // Merge: keep any user-added fields on the project's entry (e.g. headers), + // and tag it so the /mcpctl switcher can find the single *active* project. + settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url, mcpctlManaged: true }; + + // prime-agent loads every mcpServers entry, so only ONE mcpctl project should + // be active at a time. Remove any *other* mcpctl-managed project entries we + // previously installed, but preserve untagged servers (e.g. a hand-configured + // `sre`, websearch, etc) so switching never nukes unrelated integrations. + const removed: string[] = []; + for (const k of Object.keys(settings.mcpServers)) { + if (k === project) continue; + const entry = settings.mcpServers[k]; + if (entry && typeof entry === 'object' && (entry as Record)['mcpctlManaged'] === true) { + delete settings.mcpServers[k]; + removed.push(k); + } + } const totalServers = Object.keys(settings.mcpServers).length; await mkdir(dirname(settingsPath), { recursive: true }); await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); - return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers }; + return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers, removed }; } /** * Ensure `mcp:` carries `{ type: "api_key", key }` in * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` * provider credential, other `mcp:*` servers, etc). + * + * auth.json holds bearer tokens, so it is written 0600 (preserving an existing + * file's mode if present) — never the default umask. */ export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { const current = await loadPrimeAgentAuth(authPath); current[`mcp:${project}`] = { type: 'api_key', key }; await mkdir(dirname(authPath), { recursive: true }); - // auth.json is 0600 normally; preserve an existing mode if present. - await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', 'utf-8'); + // Preserve an existing 0600 mode; always 0600 on first creation. + let mode: number | undefined; + try { + const s = await stat(authPath); + mode = s.mode; + } catch { + mode = 0o600; + } + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode }); } -/** Load auth.json; missing/corrupt (non-JSON) treated as a fresh file. */ +/** + * Load auth.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it (the same + * guarantee as settings.json — one syntax error must not destroy every + * credential, including the provider API key). + */ async function loadPrimeAgentAuth(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 raw = await readFile(path, 'utf-8'); - if (raw.trim().length === 0) return {}; const parsed = JSON.parse(raw) as Record; return typeof parsed === 'object' && parsed !== null ? parsed : {}; - } catch { - return {}; + } catch (err: unknown) { + throw new Error(`auth file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); } } -/** Does the project already have a credential in auth.json? */ +/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { - try { - const raw = await readFile(authPath, 'utf-8'); - const parsed = JSON.parse(raw) as Record; - const entry = parsed?.[`mcp:${project}`]; - return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); - } catch { - return false; - } + const parsed = await loadPrimeAgentAuth(authPath) as Record; + const entry = parsed[`mcp:${project}`]; + return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); } async function pathExists(p: string): Promise { diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 1f9c7d5..ed2d993 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -61,6 +61,7 @@ describe('config prime-agent', () => { expect(written.mcpServers['homeautomation']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, + mcpctlManaged: true, }); expect(output.join('\n')).toContain('homeautomation'); }); @@ -86,6 +87,7 @@ describe('config prime-agent', () => { expect(written.mcpServers['proj-1']).toEqual({ type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, + mcpctlManaged: true, }); }); @@ -216,6 +218,45 @@ describe('config prime-agent', () => { expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull(); }); + it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(output.join('\n')).toContain('refusing to overwrite'); + expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json'); + expect(process.exitCode).toBe(1); + }); + + it('exits non-zero when a credential cannot be provisioned', async () => { + // mockClient post returns { token: ... } by default; override to no token. + const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client; + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + expect(process.exitCode).toBe(1); + // body of provisioning error surfaced + expect(output.join('\n')).toContain('no token returned'); + }); + + it('writes auth.json with mode 0600', 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', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token + const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777; + expect(mode).toBe(0o600); + }); + it('refuses to overwrite a corrupt settings.json', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, '{ this is not valid json !!!'); @@ -235,6 +276,27 @@ describe('config prime-agent', () => { expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!'); }); + it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set + homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, + }, + })); + // Active project is homeautomation (tagged). Switch to labctl. + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active + expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed + expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved + }); + it('merges a re-configured project entry, preserving user-added fields', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ @@ -253,6 +315,7 @@ describe('config prime-agent', () => { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`, headers: { Authorization: 'Bearer u' }, // user-added field preserved + mcpctlManaged: true, }); }); }); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index a6b56e5..1fcf24b 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -183,4 +183,46 @@ describe('runPrimeAgentSkillsSync', () => { expect(result2.removed).toContain('old-skill'); expect(existsSync(join(installRoot, 'old-skill'))).toBe(false); }); + + it('does not overwrite a same-named skill owned by a different project', async () => { + // Project A installs skill X. + const av = [ + { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; + const clientA = mockClient({ visible: av, full: af }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + + // Project B also has a skill named X with different content. + const bv = [ + { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' }, + ]; + const bf = { 'b-1': { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# version-b\n', files: {} } }; + const clientB = mockClient({ visible: bv, full: bf }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + expect(resultB.preserved).toContain('x-skill'); + // A's version is untouched (not clobbered by B's). + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + }); + + it('removes global orphans on a global-only sync', async () => { + // First sync a global skill. + const v = [ + { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' }, + ]; + const f = { 'g-1': { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# g\n', files: {} } }; + const client1 = mockClient({ visible: v, full: f }); + const empty = join(tmpDir, 'empty2'); + mkdirSync(empty, { recursive: true }); + await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client1)); + expect(existsSync(join(installRoot, 'gone-global'))).toBe(true); + + // Next global-only sync: the global is gone from the visible set. + const client2 = mockClient({ visible: [], full: {} }); + const result2 = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client2)); + expect(result2.removed).toContain('gone-global'); + expect(existsSync(join(installRoot, 'gone-global'))).toBe(false); + }); }); From 170dc06496d5d3843ffcf1f03bfe479f9acb9d5e Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 12:01:52 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix(cli):=20close=20third=20review=20?= =?UTF-8?q?=E2=80=94=20token=20collision,=20migration,=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- README.md | 22 +- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 2 + src/cli/src/commands/config.ts | 178 +++++++++++--- src/cli/src/commands/skills.ts | 62 +++-- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/src/config/prime-agent.ts | 118 +++++++-- src/cli/tests/commands/prime-agent.test.ts | 227 +++++++++++++++++- .../tests/utils/prime-agent-skills.test.ts | 95 ++++++++ 9 files changed, 629 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 8d781b9..28d99d1 100644 --- a/README.md +++ b/README.md @@ -125,14 +125,19 @@ mcpctl config prime-agent --project monitoring This: -1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as +1. Provisions the project's bearer credential in `~/.prime/agent/auth.json` + (`mcp:monitoring`, written 0600) — either from `--token `, an existing + entry that is still active server-side, or a freshly minted project token. + This happens first: if no credential can be provisioned the command stops + here with a non-zero exit and leaves `settings.json` alone, so the project + you are currently on keeps working. +2. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` - (merging with any existing servers and preserving all other settings). -2. Provisions the project's bearer credential in `~/.prime/agent/auth.json` - (`mcp:monitoring`) — either from `--token `, an existing entry, or an - auto-minted project token. + (merging with any existing servers and preserving all other settings), and + unmounts the previously active mcpctl project so exactly one is live. + Servers you configured by hand are never touched. 3. Writes a `.mcpctl-project` marker (only if none exists higher up, and never - from `$HOME`) so later syncs resolve the project. + from `$HOME`) so later syncs resolve the project. Skip with `--skip-marker`. 4. Syncs the project's skills into `~/.prime/agent/skills//` as markdown skills. The shared tree is ownership-tracked per project: it never deletes another project's skills or an untracked hand-authored skill. @@ -152,8 +157,13 @@ Skip individual steps as needed: mcpctl config prime-agent --project monitoring --token mcpctl_pat_xxx # provide token, don't mint mcpctl config prime-agent --project monitoring --skip-skills # don't sync skills mcpctl config prime-agent --project monitoring --skip-extension # don't install /mcpctl switcher +mcpctl config prime-agent --project monitoring --skip-marker # don't touch .mcpctl-project here ``` +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. + Preview the change without writing anything: ```bash diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 0bbaf18..0e2be01 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -125,10 +125,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; prime-agent) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; prime-agent-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --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 31867cb..325719f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -303,6 +303,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -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" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent-generate options @@ -312,6 +313,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l ga complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +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 impersonate options diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 864ed35..140803f 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -17,11 +17,21 @@ import { primeAgentSettingsPath, DEFAULT_MCPCTL_GATEWAY_URL, writePrimeAgentAuth, - hasPrimeAgentAuth, + readPrimeAgentAuthKey, + mcpTokenPrefixOf, + isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; +/** + * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint + * gets a unique `-` name because `McpToken` is unique on + * (name, projectId) and revoke is a soft delete — a fixed name could only ever + * be minted once per project. + */ +const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; + interface McpConfig { mcpServers: Record }>; } @@ -215,6 +225,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--token ', 'mcpctl project bearer token to store in auth.json (skips auto-minting)') .option('--skip-skills', 'Skip the skills sync step') .option('--skip-extension', 'Do not install the /mcpctl project-switcher extension') + .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; @@ -223,6 +234,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: token?: string; skipSkills?: boolean; skipExtension?: boolean; + skipMarker?: boolean; dryRun?: boolean; }) => { if (opts.project === undefined || opts.project === '') { @@ -245,55 +257,49 @@ export function createConfigCommand(deps?: Partial, apiDeps?: authPath, mcpServers: { [opts.project]: { type: 'http', url } }, extension: opts.skipExtension === true ? '' : extPath, + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), }, - action: 'write settings.json + write auth.json credential + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', }, null, 2); log(dry); return; } - // 1. Register the proxy MCP gateway (merge; never destroy settings). - try { - const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); - log(reg.created - ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` - : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); - } catch (err: unknown) { - log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); - process.exitCode = 1; - return; - } - - // 2. Provision the bearer credential prime-agent needs for this project. + // 1. Provision the bearer credential prime-agent needs for this project. // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must // store an mcp: token in auth.json. Use --token if given, - // keep an existing one, otherwise mint it via the API. A switch with - // no usable credential is a FAILURE (exit != 0) so the /mcpctl - // extension does not report success after leaving a project bare. + // keep a still-valid existing one, otherwise mint it via the API. + // + // This runs BEFORE settings.json is touched: registering the new + // project unmounts the previously active one, so a mint failure must + // not be able to leave prime-agent with no working project at all. + // A switch with no usable credential is a FAILURE (exit != 0) so the + // /mcpctl extension does not report success over a bare project. let provisioned = false; try { + // Whatever this auth.json held before we touched it — the only token + // this run is entitled to retire once it has a replacement. + const staleKey = await readPrimeAgentAuthKey(opts.project, authPath); if (opts.token !== undefined && opts.token !== '') { await writePrimeAgentAuth(opts.project, opts.token, authPath); log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; - } else if (await hasPrimeAgentAuth(opts.project, authPath)) { + // 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); + } + } else if (await hasUsableCredential(opts.project, staleKey)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); provisioned = true; } else if (skillsClient) { - // Revoke any prior active `prime-agent` token for this project - // first (tokens are immutable + shown once), so we never litter - // never-expiring tokens on repeated reprovisioning. - const list = await skillsClient - .get | unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project)}`) - .catch(() => []); - const existing = Array.isArray(list) ? list : []; - for (const t of existing) { - if (t.name === 'prime-agent' && t.status === 'active') { - try { await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); } catch { /* best-effort */ } - } - } + // Mint under a fresh, unique name. `McpToken` is unique on + // (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', + name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`, projectName: opts.project, ttl: 'never', description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, @@ -302,6 +308,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: await writePrimeAgentAuth(opts.project, minted.token, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; + await retireSupersededToken(opts.project, staleKey, minted.token); } else { log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } @@ -313,15 +320,37 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } if (!provisioned) { process.exitCode = 1; + log(`Aborted: leaving ${settingsPath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Register the proxy MCP gateway (merge; never destroy settings). + try { + const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl, { authPath }); + log(reg.created + ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` + : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); + if (reg.removed.length > 0) { + log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`); + } + } catch (err: unknown) { + log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; } // 3. Write the .mcpctl-project marker so later `skills sync` calls can // resolve the project. An explicit -p is authoritative: it updates a // differing up-tree marker (so the scope doesn't silently revert on // the next sync), is a no-op when it already matches, and never - // scopes $HOME itself. + // scopes $HOME itself. `--skip-marker` opts out entirely: the + // /mcpctl switcher runs this command from whatever directory + // prime-agent happens to be started in, and must not silently + // re-scope an unrelated repo that Claude Code's own sync reads. try { - if (process.cwd() !== homedir()) { + 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 === opts.project) { log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); @@ -375,6 +404,87 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (hidden) { 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); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index f5ac265..f0f9770 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -192,14 +192,19 @@ 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') : defaultStatePath()); 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') : join(homeDir, '.claude', 'skills')); @@ -243,13 +248,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { try { // If on-disk files were locally modified, preserve unless --force. @@ -346,15 +366,17 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise {\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/**\n * The single *active* mcpctl project is the mcpServers entry that carries the\n * `mcpctlManaged: true` tag (written by `config prime-agent`). Untagged entries\n * (e.g. a hand-configured `sre`, websearch) are never treated as the active\n * mcpctl project, avoiding false short-circuits.\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 for (const name of Object.keys(settings.mcpServers)) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\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 await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\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 await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\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\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\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 await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n"; diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts index 1889cb3..2e4c60e 100644 --- a/src/cli/src/config/prime-agent.ts +++ b/src/cli/src/config/prime-agent.ts @@ -17,13 +17,16 @@ * - A project's existing `mcpServers` entry is merged (user-added fields are * kept), never replaced wholesale. */ -import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, stat, chmod } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; /** Base URL of the deployed mcpctl HTTP MCP gateway. */ export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu'; +/** Every mcpctl bearer token starts with this (see `@mcpctl/shared` generateToken). */ +const MCPCTL_TOKEN_PREFIX = 'mcpctl_pat_'; + /** Resolve the prime-agent settings.json path. */ export function primeAgentSettingsPath(homeDir: string = homedir()): string { return join(homeDir, '.prime', 'agent', 'settings.json'); @@ -72,6 +75,61 @@ export async function loadPrimeAgentSettings(path: string): Promise/mcp` + * proxy URL **and** auth.json holds an `mcp:` mcpctl PAT. Older CLIs + * wrote the entry + credential pair but no tag; without adopting them a + * project switch would leave two gateways mounted at once and the `/mcpctl` + * switcher would report no active project. + * + * A hand-configured server never has an mcpctl PAT stored under `mcp:`, + * so it is never adopted — that pairing is what makes the legacy match safe. + */ +export function isMcpctlManagedEntry( + name: string, + entry: unknown, + authKeys: ReadonlySet, +): boolean { + if (entry === null || typeof entry !== 'object') return false; + const rec = entry as Record; + if (rec['mcpctlManaged'] === true) return true; + const url = rec['url']; + if (typeof url !== 'string') return false; + // Host-agnostic: adopt regardless of which gateway the old entry pointed at. + const canonical = new RegExp(`/projects/${escapeRegExp(encodeURIComponent(name))}/mcp/*$`); + return canonical.test(url) && authKeys.has(name); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Names of the projects auth.json holds an mcpctl PAT for (`mcp:`). + * Used to recognise entries an older, tag-less CLI wrote. A missing or corrupt + * auth.json yields an empty set — adoption then simply doesn't happen. + */ +export async function primeAgentAuthProjects(authPath: string): Promise> { + let parsed: Record; + try { + parsed = await loadPrimeAgentAuth(authPath); + } catch { + return new Set(); + } + const out = new Set(); + for (const [k, v] of Object.entries(parsed)) { + if (!k.startsWith('mcp:')) continue; + const key = (v as { key?: unknown } | null)?.key; + if (typeof key === 'string' && key.startsWith(MCPCTL_TOKEN_PREFIX)) out.add(k.slice(4)); + } + return out; +} + export interface RegisterMcpResult { settingsPath: string; created: boolean; // true if the settings file did not previously exist @@ -93,6 +151,7 @@ export async function registerPrimeAgentMcp( project: string, settingsPath: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, + opts: { authPath?: string } = {}, ): Promise { const existed = await pathExists(settingsPath); const settings = await loadPrimeAgentSettings(settingsPath); @@ -110,13 +169,16 @@ export async function registerPrimeAgentMcp( // prime-agent loads every mcpServers entry, so only ONE mcpctl project should // be active at a time. Remove any *other* mcpctl-managed project entries we - // previously installed, but preserve untagged servers (e.g. a hand-configured + // previously installed — including the untagged ones older CLIs wrote (see + // isMcpctlManagedEntry) — but preserve hand-configured servers (a bespoke // `sre`, websearch, etc) so switching never nukes unrelated integrations. + const authKeys = opts.authPath !== undefined + ? await primeAgentAuthProjects(opts.authPath) + : new Set(); const removed: string[] = []; for (const k of Object.keys(settings.mcpServers)) { if (k === project) continue; - const entry = settings.mcpServers[k]; - if (entry && typeof entry === 'object' && (entry as Record)['mcpctlManaged'] === true) { + if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) { delete settings.mcpServers[k]; removed.push(k); } @@ -134,22 +196,19 @@ export async function registerPrimeAgentMcp( * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` * provider credential, other `mcp:*` servers, etc). * - * auth.json holds bearer tokens, so it is written 0600 (preserving an existing - * file's mode if present) — never the default umask. + * auth.json holds never-expiring bearer tokens, so it always ends up 0600 — + * never the default umask. `writeFile`'s `mode` only applies when the file is + * created, and prime-agent itself creates auth.json 0644, so we chmod after + * writing rather than trusting the open flags. */ export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { const current = await loadPrimeAgentAuth(authPath); current[`mcp:${project}`] = { type: 'api_key', key }; await mkdir(dirname(authPath), { recursive: true }); - // Preserve an existing 0600 mode; always 0600 on first creation. - let mode: number | undefined; + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 }); try { - const s = await stat(authPath); - mode = s.mode; - } catch { - mode = 0o600; - } - await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode }); + await chmod(authPath, 0o600); + } catch { /* best-effort: a credential written is better than one refused */ } } /** @@ -176,11 +235,36 @@ async function loadPrimeAgentAuth(path: string): Promise } } -/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ -export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { +/** + * The credential currently stored for `project`, or null if there is none. + * Throws on corrupt JSON (the caller must refuse to overwrite the file). + */ +export async function readPrimeAgentAuthKey(project: string, authPath: string): Promise { const parsed = await loadPrimeAgentAuth(authPath) as Record; const entry = parsed[`mcp:${project}`]; - return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0); + if (entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0) { + return entry.key; + } + return null; +} + +/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ +export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { + return (await readPrimeAgentAuthKey(project, authPath)) !== null; +} + +/** + * The displayable prefix mcpd records for a raw token (`tokenPrefix` on + * McpToken): the first 16 characters. Lets us match a stored credential against + * the server's token list without ever sending the secret. + */ +export function mcpTokenPrefixOf(raw: string): string { + return raw.slice(0, 16); +} + +/** Is this string shaped like an mcpctl PAT (and therefore checkable server-side)? */ +export function isMcpctlToken(raw: string): boolean { + return raw.startsWith(MCPCTL_TOKEN_PREFIX); } async function pathExists(p: string): Promise { diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index ed2d993..6688d4e 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -297,6 +297,231 @@ describe('config prime-agent', () => { expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved }); + it('adopts an untagged entry an older CLI wrote, keeping hand-configured ones', async () => { + // Written by a CLI that predates `mcpctlManaged`: an untagged entry whose + // URL is canonical AND a matching mcp: PAT in auth.json. + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + legacy: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/legacy/mcp` }, + websearch: { type: 'http', url: 'https://search.example/mcp' }, // hand-configured + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // canonical URL, no credential + }, + })); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + itaz: { type: 'api_key', key: 'sk-provider' }, + 'mcp:legacy': { type: 'api_key', key: 'mcpctl_pat_legacytoken1234' }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['legacy']).toBeUndefined(); // adopted + unmounted + expect(written.mcpServers['websearch']).toBeDefined(); // unrelated, preserved + expect(written.mcpServers['sre']).toBeDefined(); // no PAT → hand-set, preserved + expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); + }); + + it('mints each credential under a unique name (never a fixed one)', 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', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const body = client.post.mock.calls.find((c) => c[0] === '/api/v1/mcptokens')?.[1] as { name: string }; + // A fixed name can only ever be minted once: McpToken is unique on + // (name, projectId) and revoke is a soft delete. + expect(body.name).not.toBe('prime-agent'); + expect(body.name).toMatch(/^prime-agent-[a-z0-9-]+$/); + }); + + it('revokes the token it replaced, only after the replacement is stored', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const authPath = join(tmpDir, 'auth.json'); + writeFileSync(authPath, JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_oldtoken00000' }, + })); + const order: string[] = []; + const api = { + get: vi.fn(async (url: string) => { + order.push(`get ${url}`); + return [ + { id: 'tok-old', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_oldto' }, + { id: 'tok-other', name: 'ci-runner', status: 'active', tokenPrefix: 'mcpctl_pat_ci000' }, + ]; + }), + post: vi.fn(async (url: string) => { + order.push(`post ${url}`); + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + // Explicitly replace the stored credential. + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_supplied00000'], { from: 'user' }); + + // The new credential landed on disk... + expect(JSON.parse(readFileSync(authPath, 'utf-8'))['mcp:p'].key).toBe('mcpctl_pat_supplied00000'); + // ...before the token it replaced was revoked — never the other way round. + const revokeAt = order.indexOf('post /api/v1/mcptokens/tok-old/revoke'); + expect(revokeAt).toBeGreaterThanOrEqual(0); + expect(statSync(authPath).mtimeMs).toBeGreaterThan(0); + // Tokens this auth.json never held are reported, never revoked. + expect(order).not.toContain('post /api/v1/mcptokens/tok-other/revoke'); + }); + + it('never revokes a token this auth.json did not hold', async () => { + // A run against a custom --output (or a second machine) must not touch the + // credential the real install is using. + const settingsPath = join(tmpDir, 'settings.json'); + const api = { + get: vi.fn(async () => [ + { id: 'tok-elsewhere', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_elsew' }, + ]), + post: vi.fn(async (url: string) => (url === '/api/v1/mcptokens' ? { token: 'mcpctl_pat_brandnew0000' } : {})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const revokes = api.post.mock.calls.filter((c) => String(c[0]).includes('/revoke')); + expect(revokes).toEqual([]); + // ...but the user is told about it rather than left guessing. + expect(output.join('\n')).toContain('prime-agent-abc'); + }); + + it('leaves settings.json untouched when the credential cannot be provisioned', async () => { + // The active project must keep working when a switch fails: registering the + // new project unmounts the old one, so it may not run before the mint. + const settingsPath = join(tmpDir, 'settings.json'); + const before = JSON.stringify({ + mcpServers: { + homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, + }, + }); + writeFileSync(settingsPath, before); + const badClient = { ...client, get: vi.fn(async () => []), post: vi.fn(async () => ({})) } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(process.exitCode).toBe(1); + expect(readFileSync(settingsPath, 'utf-8')).toBe(before); + }); + + it('re-mints when the stored credential is no longer active', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_revoked000000' }, + })); + const api = { + get: vi.fn(async () => [ + { id: 'tok-1', name: 'prime-agent-old', status: 'revoked', tokenPrefix: 'mcpctl_pat_revo' }, + ]), + post: vi.fn(async () => ({ token: 'mcpctl_pat_fresh0000000' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:p'].key).toBe('mcpctl_pat_fresh0000000'); + expect(process.exitCode).toBe(0); + }); + + it('keeps a stored credential that is still active', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_liveaaaaaaaa' }, + })); + const api = { + get: vi.fn(async () => [ + // mcpd records the first 16 chars of the raw token as tokenPrefix. + { id: 'tok-1', name: 'prime-agent-x', status: 'active', tokenPrefix: 'mcpctl_pat_livea' }, + ]), + post: vi.fn(async () => ({ token: 'should-not-be-minted' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(api.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:p'].key).toBe('mcpctl_pat_liveaaaaaaaa'); + }); + + it('tightens a pre-existing 0644 auth.json to 0600', async () => { + // prime-agent creates auth.json itself with the default umask; writeFile's + // `mode` is ignored for an existing file, so the write must chmod. + const settingsPath = join(tmpDir, 'settings.json'); + const authPath = join(tmpDir, 'auth.json'); + writeFileSync(authPath, JSON.stringify({ itaz: { type: 'api_key', key: 'sk-x' } }), { mode: 0o644 }); + chmodSync(authPath, 0o644); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(statSync(authPath).mode & 0o777).toBe(0o600); + // The provider credential is still there. + expect(JSON.parse(readFileSync(authPath, 'utf-8')).itaz.key).toBe('sk-x'); + }); + + it('--skip-marker leaves the current directory alone', async () => { + // The /mcpctl switcher runs from whatever directory prime-agent started in. + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills', '--skip-extension', '--skip-marker', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull(); + }); + + it('the installed switcher extension passes --skip-marker', 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'); + expect(ext).toContain("'--skip-extension', '--skip-marker'"); + }); + it('merges a re-configured project entry, preserving user-added fields', async () => { const settingsPath = join(tmpDir, 'settings.json'); writeFileSync(settingsPath, JSON.stringify({ diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts index 1fcf24b..284c346 100644 --- a/src/cli/tests/utils/prime-agent-skills.test.ts +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -207,6 +207,101 @@ describe('runPrimeAgentSkillsSync', () => { expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); }); + it('does not delete legacy, ownership-less state belonging to another project', async () => { + // State written by a CLI that predates the `project` field: the skill has + // no recorded owner and the file records projA as the last syncing project. + const legacyDir = join(installRoot, 'legacy-skill'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8'); + writeFileSync(statePath, JSON.stringify({ + schemaVersion: 1, + lastSync: '2026-01-01T00:00:00.000Z', + lastSyncProject: 'projA', + skills: { + 'legacy-skill': { + id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project', + installDir: legacyDir, files: {}, postInstallHash: null, + lastSyncedAt: '2026-01-01T00:00:00.000Z', + // note: no `project` field + }, + }, + }), 'utf-8'); + + // First sync after upgrading, for a *different* project. + const client = mockClient({ visible: [], full: {} }); + const result = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(client)); + + expect(result.removed).toEqual([]); + expect(existsSync(legacyDir)).toBe(true); + }); + + it('cleans up legacy state once the owning project syncs again', async () => { + const legacyDir = join(installRoot, 'legacy-skill'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8'); + writeFileSync(statePath, JSON.stringify({ + schemaVersion: 1, + lastSync: '2026-01-01T00:00:00.000Z', + lastSyncProject: 'projA', + skills: { + 'legacy-skill': { + id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project', + installDir: legacyDir, files: {}, postInstallHash: null, + lastSyncedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), 'utf-8'); + + const client = mockClient({ visible: [], full: {} }); + const result = await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(client)); + + expect(result.removed).toContain('legacy-skill'); + expect(existsSync(legacyDir)).toBe(false); + }); + + it('keeps global skills updatable after switching projects', async () => { + // A global installed while projA was active must not be pinned to projA — + // globals are visible from every project. + const gv = (hash: string) => [ + { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, metadata: {}, scope: 'global' }, + ]; + const gf = (hash: string, body: string) => ({ + 'g-1': { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, content: body, files: {} }, + }); + + const clientA = mockClient({ visible: gv('sha256:v1'), full: gf('sha256:v1', '# v1\n') }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect((await loadState(statePath)).skills['shared-global']?.project).toBeNull(); + + // Switch to projB; the global has been updated server-side. + const clientB = mockClient({ visible: gv('sha256:v2'), full: gf('sha256:v2', '# v2\n') }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + expect(resultB.updated).toContain('shared-global'); + expect(resultB.preserved).toEqual([]); + expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toBe('# v2\n'); + }); + + it('does not let a global-only sync clobber a project-owned skill', async () => { + const av = [ + { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(mockClient({ visible: av, full: af }))); + + // A global of the same name shows up on a global-only sync. + const gv = [ + { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' }, + ]; + const gf = { 'g-9': { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# global\n', files: {} } }; + const empty = join(tmpDir, 'empty3'); + mkdirSync(empty, { recursive: true }); + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf }))); + + expect(result.preserved).toContain('x-skill'); + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n'); + }); + it('removes global orphans on a global-only sync', async () => { // First sync a global skill. const v = [ From ce7df10e06decf00720f932a808204b50ccece82 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 12:18:21 +0100 Subject: [PATCH 5/5] feat(cli): show the active mcpctl project in prime-agent's footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/mcpctl` could switch projects but there was no way to see which one was active without running a command. prime-agent exposes the same status-bar channel the model name uses (`ctx.ui.setStatus`), so the switcher now publishes `mcpctl:` there. Wired to `session_start`, which fires on startup *and* on every reload — including the reload the switch itself triggers — so the footer tracks settings.json without extra bookkeeping. Cleared when no project is mounted. Also fixes `notify(..., 'success')`: the API only accepts info|warning|error. Not a live bug (prime-agent falls through to the same showStatus path as 'info') but it fails a typecheck of the extension against the real ExtensionAPI, which is how it was found. The extension ships as a JSON-escaped string and is never compiled by our build, so it was typechecked out-of-tree against @earendil-works/pi-coding-agent's published types. 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 | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 7bc2510..6509cbd 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\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\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 await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\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 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"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 6688d4e..9d8a627 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -510,6 +510,24 @@ describe('config prime-agent', () => { expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull(); }); + it('the installed switcher extension publishes the active project to the footer', 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'); + // Footer status, refreshed on startup and on every reload (which is what + // the switch itself triggers) — the mcpctl equivalent of the model name. + expect(ext).toContain("pi.on('session_start'"); + expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + expect(ext).toContain('`mcpctl:${active}`'); + // notify() only accepts info|warning|error — 'success' is not a valid type. + expect(ext).not.toContain("'success'"); + }); + it('the installed switcher extension passes --skip-marker', async () => { const settingsPath = join(tmpDir, 'settings.json'); const cmd = createConfigCommand(