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:
323
src/cli/tests/utils/prime-agent-skills.test.ts
Normal file
323
src/cli/tests/utils/prime-agent-skills.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user