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:
@@ -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 1–2) determine whether the
|
||||
// switch succeeded. A skills error is reported but must not flip the
|
||||
// /mcpctl switch to "failed" when MCP access is already provisioned.
|
||||
if (opts.skipSkills !== true) {
|
||||
if (skillsClient) {
|
||||
try {
|
||||
const result = await runPrimeAgentSkillsSync(
|
||||
{ project: opts.project },
|
||||
{ client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters<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) {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
10
src/cli/src/config/prime-agent-extension.ts
Normal file
10
src/cli/src/config/prime-agent-extension.ts
Normal file
File diff suppressed because one or more lines are too long
277
src/cli/src/config/prime-agent.ts
Normal file
277
src/cli/src/config/prime-agent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/cli/src/utils/prime-agent-skills.ts
Normal file
54
src/cli/src/utils/prime-agent-skills.ts
Normal 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 };
|
||||
@@ -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 {
|
||||
|
||||
572
src/cli/tests/commands/prime-agent.test.ts
Normal file
572
src/cli/tests/commands/prime-agent.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/cli/tests/commands/skills.test.ts
Normal file
56
src/cli/tests/commands/skills.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
323
src/cli/tests/utils/prime-agent-skills.test.ts
Normal file
323
src/cli/tests/utils/prime-agent-skills.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user