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