Merge remote-tracking branch 'origin/main' into feat/pi-extension

# Conflicts:
#	README.md
#	src/cli/src/commands/config.ts
#	src/cli/src/commands/skills.ts
This commit is contained in:
Michal
2026-08-08 16:35:51 +01:00
13 changed files with 1849 additions and 39 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,7 +9,7 @@ import { saveCredentials, loadCredentials } from '../auth/index.js';
import { createConfigSetupCommand } from './config-setup.js';
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js';
import { writeProjectMarker } from '../utils/project-marker.js';
import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.js';
import { installManagedSessionHook } from '../utils/sessionhook.js';
import {
installExtensionFiles,
@@ -23,6 +23,25 @@ import {
writePiProjectState,
} from '../utils/pi-settings.js';
import { runSkillsSync } from './skills.js';
import {
registerPrimeAgentMcp,
primeAgentSettingsPath,
DEFAULT_MCPCTL_GATEWAY_URL,
writePrimeAgentAuth,
readPrimeAgentAuthKey,
mcpTokenPrefixOf,
isMcpctlToken,
} from '../config/prime-agent.js';
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
/**
* Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint
* gets a unique `<prefix>-<stamp>` name because `McpToken` is unique on
* (name, projectId) and revoke is a soft delete — a fixed name could only ever
* be minted once per project.
*/
const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
interface McpConfig {
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
@@ -204,6 +223,281 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
}
}
// prime-agent: register our proxy MCP gateway in prime-agent's settings.json
// + sync the project's skills into prime-agent's skills tree. Mirror of the
// claude command above, but targeting ~/.prime/agent/ instead of .mcp.json.
function registerPrimeAgentCommand(name: string, hidden: boolean): void {
const cmd = config
.command(name)
.description(hidden ? '' : 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)')
.option('-p, --project <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('--skip-extension', 'Do not install the /mcpctl project-switcher extension')
.option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory')
.option('--dry-run', 'Print what would change without writing or syncing')
.action(async (opts: {
project?: string;
output?: string;
gatewayUrl: string;
token?: string;
skipSkills?: boolean;
skipExtension?: boolean;
skipMarker?: boolean;
dryRun?: boolean;
}) => {
if (opts.project === undefined || opts.project === '') {
log('Error: --project is required');
process.exitCode = 1;
return;
}
const settingsPath = resolve(opts.output ?? primeAgentSettingsPath());
const agentDir = dirname(settingsPath);
const authPath = join(agentDir, 'auth.json');
const extPath = join(agentDir, 'extensions', MCPCTL_SWITCH_EXTENSION_FILENAME);
const gatewayBase = opts.gatewayUrl.replace(/\/+$/, '');
const url = `${gatewayBase}/projects/${encodeURIComponent(opts.project)}/mcp`;
if (opts.dryRun === true) {
const dry = JSON.stringify({
primeAgent: {
settingsPath,
authPath,
mcpServers: { [opts.project]: { type: 'http', url } },
extension: opts.skipExtension === true ? '<skipped>' : extPath,
marker: opts.skipMarker === true ? '<skipped>' : join(process.cwd(), '.mcpctl-project'),
},
action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
}, null, 2);
log(dry);
return;
}
// 1. Provision the bearer credential prime-agent needs for this project.
// mcpctl's stdio bridge supplied auth implicitly; over HTTP we must
// store an mcp:<project> token in auth.json. Use --token if given,
// keep a still-valid existing one, otherwise mint it via the API.
//
// This runs BEFORE settings.json is touched: registering the new
// project unmounts the previously active one, so a mint failure must
// not be able to leave prime-agent with no working project at all.
// A switch with no usable credential is a FAILURE (exit != 0) so the
// /mcpctl extension does not report success over a bare project.
let provisioned = false;
try {
// Whatever this auth.json held before we touched it — the only token
// this run is entitled to retire once it has a replacement.
const staleKey = await readPrimeAgentAuthKey(opts.project, authPath);
if (opts.token !== undefined && opts.token !== '') {
await writePrimeAgentAuth(opts.project, opts.token, authPath);
log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
provisioned = true;
// Only when we actually replaced something: `--token` with a fresh
// auth.json must stay entirely offline, as documented.
if (staleKey !== null) {
await retireSupersededToken(opts.project, staleKey, opts.token);
}
} else if (await hasUsableCredential(opts.project, staleKey)) {
log(`Bearer credential for '${opts.project}' already present in ${authPath}`);
provisioned = true;
} else if (skillsClient) {
// Mint under a fresh, unique name. `McpToken` is unique on
// (name, projectId) and revoke is a soft delete, so reusing a fixed
// name would collide with the revoked row forever. Retire the old
// tokens only *after* the replacement is safely on disk.
const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', {
name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`,
projectName: opts.project,
ttl: 'never',
description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`,
});
if (typeof minted?.token === 'string' && minted.token.length > 0) {
await writePrimeAgentAuth(opts.project, minted.token, authPath);
log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
provisioned = true;
await retireSupersededToken(opts.project, staleKey, minted.token);
} else {
log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`);
}
} else {
log('Error: no API client available to mint a project token — pass --token <pat> to provision auth.json');
}
} catch (err: unknown) {
log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`);
}
if (!provisioned) {
process.exitCode = 1;
log(`Aborted: leaving ${settingsPath} unchanged so the currently active project keeps working`);
return;
}
// 2. Register the proxy MCP gateway (merge; never destroy settings).
try {
const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl, { authPath });
log(reg.created
? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})`
: `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`);
if (reg.removed.length > 0) {
log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`);
}
} catch (err: unknown) {
log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
return;
}
// 3. Write the .mcpctl-project marker so later `skills sync` calls can
// resolve the project. An explicit -p is authoritative: it updates a
// differing up-tree marker (so the scope doesn't silently revert on
// the next sync), is a no-op when it already matches, and never
// scopes $HOME itself. `--skip-marker` opts out entirely: the
// /mcpctl switcher runs this command from whatever directory
// prime-agent happens to be started in, and must not silently
// re-scope an unrelated repo that Claude Code's own sync reads.
try {
if (opts.skipMarker === true) {
log('Skipped .mcpctl-project marker (--skip-marker)');
} else if (process.cwd() !== homedir()) {
const existing = await findProjectMarker(process.cwd(), homedir());
if (existing !== null && existing.project === opts.project) {
log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`);
} else {
const markerPath = await writeProjectMarker(process.cwd(), opts.project);
log(existing !== null
? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.project}')`
: `Wrote ${markerPath}`);
}
} else {
log('Skipped .mcpctl-project marker (running from $HOME)');
}
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
}
// 4. Sync skills into prime-agent's skills tree (skippable).
// Best-effort: settings + auth (steps 12) determine whether the
// switch succeeded. A skills error is reported but must not flip the
// /mcpctl switch to "failed" when MCP access is already provisioned.
if (opts.skipSkills !== true) {
if (skillsClient) {
try {
const result = await runPrimeAgentSkillsSync(
{ project: opts.project },
{ client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
);
const total = result.installed.length + result.updated.length + result.removed.length;
if (total > 0 || result.errors.length > 0) {
log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`);
}
} catch (err: unknown) {
log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
}
} else {
log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)');
}
}
// 5. Install the /mcpctl project-switcher extension (skippable).
if (opts.skipExtension !== true) {
try {
mkdirSync(dirname(extPath), { recursive: true });
writeFileSync(extPath, MCPCTL_SWITCH_EXTENSION, 'utf-8');
log(`Installed /mcpctl switcher extension: ${extPath}`);
} catch (err: unknown) {
log(`Warning: failed to install /mcpctl switcher extension: ${err instanceof Error ? err.message : String(err)}`);
}
}
});
if (hidden) {
void cmd;
}
/**
* Is the credential already in auth.json still usable?
*
* A key being *present* proves nothing — a revoked or expired token would
* short-circuit provisioning and leave prime-agent silently unable to reach
* the gateway while the command reported success. mcptokens are only ever
* shown once, so we compare the stored token's 16-char `tokenPrefix`
* against the project's *active* tokens instead of sending the secret.
*
* Fails open: no client, a non-mcpctl token (a user-supplied PAT of some
* other kind), or an unreachable API all mean "keep what's there" rather
* than minting a duplicate on every run.
*/
async function hasUsableCredential(project: string, key: string | null): Promise<boolean> {
if (key === null) return false;
if (!skillsClient || !isMcpctlToken(key)) return true;
const tokens = await listProjectTokens(project);
if (tokens === null) return true; // can't check → don't churn credentials
const prefix = mcpTokenPrefixOf(key);
const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix);
if (!live) {
log(`Stored credential for '${project}' is no longer active — minting a replacement`);
}
return live;
}
/**
* Retire the token this auth.json used to hold, now that `keepToken` has
* replaced it on disk.
*
* Scoped to that one credential on purpose. Sweeping every `prime-agent`
* token for the project would revoke the one a *different* auth.json is
* using — another machine, or this machine when the run targeted a custom
* `--output`. Anything else that looks orphaned is reported, not deleted:
* an unnecessary token costs nothing, a revoked one costs a broken install.
* Best-effort throughout, and only ever called once the replacement is
* safely stored.
*/
async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise<void> {
if (!skillsClient) return;
const keepPrefix = mcpTokenPrefixOf(keepToken);
const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null;
if (stalePrefix === keepPrefix) return;
const tokens = await listProjectTokens(project);
if (tokens === null) return;
const orphans: string[] = [];
for (const t of tokens) {
if (typeof t.id !== 'string' || t.status !== 'active') continue;
if (t.tokenPrefix === keepPrefix) continue;
// Only ever consider tokens minted for this purpose.
const name = t.name ?? '';
if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue;
if (t.tokenPrefix === stalePrefix) {
try {
await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`);
log(`Revoked the superseded '${name}' token for '${project}'`);
} catch { /* best-effort */ }
} else {
orphans.push(name);
}
}
if (orphans.length > 0) {
log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. `
+ `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken <name> --project ${project}\`.`);
}
}
interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string }
/** The project's tokens, or null when the API can't be consulted. */
async function listProjectTokens(project: string): Promise<ProjectToken[] | null> {
if (!skillsClient) return null;
try {
const list = await skillsClient.get<unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`);
return Array.isArray(list) ? list as ProjectToken[] : null;
} catch {
return null;
}
}
}
registerClaudeCommand('claude', false);
registerClaudeCommand('claude-generate', true); // backward compat
@@ -278,7 +572,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (!opts.skipSkills && skillsClient) {
try {
const result = await runSkillsSync(
{ project: opts.project, installRoot: skillsInstall },
{ project: opts.project, target: 'pi', installRoot: skillsInstall },
{ client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
);
const total = result.installed.length + result.updated.length + result.removed.length;
@@ -299,6 +593,10 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
log(`project will be registered automatically. Use /mcpctl to switch projects.`);
});
registerPrimeAgentCommand('prime-agent', false);
registerPrimeAgentCommand('prime-agent-generate', true); // backward compat
config.addCommand(createConfigSetupCommand({ configDeps }));
if (apiDeps) {

View File

@@ -10,6 +10,7 @@ import {
detectModifiedFiles,
type SkillState,
defaultStatePath,
pathExists,
} from '../utils/skills-state.js';
import {
installSkillAtomic,
@@ -87,10 +88,22 @@ export interface SyncOpts {
keepOrphans?: boolean;
/** For tests: override cwd start for the marker walk-up. */
cwd?: string;
/** For tests: override skills install root (default: ~/.claude/skills). */
/** For tests: override skills install root (default depends on target). */
installRoot?: string;
/** For tests: override state file path. */
/** For tests: override state file path (default depends on target). */
statePath?: string;
/** Override $HOME used for default paths (tests). */
homeDir?: string;
/**
* Which agent's skill tree to sync into:
* 'claude' (default) — ~/.claude/skills, with hooks + postInstall.
* 'prime-agent' — ~/.prime/agent/skills; no hooks/postInstall,
* shared flat tree with per-project ownership so
* configuring a second project never deletes the
* first project's skills, and pre-existing
* (untracked) skill dirs are preserved.
*/
target?: 'claude' | 'prime-agent' | 'pi';
}
export interface SyncResult {
@@ -120,6 +133,8 @@ export interface SyncDeps {
*/
export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<SyncResult> {
const { client, log, warn } = deps;
const target = opts.target ?? 'claude';
const homeDir = opts.homeDir ?? homedir();
const result: SyncResult = {
installed: [],
updated: [],
@@ -173,10 +188,35 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<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 isPi = target === 'pi';
// prime-agent and pi share the same skill semantics: their own flat skill
// tree + separate state file, no SessionStart hooks / postInstall, no
// mcpServers auto-attach. Only claude gets Claude-specific behaviour.
const isSharedTree = isPrimeAgent || isPi;
// Canonical ownership scope for a *skill*: null when the skill is global
// (globals are visible from every project, so pinning one to whichever
// project happened to sync it would lock every other project out of ever
// updating it), otherwise the project that installed it.
const ownerOf = (s: VisibleSkill): string | null => (s.scope === 'global' ? null : (projectName ?? null));
const statePath = opts.statePath ?? (isPrimeAgent
? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json')
: isPi
? join(homeDir, '.mcpctl', 'skills-state-pi.json')
: defaultStatePath());
const state = await loadState(statePath);
const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills');
// Which project last wrote this state file, captured before step 7 overwrites
// it. Skills tracked by a CLI that predates ownership recording carry no
// `project` field; this is the only evidence of who installed them.
const priorSyncProject = state.lastSyncProject;
const installRoot = opts.installRoot ?? (isPrimeAgent
? join(homeDir, '.prime', 'agent', 'skills')
: isPi
? join(homeDir, '.pi', 'agent', 'skills')
: join(homeDir, '.claude', 'skills'));
// 4. Diff.
const visibleByName = new Map(visible.map((s) => [s.name, s]));
@@ -205,12 +245,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<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 (isSharedTree && !ownsOrphan(prior)) continue;
try {
// Preserve user-modified skills — warn + skip.
const modified = await detectModifiedFiles(prior.installDir, prior.files);
@@ -224,8 +271,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<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
// shared-tree paths never touch ~/.claude/settings.json).
if (!isSharedTree) {
try { await removeManagedHooks(name); } catch { /* best-effort */ }
}
delete state.skills[name];
result.removed.push(name);
} catch (err: unknown) {
@@ -268,7 +318,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(', ')}`);
@@ -277,6 +327,27 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
return result;
/**
* May this sync delete `prior` from the shared prime-agent tree now that it
* has left the visible set? The tree is flat and shared across projects while
* each project syncs only its own slice, so "not visible to me" is never on
* its own a reason to delete.
*
* - global (`null`) — globals are visible from every project and on a
* global-only sync, so gone here means gone. Removable.
* - a project name — removable only while syncing that same project.
* - `undefined` — written before ownership tracking existed. The only
* evidence of the owner is which project last wrote
* the state file; when that isn't the project syncing
* now, leave it alone rather than delete another
* project's skills on the first post-upgrade sync.
*/
function ownsOrphan(prior: SkillState): boolean {
if (prior.project === null) return true;
if (typeof prior.project === 'string') return prior.project === projectName;
return priorSyncProject !== null && priorSyncProject === projectName;
}
async function applyOne(v: VisibleSkill): Promise<void> {
try {
// If on-disk files were locally modified, preserve unless --force.
@@ -290,6 +361,36 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
return;
}
}
// Shared-tree (prime-agent / pi): 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 tree must never
// be rm -rf'd just because our state file is fresh (empty) on first sync.
if (isSharedTree && !prior) {
const dirExists = await pathExists(targetDir);
if (dirExists && !opts.force) {
warn(`mcpctl: '${v.name}' already exists at ${targetDir} but is not tracked by this agent's sync — leaving it untouched. Re-run with --force to overwrite.`);
result.preserved.push(v.name);
return;
}
}
// prime-agent/pi: if this skill name is *tracked* to a specific project
// in the shared flat tree, don't silently overwrite it with a different
// project's (or a global) skill of the same name — that's the exact
// cross-project data loss ownership tracking was added to prevent.
// Same-owner updates, globals (owner null) and untracked legacy entries
// (owner undefined, adopted on write) all proceed normally.
const owner = ownerOf(v);
if (isSharedTree && prior !== undefined &&
typeof prior.project === 'string' && prior.project !== owner) {
if (!opts.force) {
warn(`mcpctl: '${v.name}' is owned by project '${prior.project}' — leaving it untouched while syncing ${owner === null ? 'globals' : `'${owner}'`}. 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);
@@ -303,22 +404,26 @@ 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/pi have 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 (!isSharedTree) {
if (meta.hooks && typeof meta.hooks === 'object') {
try {
const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
if (hookRes.updated) result.hooksApplied.push(v.name);
} catch (err: unknown) {
warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
}
} else if (prior !== undefined) {
// Skill no longer declares hooks but used to — clean up.
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
}
} else if (prior !== undefined) {
// Skill no longer declares hooks but used to — clean up.
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
}
// ── mcpServers: auto-attach declared deps to the active project ──
@@ -327,7 +432,16 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
// servers (warn + skip). Idempotent — re-syncing a skill whose
// deps are already attached is a no-op.
const mcpServerDeps = parseMcpServerDeps(meta.mcpServers);
if (mcpServerDeps.length > 0 && projectName) {
if (isSharedTree) {
// prime-agent/pi talk to the gateway over HTTP (or directly);
// auto-attaching a skill's declared server deps would mutate the
// *shared* mcpd project attachments (and a /mcpctl switch would
// re-trigger it). Deliberately never attach for shared-tree agents,
// but say so instead of being silent.
if (mcpServerDeps.length > 0) {
warn(`mcpctl: skill '${v.name}' declares mcpServers but this agent's sync does not attach project servers; skipping attach`);
}
} else if (mcpServerDeps.length > 0 && projectName) {
try {
const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn);
for (const srv of att.attached) {
@@ -351,7 +465,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<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/pi.
if (
!isSharedTree &&
!opts.skipPostInstall &&
typeof meta.postInstall === 'string' &&
meta.postInstall.length > 0
@@ -418,6 +535,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
files: fileStates,
postInstallHash,
lastSyncedAt: new Date().toISOString(),
...(isSharedTree ? { project: owner } : {}),
};
state.skills[v.name] = newState;
if (prior) result.updated.push(v.name);
@@ -460,12 +578,12 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
console.error(...(args as Parameters<typeof console.error>));
};
const cmd = new Command('skills').description('Manage Agent-skill bundles synced from mcpd');
const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)');
cmd.command('sync')
.description('Sync skills from mcpd onto disk')
.description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)')
.option('-p, --project <name>', 'Project to sync (overrides .mcpctl-project marker)')
.option('--agent <name>', 'Sync target install root: claude (default), prime-agent, or pi', 'claude')
.option('--agent <name>', 'Sync target: claude (default), prime-agent, or pi', 'claude')
.option('--dry-run', 'Print what would change without writing anything')
.option('--force', 'Overwrite locally-modified skills')
.option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)')
@@ -480,7 +598,14 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
skipPostinstall?: boolean;
keepOrphans?: boolean;
}) => {
const installRoot = agentInstallRoot(opts.agent);
// Validate --agent so an unknown value fails loudly instead of silently
// running the default (Claude) sync.
const agent = opts.agent ?? 'claude';
if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') {
warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`);
process.exitCode = 1;
return;
}
const result = await runSkillsSync(
{
...(opts.project !== undefined ? { project: opts.project } : {}),
@@ -489,7 +614,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
installRoot,
target: agent as 'claude' | 'prime-agent' | 'pi',
installRoot: agentInstallRoot(agent),
},
{ client, log, warn },
);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,277 @@
/**
* Read/merge/write helpers for prime-agent's own configuration files, used
* by `mcpctl config prime-agent`.
*
* prime-agent keeps two user-editable files under `~/.prime/agent/`:
* - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus
* model/provider preferences. `mcpctl config prime-agent` registers our
* proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`.
* - `auth.json` — per-server bearer tokens keyed as `mcp:<server>`.
*
* Safety invariants:
* - We only ever *merge* the `mcpServers` map, preserving every other key
* and any servers the user already configured.
* - If `settings.json` exists but is corrupt, we fail loudly instead of
* swallowing the parse error and rewriting (which would destroy every
* non-mcpServers setting). Untouched corrupt files are never overwritten.
* - A project's existing `mcpServers` entry is merged (user-added fields are
* kept), never replaced wholesale.
*/
import { readFile, writeFile, mkdir, stat, chmod } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
/** Base URL of the deployed mcpctl HTTP MCP gateway. */
export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu';
/** Every mcpctl bearer token starts with this (see `@mcpctl/shared` generateToken). */
const MCPCTL_TOKEN_PREFIX = 'mcpctl_pat_';
/** Resolve the prime-agent settings.json path. */
export function primeAgentSettingsPath(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'settings.json');
}
/** Resolve the prime-agent auth.json path. */
export function primeAgentAuthPath(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'auth.json');
}
/** Resolve the prime-agent extensions directory (auto-discovered by the app). */
export function primeAgentExtensionsDir(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'extensions');
}
/** Proxy MCP URL for a given project on the gateway. */
export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string {
const base = gatewayUrl.replace(/\/+$/, '');
return `${base}/projects/${encodeURIComponent(project)}/mcp`;
}
export interface PrimeAgentSettings {
mcpServers?: Record<string, Record<string, unknown>>;
[key: string]: unknown;
}
/**
* Load prime-agent settings.
* - Missing file → returns `{}` (a brand-new file about to be created).
* - Unreadable/corrupt → throws, so the caller refuses to overwrite it.
*/
export async function loadPrimeAgentSettings(path: string): Promise<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 parsed = JSON.parse(raw) as PrimeAgentSettings;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (err: unknown) {
throw new Error(`setting file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`);
}
}
/**
* Is the `mcpServers` entry named `name` one that mcpctl installed?
*
* Two shapes count:
* - `mcpctlManaged: true` — written by this CLI (current releases tag every
* entry they write).
* - *untagged*, but the URL is exactly the canonical `/projects/<name>/mcp`
* proxy URL **and** auth.json holds an `mcp:<name>` mcpctl PAT. Older CLIs
* wrote the entry + credential pair but no tag; without adopting them a
* project switch would leave two gateways mounted at once and the `/mcpctl`
* switcher would report no active project.
*
* A hand-configured server never has an mcpctl PAT stored under `mcp:<name>`,
* so it is never adopted — that pairing is what makes the legacy match safe.
*/
export function isMcpctlManagedEntry(
name: string,
entry: unknown,
authKeys: ReadonlySet<string>,
): boolean {
if (entry === null || typeof entry !== 'object') return false;
const rec = entry as Record<string, unknown>;
if (rec['mcpctlManaged'] === true) return true;
const url = rec['url'];
if (typeof url !== 'string') return false;
// Host-agnostic: adopt regardless of which gateway the old entry pointed at.
const canonical = new RegExp(`/projects/${escapeRegExp(encodeURIComponent(name))}/mcp/*$`);
return canonical.test(url) && authKeys.has(name);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Names of the projects auth.json holds an mcpctl PAT for (`mcp:<project>`).
* Used to recognise entries an older, tag-less CLI wrote. A missing or corrupt
* auth.json yields an empty set — adoption then simply doesn't happen.
*/
export async function primeAgentAuthProjects(authPath: string): Promise<Set<string>> {
let parsed: Record<string, unknown>;
try {
parsed = await loadPrimeAgentAuth(authPath);
} catch {
return new Set();
}
const out = new Set<string>();
for (const [k, v] of Object.entries(parsed)) {
if (!k.startsWith('mcp:')) continue;
const key = (v as { key?: unknown } | null)?.key;
if (typeof key === 'string' && key.startsWith(MCPCTL_TOKEN_PREFIX)) out.add(k.slice(4));
}
return out;
}
export interface RegisterMcpResult {
settingsPath: string;
created: boolean; // true if the settings file did not previously exist
addedServer: string;
newServer: boolean; // true if the project's MCP entry was not already present
url: string;
totalServers: number;
/** Previously-managed mcpctl project entries removed so `addedServer` is the sole active one. */
removed: string[];
}
/**
* Merge a proxy MCP `{ type: "http", url }` entry for `project` into the
* prime-agent settings file. Preserves all other fields and servers, and
* merges into an existing `mcpServers[project]` entry (keeping any user-added
* keys like `headers`) rather than replacing it wholesale.
*/
export async function registerPrimeAgentMcp(
project: string,
settingsPath: string,
gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL,
opts: { authPath?: string } = {},
): Promise<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),
// and tag it so the /mcpctl switcher can find the single *active* project.
settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url, mcpctlManaged: true };
// prime-agent loads every mcpServers entry, so only ONE mcpctl project should
// be active at a time. Remove any *other* mcpctl-managed project entries we
// previously installed — including the untagged ones older CLIs wrote (see
// isMcpctlManagedEntry) — but preserve hand-configured servers (a bespoke
// `sre`, websearch, etc) so switching never nukes unrelated integrations.
const authKeys = opts.authPath !== undefined
? await primeAgentAuthProjects(opts.authPath)
: new Set<string>();
const removed: string[] = [];
for (const k of Object.keys(settings.mcpServers)) {
if (k === project) continue;
if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) {
delete settings.mcpServers[k];
removed.push(k);
}
}
const totalServers = Object.keys(settings.mcpServers).length;
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers, removed };
}
/**
* Ensure `mcp:<project>` carries `{ type: "api_key", key }` in
* `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz`
* provider credential, other `mcp:*` servers, etc).
*
* auth.json holds never-expiring bearer tokens, so it always ends up 0600 —
* never the default umask. `writeFile`'s `mode` only applies when the file is
* created, and prime-agent itself creates auth.json 0644, so we chmod after
* writing rather than trusting the open flags.
*/
export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise<void> {
const current = await loadPrimeAgentAuth(authPath);
current[`mcp:${project}`] = { type: 'api_key', key };
await mkdir(dirname(authPath), { recursive: true });
await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
try {
await chmod(authPath, 0o600);
} catch { /* best-effort: a credential written is better than one refused */ }
}
/**
* Load auth.json.
* - Missing/empty → `{}` (a brand-new file about to be created).
* - Corrupt JSON → throws, so the caller refuses to overwrite it (the same
* guarantee as settings.json — one syntax error must not destroy every
* credential, including the provider API key).
*/
async function loadPrimeAgentAuth(path: string): Promise<Record<string, unknown>> {
let raw: string;
try {
raw = await readFile(path, 'utf-8');
} catch (err: unknown) {
if ((err as { code?: string }).code === 'ENOENT') return {};
throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
}
if (raw.trim().length === 0) return {};
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (err: unknown) {
throw new Error(`auth file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`);
}
}
/**
* The credential currently stored for `project`, or null if there is none.
* Throws on corrupt JSON (the caller must refuse to overwrite the file).
*/
export async function readPrimeAgentAuthKey(project: string, authPath: string): Promise<string | null> {
const parsed = await loadPrimeAgentAuth(authPath) as Record<string, { type?: string; key?: string } | undefined>;
const entry = parsed[`mcp:${project}`];
if (entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0) {
return entry.key;
}
return null;
}
/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */
export async function hasPrimeAgentAuth(project: string, authPath: string): Promise<boolean> {
return (await readPrimeAgentAuthKey(project, authPath)) !== null;
}
/**
* The displayable prefix mcpd records for a raw token (`tokenPrefix` on
* McpToken): the first 16 characters. Lets us match a stored credential against
* the server's token list without ever sending the secret.
*/
export function mcpTokenPrefixOf(raw: string): string {
return raw.slice(0, 16);
}
/** Is this string shaped like an mcpctl PAT (and therefore checkable server-side)? */
export function isMcpctlToken(raw: string): boolean {
return raw.startsWith(MCPCTL_TOKEN_PREFIX);
}
async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,54 @@
/**
* Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`.
*
* This is a thin convenience wrapper around the shared [`runSkillsSync`]
* implementation in `commands/skills.ts`, invoked with `target: 'prime-agent'`.
* All diffing, atomic install, preservation and orphan logic lives there; this
* module only:
* - resolves the prime-agent install root and state file paths, and
* - exposes a `runPrimeAgentSkillsSync` entry that delegates to the unified
* sync so callers and tests keep a stable, intent-revealing name.
*
* Target-specific behaviour (handled by the shared implementation):
* - installs markdown skills under `~/.prime/agent/skills/<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 { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js';
/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */
export function primeAgentSkillsRoot(homeDir: string = homedir()): string {
return join(homeDir, '.prime', 'agent', 'skills');
}
/** prime-agent keeps its own state file so it never collides with Claude's. */
export function primeAgentStatePath(homeDir: string = homedir()): string {
return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json');
}
export type PrimeAgentSyncOpts = Pick<
SyncOpts,
'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir'
>;
export type PrimeAgentSyncResult = SyncResult;
export type PrimeAgentSyncDeps = SyncDeps;
/**
* Sync the active project's skills into prime-agent's markdown skills tree.
* Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2
* disk/state error.
*/
export async function runPrimeAgentSkillsSync(
opts: PrimeAgentSyncOpts,
deps: PrimeAgentSyncDeps,
): Promise<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

@@ -0,0 +1,572 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js';
import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('config prime-agent', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' '));
let prevCwd: string;
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-'));
// config prime-agent writes the .mcpctl-project marker into cwd, so run
// every test from an isolated temp dir to avoid polluting the repo.
prevCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
process.exitCode = 0;
rmSync(tmpDir, { recursive: true, force: true });
});
it('requires --project', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' });
expect(output.join('\n')).toContain('--project is required');
expect(process.exitCode).toBe(1);
});
it('writes proxy MCP entry into prime-agent settings.json', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['homeautomation']).toEqual({
type: 'http',
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`,
mcpctlManaged: true,
});
expect(output.join('\n')).toContain('homeautomation');
});
it('merges with existing servers and preserves other settings', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({
defaultProvider: 'itaz',
mcpServers: {
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` },
},
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.defaultProvider).toBe('itaz'); // untouched
expect(written.mcpServers['sre']).toBeDefined(); // preserved
expect(written.mcpServers['proj-1']).toEqual({
type: 'http',
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`,
mcpctlManaged: true,
});
});
it('writes a project marker for later skills sync', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' });
const markerPath = join(tmpDir, '.mcpctl-project');
expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre');
});
it('--dry-run prints the change without writing', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' });
expect(output.join('\n')).toContain('proj-2');
// No file should have been created.
expect(exceptionSafeRead(settingsPath)).toBeNull();
});
it('does not call the API when --skip-skills and --token are given', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.post).not.toHaveBeenCalled();
});
it('backward compat: prime-agent-generate still works', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['proj-1']).toBeDefined();
});
it('provisions auth.json by minting a project token', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' }));
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' });
});
it('uses --token without calling the API', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' });
});
it('keeps an existing credential and does not re-mint', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } }));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:labctl'].key).toBe('existing');
});
it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
const extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts');
expect(existsSync(extPath)).toBe(true);
expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'");
output.length = 0;
const cmd2 = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
expect(output.join('\n')).not.toContain('switcher extension');
});
it('does not write a .mcpctl-project marker when run from $HOME', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const prevCwd = process.cwd();
process.chdir(homedir());
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
try {
await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
} finally {
process.chdir(prevCwd);
}
expect(output.join('\n')).toContain('Skipped .mcpctl-project marker');
expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull();
});
it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(output.join('\n')).toContain('refusing to overwrite');
expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json');
expect(process.exitCode).toBe(1);
});
it('exits non-zero when a credential cannot be provisioned', async () => {
// mockClient post returns { token: ... } by default; override to no token.
const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client;
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: badClient, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(process.exitCode).toBe(1);
// body of provisioning error surfaced
expect(output.join('\n')).toContain('no token returned');
});
it('writes auth.json with mode 0600', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token
const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777;
expect(mode).toBe(0o600);
});
it('refuses to overwrite a corrupt settings.json', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, '{ this is not valid json !!!');
const prevCwd = process.cwd();
process.chdir(tmpDir);
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
try {
await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
} finally {
process.chdir(prevCwd);
}
expect(output.join('\n')).toContain('refusing to overwrite');
// The corrupt file is untouched.
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!');
});
it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({
mcpServers: {
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set
homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true },
},
}));
// Active project is homeautomation (tagged). Switch to labctl.
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active
expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed
expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved
});
it('adopts an untagged entry an older CLI wrote, keeping hand-configured ones', async () => {
// Written by a CLI that predates `mcpctlManaged`: an untagged entry whose
// URL is canonical AND a matching mcp:<project> PAT in auth.json.
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({
mcpServers: {
legacy: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/legacy/mcp` },
websearch: { type: 'http', url: 'https://search.example/mcp' }, // hand-configured
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // canonical URL, no credential
},
}));
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
itaz: { type: 'api_key', key: 'sk-provider' },
'mcp:legacy': { type: 'api_key', key: 'mcpctl_pat_legacytoken1234' },
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['legacy']).toBeUndefined(); // adopted + unmounted
expect(written.mcpServers['websearch']).toBeDefined(); // unrelated, preserved
expect(written.mcpServers['sre']).toBeDefined(); // no PAT → hand-set, preserved
expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true);
});
it('mints each credential under a unique name (never a fixed one)', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
const body = client.post.mock.calls.find((c) => c[0] === '/api/v1/mcptokens')?.[1] as { name: string };
// A fixed name can only ever be minted once: McpToken is unique on
// (name, projectId) and revoke is a soft delete.
expect(body.name).not.toBe('prime-agent');
expect(body.name).toMatch(/^prime-agent-[a-z0-9-]+$/);
});
it('revokes the token it replaced, only after the replacement is stored', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const authPath = join(tmpDir, 'auth.json');
writeFileSync(authPath, JSON.stringify({
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_oldtoken00000' },
}));
const order: string[] = [];
const api = {
get: vi.fn(async (url: string) => {
order.push(`get ${url}`);
return [
{ id: 'tok-old', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_oldto' },
{ id: 'tok-other', name: 'ci-runner', status: 'active', tokenPrefix: 'mcpctl_pat_ci000' },
];
}),
post: vi.fn(async (url: string) => {
order.push(`post ${url}`);
return {};
}),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
);
// Explicitly replace the stored credential.
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_supplied00000'], { from: 'user' });
// The new credential landed on disk...
expect(JSON.parse(readFileSync(authPath, 'utf-8'))['mcp:p'].key).toBe('mcpctl_pat_supplied00000');
// ...before the token it replaced was revoked — never the other way round.
const revokeAt = order.indexOf('post /api/v1/mcptokens/tok-old/revoke');
expect(revokeAt).toBeGreaterThanOrEqual(0);
expect(statSync(authPath).mtimeMs).toBeGreaterThan(0);
// Tokens this auth.json never held are reported, never revoked.
expect(order).not.toContain('post /api/v1/mcptokens/tok-other/revoke');
});
it('never revokes a token this auth.json did not hold', async () => {
// A run against a custom --output (or a second machine) must not touch the
// credential the real install is using.
const settingsPath = join(tmpDir, 'settings.json');
const api = {
get: vi.fn(async () => [
{ id: 'tok-elsewhere', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_elsew' },
]),
post: vi.fn(async (url: string) => (url === '/api/v1/mcptokens' ? { token: 'mcpctl_pat_brandnew0000' } : {})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
const revokes = api.post.mock.calls.filter((c) => String(c[0]).includes('/revoke'));
expect(revokes).toEqual([]);
// ...but the user is told about it rather than left guessing.
expect(output.join('\n')).toContain('prime-agent-abc');
});
it('leaves settings.json untouched when the credential cannot be provisioned', async () => {
// The active project must keep working when a switch fails: registering the
// new project unmounts the old one, so it may not run before the mint.
const settingsPath = join(tmpDir, 'settings.json');
const before = JSON.stringify({
mcpServers: {
homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true },
},
});
writeFileSync(settingsPath, before);
const badClient = { ...client, get: vi.fn(async () => []), post: vi.fn(async () => ({})) } as unknown as ApiClient;
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: badClient, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(process.exitCode).toBe(1);
expect(readFileSync(settingsPath, 'utf-8')).toBe(before);
});
it('re-mints when the stored credential is no longer active', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_revoked000000' },
}));
const api = {
get: vi.fn(async () => [
{ id: 'tok-1', name: 'prime-agent-old', status: 'revoked', tokenPrefix: 'mcpctl_pat_revo' },
]),
post: vi.fn(async () => ({ token: 'mcpctl_pat_fresh0000000' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:p'].key).toBe('mcpctl_pat_fresh0000000');
expect(process.exitCode).toBe(0);
});
it('keeps a stored credential that is still active', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_liveaaaaaaaa' },
}));
const api = {
get: vi.fn(async () => [
// mcpd records the first 16 chars of the raw token as tokenPrefix.
{ id: 'tok-1', name: 'prime-agent-x', status: 'active', tokenPrefix: 'mcpctl_pat_livea' },
]),
post: vi.fn(async () => ({ token: 'should-not-be-minted' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(api.post).not.toHaveBeenCalled();
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
expect(auth['mcp:p'].key).toBe('mcpctl_pat_liveaaaaaaaa');
});
it('tightens a pre-existing 0644 auth.json to 0600', async () => {
// prime-agent creates auth.json itself with the default umask; writeFile's
// `mode` is ignored for an existing file, so the write must chmod.
const settingsPath = join(tmpDir, 'settings.json');
const authPath = join(tmpDir, 'auth.json');
writeFileSync(authPath, JSON.stringify({ itaz: { type: 'api_key', key: 'sk-x' } }), { mode: 0o644 });
chmodSync(authPath, 0o644);
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
expect(statSync(authPath).mode & 0o777).toBe(0o600);
// The provider credential is still there.
expect(JSON.parse(readFileSync(authPath, 'utf-8')).itaz.key).toBe('sk-x');
});
it('--skip-marker leaves the current directory alone', async () => {
// The /mcpctl switcher runs from whatever directory prime-agent started in.
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills', '--skip-extension', '--skip-marker', '--token', 'mcpctl_pat_x'], { from: 'user' });
expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull();
});
it('the installed switcher extension publishes the active project to the footer', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
// Footer status, refreshed on startup and on every reload (which is what
// the switch itself triggers) — the mcpctl equivalent of the model name.
expect(ext).toContain("pi.on('session_start'");
expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY');
expect(ext).toContain('`mcpctl:${active}`');
// notify() only accepts info|warning|error — 'success' is not a valid type.
expect(ext).not.toContain("'success'");
});
it('the installed switcher extension passes --skip-marker', async () => {
const settingsPath = join(tmpDir, 'settings.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
expect(ext).toContain("'--skip-extension', '--skip-marker'");
});
it('merges a re-configured project entry, preserving user-added fields', async () => {
const settingsPath = join(tmpDir, 'settings.json');
writeFileSync(settingsPath, JSON.stringify({
mcpServers: {
ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } },
},
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(written.mcpServers['ha']).toEqual({
type: 'http',
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`,
headers: { Authorization: 'Bearer u' }, // user-added field preserved
mcpctlManaged: true,
});
});
});
function exceptionSafeRead(path: string): string | null {
try {
return readFileSync(path, 'utf-8');
} catch {
return null;
}
}

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

@@ -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');

View File

@@ -0,0 +1,323 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js';
import { loadState } from '../../src/utils/skills-state.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(overrides: Record<string, unknown> = {}): 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<string, unknown>)?.[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<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);
});
it('does not overwrite a same-named skill owned by a different project', async () => {
// Project A installs skill X.
const av = [
{ id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' },
];
const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } };
const clientA = mockClient({ visible: av, full: af });
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA));
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n');
// Project B also has a skill named X with different content.
const bv = [
{ id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' },
];
const bf = { 'b-1': { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# version-b\n', files: {} } };
const clientB = mockClient({ visible: bv, full: bf });
const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB));
expect(resultB.preserved).toContain('x-skill');
// A's version is untouched (not clobbered by B's).
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n');
});
it('does not delete legacy, ownership-less state belonging to another project', async () => {
// State written by a CLI that predates the `project` field: the skill has
// no recorded owner and the file records projA as the last syncing project.
const legacyDir = join(installRoot, 'legacy-skill');
mkdirSync(legacyDir, { recursive: true });
writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8');
writeFileSync(statePath, JSON.stringify({
schemaVersion: 1,
lastSync: '2026-01-01T00:00:00.000Z',
lastSyncProject: 'projA',
skills: {
'legacy-skill': {
id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project',
installDir: legacyDir, files: {}, postInstallHash: null,
lastSyncedAt: '2026-01-01T00:00:00.000Z',
// note: no `project` field
},
},
}), 'utf-8');
// First sync after upgrading, for a *different* project.
const client = mockClient({ visible: [], full: {} });
const result = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(client));
expect(result.removed).toEqual([]);
expect(existsSync(legacyDir)).toBe(true);
});
it('cleans up legacy state once the owning project syncs again', async () => {
const legacyDir = join(installRoot, 'legacy-skill');
mkdirSync(legacyDir, { recursive: true });
writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8');
writeFileSync(statePath, JSON.stringify({
schemaVersion: 1,
lastSync: '2026-01-01T00:00:00.000Z',
lastSyncProject: 'projA',
skills: {
'legacy-skill': {
id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project',
installDir: legacyDir, files: {}, postInstallHash: null,
lastSyncedAt: '2026-01-01T00:00:00.000Z',
},
},
}), 'utf-8');
const client = mockClient({ visible: [], full: {} });
const result = await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(client));
expect(result.removed).toContain('legacy-skill');
expect(existsSync(legacyDir)).toBe(false);
});
it('keeps global skills updatable after switching projects', async () => {
// A global installed while projA was active must not be pinned to projA —
// globals are visible from every project.
const gv = (hash: string) => [
{ id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, metadata: {}, scope: 'global' },
];
const gf = (hash: string, body: string) => ({
'g-1': { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, content: body, files: {} },
});
const clientA = mockClient({ visible: gv('sha256:v1'), full: gf('sha256:v1', '# v1\n') });
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA));
expect((await loadState(statePath)).skills['shared-global']?.project).toBeNull();
// Switch to projB; the global has been updated server-side.
const clientB = mockClient({ visible: gv('sha256:v2'), full: gf('sha256:v2', '# v2\n') });
const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB));
expect(resultB.updated).toContain('shared-global');
expect(resultB.preserved).toEqual([]);
expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toBe('# v2\n');
});
it('does not let a global-only sync clobber a project-owned skill', async () => {
const av = [
{ id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' },
];
const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } };
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(mockClient({ visible: av, full: af })));
// A global of the same name shows up on a global-only sync.
const gv = [
{ id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' },
];
const gf = { 'g-9': { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# global\n', files: {} } };
const empty = join(tmpDir, 'empty3');
mkdirSync(empty, { recursive: true });
const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf })));
expect(result.preserved).toContain('x-skill');
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n');
});
it('removes global orphans on a global-only sync', async () => {
// First sync a global skill.
const v = [
{ id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' },
];
const f = { 'g-1': { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# g\n', files: {} } };
const client1 = mockClient({ visible: v, full: f });
const empty = join(tmpDir, 'empty2');
mkdirSync(empty, { recursive: true });
await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client1));
expect(existsSync(join(installRoot, 'gone-global'))).toBe(true);
// Next global-only sync: the global is gone from the visible set.
const client2 = mockClient({ visible: [], full: {} });
const result2 = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client2));
expect(result2.removed).toContain('gone-global');
expect(existsSync(join(installRoot, 'gone-global'))).toBe(false);
});
});