diff --git a/README.md b/README.md index e12537e..db6d3ec 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,37 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re mcpctl console monitoring # Preview what Claude sees ``` +### Connect prime-agent + +Prime-agent (Claude's open-source counterpart) talks to the same proxy MCP +gateway over HTTP rather than stdio. Register a project and sync its skills +into `~/.prime/agent/`: + +```bash +mcpctl config prime-agent --project monitoring +``` + +This: + +1. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as + `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` + (merging with any existing servers — the bundled `sre` project is preserved). +2. Writes a `.mcpctl-project` marker so later syncs resolve the project. +3. Syncs the project's skills into `~/.prime/agent/skills//` as markdown + skills (prime-agent auto-discovers them at session start). + +Re-sync later with: + +```bash +mcpctl skills sync --agent prime-agent --project monitoring +``` + +Preview the change without writing anything: + +```bash +mcpctl config prime-agent --project monitoring --dry-run +``` + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index b544435..9c6d22b 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -124,6 +124,12 @@ _mcpctl() { claude-generate) COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; + prime-agent) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + ;; + prime-agent-generate) + COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --skip-skills --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; @@ -378,7 +384,7 @@ _mcpctl() { else case "$skills_sub" in sync) - COMPREPLY=($(compgen -W "-p --project --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --agent --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) ;; *) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index ff85934..972cf97 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -239,7 +239,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Manage Claude Code skill bundles synced from mcpd' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -267,13 +267,15 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration to defaults' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -294,6 +296,20 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +# config prime-agent options +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l dry-run -d 'Print the settings.json change without writing or syncing' + +# config prime-agent-generate options +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print the settings.json change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -495,10 +511,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk under ~/.claude/skills/' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default) or prime-agent' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by SessionStart hook)' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 44330ee..f3bc0cb 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -12,6 +12,12 @@ import type { ApiClient } from '../api-client.js'; import { writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; import { runSkillsSync } from './skills.js'; +import { + registerPrimeAgentMcp, + primeAgentSettingsPath, + DEFAULT_MCPCTL_GATEWAY_URL, +} from '../config/prime-agent.js'; +import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; interface McpConfig { mcpServers: Record }>; @@ -193,9 +199,97 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } } + // prime-agent: register our proxy MCP gateway in prime-agent's settings.json + // + sync the project's skills into prime-agent's skills tree. Mirror of the + // claude command above, but targeting ~/.prime/agent/ instead of .mcp.json. + function registerPrimeAgentCommand(name: string, hidden: boolean): void { + const cmd = config + .command(name) + .description(hidden ? '' : 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)') + .option('-p, --project ', 'Project name') + .option('-o, --output ', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--skip-skills', 'Skip the skills sync step') + .option('--dry-run', 'Print the settings.json change without writing or syncing') + .action(async (opts: { + project?: string; + output?: string; + gatewayUrl: string; + skipSkills?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + const settingsPath = resolve(opts.output ?? primeAgentSettingsPath()); + + if (opts.dryRun === true) { + const dry = JSON.stringify({ + primeAgent: { + settingsPath, + mcpServers: { + [opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` }, + }, + }, + action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + }, null, 2); + log(dry); + return; + } + + try { + const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl); + log(reg.created + ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` + : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); + } catch (err: unknown) { + log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // Write the project marker in cwd so later `skills sync` calls resolve scope. + try { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // Sync skills into prime-agent's skills tree (skippable). + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runPrimeAgentSkillsSync( + { project: opts.project }, + { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } + } catch (err: unknown) { + log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)'); + } + } + }); + if (hidden) { + void cmd; + } + } + registerClaudeCommand('claude', false); registerClaudeCommand('claude-generate', true); // backward compat + registerPrimeAgentCommand('prime-agent', false); + registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + config.addCommand(createConfigSetupCommand({ configDeps })); if (apiDeps) { diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 627799d..edc4dbb 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -31,6 +31,7 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -441,11 +442,12 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { console.error(...(args as Parameters)); }; - const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk under ~/.claude/skills/') + .description('Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--agent ', 'Sync target: claude (default) or prime-agent', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)') @@ -453,12 +455,30 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') .action(async (opts: { project?: string; + agent?: string; dryRun?: boolean; force?: boolean; quiet?: boolean; skipPostinstall?: boolean; keepOrphans?: boolean; }) => { + if (opts.agent === 'prime-agent') { + const result = await runPrimeAgentSkillsSync( + { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), + ...(opts.force !== undefined ? { force: opts.force } : {}), + ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), + ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + }, + { client, log, warn }, + ); + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + } + return; + } + const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), diff --git a/src/cli/src/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts new file mode 100644 index 0000000..a4bff7f --- /dev/null +++ b/src/cli/src/config/prime-agent.ts @@ -0,0 +1,97 @@ +/** + * Read/merge/write helpers for prime-agent's own configuration files, used + * by `mcpctl config prime-agent`. + * + * prime-agent keeps two user-editable files under `~/.prime/agent/`: + * - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus + * model/provider preferences. `mcpctl config prime-agent` registers our + * proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`. + * - `auth.json` — per-server bearer tokens keyed as `mcp:`. + * + * We only ever merge the `mcpServers` map, preserving every other key and any + * servers the user has already configured (including non-mcpctl gateways like + * the bundled `sre` project). + */ +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +/** Base URL of the deployed mcpctl HTTP MCP gateway. */ +export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu'; + +/** Resolve the prime-agent settings.json path. */ +export function primeAgentSettingsPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'settings.json'); +} + +/** Proxy MCP URL for a given project on the gateway. */ +export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string { + const base = gatewayUrl.replace(/\/+$/, ''); + return `${base}/projects/${encodeURIComponent(project)}/mcp`; +} + +interface PrimeAgentSettings { + mcpServers?: Record; + [key: string]: unknown; +} + +/** Load prime-agent settings; return an empty object if absent/invalid. */ +export async function loadPrimeAgentSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as PrimeAgentSettings; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +export interface RegisterMcpResult { + settingsPath: string; + created: boolean; // true if the settings file did not previously exist + addedServer: string; + newServer: boolean; // true if the project's MCP entry was not already present + url: string; + totalServers: number; +} + +/** + * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the + * prime-agent settings file, preserving all other fields and servers. + * Returns a summary of what changed. + */ +async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +export async function registerPrimeAgentMcp( + project: string, + settingsPath: string, + gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, +): Promise { + const existed = await pathExists(settingsPath); + const settings = await loadPrimeAgentSettings(settingsPath); + + settings.mcpServers = settings.mcpServers ?? {}; + const url = projectMcpUrl(project, gatewayUrl); + const isNewServer = !Object.prototype.hasOwnProperty.call(settings.mcpServers, project); + settings.mcpServers[project] = { type: 'http', url }; + const totalServers = Object.keys(settings.mcpServers).length; + + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + + return { + settingsPath, + created: !existed, + addedServer: project, + newServer: isNewServer, + url, + totalServers, + }; +} diff --git a/src/cli/src/utils/prime-agent-skills.ts b/src/cli/src/utils/prime-agent-skills.ts new file mode 100644 index 0000000..9663bb4 --- /dev/null +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -0,0 +1,287 @@ +/** + * Prime-agent skill sync for `mcpctl config prime-agent`. + * + * Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`) + * but materialises server-side skills as *markdown* skills for prime-agent + * under `~/.prime/agent/skills//`. + * + * Why a separate module instead of parameterising `runSkillsSync`: + * - prime-agent skills carry no `hooks` (there is no SessionStart hook on + * the prime-agent side) and must never touch `~/.claude/settings.json`, + * so the hooks side-effect in `runSkillsSync` would be wrong here. + * - prime-agent skills have no `postInstall` scripts (server scripts assume + * a Claude-esque shell), so we skip that machinery too. + * + * The on-disk format is deliberately the same as what prime-agent already + * ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary + * `files`). prime-agent auto-discovers these at session start, so once the + * config command has pointed prime-agent at the proxy MCP and synced the + * project's skills, later `mcpctl skills sync --agent prime-agent` calls (or + * the config command itself) keep the tree up to date. + */ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import type { ApiClient } from '../api-client.js'; +import { ApiError } from '../api-client.js'; +import { findProjectMarker } from './project-marker.js'; +import { + loadState, + saveState, + detectModifiedFiles, + type SkillState, +} from './skills-state.js'; +import { + installSkillAtomic, + removeSkillAtomic, +} from './skills-disk.js'; + +/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */ +export function primeAgentSkillsRoot(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'skills'); +} + +/** State bookkeeping lives separately from the Claude skills state. */ +export function primeAgentStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json'); +} + +/** Shape of a server-side visible skill (subset we act on). */ +interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; +} + +/** Full skill body fetched from /api/v1/skills/:id (subset we install). */ +interface FullSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + content: string; + files: Record; +} + +export interface PrimeAgentSyncOpts { + /** Project name; otherwise resolved from the .mcpctl-project marker. */ + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + keepOrphans?: boolean; + /** For tests: override cwd for the marker walk-up. */ + cwd?: string; + /** For tests: override the prime-agent skills root. */ + installRoot?: string; + /** For tests: override the state file path. */ + statePath?: string; + /** For tests: override $HOME used for default paths. */ + homeDir?: string; +} + +export interface PrimeAgentSyncResult { + installed: string[]; + updated: string[]; + skipped: string[]; + removed: string[]; + preserved: string[]; + errors: Array<{ skill: string; error: string }>; + exitCode: 0 | 1 | 2; +} + +export interface PrimeAgentSyncDeps { + client: ApiClient; + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +} + +/** + * Sync the active project's skills into prime-agent's markdown skills tree. + * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 + * disk/state error. + */ +export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise { + const { client, log, warn } = deps; + const result: PrimeAgentSyncResult = { + installed: [], + updated: [], + skipped: [], + removed: [], + preserved: [], + errors: [], + exitCode: 0, + }; + + // 1. Resolve project scope (explicit flag beats the marker walk-up). + let projectName = opts.project; + if (projectName === undefined || projectName === '') { + const marker = await findProjectMarker(opts.cwd ?? process.cwd(), opts.homeDir ?? homedir()); + if (marker) projectName = marker.project; + } + + // 2. Fetch the visible skill list. + let visible: VisibleSkill[]; + try { + if (projectName !== undefined) { + visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); + } else { + visible = await client.get('/api/v1/skills?scope=global'); + } + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 401) { + warn('mcpctl: auth failed — run `mcpctl login`'); + result.exitCode = 1; + return result; + } + if (opts.quiet === true) { + // Fail-open in quiet mode so a hung mcpd never blocks agent startup. + warn(`mcpctl: prime-agent skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 0; + return result; + } + throw err; + } + + // Agent-scoped skills aren't surfaced to a user's prime-agent session. + visible = visible.filter((s) => s.scope !== 'agent'); + + // 3. Load state + resolve install root. + const statePath = opts.statePath ?? primeAgentStatePath(opts.homeDir ?? homedir()); + const state = await loadState(statePath); + const installRoot = opts.installRoot ?? primeAgentSkillsRoot(opts.homeDir ?? homedir()); + + // 4. Diff against last sync. + const visibleByName = new Map(visible.map((s) => [s.name, s])); + const stateNames = Object.keys(state.skills); + + const toFetch: VisibleSkill[] = []; + for (const v of visible) { + const prior = state.skills[v.name]; + if (!prior) { + toFetch.push(v); + continue; + } + if (prior.contentHash === v.contentHash) { + result.skipped.push(v.name); + continue; + } + toFetch.push(v); + } + + // 5. Apply install/update (concurrency limit 5). + const concurrency = 5; + for (let i = 0; i < toFetch.length; i += concurrency) { + const batch = toFetch.slice(i, i + concurrency); + await Promise.all(batch.map((v) => applyOne(v))); + } + + // 6. Orphan removal. + if (opts.keepOrphans !== true) { + for (const name of stateNames) { + if (visibleByName.has(name)) continue; + const prior = state.skills[name]; + if (!prior) continue; + try { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0 && opts.force !== true) { + warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); + result.preserved.push(name); + continue; + } + if (opts.dryRun === true) { + result.removed.push(name); + continue; + } + await removeSkillAtomic(prior.installDir); + delete state.skills[name]; + result.removed.push(name); + } catch (err: unknown) { + result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); + } + } + } + + // 7. Persist state. + state.lastSync = new Date().toISOString(); + if (projectName !== undefined) state.lastSyncProject = projectName; + if (opts.dryRun !== true) { + try { + await saveState(state, statePath); + } catch (err: unknown) { + warn(`mcpctl: failed to persist prime-agent skills state — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 2; + } + } + + // 8. Summary. + const anythingHappened = + result.errors.length > 0 || + result.installed.length > 0 || + result.updated.length > 0 || + result.removed.length > 0; + if (opts.quiet !== true || anythingHappened) { + const parts: string[] = []; + if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); + if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); + if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); + if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); + if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); + if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); + if (parts.length === 0) parts.push('no changes'); + if (opts.quiet !== true) { + log(`mcpctl prime-agent skills sync${projectName !== undefined ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); + } else { + warn(`mcpctl: ${parts.join(', ')}`); + } + } + + return result; + + async function applyOne(v: VisibleSkill): Promise { + try { + const prior = state.skills[v.name]; + const targetDir = prior?.installDir ?? join(installRoot, v.name); + if (prior !== undefined && opts.force !== true) { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0) { + warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); + result.preserved.push(v.name); + return; + } + } + if (opts.dryRun === true) { + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + return; + } + + const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); + const files = await installSkillAtomic(targetDir, { + content: full.content, + ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), + }); + + const newState: SkillState = { + id: v.id, + semver: v.semver, + contentHash: v.contentHash, + scope: v.scope, + installDir: targetDir, + files, + postInstallHash: null, + lastSyncedAt: new Date().toISOString(), + }; + state.skills[v.name] = newState; + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + } catch (err: unknown) { + result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); + } + } +} diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts new file mode 100644 index 0000000..947b87e --- /dev/null +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; +import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config prime-agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + let prevCwd: string; + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-')); + // config prime-agent writes the .mcpctl-project marker into cwd, so run + // every test from an isolated temp dir to avoid polluting the repo. + prevCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + process.exitCode = 0; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('requires --project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(process.exitCode).toBe(1); + }); + + it('writes proxy MCP entry into prime-agent settings.json', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['homeautomation']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, + }); + expect(output.join('\n')).toContain('homeautomation'); + }); + + it('merges with existing servers and preserves other settings', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + defaultProvider: 'itaz', + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, + }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.defaultProvider).toBe('itaz'); // untouched + expect(written.mcpServers['sre']).toBeDefined(); // preserved + expect(written.mcpServers['proj-1']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, + }); + }); + + it('writes a project marker for later skills sync', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const markerPath = join(tmpDir, '.mcpctl-project'); + expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre'); + }); + + it('--dry-run prints the change without writing', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' }); + + expect(output.join('\n')).toContain('proj-2'); + // No file should have been created. + expect(exceptionSafeRead(settingsPath)).toBeNull(); + }); + + it('does not call the API when --skip-skills is set', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + expect(client.get).not.toHaveBeenCalled(); + }); + + it('backward compat: prime-agent-generate still works', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['proj-1']).toBeDefined(); + }); +}); + +function exceptionSafeRead(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/cli/tests/completions.test.ts b/src/cli/tests/completions.test.ts index 2e303ab..cfbfbf6 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -234,7 +234,7 @@ describe('agent + chat completions', () => { }); it('bash dispatches `create agent` with the correct flags', () => { - const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? ''; + const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? ''; expect(createBlock).toContain('--llm'); expect(createBlock).toContain('--system-prompt'); expect(createBlock).toContain('--default-temperature'); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts new file mode 100644 index 0000000..d696724 --- /dev/null +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js'; +import { loadState } from '../../src/utils/skills-state.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(overrides: Record = {}): ApiClient { + return { + get: vi.fn(async (url: string) => { + if (url.includes('/skills/visible')) { + return overrides['visible'] ?? []; + } + if (url.startsWith('/api/v1/skills/')) { + const id = url.split('/').pop() as string; + const full = (overrides['full'] as Record)?.[id]; + if (!full) throw new Error(`no full skill for ${id}`); + return full; + } + if (url.endsWith('/skills?scope=global')) { + return overrides['visible'] ?? []; + } + throw new Error(`unexpected get: ${url}`); + }), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +const SKILL_MD = `--- +name: sample-skill +description: A test skill synced into prime-agent. +--- + +# Sample Skill + +Body text. +`; + +describe('runPrimeAgentSkillsSync', () => { + let tmpDir: string; + let installRoot: string; + let statePath: string; + const log = (..._a: unknown[]) => {}; + const warn = (..._a: unknown[]) => {}; + + function deps(client: ApiClient) { + return { client, log, warn }; + } + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-pa-sync-')); + installRoot = join(tmpDir, 'skills'); + statePath = join(tmpDir, 'skills-state.json'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs a new markdown skill into the prime-agent skills root', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync( + { project: 'proj', installRoot, statePath }, + deps(client), + ); + + expect(result.installed).toEqual(['sample-skill']); + expect(result.errors).toEqual([]); + + const skillDir = join(installRoot, 'sample-skill'); + expect(existsSync(skillDir)).toBe(true); + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe(SKILL_MD); + + // State persisted so a re-sync is a no-op. + const state = await loadState(statePath); + expect(state.skills['sample-skill'].contentHash).toBe('sha256:h1'); + }); + + it('skips unchanged skills on re-sync', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.skipped).toEqual(['sample-skill']); + expect(result.installed).toEqual([]); + }); + + it('syncs the global set when no project is provided', async () => { + const visible = [ + { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', metadata: {}, scope: 'global' }, + ]; + const full = { + 'skill-2': { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', content: '# global\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + // Isolate cwd so no stray .mcpctl-project marker is discovered. + const empty = join(tmpDir, 'empty'); + mkdirSync(empty, { recursive: true }); + + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client)); + + expect(result.installed).toEqual(['global-skill']); + const getCalls = (client.get as ReturnType).mock.calls.map((c) => String(c[0])); + expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true); + }); +});