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

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:
Michal
2026-08-08 09:34:15 +01:00
parent 2c8419eddb
commit 582f6f185b
10 changed files with 831 additions and 8 deletions

View File

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

View File

@@ -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 } : {}),

View 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,
};
}

View 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) });
}
}
}

View 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;
}
}

View File

@@ -234,7 +234,7 @@ describe('agent + chat completions', () => {
});
it('bash dispatches `create agent` with the correct flags', () => {
const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? '';
const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? '';
expect(createBlock).toContain('--llm');
expect(createBlock).toContain('--system-prompt');
expect(createBlock).toContain('--default-temperature');

View File

@@ -0,0 +1,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);
});
});