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:
Michal
2026-08-08 16:35:51 +01:00
13 changed files with 1849 additions and 39 deletions

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

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

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