agentic-team-templates 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,947 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { run, _internals } from './index.js';
6
+
7
+ const {
8
+ TEMPLATES,
9
+ SHARED_RULES,
10
+ SUPPORTED_IDES,
11
+ DEFAULT_IDES,
12
+ filesMatch,
13
+ parseMarkdownSections,
14
+ generateSectionSignature,
15
+ findMissingSections,
16
+ mergeClaudeContent,
17
+ getAlternateFilename,
18
+ copyFile,
19
+ generateClaudeMdContent,
20
+ generateCopilotInstructionsContent,
21
+ isOurFile,
22
+ install,
23
+ remove,
24
+ reset,
25
+ } = _internals;
26
+
27
+ // ============================================================================
28
+ // Constants & Configuration Tests
29
+ // ============================================================================
30
+
31
+ describe('Constants', () => {
32
+ describe('TEMPLATES', () => {
33
+ it('should have all expected templates', () => {
34
+ const expectedTemplates = [
35
+ 'blockchain',
36
+ 'cli-tools',
37
+ 'documentation',
38
+ 'fullstack',
39
+ 'mobile',
40
+ 'utility-agent',
41
+ 'web-backend',
42
+ 'web-frontend',
43
+ ];
44
+
45
+ expect(Object.keys(TEMPLATES).sort()).toEqual(expectedTemplates.sort());
46
+ });
47
+
48
+ it('each template should have description and rules array', () => {
49
+ for (const [name, template] of Object.entries(TEMPLATES)) {
50
+ expect(template).toHaveProperty('description');
51
+ expect(typeof template.description).toBe('string');
52
+ expect(template.description.length).toBeGreaterThan(0);
53
+
54
+ expect(template).toHaveProperty('rules');
55
+ expect(Array.isArray(template.rules)).toBe(true);
56
+ expect(template.rules.length).toBeGreaterThan(0);
57
+ }
58
+ });
59
+
60
+ it('each template should have overview.md in rules', () => {
61
+ for (const [name, template] of Object.entries(TEMPLATES)) {
62
+ expect(template.rules).toContain('overview.md');
63
+ }
64
+ });
65
+ });
66
+
67
+ describe('SHARED_RULES', () => {
68
+ it('should have expected shared rules', () => {
69
+ expect(SHARED_RULES).toContain('code-quality.md');
70
+ expect(SHARED_RULES).toContain('communication.md');
71
+ expect(SHARED_RULES).toContain('core-principles.md');
72
+ expect(SHARED_RULES).toContain('git-workflow.md');
73
+ expect(SHARED_RULES).toContain('security-fundamentals.md');
74
+ });
75
+
76
+ it('all rules should end with .md', () => {
77
+ for (const rule of SHARED_RULES) {
78
+ expect(rule).toMatch(/\.md$/);
79
+ }
80
+ });
81
+ });
82
+
83
+ describe('SUPPORTED_IDES', () => {
84
+ it('should contain cursor, claude, and codex', () => {
85
+ expect(SUPPORTED_IDES).toContain('cursor');
86
+ expect(SUPPORTED_IDES).toContain('claude');
87
+ expect(SUPPORTED_IDES).toContain('codex');
88
+ });
89
+ });
90
+
91
+ describe('DEFAULT_IDES', () => {
92
+ it('should default to all supported IDEs', () => {
93
+ expect(DEFAULT_IDES).toEqual(SUPPORTED_IDES);
94
+ });
95
+ });
96
+ });
97
+
98
+ // ============================================================================
99
+ // Utility Functions Tests
100
+ // ============================================================================
101
+
102
+ describe('Utility Functions', () => {
103
+ describe('getAlternateFilename', () => {
104
+ it('should add -1 suffix before extension', () => {
105
+ expect(getAlternateFilename('/path/to/file.md')).toBe('/path/to/file-1.md');
106
+ expect(getAlternateFilename('/path/to/code-quality.md')).toBe('/path/to/code-quality-1.md');
107
+ });
108
+
109
+ it('should handle files without directory', () => {
110
+ expect(getAlternateFilename('file.md')).toBe('file-1.md');
111
+ });
112
+
113
+ it('should handle different extensions', () => {
114
+ expect(getAlternateFilename('/path/to/file.txt')).toBe('/path/to/file-1.txt');
115
+ expect(getAlternateFilename('/path/to/file.json')).toBe('/path/to/file-1.json');
116
+ });
117
+ });
118
+
119
+ describe('filesMatch', () => {
120
+ let tempDir;
121
+
122
+ beforeEach(() => {
123
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-templates-test-'));
124
+ });
125
+
126
+ afterEach(() => {
127
+ fs.rmSync(tempDir, { recursive: true, force: true });
128
+ });
129
+
130
+ it('should return true for identical files', () => {
131
+ const file1 = path.join(tempDir, 'file1.md');
132
+ const file2 = path.join(tempDir, 'file2.md');
133
+ const content = '# Test Content\n\nSome text here.';
134
+
135
+ fs.writeFileSync(file1, content);
136
+ fs.writeFileSync(file2, content);
137
+
138
+ expect(filesMatch(file1, file2)).toBe(true);
139
+ });
140
+
141
+ it('should return false for different files', () => {
142
+ const file1 = path.join(tempDir, 'file1.md');
143
+ const file2 = path.join(tempDir, 'file2.md');
144
+
145
+ fs.writeFileSync(file1, '# Content A');
146
+ fs.writeFileSync(file2, '# Content B');
147
+
148
+ expect(filesMatch(file1, file2)).toBe(false);
149
+ });
150
+
151
+ it('should return false if file does not exist', () => {
152
+ const file1 = path.join(tempDir, 'exists.md');
153
+ const file2 = path.join(tempDir, 'not-exists.md');
154
+
155
+ fs.writeFileSync(file1, '# Content');
156
+
157
+ expect(filesMatch(file1, file2)).toBe(false);
158
+ });
159
+ });
160
+
161
+ describe('isOurFile', () => {
162
+ let tempDir;
163
+
164
+ beforeEach(() => {
165
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-templates-test-'));
166
+ });
167
+
168
+ afterEach(() => {
169
+ fs.rmSync(tempDir, { recursive: true, force: true });
170
+ });
171
+
172
+ it('should return false if file does not exist', () => {
173
+ const filePath = path.join(tempDir, 'nonexistent.md');
174
+ const templatePath = path.join(tempDir, 'template.md');
175
+
176
+ expect(isOurFile(filePath, templatePath)).toBe(false);
177
+ });
178
+
179
+ it('should return true if template does not exist (assume ours)', () => {
180
+ const filePath = path.join(tempDir, 'file.md');
181
+ const templatePath = path.join(tempDir, 'nonexistent-template.md');
182
+
183
+ fs.writeFileSync(filePath, '# Content');
184
+
185
+ expect(isOurFile(filePath, templatePath)).toBe(true);
186
+ });
187
+
188
+ it('should return true if files match', () => {
189
+ const filePath = path.join(tempDir, 'file.md');
190
+ const templatePath = path.join(tempDir, 'template.md');
191
+ const content = '# Same Content';
192
+
193
+ fs.writeFileSync(filePath, content);
194
+ fs.writeFileSync(templatePath, content);
195
+
196
+ expect(isOurFile(filePath, templatePath)).toBe(true);
197
+ });
198
+
199
+ it('should return false if files differ', () => {
200
+ const filePath = path.join(tempDir, 'file.md');
201
+ const templatePath = path.join(tempDir, 'template.md');
202
+
203
+ fs.writeFileSync(filePath, '# Modified Content');
204
+ fs.writeFileSync(templatePath, '# Original Template');
205
+
206
+ expect(isOurFile(filePath, templatePath)).toBe(false);
207
+ });
208
+ });
209
+ });
210
+
211
+ // ============================================================================
212
+ // Markdown Parsing Tests
213
+ // ============================================================================
214
+
215
+ describe('Markdown Parsing', () => {
216
+ describe('parseMarkdownSections', () => {
217
+ it('should parse sections with ## headings', () => {
218
+ const content = `# Title
219
+
220
+ Some preamble text.
221
+
222
+ ## Section One
223
+
224
+ Content for section one.
225
+
226
+ ## Section Two
227
+
228
+ Content for section two.
229
+ `;
230
+ const result = parseMarkdownSections(content);
231
+
232
+ expect(result.preamble).toContain('# Title');
233
+ expect(result.preamble).toContain('Some preamble text.');
234
+ expect(result.sections).toHaveLength(2);
235
+ expect(result.sections[0].heading).toBe('Section One');
236
+ expect(result.sections[1].heading).toBe('Section Two');
237
+ });
238
+
239
+ it('should handle content with no sections', () => {
240
+ const content = '# Just a title\n\nSome content without sections.';
241
+ const result = parseMarkdownSections(content);
242
+
243
+ expect(result.sections).toHaveLength(0);
244
+ expect(result.preamble).toContain('# Just a title');
245
+ });
246
+
247
+ it('should preserve section content', () => {
248
+ const content = `## My Section
249
+
250
+ Line 1
251
+ Line 2
252
+ Line 3
253
+ `;
254
+ const result = parseMarkdownSections(content);
255
+
256
+ expect(result.sections[0].content).toContain('Line 1');
257
+ expect(result.sections[0].content).toContain('Line 2');
258
+ expect(result.sections[0].content).toContain('Line 3');
259
+ });
260
+
261
+ it('should generate signatures for sections', () => {
262
+ const content = `## Test Section
263
+
264
+ Some meaningful content here.
265
+ `;
266
+ const result = parseMarkdownSections(content);
267
+
268
+ expect(result.sections[0]).toHaveProperty('signature');
269
+ expect(typeof result.sections[0].signature).toBe('string');
270
+ expect(result.sections[0].signature.length).toBeGreaterThan(0);
271
+ });
272
+ });
273
+
274
+ describe('generateSectionSignature', () => {
275
+ it('should normalize heading to lowercase', () => {
276
+ const sig1 = generateSectionSignature('My Heading', ['content']);
277
+ const sig2 = generateSectionSignature('my heading', ['content']);
278
+
279
+ expect(sig1).toBe(sig2);
280
+ });
281
+
282
+ it('should remove special characters from heading', () => {
283
+ const sig1 = generateSectionSignature('Heading: With (Special) Chars!', ['content']);
284
+ const sig2 = generateSectionSignature('Heading With Special Chars', ['content']);
285
+
286
+ expect(sig1).toBe(sig2);
287
+ });
288
+
289
+ it('should include content in signature', () => {
290
+ const sig1 = generateSectionSignature('Heading', ['Line A']);
291
+ const sig2 = generateSectionSignature('Heading', ['Line B']);
292
+
293
+ expect(sig1).not.toBe(sig2);
294
+ });
295
+
296
+ it('should filter out empty lines and special lines', () => {
297
+ const sig = generateSectionSignature('Heading', [
298
+ '',
299
+ '# Subheading',
300
+ '| table |',
301
+ '- list item',
302
+ 'Meaningful content',
303
+ ]);
304
+
305
+ expect(sig).toContain('meaningful content');
306
+ });
307
+ });
308
+
309
+ describe('findMissingSections', () => {
310
+ it('should find sections in template that are missing from existing', () => {
311
+ const existing = `## Section A
312
+
313
+ Content A
314
+
315
+ ## Section B
316
+
317
+ Content B
318
+ `;
319
+ const template = `## Section A
320
+
321
+ Content A
322
+
323
+ ## Section B
324
+
325
+ Content B
326
+
327
+ ## Section C
328
+
329
+ Content C
330
+ `;
331
+ const result = findMissingSections(existing, template);
332
+
333
+ expect(result.missing).toHaveLength(1);
334
+ expect(result.missing[0].heading).toBe('Section C');
335
+ expect(result.matchedCount).toBe(2);
336
+ });
337
+
338
+ it('should return empty array when all sections present', () => {
339
+ const content = `## Section A
340
+
341
+ Content
342
+
343
+ ## Section B
344
+
345
+ Content
346
+ `;
347
+ const result = findMissingSections(content, content);
348
+
349
+ expect(result.missing).toHaveLength(0);
350
+ expect(result.matchedCount).toBe(2);
351
+ });
352
+
353
+ it('should match by heading regardless of case', () => {
354
+ const existing = '## SECTION A\n\nContent';
355
+ const template = '## Section A\n\nContent';
356
+
357
+ const result = findMissingSections(existing, template);
358
+
359
+ expect(result.missing).toHaveLength(0);
360
+ expect(result.matchedCount).toBe(1);
361
+ });
362
+ });
363
+
364
+ describe('mergeClaudeContent', () => {
365
+ it('should merge missing sections into existing content', () => {
366
+ const existing = `# Title
367
+
368
+ ## Section A
369
+
370
+ Content A
371
+ `;
372
+ const template = `# Title
373
+
374
+ ## Section A
375
+
376
+ Content A
377
+
378
+ ## Section B
379
+
380
+ Content B
381
+ `;
382
+ const result = mergeClaudeContent(existing, template);
383
+
384
+ expect(result.merged).toContain('## Section A');
385
+ expect(result.merged).toContain('## Section B');
386
+ expect(result.addedSections).toContain('Section B');
387
+ });
388
+
389
+ it('should return unchanged content when nothing to merge', () => {
390
+ const content = `## Section A
391
+
392
+ Content
393
+ `;
394
+ const result = mergeClaudeContent(content, content);
395
+
396
+ expect(result.merged).toBe(content);
397
+ expect(result.addedSections).toHaveLength(0);
398
+ });
399
+
400
+ it('should preserve preamble', () => {
401
+ const existing = `# My Title
402
+
403
+ Introduction paragraph.
404
+
405
+ ## Existing Section
406
+
407
+ Content
408
+ `;
409
+ const template = `## Existing Section
410
+
411
+ Content
412
+
413
+ ## New Section
414
+
415
+ New content
416
+ `;
417
+ const result = mergeClaudeContent(existing, template);
418
+
419
+ expect(result.merged).toContain('# My Title');
420
+ expect(result.merged).toContain('Introduction paragraph');
421
+ });
422
+ });
423
+ });
424
+
425
+ // ============================================================================
426
+ // Content Generation Tests
427
+ // ============================================================================
428
+
429
+ describe('Content Generation', () => {
430
+ describe('generateClaudeMdContent', () => {
431
+ it('should generate valid markdown', () => {
432
+ const content = generateClaudeMdContent(['web-frontend']);
433
+
434
+ expect(content).toContain('# CLAUDE.md - Development Guide');
435
+ expect(content).toContain('web-frontend');
436
+ });
437
+
438
+ it('should include template description', () => {
439
+ const content = generateClaudeMdContent(['web-frontend']);
440
+
441
+ expect(content).toContain(TEMPLATES['web-frontend'].description);
442
+ });
443
+
444
+ it('should include multiple templates', () => {
445
+ const content = generateClaudeMdContent(['web-frontend', 'web-backend']);
446
+
447
+ expect(content).toContain('web-frontend');
448
+ expect(content).toContain('web-backend');
449
+ expect(content).toContain(TEMPLATES['web-frontend'].description);
450
+ expect(content).toContain(TEMPLATES['web-backend'].description);
451
+ });
452
+
453
+ it('should include shared rules table', () => {
454
+ const content = generateClaudeMdContent(['web-frontend']);
455
+
456
+ expect(content).toContain('core-principles.md');
457
+ expect(content).toContain('code-quality.md');
458
+ expect(content).toContain('security-fundamentals.md');
459
+ });
460
+
461
+ it('should include development principles', () => {
462
+ const content = generateClaudeMdContent(['web-frontend']);
463
+
464
+ expect(content).toContain('Honesty Over Output');
465
+ expect(content).toContain('Security First');
466
+ expect(content).toContain('Tests Are Required');
467
+ });
468
+
469
+ it('should include definition of done', () => {
470
+ const content = generateClaudeMdContent(['web-frontend']);
471
+
472
+ expect(content).toContain('Definition of Done');
473
+ expect(content).toContain('Code written and reviewed');
474
+ expect(content).toContain('Tests written and passing');
475
+ });
476
+ });
477
+
478
+ describe('generateCopilotInstructionsContent', () => {
479
+ it('should generate valid markdown', () => {
480
+ const content = generateCopilotInstructionsContent(['web-frontend']);
481
+
482
+ expect(content).toContain('# Copilot Instructions');
483
+ expect(content).toContain('web-frontend');
484
+ });
485
+
486
+ it('should include installed templates list', () => {
487
+ const content = generateCopilotInstructionsContent(['web-frontend', 'web-backend']);
488
+
489
+ expect(content).toContain('**Installed Templates:** web-frontend, web-backend');
490
+ });
491
+
492
+ it('should include core principles', () => {
493
+ const content = generateCopilotInstructionsContent(['web-frontend']);
494
+
495
+ expect(content).toContain('Honesty Over Output');
496
+ expect(content).toContain('Security First');
497
+ expect(content).toContain('Tests Are Required');
498
+ });
499
+ });
500
+ });
501
+
502
+ // ============================================================================
503
+ // File Operations Tests
504
+ // ============================================================================
505
+
506
+ describe('File Operations', () => {
507
+ let tempDir;
508
+
509
+ beforeEach(() => {
510
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-templates-test-'));
511
+ });
512
+
513
+ afterEach(() => {
514
+ fs.rmSync(tempDir, { recursive: true, force: true });
515
+ });
516
+
517
+ describe('copyFile', () => {
518
+ it('should copy file to new location', () => {
519
+ const src = path.join(tempDir, 'source.md');
520
+ const dest = path.join(tempDir, 'dest.md');
521
+ const content = '# Test Content';
522
+
523
+ fs.writeFileSync(src, content);
524
+
525
+ const result = copyFile(src, dest);
526
+
527
+ expect(result.status).toBe('copied');
528
+ expect(result.destFile).toBe(dest);
529
+ expect(fs.readFileSync(dest, 'utf8')).toBe(content);
530
+ });
531
+
532
+ it('should create destination directory if needed', () => {
533
+ const src = path.join(tempDir, 'source.md');
534
+ const dest = path.join(tempDir, 'subdir', 'dest.md');
535
+
536
+ fs.writeFileSync(src, '# Content');
537
+
538
+ const result = copyFile(src, dest);
539
+
540
+ expect(result.status).toBe('copied');
541
+ expect(fs.existsSync(dest)).toBe(true);
542
+ });
543
+
544
+ it('should skip identical files', () => {
545
+ const src = path.join(tempDir, 'source.md');
546
+ const dest = path.join(tempDir, 'dest.md');
547
+ const content = '# Same Content';
548
+
549
+ fs.writeFileSync(src, content);
550
+ fs.writeFileSync(dest, content);
551
+
552
+ const result = copyFile(src, dest);
553
+
554
+ expect(result.status).toBe('skipped');
555
+ expect(result.destFile).toBe(dest);
556
+ });
557
+
558
+ it('should rename to -1 when destination differs', () => {
559
+ const src = path.join(tempDir, 'source.md');
560
+ const dest = path.join(tempDir, 'dest.md');
561
+
562
+ fs.writeFileSync(src, '# New Content');
563
+ fs.writeFileSync(dest, '# Existing Different Content');
564
+
565
+ const result = copyFile(src, dest, false);
566
+
567
+ expect(result.status).toBe('renamed');
568
+ expect(result.destFile).toBe(path.join(tempDir, 'dest-1.md'));
569
+ expect(fs.existsSync(path.join(tempDir, 'dest-1.md'))).toBe(true);
570
+ // Original should be preserved
571
+ expect(fs.readFileSync(dest, 'utf8')).toBe('# Existing Different Content');
572
+ });
573
+
574
+ it('should overwrite with force flag', () => {
575
+ const src = path.join(tempDir, 'source.md');
576
+ const dest = path.join(tempDir, 'dest.md');
577
+
578
+ fs.writeFileSync(src, '# New Content');
579
+ fs.writeFileSync(dest, '# Old Content');
580
+
581
+ const result = copyFile(src, dest, true);
582
+
583
+ expect(result.status).toBe('updated');
584
+ expect(fs.readFileSync(dest, 'utf8')).toBe('# New Content');
585
+ });
586
+ });
587
+ });
588
+
589
+ // ============================================================================
590
+ // Install/Remove/Reset Integration Tests
591
+ // ============================================================================
592
+
593
+ describe('Install/Remove/Reset Operations', () => {
594
+ let tempDir;
595
+ let originalCwd;
596
+ let consoleLogSpy;
597
+
598
+ beforeEach(() => {
599
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-templates-test-'));
600
+ originalCwd = process.cwd();
601
+ // Suppress console output during tests
602
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
603
+ vi.spyOn(console, 'error').mockImplementation(() => {});
604
+ });
605
+
606
+ afterEach(() => {
607
+ process.chdir(originalCwd);
608
+ fs.rmSync(tempDir, { recursive: true, force: true });
609
+ vi.restoreAllMocks();
610
+ });
611
+
612
+ describe('install', () => {
613
+ it('should create .cursorrules directory', () => {
614
+ install(tempDir, ['web-frontend'], false, false, ['cursor']);
615
+
616
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules'))).toBe(true);
617
+ });
618
+
619
+ it('should install shared rules', () => {
620
+ install(tempDir, ['web-frontend'], false, false, ['cursor']);
621
+
622
+ for (const rule of SHARED_RULES) {
623
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', rule))).toBe(true);
624
+ }
625
+ });
626
+
627
+ it('should install template-specific rules with prefix', () => {
628
+ install(tempDir, ['web-frontend'], false, false, ['cursor']);
629
+
630
+ for (const rule of TEMPLATES['web-frontend'].rules) {
631
+ const prefixedName = `web-frontend-${rule}`;
632
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', prefixedName))).toBe(true);
633
+ }
634
+ });
635
+
636
+ it('should create CLAUDE.md for claude IDE', () => {
637
+ install(tempDir, ['web-frontend'], false, false, ['claude']);
638
+
639
+ expect(fs.existsSync(path.join(tempDir, 'CLAUDE.md'))).toBe(true);
640
+ const content = fs.readFileSync(path.join(tempDir, 'CLAUDE.md'), 'utf8');
641
+ expect(content).toContain('# CLAUDE.md - Development Guide');
642
+ });
643
+
644
+ it('should create copilot-instructions.md for codex IDE', () => {
645
+ install(tempDir, ['web-frontend'], false, false, ['codex']);
646
+
647
+ expect(fs.existsSync(path.join(tempDir, '.github', 'copilot-instructions.md'))).toBe(true);
648
+ });
649
+
650
+ it('should install for all IDEs by default', () => {
651
+ install(tempDir, ['web-frontend'], false, false, DEFAULT_IDES);
652
+
653
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules'))).toBe(true);
654
+ expect(fs.existsSync(path.join(tempDir, 'CLAUDE.md'))).toBe(true);
655
+ expect(fs.existsSync(path.join(tempDir, '.github', 'copilot-instructions.md'))).toBe(true);
656
+ });
657
+
658
+ it('should not write files in dry-run mode', () => {
659
+ install(tempDir, ['web-frontend'], true, false, ['cursor']);
660
+
661
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules'))).toBe(false);
662
+ });
663
+
664
+ it('should install multiple templates', () => {
665
+ install(tempDir, ['web-frontend', 'web-backend'], false, false, ['cursor']);
666
+
667
+ // Check web-frontend rules
668
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', 'web-frontend-overview.md'))).toBe(true);
669
+ // Check web-backend rules
670
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', 'web-backend-overview.md'))).toBe(true);
671
+ });
672
+ });
673
+
674
+ describe('remove', () => {
675
+ beforeEach(() => {
676
+ // First install a template
677
+ install(tempDir, ['web-frontend'], false, false, ['cursor']);
678
+ });
679
+
680
+ it('should remove template-specific files', async () => {
681
+ await remove(tempDir, ['web-frontend'], false, false, true, ['cursor']);
682
+
683
+ for (const rule of TEMPLATES['web-frontend'].rules) {
684
+ const prefixedName = `web-frontend-${rule}`;
685
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', prefixedName))).toBe(false);
686
+ }
687
+ });
688
+
689
+ it('should keep shared rules when removing template', async () => {
690
+ await remove(tempDir, ['web-frontend'], false, false, true, ['cursor']);
691
+
692
+ for (const rule of SHARED_RULES) {
693
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', rule))).toBe(true);
694
+ }
695
+ });
696
+
697
+ it('should not remove files in dry-run mode', async () => {
698
+ await remove(tempDir, ['web-frontend'], true, false, true, ['cursor']);
699
+
700
+ // Files should still exist
701
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', 'web-frontend-overview.md'))).toBe(true);
702
+ });
703
+
704
+ it('should skip modified files without force', async () => {
705
+ // Modify a file
706
+ const filePath = path.join(tempDir, '.cursorrules', 'web-frontend-overview.md');
707
+ fs.writeFileSync(filePath, '# Modified content');
708
+
709
+ await remove(tempDir, ['web-frontend'], false, false, true, ['cursor']);
710
+
711
+ // Modified file should still exist
712
+ expect(fs.existsSync(filePath)).toBe(true);
713
+ expect(fs.readFileSync(filePath, 'utf8')).toBe('# Modified content');
714
+ });
715
+
716
+ it('should remove modified files with force', async () => {
717
+ // Modify a file
718
+ const filePath = path.join(tempDir, '.cursorrules', 'web-frontend-overview.md');
719
+ fs.writeFileSync(filePath, '# Modified content');
720
+
721
+ await remove(tempDir, ['web-frontend'], false, true, true, ['cursor']);
722
+
723
+ // Modified file should be removed
724
+ expect(fs.existsSync(filePath)).toBe(false);
725
+ });
726
+ });
727
+
728
+ describe('reset', () => {
729
+ beforeEach(() => {
730
+ // Install templates
731
+ install(tempDir, ['web-frontend', 'web-backend'], false, false, DEFAULT_IDES);
732
+ });
733
+
734
+ it('should remove all template files from .cursorrules', async () => {
735
+ await reset(tempDir, false, false, true, ['cursor']);
736
+
737
+ // Template files should be removed
738
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', 'web-frontend-overview.md'))).toBe(false);
739
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', 'web-backend-overview.md'))).toBe(false);
740
+ });
741
+
742
+ it('should remove shared rules', async () => {
743
+ await reset(tempDir, false, false, true, ['cursor']);
744
+
745
+ for (const rule of SHARED_RULES) {
746
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', rule))).toBe(false);
747
+ }
748
+ });
749
+
750
+ it('should remove CLAUDE.md', async () => {
751
+ await reset(tempDir, false, false, true, ['claude']);
752
+
753
+ expect(fs.existsSync(path.join(tempDir, 'CLAUDE.md'))).toBe(false);
754
+ });
755
+
756
+ it('should remove copilot-instructions.md', async () => {
757
+ await reset(tempDir, false, false, true, ['codex']);
758
+
759
+ expect(fs.existsSync(path.join(tempDir, '.github', 'copilot-instructions.md'))).toBe(false);
760
+ });
761
+
762
+ it('should not remove files in dry-run mode', async () => {
763
+ await reset(tempDir, true, false, true, DEFAULT_IDES);
764
+
765
+ // All files should still exist
766
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules'))).toBe(true);
767
+ expect(fs.existsSync(path.join(tempDir, 'CLAUDE.md'))).toBe(true);
768
+ });
769
+
770
+ it('should remove empty .cursorrules directory', async () => {
771
+ await reset(tempDir, false, false, true, ['cursor']);
772
+
773
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules'))).toBe(false);
774
+ });
775
+
776
+ it('should keep .cursorrules if non-template files remain', async () => {
777
+ // Add a custom file
778
+ fs.writeFileSync(path.join(tempDir, '.cursorrules', 'my-custom-rules.md'), '# Custom');
779
+
780
+ await reset(tempDir, false, false, true, ['cursor']);
781
+
782
+ // Directory should still exist with custom file
783
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules'))).toBe(true);
784
+ expect(fs.existsSync(path.join(tempDir, '.cursorrules', 'my-custom-rules.md'))).toBe(true);
785
+ });
786
+ });
787
+ });
788
+
789
+ // ============================================================================
790
+ // CLI Argument Parsing Tests
791
+ // ============================================================================
792
+
793
+ describe('CLI Argument Parsing', () => {
794
+ let originalCwd;
795
+ let tempDir;
796
+ let exitSpy;
797
+ let consoleLogSpy;
798
+ let consoleErrorSpy;
799
+
800
+ beforeEach(() => {
801
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-templates-test-'));
802
+ originalCwd = process.cwd();
803
+ process.chdir(tempDir);
804
+
805
+ // Mock process.exit to prevent test from exiting
806
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
807
+ throw new Error('process.exit called');
808
+ });
809
+
810
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
811
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
812
+ });
813
+
814
+ afterEach(() => {
815
+ process.chdir(originalCwd);
816
+ fs.rmSync(tempDir, { recursive: true, force: true });
817
+ vi.restoreAllMocks();
818
+ });
819
+
820
+ it('should show help with --help', async () => {
821
+ await expect(run(['--help'])).rejects.toThrow('process.exit');
822
+
823
+ expect(exitSpy).toHaveBeenCalledWith(0);
824
+ expect(consoleLogSpy).toHaveBeenCalled();
825
+ });
826
+
827
+ it('should show help with -h', async () => {
828
+ await expect(run(['-h'])).rejects.toThrow('process.exit');
829
+
830
+ expect(exitSpy).toHaveBeenCalledWith(0);
831
+ });
832
+
833
+ it('should list templates with --list', async () => {
834
+ await expect(run(['--list'])).rejects.toThrow('process.exit');
835
+
836
+ expect(exitSpy).toHaveBeenCalledWith(0);
837
+ });
838
+
839
+ it('should list templates with -l', async () => {
840
+ await expect(run(['-l'])).rejects.toThrow('process.exit');
841
+
842
+ expect(exitSpy).toHaveBeenCalledWith(0);
843
+ });
844
+
845
+ it('should error on unknown option', async () => {
846
+ await expect(run(['--unknown-option'])).rejects.toThrow('process.exit');
847
+
848
+ expect(exitSpy).toHaveBeenCalledWith(1);
849
+ expect(consoleErrorSpy).toHaveBeenCalled();
850
+ });
851
+
852
+ it('should error when no templates specified', async () => {
853
+ await expect(run([])).rejects.toThrow('process.exit');
854
+
855
+ expect(exitSpy).toHaveBeenCalledWith(1);
856
+ });
857
+
858
+ it('should error on unknown template', async () => {
859
+ await expect(run(['nonexistent-template'])).rejects.toThrow('process.exit');
860
+
861
+ expect(exitSpy).toHaveBeenCalledWith(1);
862
+ });
863
+
864
+ it('should error on unknown IDE', async () => {
865
+ await expect(run(['web-frontend', '--ide=unknown'])).rejects.toThrow('process.exit');
866
+
867
+ expect(exitSpy).toHaveBeenCalledWith(1);
868
+ });
869
+
870
+ it('should accept valid template', async () => {
871
+ await run(['web-frontend', '--dry-run']);
872
+
873
+ // Should not exit with error
874
+ expect(exitSpy).not.toHaveBeenCalled();
875
+ });
876
+
877
+ it('should accept multiple templates', async () => {
878
+ await run(['web-frontend', 'web-backend', '--dry-run']);
879
+
880
+ expect(exitSpy).not.toHaveBeenCalled();
881
+ });
882
+
883
+ it('should accept valid IDE option', async () => {
884
+ await run(['web-frontend', '--ide=cursor', '--dry-run']);
885
+
886
+ expect(exitSpy).not.toHaveBeenCalled();
887
+ });
888
+
889
+ it('should accept multiple IDE options', async () => {
890
+ await run(['web-frontend', '--ide=cursor', '--ide=claude', '--dry-run']);
891
+
892
+ expect(exitSpy).not.toHaveBeenCalled();
893
+ });
894
+
895
+ it('should error when using --remove and --reset together', async () => {
896
+ await expect(run(['--remove', '--reset'])).rejects.toThrow('process.exit');
897
+
898
+ expect(exitSpy).toHaveBeenCalledWith(1);
899
+ });
900
+
901
+ it('should error when --reset has template arguments', async () => {
902
+ await expect(run(['--reset', 'web-frontend'])).rejects.toThrow('process.exit');
903
+
904
+ expect(exitSpy).toHaveBeenCalledWith(1);
905
+ });
906
+
907
+ it('should error when --remove has no templates', async () => {
908
+ await expect(run(['--remove'])).rejects.toThrow('process.exit');
909
+
910
+ expect(exitSpy).toHaveBeenCalledWith(1);
911
+ });
912
+
913
+ it('should accept --remove with valid template', async () => {
914
+ // First install, then remove
915
+ await run(['web-frontend']);
916
+ await run(['--remove', 'web-frontend', '--yes']);
917
+
918
+ expect(exitSpy).not.toHaveBeenCalled();
919
+ });
920
+
921
+ it('should accept --reset with --yes', async () => {
922
+ // First install, then reset
923
+ await run(['web-frontend']);
924
+ await run(['--reset', '--yes']);
925
+
926
+ expect(exitSpy).not.toHaveBeenCalled();
927
+ });
928
+
929
+ it('should accept --force flag', async () => {
930
+ await run(['web-frontend', '--force', '--dry-run']);
931
+
932
+ expect(exitSpy).not.toHaveBeenCalled();
933
+ });
934
+
935
+ it('should accept -f shorthand for force', async () => {
936
+ await run(['web-frontend', '-f', '--dry-run']);
937
+
938
+ expect(exitSpy).not.toHaveBeenCalled();
939
+ });
940
+
941
+ it('should accept -y shorthand for yes', async () => {
942
+ await run(['web-frontend']);
943
+ await run(['--reset', '-y']);
944
+
945
+ expect(exitSpy).not.toHaveBeenCalled();
946
+ });
947
+ });