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