feat(cli): add mcpctl config prime-agent — proxy MCP + skills sync for prime-agent
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m14s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m14s
CI/CD / publish (pull_request) Has been skipped
Mirror `mcpctl config claude` for prime-agent (which talks to the same mcpctl
proxy MCP gateway over HTTP instead of stdio):
`mcpctl config prime-agent --project X`:
- registers the proxy MCP gateway in ~/.prime/agent/settings.json as
mcpServers.X = { type: "http", url: <gateway>/projects/X/mcp }, merging with
any existing servers (e.g. the bundled `sre` project) and preserving all other
settings
- writes a .mcpctl-project marker so later syncs resolve the project
- syncs the project's skills into ~/.prime/agent/skills/<name>/ as markdown
skills (prime-agent auto-discovers them at session start)
New `mcpctl skills sync --agent prime-agent` target re-syncs the tree later.
- src/cli/src/config/prime-agent.ts: settings.json read/merge/write helpers
- src/cli/src/utils/prime-agent-skills.ts: prime-agent sync (reuses
installSkillAtomic + skills-state; skips Claude-only hooks/postInstall)
- completes config.ts/skills.ts wiring; regenerated shell completions
- tests: commands/prime-agent.test.ts + utils/prime-agent-skills.test.ts
This commit is contained in:
@@ -12,6 +12,12 @@ import type { ApiClient } from '../api-client.js';
|
||||
import { writeProjectMarker } from '../utils/project-marker.js';
|
||||
import { installManagedSessionHook } from '../utils/sessionhook.js';
|
||||
import { runSkillsSync } from './skills.js';
|
||||
import {
|
||||
registerPrimeAgentMcp,
|
||||
primeAgentSettingsPath,
|
||||
DEFAULT_MCPCTL_GATEWAY_URL,
|
||||
} from '../config/prime-agent.js';
|
||||
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
|
||||
@@ -193,9 +199,97 @@ 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 + sync skills 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('--skip-skills', 'Skip the skills sync step')
|
||||
.option('--dry-run', 'Print the settings.json change without writing or syncing')
|
||||
.action(async (opts: {
|
||||
project?: string;
|
||||
output?: string;
|
||||
gatewayUrl: string;
|
||||
skipSkills?: boolean;
|
||||
dryRun?: boolean;
|
||||
}) => {
|
||||
if (opts.project === undefined || opts.project === '') {
|
||||
log('Error: --project is required');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsPath = resolve(opts.output ?? primeAgentSettingsPath());
|
||||
|
||||
if (opts.dryRun === true) {
|
||||
const dry = JSON.stringify({
|
||||
primeAgent: {
|
||||
settingsPath,
|
||||
mcpServers: {
|
||||
[opts.project]: { type: 'http', url: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp` },
|
||||
},
|
||||
},
|
||||
action: 'write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
|
||||
}, null, 2);
|
||||
log(dry);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl);
|
||||
log(reg.created
|
||||
? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})`
|
||||
: `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`);
|
||||
} catch (err: unknown) {
|
||||
log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Write the project marker in cwd so later `skills sync` calls resolve scope.
|
||||
try {
|
||||
const markerPath = await writeProjectMarker(process.cwd(), opts.project);
|
||||
log(`Wrote ${markerPath}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Sync skills into prime-agent's skills tree (skippable).
|
||||
if (opts.skipSkills !== true) {
|
||||
if (skillsClient) {
|
||||
try {
|
||||
const result = await runPrimeAgentSkillsSync(
|
||||
{ project: opts.project },
|
||||
{ client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
|
||||
);
|
||||
const total = result.installed.length + result.updated.length + result.removed.length;
|
||||
if (total > 0) {
|
||||
log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: prime-agent skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} else {
|
||||
log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent prime-agent` separately)');
|
||||
}
|
||||
}
|
||||
});
|
||||
if (hidden) {
|
||||
void cmd;
|
||||
}
|
||||
}
|
||||
|
||||
registerClaudeCommand('claude', false);
|
||||
registerClaudeCommand('claude-generate', true); // backward compat
|
||||
|
||||
registerPrimeAgentCommand('prime-agent', false);
|
||||
registerPrimeAgentCommand('prime-agent-generate', true); // backward compat
|
||||
|
||||
config.addCommand(createConfigSetupCommand({ configDeps }));
|
||||
|
||||
if (apiDeps) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
parseMcpServerDeps,
|
||||
} from '../utils/mcpservers-materialiser.js';
|
||||
import { ApiError } from '../api-client.js';
|
||||
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
|
||||
|
||||
/**
|
||||
* `mcpctl skills sync` — materialise server-side skills onto disk under
|
||||
@@ -441,11 +442,12 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
|
||||
console.error(...(args as Parameters<typeof console.error>));
|
||||
};
|
||||
|
||||
const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd');
|
||||
const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)');
|
||||
|
||||
cmd.command('sync')
|
||||
.description('Sync skills from mcpd onto disk under ~/.claude/skills/')
|
||||
.description('Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)')
|
||||
.option('-p, --project <name>', 'Project to sync (overrides .mcpctl-project marker)')
|
||||
.option('--agent <name>', 'Sync target: claude (default) or prime-agent', 'claude')
|
||||
.option('--dry-run', 'Print what would change without writing anything')
|
||||
.option('--force', 'Overwrite locally-modified skills')
|
||||
.option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)')
|
||||
@@ -453,12 +455,30 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
|
||||
.option('--keep-orphans', 'Do not remove skills that are no longer in the server set')
|
||||
.action(async (opts: {
|
||||
project?: string;
|
||||
agent?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
quiet?: boolean;
|
||||
skipPostinstall?: boolean;
|
||||
keepOrphans?: boolean;
|
||||
}) => {
|
||||
if (opts.agent === 'prime-agent') {
|
||||
const result = await runPrimeAgentSkillsSync(
|
||||
{
|
||||
...(opts.project !== undefined ? { project: opts.project } : {}),
|
||||
...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}),
|
||||
...(opts.force !== undefined ? { force: opts.force } : {}),
|
||||
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
|
||||
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
|
||||
},
|
||||
{ client, log, warn },
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runSkillsSync(
|
||||
{
|
||||
...(opts.project !== undefined ? { project: opts.project } : {}),
|
||||
|
||||
97
src/cli/src/config/prime-agent.ts
Normal file
97
src/cli/src/config/prime-agent.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Read/merge/write helpers for prime-agent's own configuration files, used
|
||||
* by `mcpctl config prime-agent`.
|
||||
*
|
||||
* prime-agent keeps two user-editable files under `~/.prime/agent/`:
|
||||
* - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus
|
||||
* model/provider preferences. `mcpctl config prime-agent` registers our
|
||||
* proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`.
|
||||
* - `auth.json` — per-server bearer tokens keyed as `mcp:<server>`.
|
||||
*
|
||||
* We only ever merge the `mcpServers` map, preserving every other key and any
|
||||
* servers the user has already configured (including non-mcpctl gateways like
|
||||
* the bundled `sre` project).
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/** Base URL of the deployed mcpctl HTTP MCP gateway. */
|
||||
export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu';
|
||||
|
||||
/** Resolve the prime-agent settings.json path. */
|
||||
export function primeAgentSettingsPath(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'settings.json');
|
||||
}
|
||||
|
||||
/** Proxy MCP URL for a given project on the gateway. */
|
||||
export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string {
|
||||
const base = gatewayUrl.replace(/\/+$/, '');
|
||||
return `${base}/projects/${encodeURIComponent(project)}/mcp`;
|
||||
}
|
||||
|
||||
interface PrimeAgentSettings {
|
||||
mcpServers?: Record<string, { type: string; url: string; [k: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Load prime-agent settings; return an empty object if absent/invalid. */
|
||||
export async function loadPrimeAgentSettings(path: string): Promise<PrimeAgentSettings> {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as PrimeAgentSettings;
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisterMcpResult {
|
||||
settingsPath: string;
|
||||
created: boolean; // true if the settings file did not previously exist
|
||||
addedServer: string;
|
||||
newServer: boolean; // true if the project's MCP entry was not already present
|
||||
url: string;
|
||||
totalServers: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a proxy MCP `{ type: "http", url }` entry for `project` into the
|
||||
* prime-agent settings file, preserving all other fields and servers.
|
||||
* Returns a summary of what changed.
|
||||
*/
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPrimeAgentMcp(
|
||||
project: string,
|
||||
settingsPath: string,
|
||||
gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL,
|
||||
): Promise<RegisterMcpResult> {
|
||||
const existed = await pathExists(settingsPath);
|
||||
const settings = await loadPrimeAgentSettings(settingsPath);
|
||||
|
||||
settings.mcpServers = settings.mcpServers ?? {};
|
||||
const url = projectMcpUrl(project, gatewayUrl);
|
||||
const isNewServer = !Object.prototype.hasOwnProperty.call(settings.mcpServers, project);
|
||||
settings.mcpServers[project] = { type: 'http', url };
|
||||
const totalServers = Object.keys(settings.mcpServers).length;
|
||||
|
||||
await mkdir(dirname(settingsPath), { recursive: true });
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
|
||||
return {
|
||||
settingsPath,
|
||||
created: !existed,
|
||||
addedServer: project,
|
||||
newServer: isNewServer,
|
||||
url,
|
||||
totalServers,
|
||||
};
|
||||
}
|
||||
287
src/cli/src/utils/prime-agent-skills.ts
Normal file
287
src/cli/src/utils/prime-agent-skills.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Prime-agent skill sync for `mcpctl config prime-agent`.
|
||||
*
|
||||
* Mirrors `runSkillsSync` (which targets Claude Code's `~/.claude/skills/`)
|
||||
* but materialises server-side skills as *markdown* skills for prime-agent
|
||||
* under `~/.prime/agent/skills/<name>/`.
|
||||
*
|
||||
* Why a separate module instead of parameterising `runSkillsSync`:
|
||||
* - prime-agent skills carry no `hooks` (there is no SessionStart hook on
|
||||
* the prime-agent side) and must never touch `~/.claude/settings.json`,
|
||||
* so the hooks side-effect in `runSkillsSync` would be wrong here.
|
||||
* - prime-agent skills have no `postInstall` scripts (server scripts assume
|
||||
* a Claude-esque shell), so we skip that machinery too.
|
||||
*
|
||||
* The on-disk format is deliberately the same as what prime-agent already
|
||||
* ships natively: a directory per skill with a `SKILL.md` (plus any auxiliary
|
||||
* `files`). prime-agent auto-discovers these at session start, so once the
|
||||
* config command has pointed prime-agent at the proxy MCP and synced the
|
||||
* project's skills, later `mcpctl skills sync --agent prime-agent` calls (or
|
||||
* the config command itself) keep the tree up to date.
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { ApiError } from '../api-client.js';
|
||||
import { findProjectMarker } from './project-marker.js';
|
||||
import {
|
||||
loadState,
|
||||
saveState,
|
||||
detectModifiedFiles,
|
||||
type SkillState,
|
||||
} from './skills-state.js';
|
||||
import {
|
||||
installSkillAtomic,
|
||||
removeSkillAtomic,
|
||||
} from './skills-disk.js';
|
||||
|
||||
/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */
|
||||
export function primeAgentSkillsRoot(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'skills');
|
||||
}
|
||||
|
||||
/** State bookkeeping lives separately from the Claude skills state. */
|
||||
export function primeAgentStatePath(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json');
|
||||
}
|
||||
|
||||
/** Shape of a server-side visible skill (subset we act on). */
|
||||
interface VisibleSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
semver: string;
|
||||
contentHash: string;
|
||||
metadata: unknown;
|
||||
scope: 'project' | 'global' | 'agent';
|
||||
}
|
||||
|
||||
/** Full skill body fetched from /api/v1/skills/:id (subset we install). */
|
||||
interface FullSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
semver: string;
|
||||
contentHash: string;
|
||||
content: string;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PrimeAgentSyncOpts {
|
||||
/** Project name; otherwise resolved from the .mcpctl-project marker. */
|
||||
project?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
quiet?: boolean;
|
||||
keepOrphans?: boolean;
|
||||
/** For tests: override cwd for the marker walk-up. */
|
||||
cwd?: string;
|
||||
/** For tests: override the prime-agent skills root. */
|
||||
installRoot?: string;
|
||||
/** For tests: override the state file path. */
|
||||
statePath?: string;
|
||||
/** For tests: override $HOME used for default paths. */
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export interface PrimeAgentSyncResult {
|
||||
installed: string[];
|
||||
updated: string[];
|
||||
skipped: string[];
|
||||
removed: string[];
|
||||
preserved: string[];
|
||||
errors: Array<{ skill: string; error: string }>;
|
||||
exitCode: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
export interface PrimeAgentSyncDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the active project's skills into prime-agent's markdown skills tree.
|
||||
* Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2
|
||||
* disk/state error.
|
||||
*/
|
||||
export async function runPrimeAgentSkillsSync(opts: PrimeAgentSyncOpts, deps: PrimeAgentSyncDeps): Promise<PrimeAgentSyncResult> {
|
||||
const { client, log, warn } = deps;
|
||||
const result: PrimeAgentSyncResult = {
|
||||
installed: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
removed: [],
|
||||
preserved: [],
|
||||
errors: [],
|
||||
exitCode: 0,
|
||||
};
|
||||
|
||||
// 1. Resolve project scope (explicit flag beats the marker walk-up).
|
||||
let projectName = opts.project;
|
||||
if (projectName === undefined || projectName === '') {
|
||||
const marker = await findProjectMarker(opts.cwd ?? process.cwd(), opts.homeDir ?? homedir());
|
||||
if (marker) projectName = marker.project;
|
||||
}
|
||||
|
||||
// 2. Fetch the visible skill list.
|
||||
let visible: VisibleSkill[];
|
||||
try {
|
||||
if (projectName !== undefined) {
|
||||
visible = await client.get<VisibleSkill[]>(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`);
|
||||
} else {
|
||||
visible = await client.get<VisibleSkill[]>('/api/v1/skills?scope=global');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
warn('mcpctl: auth failed — run `mcpctl login`');
|
||||
result.exitCode = 1;
|
||||
return result;
|
||||
}
|
||||
if (opts.quiet === true) {
|
||||
// Fail-open in quiet mode so a hung mcpd never blocks agent startup.
|
||||
warn(`mcpctl: prime-agent skills sync skipped — ${err instanceof Error ? err.message : String(err)}`);
|
||||
result.exitCode = 0;
|
||||
return result;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Agent-scoped skills aren't surfaced to a user's prime-agent session.
|
||||
visible = visible.filter((s) => s.scope !== 'agent');
|
||||
|
||||
// 3. Load state + resolve install root.
|
||||
const statePath = opts.statePath ?? primeAgentStatePath(opts.homeDir ?? homedir());
|
||||
const state = await loadState(statePath);
|
||||
const installRoot = opts.installRoot ?? primeAgentSkillsRoot(opts.homeDir ?? homedir());
|
||||
|
||||
// 4. Diff against last sync.
|
||||
const visibleByName = new Map(visible.map((s) => [s.name, s]));
|
||||
const stateNames = Object.keys(state.skills);
|
||||
|
||||
const toFetch: VisibleSkill[] = [];
|
||||
for (const v of visible) {
|
||||
const prior = state.skills[v.name];
|
||||
if (!prior) {
|
||||
toFetch.push(v);
|
||||
continue;
|
||||
}
|
||||
if (prior.contentHash === v.contentHash) {
|
||||
result.skipped.push(v.name);
|
||||
continue;
|
||||
}
|
||||
toFetch.push(v);
|
||||
}
|
||||
|
||||
// 5. Apply install/update (concurrency limit 5).
|
||||
const concurrency = 5;
|
||||
for (let i = 0; i < toFetch.length; i += concurrency) {
|
||||
const batch = toFetch.slice(i, i + concurrency);
|
||||
await Promise.all(batch.map((v) => applyOne(v)));
|
||||
}
|
||||
|
||||
// 6. Orphan removal.
|
||||
if (opts.keepOrphans !== true) {
|
||||
for (const name of stateNames) {
|
||||
if (visibleByName.has(name)) continue;
|
||||
const prior = state.skills[name];
|
||||
if (!prior) continue;
|
||||
try {
|
||||
const modified = await detectModifiedFiles(prior.installDir, prior.files);
|
||||
if (modified.length > 0 && opts.force !== true) {
|
||||
warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`);
|
||||
result.preserved.push(name);
|
||||
continue;
|
||||
}
|
||||
if (opts.dryRun === true) {
|
||||
result.removed.push(name);
|
||||
continue;
|
||||
}
|
||||
await removeSkillAtomic(prior.installDir);
|
||||
delete state.skills[name];
|
||||
result.removed.push(name);
|
||||
} catch (err: unknown) {
|
||||
result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Persist state.
|
||||
state.lastSync = new Date().toISOString();
|
||||
if (projectName !== undefined) state.lastSyncProject = projectName;
|
||||
if (opts.dryRun !== true) {
|
||||
try {
|
||||
await saveState(state, statePath);
|
||||
} catch (err: unknown) {
|
||||
warn(`mcpctl: failed to persist prime-agent skills state — ${err instanceof Error ? err.message : String(err)}`);
|
||||
result.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Summary.
|
||||
const anythingHappened =
|
||||
result.errors.length > 0 ||
|
||||
result.installed.length > 0 ||
|
||||
result.updated.length > 0 ||
|
||||
result.removed.length > 0;
|
||||
if (opts.quiet !== true || anythingHappened) {
|
||||
const parts: string[] = [];
|
||||
if (result.installed.length) parts.push(`${String(result.installed.length)} installed`);
|
||||
if (result.updated.length) parts.push(`${String(result.updated.length)} updated`);
|
||||
if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`);
|
||||
if (result.removed.length) parts.push(`${String(result.removed.length)} removed`);
|
||||
if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`);
|
||||
if (result.errors.length) parts.push(`${String(result.errors.length)} errors`);
|
||||
if (parts.length === 0) parts.push('no changes');
|
||||
if (opts.quiet !== true) {
|
||||
log(`mcpctl prime-agent skills sync${projectName !== undefined ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
|
||||
} else {
|
||||
warn(`mcpctl: ${parts.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
async function applyOne(v: VisibleSkill): Promise<void> {
|
||||
try {
|
||||
const prior = state.skills[v.name];
|
||||
const targetDir = prior?.installDir ?? join(installRoot, v.name);
|
||||
if (prior !== undefined && opts.force !== true) {
|
||||
const modified = await detectModifiedFiles(prior.installDir, prior.files);
|
||||
if (modified.length > 0) {
|
||||
warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`);
|
||||
result.preserved.push(v.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (opts.dryRun === true) {
|
||||
if (prior) result.updated.push(v.name);
|
||||
else result.installed.push(v.name);
|
||||
return;
|
||||
}
|
||||
|
||||
const full = await client.get<FullSkill>(`/api/v1/skills/${encodeURIComponent(v.id)}`);
|
||||
const files = await installSkillAtomic(targetDir, {
|
||||
content: full.content,
|
||||
...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}),
|
||||
});
|
||||
|
||||
const newState: SkillState = {
|
||||
id: v.id,
|
||||
semver: v.semver,
|
||||
contentHash: v.contentHash,
|
||||
scope: v.scope,
|
||||
installDir: targetDir,
|
||||
files,
|
||||
postInstallHash: null,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
};
|
||||
state.skills[v.name] = newState;
|
||||
if (prior) result.updated.push(v.name);
|
||||
else result.installed.push(v.name);
|
||||
} catch (err: unknown) {
|
||||
result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/cli/tests/commands/prime-agent.test.ts
Normal file
147
src/cli/tests/commands/prime-agent.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('config prime-agent', () => {
|
||||
let client: ReturnType<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`,
|
||||
});
|
||||
expect(output.join('\n')).toContain('homeautomation');
|
||||
});
|
||||
|
||||
it('merges with existing servers and preserves other settings', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
defaultProvider: 'itaz',
|
||||
mcpServers: {
|
||||
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` },
|
||||
},
|
||||
}));
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.defaultProvider).toBe('itaz'); // untouched
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // preserved
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a project marker for later skills sync', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const markerPath = join(tmpDir, '.mcpctl-project');
|
||||
expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre');
|
||||
});
|
||||
|
||||
it('--dry-run prints the change without writing', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('proj-2');
|
||||
// No file should have been created.
|
||||
expect(exceptionSafeRead(settingsPath)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not call the API when --skip-skills is set', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('backward compat: prime-agent-generate still works', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['proj-1']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
function exceptionSafeRead(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
124
src/cli/tests/utils/prime-agent-skills.test.ts
Normal file
124
src/cli/tests/utils/prime-agent-skills.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js';
|
||||
import { loadState } from '../../src/utils/skills-state.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(overrides: Record<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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user