skills-cli.test.js

  1/**
  2 * End-to-end tests for `impeccable skills` subcommands.
  3 *
  4 * Creates real temp directories, runs the CLI, and verifies results.
  5 * Tests that require `npx skills` are skipped if it's not available.
  6 */
  7import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
  8import { execSync } from 'child_process';
  9import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
 10import { join } from 'path';
 11import { tmpdir } from 'os';
 12
 13const CLI = join(import.meta.dir, '..', 'bin', 'cli.js');
 14
 15function run(args, opts = {}) {
 16  return execSync(`node ${CLI} ${args}`, {
 17    encoding: 'utf8',
 18    timeout: 60000,
 19    ...opts,
 20  });
 21}
 22
 23/** Create a fake skill installation in a temp dir */
 24function createFakeSkills(root, skills = ['audit', 'polish', 'impeccable'], providers = ['.claude']) {
 25  for (const provider of providers) {
 26    for (const skill of skills) {
 27      const skillDir = join(root, provider, 'skills', skill);
 28      mkdirSync(skillDir, { recursive: true });
 29      writeFileSync(join(skillDir, 'SKILL.md'), [
 30        '---',
 31        `name: ${skill}`,
 32        'user-invocable: true',
 33        '---',
 34        '',
 35        'Run /audit first, then /polish to finish.',
 36        'Use the impeccable skill for setup.',
 37      ].join('\n'));
 38    }
 39  }
 40}
 41
 42// ─── Already-installed detection ─────────────────────────────────────────────
 43
 44describe('skills install: already-installed detection', () => {
 45  test('detects impeccable sentinel and bails', () => {
 46    const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
 47    execSync('git init', { cwd: tmp });
 48    createFakeSkills(tmp);
 49
 50    const output = run('skills install -y', { cwd: tmp });
 51    expect(output).toContain('already installed');
 52
 53    rmSync(tmp, { recursive: true, force: true });
 54  }, 15000);
 55
 56  test('detects prefixed i-impeccable', () => {
 57    const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
 58    execSync('git init', { cwd: tmp });
 59
 60    const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable');
 61    mkdirSync(skillDir, { recursive: true });
 62    writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
 63
 64    const output = run('skills install -y', { cwd: tmp });
 65    expect(output).toContain('already installed');
 66
 67    rmSync(tmp, { recursive: true, force: true });
 68  }, 15000);
 69});
 70
 71// ─── Prefix rename (real filesystem) ─────────────────────────────────────────
 72
 73describe('skills install: prefix rename', () => {
 74  let tmp;
 75
 76  beforeAll(() => {
 77    tmp = mkdtempSync(join(tmpdir(), 'imp-test-pfx-'));
 78    createFakeSkills(tmp, ['audit', 'polish', 'impeccable'], ['.claude', '.cursor']);
 79  });
 80
 81  afterAll(() => {
 82    if (tmp) rmSync(tmp, { recursive: true, force: true });
 83  });
 84
 85  test('renames folders with prefix', () => {
 86    // Write a helper script that imports and runs renameSkillsWithPrefix
 87    const helperScript = join(tmp, '_test_rename.mjs');
 88    writeFileSync(helperScript, `
 89import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
 90import { join } from 'node:path';
 91
 92function escapeRegex(str) {
 93  return str.replace(/[.*+?^$\{\}()|[\\]\\\\]/g, '\\\\$&');
 94}
 95
 96function prefixSkillContent(content, prefix, allSkillNames) {
 97  let result = content.replace(/^name:\\s*(.+)$/m, (_, name) => 'name: ' + prefix + name.trim());
 98  const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
 99  for (const name of sorted) {
100    result = result.replace(
101      new RegExp('/' + '(?=' + escapeRegex(name) + '(?:[^a-zA-Z0-9_-]|$))', 'g'),
102      '/' + prefix
103    );
104    result = result.replace(
105      new RegExp('(the) ' + escapeRegex(name) + ' skill', 'gi'),
106      (_, article) => article + ' ' + prefix + name + ' skill'
107    );
108  }
109  return result;
110}
111
112const DIRS = ['.claude', '.cursor'];
113const root = process.argv[2];
114const prefix = process.argv[3];
115
116let allNames = [];
117for (const d of DIRS) {
118  const dir = join(root, d, 'skills');
119  if (!existsSync(dir)) continue;
120  allNames = readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
121  if (allNames.length > 0) break;
122}
123
124let count = 0;
125for (const d of DIRS) {
126  const dir = join(root, d, 'skills');
127  if (!existsSync(dir)) continue;
128  for (const entry of readdirSync(dir, { withFileTypes: true })) {
129    if (!entry.isDirectory() || entry.name.startsWith(prefix)) continue;
130    const skillMd = join(dir, entry.name, 'SKILL.md');
131    if (!existsSync(skillMd)) continue;
132    renameSync(join(dir, entry.name), join(dir, prefix + entry.name));
133    let content = readFileSync(join(dir, prefix + entry.name, 'SKILL.md'), 'utf8');
134    content = prefixSkillContent(content, prefix, allNames);
135    writeFileSync(join(dir, prefix + entry.name, 'SKILL.md'), content);
136    count++;
137  }
138}
139console.log(JSON.stringify({ count }));
140    `);
141
142    const output = JSON.parse(execSync(`node ${helperScript} ${tmp} i-`, { encoding: 'utf8' }));
143    expect(output.count).toBe(6); // 3 skills x 2 providers
144
145    // Verify folders renamed
146    const skills = readdirSync(join(tmp, '.claude', 'skills'));
147    expect(skills).toContain('i-audit');
148    expect(skills).toContain('i-polish');
149    expect(skills).toContain('i-impeccable');
150    expect(skills).not.toContain('audit');
151    expect(skills).not.toContain('polish');
152  }, 15000);
153
154  test('prefixed SKILL.md has correct name and cross-references', () => {
155    const content = readFileSync(join(tmp, '.claude', 'skills', 'i-audit', 'SKILL.md'), 'utf8');
156    expect(content).toContain('name: i-audit');
157    expect(content).toContain('/i-audit');
158    expect(content).toContain('/i-polish');
159    expect(content).toContain('the i-impeccable skill');
160    // Original unprefixed references should be gone
161    expect(content).not.toMatch(/\/audit(?=[^a-zA-Z0-9_-]|$)/);
162  });
163
164  test('also prefixed in second provider', () => {
165    const skills = readdirSync(join(tmp, '.cursor', 'skills'));
166    expect(skills).toContain('i-audit');
167    expect(skills).toContain('i-impeccable');
168  });
169});
170
171// ─── Update fallback (direct download) ───────────────────────────────────────
172
173describe('skills update: direct download fallback', () => {
174  let tmp;
175
176  beforeAll(() => {
177    tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-'));
178    execSync('git init', { cwd: tmp });
179
180    // Create stale skills that the update should overwrite
181    for (const skill of ['audit', 'impeccable']) {
182      const skillDir = join(tmp, '.claude', 'skills', skill);
183      mkdirSync(skillDir, { recursive: true });
184      writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${skill}\nstale: true\n---\nOld content.\n`);
185    }
186  });
187
188  afterAll(() => {
189    if (tmp) rmSync(tmp, { recursive: true, force: true });
190  });
191
192  test('downloads universal bundle and updates skills', () => {
193    const output = run('skills update -y', { cwd: tmp });
194    expect(output).toContain('direct download');
195    expect(output).toContain('Updated');
196
197    // Skills should have fresh content (no 'stale: true')
198    const content = readFileSync(join(tmp, '.claude', 'skills', 'audit', 'SKILL.md'), 'utf8');
199    expect(content).not.toContain('stale: true');
200    expect(content).toContain('name:');
201  }, 60000);
202
203  test('update added new skills that were not present before', () => {
204    // The universal bundle has ~20 skills, we only had 2
205    const skills = readdirSync(join(tmp, '.claude', 'skills'));
206    expect(skills.length).toBeGreaterThan(5);
207  });
208});
209
210// ─── Prefix round-trip: detect, undo, re-apply ──────────────────────────────
211
212describe('prefix round-trip: detect, undo, re-apply', () => {
213  let tmp;
214
215  beforeAll(() => {
216    tmp = mkdtempSync(join(tmpdir(), 'imp-test-roundtrip-'));
217    // Create prefixed skills to simulate post-install state
218    for (const skill of ['audit', 'polish', 'impeccable']) {
219      const skillDir = join(tmp, '.claude', 'skills', 'i-' + skill);
220      mkdirSync(skillDir, { recursive: true });
221      writeFileSync(join(skillDir, 'SKILL.md'), [
222        '---',
223        `name: i-${skill}`,
224        'user-invocable: true',
225        '---',
226        '',
227        'Run /i-audit first, then /i-polish to finish.',
228        'Use the i-impeccable skill for setup.',
229      ].join('\n'));
230    }
231  });
232
233  afterAll(() => {
234    if (tmp) rmSync(tmp, { recursive: true, force: true });
235  });
236
237  test('detectPrefix finds the prefix from skill names', () => {
238    const script = join(tmp, '_detect.mjs');
239    writeFileSync(script, `
240import { existsSync, readdirSync } from 'node:fs';
241import { join } from 'node:path';
242const DIRS = ['.claude', '.cursor', '.agents'];
243const root = ${JSON.stringify(tmp)};
244for (const d of DIRS) {
245  const dir = join(root, d, 'skills');
246  if (!existsSync(dir)) continue;
247  for (const name of readdirSync(dir)) {
248    if (name === 'impeccable') { console.log(''); process.exit(); }
249    if (name.endsWith('-impeccable')) { console.log(name.slice(0, -'impeccable'.length)); process.exit(); }
250  }
251}
252console.log('');
253    `);
254    const output = execSync(`node ${script}`, { encoding: 'utf8' }).trim();
255    expect(output).toBe('i-');
256  });
257
258  test('undo removes prefix from folders and content', () => {
259    // Write undo helper script
260    const script = join(tmp, '_undo.mjs');
261    writeFileSync(script, `
262import { existsSync, readdirSync, readFileSync, lstatSync, readlinkSync, unlinkSync, renameSync, writeFileSync, symlinkSync } from 'node:fs';
263import { join } from 'node:path';
264
265function escapeRegex(str) { return str.replace(/[.*+?^$\{\\}()|[\\]\\\\]/g, '\\\\$&'); }
266
267const root = ${JSON.stringify(tmp)};
268const prefix = 'i-';
269const skillsDir = join(root, '.claude', 'skills');
270const entries = readdirSync(skillsDir);
271const prefixedNames = entries.filter(n => n.startsWith(prefix));
272
273for (const name of entries) {
274  if (!name.startsWith(prefix)) continue;
275  const unprefixed = name.slice(prefix.length);
276  const src = join(skillsDir, name);
277  const dest = join(skillsDir, unprefixed);
278
279  if (lstatSync(src).isSymbolicLink()) {
280    const target = readlinkSync(src);
281    unlinkSync(src);
282    symlinkSync(target.replace('/' + name, '/' + unprefixed), dest);
283  } else {
284    renameSync(src, dest);
285    const skillMd = join(dest, 'SKILL.md');
286    if (existsSync(skillMd)) {
287      let content = readFileSync(skillMd, 'utf8');
288      content = content.replace(new RegExp('^name:\\\\s*' + escapeRegex(prefix), 'm'), 'name: ');
289      const sorted = [...prefixedNames].sort((a, b) => b.length - a.length);
290      for (const pName of sorted) {
291        const uName = pName.slice(prefix.length);
292        content = content.replace(new RegExp('/' + escapeRegex(pName) + '(?=[^a-zA-Z0-9_-]|$)', 'g'), '/' + uName);
293        content = content.replace(new RegExp('(the) ' + escapeRegex(pName) + ' skill', 'gi'), '$1 ' + uName + ' skill');
294      }
295      writeFileSync(skillMd, content);
296    }
297  }
298}
299console.log(JSON.stringify(readdirSync(skillsDir)));
300    `);
301    const output = JSON.parse(execSync(`node ${script}`, { encoding: 'utf8' }));
302    expect(output).toContain('audit');
303    expect(output).toContain('polish');
304    expect(output).toContain('impeccable');
305    expect(output).not.toContain('i-audit');
306
307    // Verify content was un-prefixed
308    const content = readFileSync(join(tmp, '.claude', 'skills', 'audit', 'SKILL.md'), 'utf8');
309    expect(content).toContain('name: audit');
310    expect(content).toContain('/audit');
311    expect(content).toContain('/polish');
312    expect(content).toContain('the impeccable skill');
313    expect(content).not.toContain('/i-audit');
314    expect(content).not.toContain('i-impeccable');
315  });
316
317  test('re-applying prefix restores original state', () => {
318    // Now re-apply using the same helper from the rename test
319    const script = join(tmp, '_reprefix.mjs');
320    writeFileSync(script, `
321import { existsSync, readdirSync, readFileSync, statSync, lstatSync, renameSync, writeFileSync } from 'node:fs';
322import { join } from 'node:path';
323
324function escapeRegex(str) { return str.replace(/[.*+?^$\{\\}()|[\\]\\\\]/g, '\\\\$&'); }
325
326const root = ${JSON.stringify(tmp)};
327const prefix = 'i-';
328const skillsDir = join(root, '.claude', 'skills');
329const allNames = readdirSync(skillsDir).filter(n => {
330  const full = join(skillsDir, n);
331  try { return statSync(full).isDirectory() && existsSync(join(full, 'SKILL.md')); } catch { return false; }
332});
333
334for (const name of allNames) {
335  if (name.startsWith(prefix)) continue;
336  const src = join(skillsDir, name);
337  const dest = join(skillsDir, prefix + name);
338  const ls = lstatSync(src);
339  if (ls.isSymbolicLink() || !ls.isDirectory()) continue;
340  renameSync(src, dest);
341  let content = readFileSync(join(dest, 'SKILL.md'), 'utf8');
342  content = content.replace(/^name:\\s*(.+)$/m, (_, n) => 'name: ' + prefix + n.trim());
343  const sorted = [...allNames].sort((a, b) => b.length - a.length);
344  for (const n of sorted) {
345    content = content.replace(new RegExp('/' + '(?=' + escapeRegex(n) + '(?:[^a-zA-Z0-9_-]|$))', 'g'), '/' + prefix);
346    content = content.replace(new RegExp('(the) ' + escapeRegex(n) + ' skill', 'gi'), (_, art) => art + ' ' + prefix + n + ' skill');
347  }
348  writeFileSync(join(dest, 'SKILL.md'), content);
349}
350console.log(JSON.stringify(readdirSync(skillsDir)));
351    `);
352    const output = JSON.parse(execSync(`node ${script}`, { encoding: 'utf8' }));
353    expect(output).toContain('i-audit');
354    expect(output).toContain('i-polish');
355    expect(output).toContain('i-impeccable');
356    expect(output).not.toContain('audit');
357
358    // Verify content was re-prefixed
359    const content = readFileSync(join(tmp, '.claude', 'skills', 'i-audit', 'SKILL.md'), 'utf8');
360    expect(content).toContain('name: i-audit');
361    expect(content).toContain('/i-polish');
362    expect(content).toContain('the i-impeccable skill');
363  });
364});
365
366// ─── Full install e2e (with real npx skills) ─────────────────────────────────
367
368let hasNpxSkills = false;
369try {
370  execSync('npx skills --version', { encoding: 'utf8', timeout: 15000, stdio: 'pipe' });
371  hasNpxSkills = true;
372} catch {}
373
374const describeNpx = hasNpxSkills ? describe : describe.skip;
375
376describeNpx('skills install: full e2e with npx skills', () => {
377  let tmp;
378
379  beforeAll(() => {
380    tmp = mkdtempSync(join(tmpdir(), 'imp-test-full-'));
381    execSync('git init', { cwd: tmp });
382  });
383
384  afterAll(() => {
385    if (tmp) rmSync(tmp, { recursive: true, force: true });
386  });
387
388  test('installs skills into a fresh project', () => {
389    const output = run('skills install -y', { cwd: tmp });
390    expect(output).toContain('Done!');
391
392    const hasSkills = ['.claude', '.cursor'].some(d => {
393      const dir = join(tmp, d, 'skills');
394      return existsSync(dir) && readdirSync(dir).length > 0;
395    });
396    expect(hasSkills).toBe(true);
397  }, 90000);
398
399  test('install with --prefix= renames all skills', () => {
400    const output = run('skills install -y --force --prefix=x-', { cwd: tmp });
401
402    // Find the provider that has skills
403    let found = false;
404    for (const d of ['.claude', '.cursor', '.gemini', '.codex', '.agents', '.kiro']) {
405      const dir = join(tmp, d, 'skills');
406      if (!existsSync(dir)) continue;
407      const skills = readdirSync(dir);
408      if (skills.length === 0) continue;
409      found = true;
410      const prefixed = skills.filter(s => s.startsWith('x-'));
411      // All skills should be prefixed
412      expect(prefixed.length).toBe(skills.length);
413      break;
414    }
415    expect(found).toBe(true);
416  }, 90000);
417});