`/mcpctl` could switch projects but there was no way to see which one was active without running a command. prime-agent exposes the same status-bar channel the model name uses (`ctx.ui.setStatus`), so the switcher now publishes `mcpctl:<project>` there. Wired to `session_start`, which fires on startup *and* on every reload — including the reload the switch itself triggers — so the footer tracks settings.json without extra bookkeeping. Cleared when no project is mounted. Also fixes `notify(..., 'success')`: the API only accepts info|warning|error. Not a live bug (prime-agent falls through to the same showStatus path as 'info') but it fails a typecheck of the extension against the real ExtensionAPI, which is how it was found. The extension ships as a JSON-escaped string and is never compiled by our build, so it was typechecked out-of-tree against @earendil-works/pi-coding-agent's published types. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
573 lines
26 KiB
TypeScript
573 lines
26 KiB
TypeScript
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;
|
|
}
|
|
}
|