fix(cli): harden config prime-agent sync + install /mcpctl switcher extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m6s
CI/CD / test (pull_request) Successful in 3m9s
CI/CD / build (pull_request) Successful in 2m16s
CI/CD / smoke (pull_request) Failing after 3m27s
CI/CD / publish (pull_request) Has been skipped

Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.

Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
  JSON instead of swallowing it and rewriting the file (which destroyed every
  non-mcpServers setting). A project's mcpServers entry is merged (keeping
  user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
  / hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
  via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
  a thin wrapper). Under the prime-agent target it: preserves untracked
  pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
  records per-project ownership so configuring a second project never deletes
  the first project's skills, skips Claude-only hooks/postInstall, and keeps
  the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
  auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
  (b) writes the .mcpctl-project marker only when none exists up-tree and never
  from $HOME, and (c) propagates the skills sync exit code so auth failures are
  reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
  silently running the Claude sync.

New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.

Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
This commit is contained in:
Michal
2026-08-08 10:22:46 +01:00
parent 582f6f185b
commit eb1642ab1a
12 changed files with 584 additions and 368 deletions

View File

@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
@@ -9,14 +9,17 @@ import { saveCredentials, loadCredentials } from '../auth/index.js';
import { createConfigSetupCommand } from './config-setup.js';
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js';
import { writeProjectMarker } from '../utils/project-marker.js';
import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.js';
import { installManagedSessionHook } from '../utils/sessionhook.js';
import { runSkillsSync } from './skills.js';
import {
registerPrimeAgentMcp,
primeAgentSettingsPath,
DEFAULT_MCPCTL_GATEWAY_URL,
writePrimeAgentAuth,
hasPrimeAgentAuth,
} from '../config/prime-agent.js';
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
interface McpConfig {
@@ -205,17 +208,21 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
function registerPrimeAgentCommand(name: string, hidden: boolean): void {
const cmd = config
.command(name)
.description(hidden ? '' : 'Register mcpctl proxy MCP + sync skills for prime-agent (~/.prime/agent)')
.description(hidden ? '' : 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)')
.option('-p, --project <name>', 'Project name')
.option('-o, --output <path>', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)')
.option('--gateway-url <url>', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL)
.option('--token <pat>', 'mcpctl project bearer token to store in auth.json (skips auto-minting)')
.option('--skip-skills', 'Skip the skills sync step')
.option('--dry-run', 'Print the settings.json change without writing or syncing')
.option('--skip-extension', 'Do not install the /mcpctl project-switcher extension')
.option('--dry-run', 'Print what would change without writing or syncing')
.action(async (opts: {
project?: string;
output?: string;
gatewayUrl: string;
token?: string;
skipSkills?: boolean;
skipExtension?: boolean;
dryRun?: boolean;
}) => {
if (opts.project === undefined || opts.project === '') {
@@ -225,21 +232,27 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
}
const settingsPath = resolve(opts.output ?? primeAgentSettingsPath());
const agentDir = dirname(settingsPath);
const authPath = join(agentDir, 'auth.json');
const extPath = join(agentDir, 'extensions', MCPCTL_SWITCH_EXTENSION_FILENAME);
const gatewayBase = opts.gatewayUrl.replace(/\/+$/, '');
const url = `${gatewayBase}/projects/${encodeURIComponent(opts.project)}/mcp`;
if (opts.dryRun === true) {
const dry = JSON.stringify({
primeAgent: {
settingsPath,
mcpServers: {
[opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` },
},
authPath,
mcpServers: { [opts.project]: { type: 'http', url } },
extension: opts.skipExtension === true ? '<skipped>' : extPath,
},
action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
action: 'write settings.json + write auth.json credential + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
}, null, 2);
log(dry);
return;
}
// 1. Register the proxy MCP gateway (merge; never destroy settings).
try {
const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl);
log(reg.created
@@ -251,15 +264,55 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
return;
}
// Write the project marker in cwd so later `skills sync` calls resolve scope.
// 2. Provision the bearer credential prime-agent needs for this project.
// mcpctl's stdio bridge supplied auth implicitly; over HTTP we must
// store an mcp:<project> token in auth.json. Use --token if given,
// keep an existing one, otherwise mint it via the API.
try {
const markerPath = await writeProjectMarker(process.cwd(), opts.project);
log(`Wrote ${markerPath}`);
if (opts.token !== undefined && opts.token !== '') {
await writePrimeAgentAuth(opts.project, opts.token, authPath);
log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
} else if (await hasPrimeAgentAuth(opts.project, authPath)) {
log(`Bearer credential for '${opts.project}' already present in ${authPath}`);
} else if (skillsClient) {
const tokenName = `prime-agent-${Date.now()}-${Math.floor(Math.random() * 1e6).toString(36)}`;
const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', {
name: tokenName,
projectName: opts.project,
ttl: 'never',
description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`,
});
if (typeof minted?.token === 'string' && minted.token.length > 0) {
await writePrimeAgentAuth(opts.project, minted.token, authPath);
log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
} else {
log(`Warning: no token returned minting for '${opts.project}'; pass --token to supply one`);
}
} else {
log('Warning: no API client available to mint a project token — pass --token <pat> to provision auth.json');
}
} catch (err: unknown) {
log(`Warning: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`);
}
// 3. Write the .mcpctl-project marker so later `skills sync` calls can
// resolve the project. Never clobber an existing marker found by
// walk-up, and never scope $HOME itself.
try {
const existing = await findProjectMarker(process.cwd(), homedir());
if (existing !== null) {
log(`Project already scoped by existing marker ${existing.markerPath} ('${existing.project}'); not overwriting`);
} else if (process.cwd() !== homedir()) {
const markerPath = await writeProjectMarker(process.cwd(), opts.project);
log(`Wrote ${markerPath}`);
} else {
log('Skipped .mcpctl-project marker (running from $HOME)');
}
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
}
// Sync skills into prime-agent's skills tree (skippable).
// 4. Sync skills into prime-agent's skills tree (skippable).
if (opts.skipSkills !== true) {
if (skillsClient) {
try {
@@ -268,16 +321,32 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
{ client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
);
const total = result.installed.length + result.updated.length + result.removed.length;
if (total > 0) {
log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`);
if (total > 0 || result.errors.length > 0) {
log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`);
}
if (result.exitCode !== 0) {
process.exitCode = result.exitCode;
log(`Warning: prime-agent skills sync exited with code ${String(result.exitCode)}`);
}
} catch (err: unknown) {
log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
} else {
log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)');
}
}
// 5. Install the /mcpctl project-switcher extension (skippable).
if (opts.skipExtension !== true) {
try {
mkdirSync(dirname(extPath), { recursive: true });
writeFileSync(extPath, MCPCTL_SWITCH_EXTENSION, 'utf-8');
log(`Installed /mcpctl switcher extension: ${extPath}`);
} catch (err: unknown) {
log(`Warning: failed to install /mcpctl switcher extension: ${err instanceof Error ? err.message : String(err)}`);
}
}
});
if (hidden) {
void cmd;

View File

@@ -10,6 +10,7 @@ import {
detectModifiedFiles,
type SkillState,
defaultStatePath,
pathExists,
} from '../utils/skills-state.js';
import {
installSkillAtomic,
@@ -31,7 +32,6 @@ import {
parseMcpServerDeps,
} from '../utils/mcpservers-materialiser.js';
import { ApiError } from '../api-client.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
/**
* `mcpctl skills sync` — materialise server-side skills onto disk under
@@ -88,10 +88,22 @@ export interface SyncOpts {
keepOrphans?: boolean;
/** For tests: override cwd start for the marker walk-up. */
cwd?: string;
/** For tests: override skills install root (default: ~/.claude/skills). */
/** For tests: override skills install root (default depends on target). */
installRoot?: string;
/** For tests: override state file path. */
/** For tests: override state file path (default depends on target). */
statePath?: string;
/** Override $HOME used for default paths (tests). */
homeDir?: string;
/**
* Which agent's skill tree to sync into:
* 'claude' (default) — ~/.claude/skills, with hooks + postInstall.
* 'prime-agent' — ~/.prime/agent/skills; no hooks/postInstall,
* shared flat tree with per-project ownership so
* configuring a second project never deletes the
* first project's skills, and pre-existing
* (untracked) skill dirs are preserved.
*/
target?: 'claude' | 'prime-agent';
}
export interface SyncResult {
@@ -121,6 +133,8 @@ export interface SyncDeps {
*/
export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<SyncResult> {
const { client, log, warn } = deps;
const target = opts.target ?? 'claude';
const homeDir = opts.homeDir ?? homedir();
const result: SyncResult = {
installed: [],
updated: [],
@@ -174,10 +188,17 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
// becomes a concept.
visible = visible.filter((s) => s.scope !== 'agent');
// 3. Load state.
const statePath = opts.statePath ?? defaultStatePath();
// 3. Load state. Defaults depend on the sync target: Claude Code gets
// ~/.claude/skills + the shared state file; prime-agent gets its own
// tree + separate state file so the two never collide.
const isPrimeAgent = target === 'prime-agent';
const statePath = opts.statePath ?? (isPrimeAgent
? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json')
: defaultStatePath());
const state = await loadState(statePath);
const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills');
const installRoot = opts.installRoot ?? (isPrimeAgent
? join(homeDir, '.prime', 'agent', 'skills')
: join(homeDir, '.claude', 'skills'));
// 4. Diff.
const visibleByName = new Map(visible.map((s) => [s.name, s]));
@@ -206,12 +227,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
await Promise.all(batch.map((v) => applyOne(v)));
}
// 6. Orphan removal: skills in state but not in server's visible set.
// 6. Orphan removal: skills in state but not in the server's visible set.
if (!opts.keepOrphans) {
for (const name of stateNames) {
if (visibleByName.has(name)) continue;
const prior = state.skills[name];
if (!prior) continue;
// prime-agent shares one flat skill tree across projects while
// settings.json accumulates one MCP server per project. Never delete a
// skill that belongs to a *different* project (or the user would lose
// their first project just by configuring a second one). Only remove
// skills this project (or globals) previously installed and that have
// since left the visible set.
if (isPrimeAgent && prior.project !== projectName) continue;
try {
// Preserve user-modified skills — warn + skip.
const modified = await detectModifiedFiles(prior.installDir, prior.files);
@@ -225,8 +253,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
continue;
}
await removeSkillAtomic(prior.installDir);
// Drop any hook entries this skill registered.
try { await removeManagedHooks(name); } catch { /* best-effort */ }
// Drop any hook entries this skill registered (Claude only — the
// prime-agent path never touches ~/.claude/settings.json).
if (!isPrimeAgent) {
try { await removeManagedHooks(name); } catch { /* best-effort */ }
}
delete state.skills[name];
result.removed.push(name);
} catch (err: unknown) {
@@ -269,7 +300,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
if (result.errors.length) parts.push(`${String(result.errors.length)} errors`);
if (parts.length === 0) parts.push('no changes');
if (!opts.quiet) {
log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
log(`mcpctl skills sync (${target})${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
} else if (anythingHappened) {
// Quiet mode: only emit a single line if something actually happened.
warn(`mcpctl: ${parts.join(', ')}`);
@@ -291,6 +322,20 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
return;
}
}
// prime-agent: never clobber an untracked, pre-existing directory (e.g.
// a hand-authored skill like `sre`, or a skill another project owns).
// The shared, hand-editable ~/.prime/agent tree must never be rm -rf'd
// just because our state file is fresh (empty) on first sync.
if (isPrimeAgent && !prior) {
const dirExists = await pathExists(targetDir);
if (dirExists && !opts.force) {
warn(`mcpctl: '${v.name}' already exists at ${targetDir} but is not tracked by the prime-agent sync — leaving it untouched. Re-run with --force to overwrite.`);
result.preserved.push(v.name);
return;
}
}
if (opts.dryRun) {
if (prior) result.updated.push(v.name);
else result.installed.push(v.name);
@@ -304,22 +349,25 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
};
const fileStates = await installSkillAtomic(targetDir, body);
// ── hooks: register metadata.hooks in ~/.claude/settings.json ──
// Tagged with _mcpctl_source: <skill-name> so each skill's hooks
// can be cleanly added/updated/removed without trampling other
// skills or user-added hooks. No-op when the field is absent or
// empty.
const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
if (meta.hooks && typeof meta.hooks === 'object') {
try {
const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
if (hookRes.updated) result.hooksApplied.push(v.name);
} catch (err: unknown) {
warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
// ── hooks (Claude only) ──
// prime-agent has no SessionStart-hook equivalent and must never touch
// ~/.claude/settings.json. Tagged with _mcpctl_source: <skill-name> so
// each skill's hooks can be cleanly added/updated/removed without
// trampling other skills or user-added hooks. No-op when absent.
if (!isPrimeAgent) {
if (meta.hooks && typeof meta.hooks === 'object') {
try {
const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
if (hookRes.updated) result.hooksApplied.push(v.name);
} catch (err: unknown) {
warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
}
} else if (prior !== undefined) {
// Skill no longer declares hooks but used to — clean up.
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
}
} else if (prior !== undefined) {
// Skill no longer declares hooks but used to — clean up.
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
}
// ── mcpServers: auto-attach declared deps to the active project ──
@@ -352,7 +400,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
// what state recorded. Failures DO NOT update the recorded hash so
// the next sync retries. Other skills continue regardless.
let postInstallHash: string | null = prior?.postInstallHash ?? null;
// postInstall scripts assume a Claude-esque shell and are skipped for
// prime-agent.
if (
!isPrimeAgent &&
!opts.skipPostInstall &&
typeof meta.postInstall === 'string' &&
meta.postInstall.length > 0
@@ -419,6 +470,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
files: fileStates,
postInstallHash,
lastSyncedAt: new Date().toISOString(),
...(isPrimeAgent ? { project: projectName ?? null } : {}),
};
state.skills[v.name] = newState;
if (prior) result.updated.push(v.name);
@@ -462,23 +514,14 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
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;
}
// Validate --agent so an unknown value fails loudly instead of silently
// running the default (Claude) sync.
const agent = opts.agent ?? 'claude';
if (agent !== 'claude' && agent !== 'prime-agent') {
warn(`mcpctl: unknown sync target '${agent}' (expected 'claude' or 'prime-agent')`);
process.exitCode = 1;
return;
}
const result = await runSkillsSync(
{
...(opts.project !== undefined ? { project: opts.project } : {}),
@@ -487,6 +530,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
target: agent as 'claude' | 'prime-agent',
},
{ client, log, warn },
);

View File

@@ -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<string> {\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<ProjectInfo[]> {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\nasync function activeProject(): Promise<string | null> {\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<string, { url?: string }> };\n if (!settings.mcpServers) return null;\n for (const name of Object.keys(settings.mcpServers)) {\n const url = settings.mcpServers[name]?.url ?? '';\n const m = url.match(/\\/projects\\/([^/]+)\\/mcp$/);\n if (m && m[1] === name) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const items = projects.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n\n const picked = await ctx.ui.select(\n active ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project',\n items,\n );\n if (!picked) return;\n\n const name = picked.split(' — ')[0]?.trim();\n if (!name) return;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n await ctx.reload();\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'success');\n },\n });\n}\n";

View File

@@ -8,9 +8,14 @@
* proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`.
* - `auth.json` — per-server bearer tokens keyed as `mcp:<server>`.
*
* We only ever merge the `mcpServers` map, preserving every other key and any
* servers the user has already configured (including non-mcpctl gateways like
* the bundled `sre` project).
* Safety invariants:
* - We only ever *merge* the `mcpServers` map, preserving every other key
* and any servers the user already configured.
* - If `settings.json` exists but is corrupt, we fail loudly instead of
* swallowing the parse error and rewriting (which would destroy every
* non-mcpServers setting). Untouched corrupt files are never overwritten.
* - A project's existing `mcpServers` entry is merged (user-added fields are
* kept), never replaced wholesale.
*/
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { join, dirname } from 'node:path';
@@ -24,25 +29,46 @@ export function primeAgentSettingsPath(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'settings.json');
}
/** Resolve the prime-agent auth.json path. */
export function primeAgentAuthPath(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'auth.json');
}
/** Resolve the prime-agent extensions directory (auto-discovered by the app). */
export function primeAgentExtensionsDir(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'extensions');
}
/** Proxy MCP URL for a given project on the gateway. */
export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string {
const base = gatewayUrl.replace(/\/+$/, '');
return `${base}/projects/${encodeURIComponent(project)}/mcp`;
}
interface PrimeAgentSettings {
mcpServers?: Record<string, { type: string; url: string; [k: string]: unknown }>;
export interface PrimeAgentSettings {
mcpServers?: Record<string, Record<string, unknown>>;
[key: string]: unknown;
}
/** Load prime-agent settings; return an empty object if absent/invalid. */
/**
* Load prime-agent settings.
* - Missing file → returns `{}` (a brand-new file about to be created).
* - Unreadable/corrupt → throws, so the caller refuses to overwrite it.
*/
export async function loadPrimeAgentSettings(path: string): Promise<PrimeAgentSettings> {
let raw: string;
try {
raw = await readFile(path, 'utf-8');
} catch (err: unknown) {
if ((err as { code?: string }).code === 'ENOENT') return {};
throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
}
if (raw.trim().length === 0) return {};
try {
const raw = await readFile(path, 'utf-8');
const parsed = JSON.parse(raw) as PrimeAgentSettings;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
return {};
} catch (err: unknown) {
throw new Error(`setting file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`);
}
}
@@ -57,9 +83,72 @@ export interface RegisterMcpResult {
/**
* Merge a proxy MCP `{ type: "http", url }` entry for `project` into the
* prime-agent settings file, preserving all other fields and servers.
* Returns a summary of what changed.
* prime-agent settings file. Preserves all other fields and servers, and
* merges into an existing `mcpServers[project]` entry (keeping any user-added
* keys like `headers`) rather than replacing it wholesale.
*/
export async function registerPrimeAgentMcp(
project: string,
settingsPath: string,
gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL,
): Promise<RegisterMcpResult> {
const existed = await pathExists(settingsPath);
const settings = await loadPrimeAgentSettings(settingsPath);
if (settings.mcpServers !== undefined && (typeof settings.mcpServers !== 'object' || settings.mcpServers === null)) {
throw new Error(`invalid mcpServers block in ${settingsPath} — refusing to overwrite it`);
}
settings.mcpServers = settings.mcpServers ?? {};
const url = projectMcpUrl(project, gatewayUrl);
const existing = settings.mcpServers[project];
const newServer = existing === undefined;
// Merge: keep any user-added fields on the project's entry (e.g. headers).
settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url };
const totalServers = Object.keys(settings.mcpServers).length;
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers };
}
/**
* Ensure `mcp:<project>` carries `{ type: "api_key", key }` in
* `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz`
* provider credential, other `mcp:*` servers, etc).
*/
export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise<void> {
const current = await loadPrimeAgentAuth(authPath);
current[`mcp:${project}`] = { type: 'api_key', key };
await mkdir(dirname(authPath), { recursive: true });
// auth.json is 0600 normally; preserve an existing mode if present.
await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', 'utf-8');
}
/** Load auth.json; missing/corrupt (non-JSON) treated as a fresh file. */
async function loadPrimeAgentAuth(path: string): Promise<Record<string, unknown>> {
try {
const raw = await readFile(path, 'utf-8');
if (raw.trim().length === 0) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
return {};
}
}
/** Does the project already have a credential in auth.json? */
export async function hasPrimeAgentAuth(project: string, authPath: string): Promise<boolean> {
try {
const raw = await readFile(authPath, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, { type?: string; key?: string } | undefined>;
const entry = parsed?.[`mcp:${project}`];
return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0);
} catch {
return false;
}
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
@@ -68,30 +157,3 @@ async function pathExists(p: string): Promise<boolean> {
return false;
}
}
export async function registerPrimeAgentMcp(
project: string,
settingsPath: string,
gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL,
): Promise<RegisterMcpResult> {
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,
};
}

View File

@@ -1,287 +1,54 @@
/**
* Prime-agent skill sync for `mcpctl config prime-agent`.
* Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`.
*
* Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`)
* but materialises server-side skills as *markdown* skills for prime-agent
* under `~/.prime/agent/skills/<name>/`.
* This is a thin convenience wrapper around the shared [`runSkillsSync`]
* implementation in `commands/skills.ts`, invoked with `target: 'prime-agent'`.
* All diffing, atomic install, preservation and orphan logic lives there; this
* module only:
* - resolves the prime-agent install root and state file paths, and
* - exposes a `runPrimeAgentSkillsSync` entry that delegates to the unified
* sync so callers and tests keep a stable, intent-revealing name.
*
* Why a separate module instead of parameterising `runSkillsSync`:
* - prime-agent skills carry no `hooks` (there is no SessionStart hook on
* the prime-agent side) and must never touch `~/.claude/settings.json`,
* so the hooks side-effect in `runSkillsSync` would be wrong here.
* - prime-agent skills have no `postInstall` scripts (server scripts assume
* a Claude-esque shell), so we skip that machinery too.
*
* The on-disk format is deliberately the same as what prime-agent already
* ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary
* `files`). prime-agent auto-discovers these at session start, so once the
* config command has pointed prime-agent at the proxy MCP and synced the
* project's skills, later `mcpctl skills sync --agent prime-agent` calls (or
* the config command itself) keep the tree up to date.
* Target-specific behaviour (handled by the shared implementation):
* - installs markdown skills under `~/.prime/agent/skills/<name>/`
* - never touches `~/.claude/settings.json` (no hooks / postInstall)
* - still auto-attaches skill-declared `mcpServers` deps to the project
* - records per-project ownership and preserves untracked / cross-project
* skill dirs so a shared, hand-editable tree is never silently wiped
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { ApiClient } from '../api-client.js';
import { ApiError } from '../api-client.js';
import { findProjectMarker } from './project-marker.js';
import {
loadState,
saveState,
detectModifiedFiles,
type SkillState,
} from './skills-state.js';
import {
installSkillAtomic,
removeSkillAtomic,
} from './skills-disk.js';
import { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js';
/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */
export function primeAgentSkillsRoot(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'skills');
}
/** State bookkeeping lives separately from the Claude skills state. */
/** prime-agent keeps its own state file so it never collides with Claude's. */
export function primeAgentStatePath(homeDir: string = homedir()): string {
return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json');
}
/** Shape of a server-side visible skill (subset we act on). */
interface VisibleSkill {
id: string;
name: string;
description: string;
semver: string;
contentHash: string;
metadata: unknown;
scope: 'project' | 'global' | 'agent';
}
/** Full skill body fetched from /api/v1/skills/:id (subset we install). */
interface FullSkill {
id: string;
name: string;
description: string;
semver: string;
contentHash: string;
content: string;
files: Record<string, string>;
}
export interface PrimeAgentSyncOpts {
/** Project name; otherwise resolved from the .mcpctl-project marker. */
project?: string;
dryRun?: boolean;
force?: boolean;
quiet?: boolean;
keepOrphans?: boolean;
/** For tests: override cwd for the marker walk-up. */
cwd?: string;
/** For tests: override the prime-agent skills root. */
installRoot?: string;
/** For tests: override the state file path. */
statePath?: string;
/** For tests: override $HOME used for default paths. */
homeDir?: string;
}
export interface PrimeAgentSyncResult {
installed: string[];
updated: string[];
skipped: string[];
removed: string[];
preserved: string[];
errors: Array<{ skill: string; error: string }>;
exitCode: 0 | 1 | 2;
}
export interface PrimeAgentSyncDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
export type PrimeAgentSyncOpts = Pick<
SyncOpts,
'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir'
>;
export type PrimeAgentSyncResult = SyncResult;
export type PrimeAgentSyncDeps = SyncDeps;
/**
* Sync the active project's skills into prime-agent's markdown skills tree.
* Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2
* disk/state error.
*/
export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise<PrimeAgentSyncResult> {
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<VisibleSkill[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`);
} else {
visible = await client.get<VisibleSkill[]>('/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<void> {
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<FullSkill>(`/api/v1/skills/${encodeURIComponent(v.id)}`);
const files = await installSkillAtomic(targetDir, {
content: full.content,
...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}),
});
const newState: SkillState = {
id: v.id,
semver: v.semver,
contentHash: v.contentHash,
scope: v.scope,
installDir: targetDir,
files,
postInstallHash: null,
lastSyncedAt: new Date().toISOString(),
};
state.skills[v.name] = newState;
if (prior) result.updated.push(v.name);
else result.installed.push(v.name);
} catch (err: unknown) {
result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) });
}
}
export async function runPrimeAgentSkillsSync(
opts: PrimeAgentSyncOpts,
deps: PrimeAgentSyncDeps,
): Promise<PrimeAgentSyncResult> {
return runSkillsSync({ ...opts, target: 'prime-agent' }, deps);
}
// Re-export for callers that prefer to use the shared function directly.
export { runSkillsSync };

View File

@@ -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 {

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { tmpdir, homedir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js';
import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js';
@@ -114,15 +114,16 @@ describe('config prime-agent', () => {
expect(exceptionSafeRead(settingsPath)).toBeNull();
});
it('does not call the API when --skip-skills is set', async () => {
it('does not call the API when --skip-skills and --token are given', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' });
await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.post).not.toHaveBeenCalled();
});
it('backward compat: prime-agent-generate still works', async () => {
@@ -136,6 +137,124 @@ describe('config prime-agent', () => {
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['proj-1']).toBeDefined();
});
it('provisions auth.json by minting a project token', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' }));
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' });
});
it('uses --token without calling the API', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' });
});
it('keeps an existing credential and does not re-mint', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } }));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:labctl'].key).toBe('existing');
});
it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
const extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts');
expect(existsSync(extPath)).toBe(true);
expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'");
output.length = 0;
const cmd2 = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
expect(output.join('\n')).not.toContain('switcher extension');
});
it('does not write a .mcpctl-project marker when run from $HOME', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const prevCwd = process.cwd();
process.chdir(homedir());
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
try {
await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
} finally {
process.chdir(prevCwd);
}
expect(output.join('\n')).toContain('Skipped .mcpctl-project marker');
expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull();
});
it('refuses to overwrite a corrupt settings.json', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, '{ this is not valid json !!!');
const prevCwd = process.cwd();
process.chdir(tmpDir);
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
try {
await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
} finally {
process.chdir(prevCwd);
}
expect(output.join('\n')).toContain('refusing to overwrite');
// The corrupt file is untouched.
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!');
});
it('merges a re-configured project entry, preserving user-added fields', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({
mcpServers: {
ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } },
},
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['ha']).toEqual({
type: 'http',
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`,
headers: { Authorization: 'Bearer u' }, // user-added field preserved
});
});
});
function exceptionSafeRead(path: string): string | null {

View File

@@ -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<typeof mockClient>;
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();
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js';
@@ -121,4 +121,66 @@ describe('runPrimeAgentSkillsSync', () => {
const getCalls = (client.get as ReturnType<typeof vi.fn>).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);
});
});