diff --git a/README.md b/README.md index 6a0ae84..3f7f839 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,64 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re mcpctl console monitoring # Preview what Claude sees ``` -### 7. Use mcpctl with pi (no MCP client, no Claude) +### 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. 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), 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. 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. +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: + +```bash +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 +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 +mcpctl config prime-agent --project monitoring --dry-run +``` + +### Connect pi [pi](https://github.com/earendil-works/pi) does not support MCP, but it supports **extensions** and **skills**. mcpctl ships a native pi integration that talks @@ -134,6 +191,7 @@ Inside pi: - `/mcpctl` — status, **switch project** (from the GUI), refresh tools, sync skills - gated projects auto-ungate: call `begin_session` once and the full tool set opens +- re-sync with `mcpctl skills sync --agent pi --project monitoring` See [docs/pi-extension.md](docs/pi-extension.md) for full details. diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b544435..0e2be01 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 --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 --skip-marker --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..325719f 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 + 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' @@ -294,6 +296,26 @@ 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 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 +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 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 complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -495,10 +517,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 ca826b2..9f0c263 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,7 +9,7 @@ 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 { installExtensionFiles, @@ -23,6 +23,25 @@ import { writePiProjectState, } from '../utils/pi-settings.js'; import { runSkillsSync } from './skills.js'; +import { + registerPrimeAgentMcp, + primeAgentSettingsPath, + DEFAULT_MCPCTL_GATEWAY_URL, + writePrimeAgentAuth, + 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 }>; @@ -204,6 +223,281 @@ 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 + 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('--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; + output?: string; + gatewayUrl: string; + token?: string; + skipSkills?: boolean; + skipExtension?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + 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, + authPath, + mcpServers: { [opts.project]: { type: 'http', url } }, + extension: opts.skipExtension === true ? '' : extPath, + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + }, null, 2); + log(dry); + return; + } + + // 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 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; + // 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) { + // 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_TOKEN_PREFIX}-${stamp}`, + projectName: opts.project, + ttl: 'never', + description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, + }); + if (typeof minted?.token === 'string' && minted.token.length > 0) { + await writePrimeAgentAuth(opts.project, minted.token, authPath); + 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`); + } + } else { + log('Error: no API client available to mint a project token — pass --token to provision auth.json'); + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + 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. `--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 (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}')`); + } 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)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 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 { + 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 || 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)`); + } + } 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)'); + } + } + + // 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; + } + + /** + * 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); registerClaudeCommand('claude-generate', true); // backward compat @@ -278,7 +572,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (!opts.skipSkills && skillsClient) { try { const result = await runSkillsSync( - { project: opts.project, installRoot: skillsInstall }, + { project: opts.project, target: 'pi', installRoot: skillsInstall }, { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, ); const total = result.installed.length + result.updated.length + result.removed.length; @@ -299,6 +593,10 @@ export function createConfigCommand(deps?: Partial, apiDeps?: log(`project will be registered automatically. Use /mcpctl to switch projects.`); }); + 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 b507154..7c53397 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, @@ -87,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' | 'pi'; } export interface SyncResult { @@ -120,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: [], @@ -173,10 +188,35 @@ 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 isPi = target === 'pi'; + // prime-agent and pi share the same skill semantics: their own flat skill + // tree + separate state file, no SessionStart hooks / postInstall, no + // mcpServers auto-attach. Only claude gets Claude-specific behaviour. + const isSharedTree = isPrimeAgent || isPi; + // Canonical ownership scope for a *skill*: null when the skill is global + // (globals are visible from every project, so pinning one to whichever + // project happened to sync it would lock every other project out of ever + // updating it), otherwise the project that installed it. + const ownerOf = (s: VisibleSkill): string | null => (s.scope === 'global' ? null : (projectName ?? null)); + const statePath = opts.statePath ?? (isPrimeAgent + ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') + : isPi + ? join(homeDir, '.mcpctl', 'skills-state-pi.json') + : defaultStatePath()); const state = await loadState(statePath); - const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills'); + // Which project last wrote this state file, captured before step 7 overwrites + // it. Skills tracked by a CLI that predates ownership recording carry no + // `project` field; this is the only evidence of who installed them. + const priorSyncProject = state.lastSyncProject; + const installRoot = opts.installRoot ?? (isPrimeAgent + ? join(homeDir, '.prime', 'agent', 'skills') + : isPi + ? join(homeDir, '.pi', 'agent', 'skills') + : join(homeDir, '.claude', 'skills')); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -205,12 +245,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 (isSharedTree && !ownsOrphan(prior)) continue; try { // Preserve user-modified skills — warn + skip. const modified = await detectModifiedFiles(prior.installDir, prior.files); @@ -224,8 +271,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { try { // If on-disk files were locally modified, preserve unless --force. @@ -290,6 +361,36 @@ 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/pi have 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 (!isSharedTree) { + 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 ── @@ -327,7 +432,16 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 && projectName) { + if (isSharedTree) { + // prime-agent/pi talk to the gateway over HTTP (or directly); + // 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 shared-tree agents, + // but say so instead of being silent. + if (mcpServerDeps.length > 0) { + warn(`mcpctl: skill '${v.name}' declares mcpServers but this agent's 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) { @@ -351,7 +465,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 @@ -418,6 +535,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise)); }; - const cmd = new Command('skills').description('Manage Agent-skill bundles synced from mcpd'); + const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk') + .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') - .option('--agent ', 'Sync target install root: claude (default), prime-agent, or pi', 'claude') + .option('--agent ', 'Sync target: claude (default), prime-agent, or pi', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') @@ -480,7 +598,14 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { skipPostinstall?: boolean; keepOrphans?: boolean; }) => { - const installRoot = agentInstallRoot(opts.agent); + // Validate --agent so an unknown value fails loudly instead of silently + // running the default (Claude) sync. + const agent = opts.agent ?? 'claude'; + if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') { + warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`); + process.exitCode = 1; + return; + } const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), @@ -489,7 +614,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - installRoot, + target: agent as 'claude' | 'prime-agent' | 'pi', + installRoot: agentInstallRoot(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..6509cbd --- /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\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/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts new file mode 100644 index 0000000..2e4c60e --- /dev/null +++ b/src/cli/src/config/prime-agent.ts @@ -0,0 +1,277 @@ +/** + * 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:`. + * + * 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, 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'); +} + +/** 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`; +} + +export interface PrimeAgentSettings { + mcpServers?: Record>; + [key: string]: unknown; +} + +/** + * 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 parsed = JSON.parse(raw) as PrimeAgentSettings; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } 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)})`); + } +} + +/** + * Is the `mcpServers` entry named `name` one that mcpctl installed? + * + * Two shapes count: + * - `mcpctlManaged: true` — written by this CLI (current releases tag every + * entry they write). + * - *untagged*, but the URL is exactly the canonical `/projects//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 + addedServer: string; + 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[]; +} + +/** + * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the + * 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, + opts: { authPath?: string } = {}, +): 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), + // 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 — 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; + if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) { + 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, 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 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 }); + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(authPath, 0o600); + } catch { /* best-effort: a credential written is better than one refused */ } +} + +/** + * 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 parsed = JSON.parse(raw) as Record; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } 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)})`); + } +} + +/** + * 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}`]; + 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 { + try { + await stat(p); + return true; + } catch { + return false; + } +} 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..4ff7cc0 --- /dev/null +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -0,0 +1,54 @@ +/** + * Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`. + * + * 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. + * + * 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 { 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'); +} + +/** 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'); +} + +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 { + 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 new file mode 100644 index 0000000..9d8a627 --- /dev/null +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -0,0 +1,572 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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'; +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`, + mcpctlManaged: true, + }); + 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`, + mcpctlManaged: true, + }); + }); + + 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 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', '--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 () => { + 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(); + }); + + 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 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 !!!'); + 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('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('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 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( + { 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({ + 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 + mcpctlManaged: true, + }); + }); +}); + +function exceptionSafeRead(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return 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/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..284c346 --- /dev/null +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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'; +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); + }); + + 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); + }); + + 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('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 = [ + { 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); + }); +});