import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, readFileSync, readdirSync, lstatSync, existsSync, realpathSync, utimesSync, chmodSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { planSync, syncSkillStore, summarize, autoSync, readStoreState, writeStoreState, reportStoreConflicts, forgetStoreConflicts, findOneCliRepoSkills, reportRepoSkills, forgetRepoSkills } from '../server/skill-store.js'; import { scanFamilies, readMap } from '../server/skill-families.js'; import { removeTempDir } from './temp-dir.js'; let root, homes, backupRoot, mapFile, stateFile; const skill = (dir, name = dir.split(/[\\/]/).pop(), body = 'body') => { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: Does ${name}.\n---\n${body}\n`); }; const claude = (n) => join(homes.claudeDir, 'skills', n); const codex = (n) => join(homes.codexDir, 'skills', n); const store = (n) => join(homes.agentsDir, 'skills', n); const isLink = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }; const map = () => readMap(mapFile); const sync = (opts = {}) => syncSkillStore({ homes, map: map(), backupRoot, now: new Date('2026-10-09T12:00:00Z'), ...opts }); // Every path under root with what it is, to show a dry run touched nothing. const tree = (dir = root) => readdirSync(dir).flatMap((n) => { const p = join(dir, n); const st = lstatSync(p); return st.isSymbolicLink() ? [`${p} -> link`] : st.isDirectory() ? [p, ...tree(p)] : [`${p} ${readFileSync(p, 'utf8')}`]; }); beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'a007-store-')); homes = { claudeDir: join(root, '.claude'), codexDir: join(root, '.codex'), agentsDir: join(root, '.agents') }; backupRoot = join(root, '.agent-007', 'skill-backup'); mapFile = join(root, '.agent-007', 'skill-families.json'); stateFile = join(root, '.agent-007', 'skill-store.json'); mkdirSync(join(homes.claudeDir, 'skills'), { recursive: true }); mkdirSync(join(homes.codexDir, 'skills'), { recursive: true }); mkdirSync(join(root, '.agent-007')); forgetStoreConflicts(); forgetRepoSkills(); }); afterEach(() => removeTempDir(root)); describe('skill store sync', () => { it('moves a Claude-only skill into the store, links it back and keeps a backup', () => { skill(claude('copywriting'), 'copywriting', 'v1'); const r = sync(); expect(r.moved).toEqual(['copywriting']); expect(r.linked).toEqual(['copywriting']); expect(readFileSync(join(store('copywriting'), 'SKILL.md'), 'utf8')).toMatch(/v1/); expect(isLink(claude('copywriting'))).toBe(true); expect(realpathSync(claude('copywriting'))).toBe(realpathSync(store('copywriting'))); expect(readFileSync(join(r.backup, 'claude', 'copywriting', 'SKILL.md'), 'utf8')).toMatch(/v1/); expect(r.backup.startsWith(backupRoot)).toBe(true); }); it('moves a Codex-only skill into the store, with no link left in ~/.codex/skills, and links it for Claude', () => { skill(codex('seo-audit')); const r = sync(); expect(r.moved).toEqual(['seo-audit']); expect(existsSync(codex('seo-audit'))).toBe(false); expect(existsSync(join(store('seo-audit'), 'SKILL.md'))).toBe(true); expect(isLink(claude('seo-audit'))).toBe(true); expect(existsSync(join(r.backup, 'codex', 'seo-audit', 'SKILL.md'))).toBe(true); }); it('links a skill already in the store that Claude Code cannot see', () => { skill(store('ads')); const r = sync(); expect(r).toMatchObject({ moved: [], linked: ['ads'], backup: null }); expect(isLink(claude('ads'))).toBe(true); }); it('keeps one of identical copies in Claude, Codex and the store', () => { for (const p of [claude('pdf'), codex('pdf'), store('pdf')]) skill(p, 'pdf', 'same'); const r = sync(); expect(r).toMatchObject({ moved: [], folded: ['pdf'], linked: ['pdf'], conflicts: [] }); expect(isLink(claude('pdf'))).toBe(true); expect(existsSync(codex('pdf'))).toBe(false); expect(existsSync(join(r.backup, 'claude', 'pdf', 'SKILL.md')) && existsSync(join(r.backup, 'codex', 'pdf', 'SKILL.md'))).toBe(true); }); it('leaves one name with different contents alone and tells Billion once', () => { skill(claude('xlsx'), 'xlsx', 'one'); skill(codex('xlsx'), 'xlsx', 'two'); const before = tree(); const r = sync(); expect(r.conflicts.map(c => c.name)).toEqual(['xlsx']); expect(r.conflicts[0].paths).toEqual([claude('xlsx'), codex('xlsx')]); expect(tree()).toEqual(before); expect(summarize(r)).toMatch(/Left 1 skill alone \(xlsx: copies with different contents\)/); const sent = []; const send = (to, headline, lines) => sent.push({ to, headline, lines }); expect(reportStoreConflicts('billion', send)).toBe(1); expect(reportStoreConflicts('billion', send)).toBe(0); expect(sent[0].headline).toMatch(/left "xlsx" where it is/); expect(sent[0].lines.join('\n')).toMatch(/deletes nothing/); expect(reportStoreConflicts(null, send)).toBe(0); }); it('a conflict a send could not deliver is told on the next try', () => { skill(claude('xlsx'), 'xlsx', 'one'); skill(codex('xlsx'), 'xlsx', 'two'); sync(); expect(reportStoreConflicts('billion', () => false)).toBe(0); expect(reportStoreConflicts('billion', () => true)).toBe(1); }); it('a store copy that differs is a conflict too', () => { skill(claude('cro'), 'cro', 'mine'); skill(store('cro'), 'cro', 'theirs'); expect(sync().conflicts.map(c => c.paths.length)).toEqual([2]); expect(isLink(claude('cro'))).toBe(false); }); it('skips links, synced, .system, gstack and its aliases, folders with .git or .gstack-owned and claudeOnly skills', () => { skill(store('linked')); symlinkSync(store('linked'), claude('linked'), 'junction'); skill(join(claude('synced'), 'pdf')); skill(claude('synced')); skill(codex('.system')); skill(join(codex('.system'), 'imagegen')); skill(claude('gstack')); skill(claude('connect-chrome')); skill(codex('gstack-ship')); writeFileSync(join(codex('gstack-ship'), '.gstack-owned'), ''); skill(claude('cloned')); mkdirSync(join(claude('cloned'), '.git')); skill(claude('mine')); writeFileSync(mapFile, JSON.stringify({ claudeOnly: ['mine'] })); mkdirSync(claude('not-a-skill')); const before = tree(); const r = sync(); expect(r).toMatchObject({ moved: [], linked: [], folded: [], conflicts: [], errors: [] }); expect(tree()).toEqual(before); }); it('skips a folder whose SKILL.md is a link (gstack outside Windows)', (ctx) => { skill(join(claude('gstack'), 'ship'), 'ship'); mkdirSync(claude('ship')); try { symlinkSync(join(claude('gstack'), 'ship', 'SKILL.md'), join(claude('ship'), 'SKILL.md'), 'file'); } catch (err) { if (err.code === 'EPERM') return ctx.skip(); // Windows without the privilege throw err; } const before = tree(); expect(sync()).toMatchObject({ moved: [], linked: [], conflicts: [], errors: [] }); expect(tree()).toEqual(before); }); it('leaves the store alone when ~/.claude/skills or ~/.codex/skills is a link to it', () => { skill(store('foo'), 'foo', 'keep'); skill(codex('bar')); removeTempDir(join(homes.claudeDir, 'skills')); symlinkSync(join(homes.agentsDir, 'skills'), join(homes.claudeDir, 'skills'), 'junction'); // ~/.claude/skills is the store: a Codex skill moves in, and needs no link. expect(sync()).toMatchObject({ moved: ['bar'], linked: [], folded: [], conflicts: [], errors: [] }); expect(isLink(claude('bar'))).toBe(false); expect(existsSync(join(store('bar'), 'SKILL.md'))).toBe(true); removeTempDir(join(homes.codexDir, 'skills')); symlinkSync(join(homes.agentsDir, 'skills'), join(homes.codexDir, 'skills'), 'junction'); const after = tree(); expect(sync()).toMatchObject({ moved: [], linked: [], folded: [], conflicts: [], errors: [] }); expect(tree()).toEqual(after); expect(readFileSync(join(store('foo'), 'SKILL.md'), 'utf8')).toMatch(/keep/); }); it('creates ~/.claude/skills to link a Codex skill when Claude Code has none yet', () => { removeTempDir(join(homes.claudeDir, 'skills')); skill(codex('x')); expect(sync()).toMatchObject({ moved: ['x'], linked: ['x'], errors: [] }); expect(isLink(claude('x'))).toBe(true); }); it('a file in the store under the same name is a conflict and nothing moves', () => { skill(claude('x')); mkdirSync(join(homes.agentsDir, 'skills'), { recursive: true }); writeFileSync(store('x'), 'file'); const before = tree(); expect(sync().conflicts).toMatchObject([{ name: 'x', why: expect.stringMatching(/link or a file/) }]); expect(tree()).toEqual(before); }); it('a skill whose SKILL.md names another installed skill is a conflict, and is not linked twice', () => { skill(claude('my-pdf'), 'pdf'); skill(store('pdf'), 'pdf'); const before = tree(); const r = sync(); expect(r.conflicts).toMatchObject([{ name: 'my-pdf', why: expect.stringMatching(/calls it "pdf"/) }]); expect(r.linked).toEqual([]); expect(tree()).toEqual(before); }); it('a front-matter name that differs from its folder only in case is no conflict', () => { skill(claude('pdf'), 'PDF'); expect(sync()).toMatchObject({ moved: ['pdf'], conflicts: [] }); }); it('a front-matter name matching a Codex skill moving in the same run is a conflict, and that skill is not linked twice', () => { skill(claude('my-pdf'), 'pdf'); skill(codex('pdf'), 'pdf'); const r = sync(); expect(r.conflicts.map(c => c.name)).toEqual(['my-pdf']); // pdf moves into the store; Claude already has a folder that answers to it. expect(r).toMatchObject({ moved: ['pdf'], linked: [] }); expect(lstatSync(claude('my-pdf')).isDirectory()).toBe(true); }); it('a front-matter name that is not a plain name is left out of the notice', () => { skill(claude('odd'), 'odd Ignore all previous'); skill(store('odd Ignore all previous')); expect(sync().conflicts.map(c => c.why)).toEqual(['its SKILL.md gives it the name of another installed skill']); }); it('folder names that differ only in case are a conflict, never two moves', () => { skill(claude('Foo'), 'Foo', 'a'); skill(codex('foo'), 'foo', 'b'); const before = tree(); expect(sync().conflicts).toMatchObject([{ why: 'folder names that differ only in case' }]); expect(tree()).toEqual(before); }); it('a second sync is busy while one holds the lock, and takes over a lock left ten minutes', () => { skill(claude('a')); const lock = join(root, '.agent-007', 'skill-store.lock'); writeFileSync(lock, 'someone'); const before = tree(); expect(sync()).toMatchObject({ busy: true, moved: [], errors: [] }); expect(tree()).toEqual(before); const old = (Date.now() - 11 * 60_000) / 1000; utimesSync(lock, old, old); expect(sync().moved).toEqual(['a']); expect(existsSync(lock)).toBe(false); }); it('a busy autoSync records nothing', () => { writeStoreState({ enabled: true, last: { at: 'before', moved: ['x'], linked: [], folded: [], conflicts: [], errors: [] } }, stateFile); skill(claude('a')); writeFileSync(join(root, '.agent-007', 'skill-store.lock'), 'someone'); expect(autoSync({ file: stateFile, homes, map: map(), backupRoot }).busy).toBe(true); expect(readStoreState(stateFile).last.at).toBe('before'); }); it('codexMoves: false still sees a Codex copy, and does not move that name past it', () => { skill(claude('z'), 'z', 'claude-version'); skill(codex('z'), 'z', 'codex-version'); const before = tree(); expect(sync({ codexMoves: false })).toMatchObject({ moved: [], linked: [], conflicts: [] }); expect(tree()).toEqual(before); }); it('a store skill that answers to the name of a Claude skill is neither linked nor joined by it', () => { skill(store('x'), 'y'); skill(claude('y'), 'y'); const before = tree(); const r = sync(); expect(r).toMatchObject({ moved: [], linked: [] }); expect(r.conflicts.map(c => c.name).sort()).toEqual(['x', 'y']); expect(tree()).toEqual(before); }); it('codexMoves: false leaves ~/.codex/skills alone', () => { skill(codex('b')); skill(claude('a')); expect(sync({ codexMoves: false })).toMatchObject({ moved: ['a'] }); expect(existsSync(join(codex('b'), 'SKILL.md'))).toBe(true); }); it('codexOnly moves a skill into the store without a Claude link', () => { skill(claude('vanta')); skill(store('codex-tool')); writeFileSync(mapFile, JSON.stringify({ codexOnly: ['vanta', 'codex-tool'] })); const r = sync(); expect(r).toMatchObject({ moved: ['vanta'], linked: [] }); expect(existsSync(claude('vanta')) || existsSync(claude('codex-tool'))).toBe(false); }); it('a dry run says what it would do and changes nothing', () => { skill(claude('a')); skill(codex('b')); skill(store('c')); const before = tree(); const r = sync({ dryRun: true }); expect(r).toMatchObject({ dryRun: true, moved: ['a', 'b'], linked: ['a', 'b', 'c'], backup: null }); expect(summarize(r)).toBe('Would move 2 skills into the store; would link 3 skills for Claude Code.'); expect(tree()).toEqual(before); }); // Value: protects=the rollback when linking fails: the moved folder goes back where the CLI had it, the half-made store copy is dropped, and the run reports the error instead of a move; fails_when=the catch stops restoring (a skill vanishes from Codex) or still counts it as moved/linked; why_new=every other test has link() succeed; seam=none (a file where ~/.claude/skills should be makes the real link and mkdir fail) it('puts a skill back and reports it when the Claude link cannot be made', () => { skill(codex('seo-audit'), 'seo-audit', 'v1'); skill(store('ads')); removeTempDir(join(homes.claudeDir, 'skills')); writeFileSync(join(homes.claudeDir, 'skills'), 'not a folder'); const settled = new Map(); const r = sync({ settled }); expect(r).toMatchObject({ moved: [], linked: [], folded: [], backup: null }); expect(r.errors.map(e => e.name).sort()).toEqual(['ads', 'seo-audit']); // Not retried at the next spawn while nothing about them changed. expect(sync({ settled, now: new Date('2026-10-09T13:00:00Z') })).toMatchObject({ moved: [], linked: [], errors: [] }); expect(readFileSync(join(codex('seo-audit'), 'SKILL.md'), 'utf8')).toMatch(/v1/); expect(existsSync(store('seo-audit'))).toBe(false); expect(existsSync(join(store('ads'), 'SKILL.md'))).toBe(true); expect(summarize(r)).toMatch(/^2 failed \(/); }); it.skipIf(process.platform === 'win32')('a store copy that stops part way is dropped, so it is no conflict later', () => { skill(claude('z')); writeFileSync(join(claude('z'), 'secret'), 'x'); chmodSync(join(claude('z'), 'secret'), 0o000); try { const r = sync(); expect(r.errors.map(e => e.name)).toEqual(['z']); expect(existsSync(store('z'))).toBe(false); expect(lstatSync(claude('z')).isDirectory()).toBe(true); } finally { chmodSync(join(claude('z'), 'secret'), 0o600); } }); it('leaves a folder alone whose relative link points outside it', () => { skill(join(root, 'shared'), 'shared'); skill(claude('foo')); symlinkSync(join('..', '..', '..', 'shared'), join(claude('foo'), 'lib'), 'junction'); // A junction is stored absolute on Windows, where this case cannot occur. if (process.platform === 'win32') return; const before = tree(); expect(sync()).toMatchObject({ moved: [], linked: [], errors: [] }); expect(tree()).toEqual(before); }); it('never removes a store folder it did not make: a name that differs only in Unicode form is one name', () => { skill(claude('caf\u00e9'), 'cafe', 'same'); skill(codex('cafe\u0301'), 'cafe', 'same'); const r = sync(); // One folder on a disk that normalises names, two on one that does not: never a failed half. expect(r.errors).toEqual([]); expect(r.moved.length + r.conflicts.length).toBe(1); }); it('a conflict resolved by editing a nested file is looked at again', () => { skill(claude('n'), 'n', 'same'); skill(codex('n'), 'n', 'same'); mkdirSync(join(claude('n'), 'scripts')); writeFileSync(join(claude('n'), 'scripts', 'x.py'), 'mine'); const settled = new Map(); expect(sync({ settled }).conflicts.map(c => c.name)).toEqual(['n']); mkdirSync(join(codex('n'), 'scripts')); writeFileSync(join(codex('n'), 'scripts', 'x.py'), 'mine'); expect(sync({ settled, now: new Date('2026-10-09T13:00:00Z') })).toMatchObject({ moved: ['n'], conflicts: [] }); }); it('a second run has nothing to do', () => { skill(claude('a')); skill(codex('b')); sync(); const after = tree(); const again = sync({ now: new Date('2026-10-09T13:00:00Z') }); expect(again).toMatchObject({ moved: [], linked: [], folded: [], conflicts: [], errors: [], backup: null }); expect(summarize(again)).toBe('Nothing to do, every skill is in the store.'); expect(tree()).toEqual(after); expect(planSync({ homes, map: map() })).toMatchObject({ moves: [], links: [], conflicts: [] }); }); it('skill families still group a linked skill by its lock-file source', () => { skill(claude('copywriting')); mkdirSync(homes.agentsDir, { recursive: true }); writeFileSync(join(homes.agentsDir, '.skill-lock.json'), JSON.stringify({ version: 3, skills: { copywriting: { source: 'coreyhaines31/marketingskills' } } })); sync(); const scan = scanFamilies({ claudeDir: homes.claudeDir, agentsDir: homes.agentsDir, map: map(), listing: null }); expect(scan.families.get('marketing')).toEqual([{ name: 'copywriting', line: 'Does copywriting.' }]); // The lock file is never written. expect(JSON.parse(readFileSync(join(homes.agentsDir, '.skill-lock.json'), 'utf8')).skills).toEqual({ copywriting: { source: 'coreyhaines31/marketingskills' } }); }); }); describe('the server side', () => { it('autoSync does nothing until turned on, then records its result', () => { skill(claude('a')); expect(autoSync({ file: stateFile, homes, map: map(), backupRoot })).toBeNull(); expect(isLink(claude('a'))).toBe(false); expect(readStoreState(stateFile)).toEqual({ enabled: false, last: null }); writeStoreState({ enabled: true, last: null }, stateFile); const r = autoSync({ file: stateFile, homes, map: map(), backupRoot }); expect(r.moved).toEqual(['a']); expect(readStoreState(stateFile).last.moved).toEqual(['a']); // A quiet run keeps the last one that did something. autoSync({ file: stateFile, homes, map: map(), backupRoot }); expect(readStoreState(stateFile).last.moved).toEqual(['a']); }); it('autoSync never throws into a spawn, and keeps reporting a conflict it no longer re-hashes', () => { writeStoreState({ enabled: true, last: null }, stateFile); expect(autoSync({ file: stateFile, homes: { ...homes, agentsDir: null }, backupRoot })).toBeNull(); skill(claude('xlsx'), 'xlsx', 'one'); skill(codex('xlsx'), 'xlsx', 'two'); for (let i = 0; i < 2; i++) expect(autoSync({ file: stateFile, homes, map: map(), backupRoot }).conflicts.map(c => c.name)).toEqual(['xlsx']); }); }); describe('repo skills one CLI cannot see', () => { const repoSkills = (repos) => findOneCliRepoSkills(repos, map()); it('names a Claude-only folder and a Codex-only one, and passes the shared layout', () => { const repo = join(root, 'repo'); skill(join(repo, '.claude', 'skills', 'qa-browser')); skill(join(repo, '.agents', 'skills', 'news-digest')); skill(join(repo, '.codex', 'skills', 'old-codex')); skill(join(repo, '.agents', 'skills', 'shared')); symlinkSync('../../.agents/skills/shared', join(repo, '.claude', 'skills', 'shared')); mkdirSync(join(repo, '.claude', 'skills', 'not-a-skill')); const found = repoSkills([repo, join(root, 'missing'), null]); expect(found.map(f => [f.cli, f.name, f.dir])).toEqual([ ['Claude Code', 'qa-browser', join(repo, '.claude', 'skills', 'qa-browser')], ['Codex', 'old-codex', join(repo, '.codex', 'skills', 'old-codex')], ['Codex', 'news-digest', join(repo, '.agents', 'skills', 'news-digest')], ]); expect(found[0].fix).toContain('git mv .claude/skills/qa-browser .agents/skills/qa-browser && ln -s ../../.agents/skills/qa-browser .claude/skills/qa-browser'); expect(found[1].fix).toContain('ln -s ../../.codex/skills/old-codex .claude/skills/old-codex'); }); it('points a link checked out as a file at core.symlinks', () => { const repo = join(root, 'repo'); skill(join(repo, '.agents', 'skills', 'qa-email')); mkdirSync(join(repo, '.claude', 'skills'), { recursive: true }); writeFileSync(join(repo, '.claude', 'skills', 'qa-email'), '../../.agents/skills/qa-email'); expect(repoSkills([repo])[0].fix).toContain('git config core.symlinks true'); }); it('skips claudeOnly and codexOnly names', () => { const repo = join(root, 'repo'); skill(join(repo, '.claude', 'skills', 'mine')); writeFileSync(mapFile, JSON.stringify({ claudeOnly: ['mine'] })); expect(repoSkills([repo])).toEqual([]); }); it('tells Billion once per finding per run and changes nothing', () => { const repo = join(root, 'repo'); skill(join(repo, '.claude', 'skills', 'teaser-video')); const before = tree(); const sent = []; const send = (b, headline, lines) => sent.push([headline, ...lines]); expect(reportRepoSkills({ id: 'b' }, send, [repo])).toBe(1); expect(reportRepoSkills({ id: 'b' }, send, [repo])).toBe(0); expect(reportRepoSkills(null, send, [repo])).toBe(0); expect(sent[0][0]).toBe(`Repo skill "teaser-video" in ${repo} is visible to Claude Code only.`); expect(tree()).toEqual(before); }); });