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

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