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); + }); });