Ctrl K
ComunidadGuías…

test/skill-store.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  });
});