fix(cli): harden config prime-agent sync + install /mcpctl switcher extension
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m6s
CI/CD / test (pull_request) Successful in 3m9s
CI/CD / build (pull_request) Successful in 2m16s
CI/CD / smoke (pull_request) Failing after 3m27s
CI/CD / publish (pull_request) Has been skipped

Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.

Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
  JSON instead of swallowing it and rewriting the file (which destroyed every
  non-mcpServers setting). A project's mcpServers entry is merged (keeping
  user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
  / hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
  via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
  a thin wrapper). Under the prime-agent target it: preserves untracked
  pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
  records per-project ownership so configuring a second project never deletes
  the first project's skills, skips Claude-only hooks/postInstall, and keeps
  the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
  auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
  (b) writes the .mcpctl-project marker only when none exists up-tree and never
  from $HOME, and (c) propagates the skills sync exit code so auth failures are
  reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
  silently running the Claude sync.

New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.

Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
This commit is contained in:
Michal
2026-08-08 10:22:46 +01:00
parent 582f6f185b
commit eb1642ab1a
12 changed files with 584 additions and 368 deletions

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
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';
@@ -114,15 +114,16 @@ describe('config prime-agent', () => {
expect(exceptionSafeRead(settingsPath)).toBeNull();
});
it('does not call the API when --skip-skills is set', async () => {
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'], { from: 'user' });
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 () => {
@@ -136,6 +137,124 @@ describe('config prime-agent', () => {
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 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('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
});
});
});
function exceptionSafeRead(path: string): string | null {

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createSkillsCommand } from '../../src/commands/skills.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('skills sync --agent', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-skills-agent-'));
process.exitCode = 0;
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
process.exitCode = 0;
});
it('defaults to claude and runs the normal sync', async () => {
const cmd = createSkillsCommand({ client, log });
await cmd.parseAsync(['sync', '--project', 'proj', '--skip-postinstall'], { from: 'user' });
// claude path calls the project visible endpoint.
expect(String(client.get.mock.calls[0]?.[0])).toContain('/skills/visible');
});
it('routes --agent prime-agent to the prime-agent target', async () => {
const cmd = createSkillsCommand({ client, log });
await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'prime-agent'], { from: 'user' });
// prime-agent path also hits the project visible endpoint, and the summary
// line should mention the target.
expect(output.join('\n')).toContain('prime-agent');
});
it('rejects an unknown --agent value with a non-zero exit', async () => {
const cmd = createSkillsCommand({ client, log });
await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' });
expect(process.exitCode).toBe(1);
expect(client.get).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
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';
@@ -121,4 +121,66 @@ describe('runPrimeAgentSkillsSync', () => {
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);
});
});