xp-gate 0.5.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.
Files changed (90) hide show
  1. package/adapter-common.sh +192 -0
  2. package/adapters/cpp.sh +76 -0
  3. package/adapters/dart.sh +41 -0
  4. package/adapters/flutter.sh +41 -0
  5. package/adapters/go.sh +59 -0
  6. package/adapters/iac.sh +189 -0
  7. package/adapters/java.sh +191 -0
  8. package/adapters/kotlin.sh +77 -0
  9. package/adapters/objectivec.sh +38 -0
  10. package/adapters/powershell.sh +138 -0
  11. package/adapters/python.sh +104 -0
  12. package/adapters/shell.sh +55 -0
  13. package/adapters/swift.sh +44 -0
  14. package/adapters/typescript.sh +61 -0
  15. package/bin/xp-gate.js +157 -0
  16. package/hooks/adapter-common.sh +192 -0
  17. package/hooks/pre-commit +1667 -0
  18. package/hooks/pre-push +395 -0
  19. package/lib/__tests__/detect-deps.test.js +209 -0
  20. package/lib/__tests__/doctor.test.js +448 -0
  21. package/lib/__tests__/download-skill.test.js +281 -0
  22. package/lib/__tests__/init.test.js +327 -0
  23. package/lib/__tests__/install-skill.test.js +326 -0
  24. package/lib/__tests__/migrate.test.js +212 -0
  25. package/lib/__tests__/rollback.test.js +183 -0
  26. package/lib/__tests__/ui-detector.test.ts +200 -0
  27. package/lib/__tests__/uninstall-skill.test.js +189 -0
  28. package/lib/__tests__/uninstall.test.js +589 -0
  29. package/lib/__tests__/update-skill.test.js +276 -0
  30. package/lib/detect-deps.js +157 -0
  31. package/lib/doctor.js +370 -0
  32. package/lib/download-skill.js +96 -0
  33. package/lib/init.js +367 -0
  34. package/lib/install-skill.js +184 -0
  35. package/lib/migrate.js +120 -0
  36. package/lib/rollback.js +78 -0
  37. package/lib/ui-detector.ts +99 -0
  38. package/lib/uninstall-skill.js +69 -0
  39. package/lib/uninstall.js +401 -0
  40. package/lib/update-skill.js +90 -0
  41. package/package.json +39 -0
  42. package/plugins/claude-code/.claude-plugin/plugin.json +21 -0
  43. package/plugins/claude-code/bin/delphi-review-guard.sh +68 -0
  44. package/plugins/claude-code/bin/xp-gate-check +47 -0
  45. package/plugins/claude-code/hooks/hooks.json +37 -0
  46. package/skills/delphi-review/.delphi-config.json.example +45 -0
  47. package/skills/delphi-review/AGENTS.md +54 -0
  48. package/skills/delphi-review/INSTALL.md +152 -0
  49. package/skills/delphi-review/SKILL.md +371 -0
  50. package/skills/delphi-review/evals/evals.json +82 -0
  51. package/skills/delphi-review/opencode.json.delphi.example +56 -0
  52. package/skills/delphi-review/references/code-walkthrough.md +486 -0
  53. package/skills/ralph-loop/SKILL.md +330 -0
  54. package/skills/ralph-loop/evals/evals.json +311 -0
  55. package/skills/ralph-loop/evolution-history.json +59 -0
  56. package/skills/ralph-loop/evolution-log.md +16 -0
  57. package/skills/ralph-loop/references/components/memory.md +55 -0
  58. package/skills/ralph-loop/references/components/middleware.md +54 -0
  59. package/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  60. package/skills/ralph-loop/references/components/system-prompt.md +24 -0
  61. package/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  62. package/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  63. package/skills/ralph-loop/templates/progress-log.md +36 -0
  64. package/skills/sprint-flow/SKILL.md +600 -0
  65. package/skills/sprint-flow/evals/evals.json +78 -0
  66. package/skills/sprint-flow/evolution-history.json +39 -0
  67. package/skills/sprint-flow/evolution-log.md +23 -0
  68. package/skills/sprint-flow/references/components/memory.md +87 -0
  69. package/skills/sprint-flow/references/components/middleware.md +72 -0
  70. package/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  71. package/skills/sprint-flow/references/components/system-prompt.md +27 -0
  72. package/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  73. package/skills/sprint-flow/references/phase-0-think.md +115 -0
  74. package/skills/sprint-flow/references/phase-1-plan.md +178 -0
  75. package/skills/sprint-flow/references/phase-2-build.md +198 -0
  76. package/skills/sprint-flow/references/phase-3-review.md +213 -0
  77. package/skills/sprint-flow/references/phase-4-uat.md +125 -0
  78. package/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  79. package/skills/sprint-flow/references/phase-6-ship.md +193 -0
  80. package/skills/sprint-flow/references/phase-7-land.md +140 -0
  81. package/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  82. package/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  83. package/skills/sprint-flow/templates/pain-document-template.md +115 -0
  84. package/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  85. package/skills/test-specification-alignment/AGENTS.md +59 -0
  86. package/skills/test-specification-alignment/SKILL.md +605 -0
  87. package/skills/test-specification-alignment/evals/evals.json +75 -0
  88. package/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  89. package/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  90. package/skills/test-specification-alignment/references/specification-format.md +348 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @test rollback
3
+ * @intent Verify rollback(), createBackup(), cleanupBackup() correctly backup and restore skill directories
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ describe('rollback', () => {
10
+ let tmpHome;
11
+ let originalHome;
12
+ let logSpy;
13
+
14
+ beforeEach(() => {
15
+ originalHome = process.env.HOME;
16
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-rb-'));
17
+ process.env.HOME = tmpHome;
18
+ vi.resetModules();
19
+ delete require.cache[require.resolve('../rollback')];
20
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
21
+ });
22
+
23
+ afterEach(() => {
24
+ process.env.HOME = originalHome;
25
+ fs.rmSync(tmpHome, { recursive: true, force: true });
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ function skillsDir() {
30
+ return path.join(tmpHome, '.config', 'opencode', 'skills');
31
+ }
32
+
33
+ function backupRoot() {
34
+ return path.join(tmpHome, '.config', 'xp-gate', 'backup');
35
+ }
36
+
37
+ describe('createBackup', () => {
38
+ it('returns null when targetDir does not exist', async () => {
39
+ const { createBackup } = require('../rollback');
40
+ const result = await createBackup('install-1', 'nonexistent-skill');
41
+ expect(result).toBeNull();
42
+ });
43
+
44
+ it('copies dir contents (files) into backup when targetDir exists', async () => {
45
+ const target = path.join(skillsDir(), 'my-skill');
46
+ fs.mkdirSync(target, { recursive: true });
47
+ fs.writeFileSync(path.join(target, 'SKILL.md'), 'content-a');
48
+ fs.writeFileSync(path.join(target, 'README.md'), 'content-b');
49
+
50
+ const { createBackup } = require('../rollback');
51
+ const backupPath = await createBackup('install-1', 'my-skill');
52
+
53
+ expect(backupPath).toBe(path.join(backupRoot(), 'install-1'));
54
+ expect(fs.existsSync(path.join(backupPath, 'SKILL.md'))).toBe(true);
55
+ expect(fs.existsSync(path.join(backupPath, 'README.md'))).toBe(true);
56
+ expect(fs.readFileSync(path.join(backupPath, 'SKILL.md'), 'utf8')).toBe('content-a');
57
+ });
58
+
59
+ it('copies nested directories recursively', async () => {
60
+ const target = path.join(skillsDir(), 'nested-skill');
61
+ fs.mkdirSync(path.join(target, 'subdir', 'deeper'), { recursive: true });
62
+ fs.writeFileSync(path.join(target, 'top.txt'), 'top');
63
+ fs.writeFileSync(path.join(target, 'subdir', 'mid.txt'), 'mid');
64
+ fs.writeFileSync(path.join(target, 'subdir', 'deeper', 'bottom.txt'), 'bottom');
65
+
66
+ const { createBackup } = require('../rollback');
67
+ const backupPath = await createBackup('install-2', 'nested-skill');
68
+
69
+ expect(fs.readFileSync(path.join(backupPath, 'top.txt'), 'utf8')).toBe('top');
70
+ expect(fs.readFileSync(path.join(backupPath, 'subdir', 'mid.txt'), 'utf8')).toBe('mid');
71
+ expect(
72
+ fs.readFileSync(path.join(backupPath, 'subdir', 'deeper', 'bottom.txt'), 'utf8')
73
+ ).toBe('bottom');
74
+ });
75
+ });
76
+
77
+ describe('rollback', () => {
78
+ it('is no-op when backup dir does not exist', async () => {
79
+ const { rollback } = require('../rollback');
80
+ await rollback('nonexistent-install');
81
+ expect(logSpy).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('restores files from backup to skills dir', async () => {
85
+ // Setup: create backup with one skill entry
86
+ const backupDir = path.join(backupRoot(), 'install-3');
87
+ const backupEntry = path.join(backupDir, 'restored-skill');
88
+ fs.mkdirSync(backupEntry, { recursive: true });
89
+ fs.writeFileSync(path.join(backupEntry, 'SKILL.md'), 'restored content');
90
+
91
+ // Ensure skills dir exists (parent of dest must exist for renameSync)
92
+ fs.mkdirSync(skillsDir(), { recursive: true });
93
+
94
+ const { rollback } = require('../rollback');
95
+ await rollback('install-3');
96
+
97
+ const dest = path.join(skillsDir(), 'restored-skill');
98
+ expect(fs.existsSync(dest)).toBe(true);
99
+ expect(fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8')).toBe('restored content');
100
+ // Backup dir removed after rollback
101
+ expect(fs.existsSync(backupDir)).toBe(false);
102
+ expect(logSpy).toHaveBeenCalledWith('Rolling back...');
103
+ expect(logSpy).toHaveBeenCalledWith('Rollback complete');
104
+ });
105
+
106
+ it('removes existing dest before restoring from backup', async () => {
107
+ // Pre-existing skill (newer version) that should be overwritten by rollback
108
+ const dest = path.join(skillsDir(), 'overwrite-skill');
109
+ fs.mkdirSync(dest, { recursive: true });
110
+ fs.writeFileSync(path.join(dest, 'NEW.md'), 'new content');
111
+
112
+ // Backup with old version
113
+ const backupDir = path.join(backupRoot(), 'install-4');
114
+ const backupEntry = path.join(backupDir, 'overwrite-skill');
115
+ fs.mkdirSync(backupEntry, { recursive: true });
116
+ fs.writeFileSync(path.join(backupEntry, 'OLD.md'), 'old content');
117
+
118
+ const { rollback } = require('../rollback');
119
+ await rollback('install-4');
120
+
121
+ // NEW.md should be gone, OLD.md restored
122
+ expect(fs.existsSync(path.join(dest, 'NEW.md'))).toBe(false);
123
+ expect(fs.existsSync(path.join(dest, 'OLD.md'))).toBe(true);
124
+ expect(fs.readFileSync(path.join(dest, 'OLD.md'), 'utf8')).toBe('old content');
125
+ });
126
+
127
+ it('handles multiple skill entries in single backup', async () => {
128
+ const backupDir = path.join(backupRoot(), 'install-5');
129
+ fs.mkdirSync(path.join(backupDir, 'skill-a'), { recursive: true });
130
+ fs.mkdirSync(path.join(backupDir, 'skill-b'), { recursive: true });
131
+ fs.writeFileSync(path.join(backupDir, 'skill-a', 'a.md'), 'A');
132
+ fs.writeFileSync(path.join(backupDir, 'skill-b', 'b.md'), 'B');
133
+
134
+ fs.mkdirSync(skillsDir(), { recursive: true });
135
+
136
+ const { rollback } = require('../rollback');
137
+ await rollback('install-5');
138
+
139
+ expect(fs.existsSync(path.join(skillsDir(), 'skill-a', 'a.md'))).toBe(true);
140
+ expect(fs.existsSync(path.join(skillsDir(), 'skill-b', 'b.md'))).toBe(true);
141
+ });
142
+ });
143
+
144
+ describe('cleanupBackup', () => {
145
+ it('removes backup dir when it exists', async () => {
146
+ const backupDir = path.join(backupRoot(), 'install-6');
147
+ fs.mkdirSync(backupDir, { recursive: true });
148
+ fs.writeFileSync(path.join(backupDir, 'stale.txt'), 'stale');
149
+
150
+ const { cleanupBackup } = require('../rollback');
151
+ cleanupBackup('install-6');
152
+
153
+ expect(fs.existsSync(backupDir)).toBe(false);
154
+ });
155
+
156
+ it('is no-op when backup dir does not exist', async () => {
157
+ const { cleanupBackup } = require('../rollback');
158
+ // Should not throw
159
+ expect(() => cleanupBackup('nonexistent-id')).not.toThrow();
160
+ });
161
+ });
162
+
163
+ describe('round trip: createBackup + rollback', () => {
164
+ it('createBackup writes entries that rollback restores as top-level skills', async () => {
165
+ const target = path.join(skillsDir(), 'roundtrip-skill');
166
+ fs.mkdirSync(target, { recursive: true });
167
+ fs.writeFileSync(path.join(target, 'v1.txt'), 'version 1');
168
+
169
+ const { createBackup, rollback } = require('../rollback');
170
+ await createBackup('rt-install', 'roundtrip-skill');
171
+
172
+ const backupDir = path.join(backupRoot(), 'rt-install');
173
+ expect(fs.existsSync(path.join(backupDir, 'v1.txt'))).toBe(true);
174
+
175
+ fs.unlinkSync(path.join(target, 'v1.txt'));
176
+
177
+ await rollback('rt-install');
178
+
179
+ expect(fs.existsSync(path.join(skillsDir(), 'v1.txt'))).toBe(true);
180
+ expect(fs.readFileSync(path.join(skillsDir(), 'v1.txt'), 'utf8')).toBe('version 1');
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @test ui-detector
3
+ * @intent Verify detectUiSprint correctly identifies UI file changes
4
+ */
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { execSync } from 'child_process';
7
+
8
+ vi.mock('child_process', () => ({
9
+ execSync: vi.fn(),
10
+ }));
11
+
12
+ const mockExecSync = vi.mocked(execSync);
13
+
14
+ describe('ui-detector', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ describe('detectUiSprint', () => {
20
+ it('should return false for empty diff', async () => {
21
+ mockExecSync.mockReturnValue('');
22
+ const { detectUiSprint } = await import('../ui-detector');
23
+ const result = detectUiSprint();
24
+ expect(result.isUiSprint).toBe(false);
25
+ expect(result.matchedFiles).toEqual([]);
26
+ expect(result.matchedRules).toEqual([]);
27
+ });
28
+
29
+ it('should return false for pure backend changes', async () => {
30
+ mockExecSync.mockReturnValue('src/auth.ts\nsrc/db.ts\n');
31
+ const { detectUiSprint } = await import('../ui-detector');
32
+ const result = detectUiSprint('main');
33
+ expect(result.isUiSprint).toBe(false);
34
+ });
35
+
36
+ it('should return true for template files in view directories', async () => {
37
+ mockExecSync.mockReturnValue('views/index.njk\n');
38
+ const { detectUiSprint } = await import('../ui-detector');
39
+ const result = detectUiSprint();
40
+ expect(result.isUiSprint).toBe(true);
41
+ expect(result.matchedFiles).toContain('views/index.njk');
42
+ expect(result.matchedRules.some(r => r.includes('template'))).toBe(true);
43
+ });
44
+
45
+ it('should return true for component files in view directories', async () => {
46
+ mockExecSync.mockReturnValue('src/components/Button.tsx\n');
47
+ const { detectUiSprint } = await import('../ui-detector');
48
+ const result = detectUiSprint();
49
+ expect(result.isUiSprint).toBe(true);
50
+ expect(result.matchedFiles).toContain('src/components/Button.tsx');
51
+ });
52
+
53
+ it('should return false for component files NOT in view directories', async () => {
54
+ mockExecSync.mockReturnValue('src/hooks/useAuth.tsx\n');
55
+ const { detectUiSprint } = await import('../ui-detector');
56
+ const result = detectUiSprint();
57
+ expect(result.isUiSprint).toBe(false);
58
+ });
59
+
60
+ it('should return true for style files in view directories', async () => {
61
+ mockExecSync.mockReturnValue('views/styles/main.css\n');
62
+ const { detectUiSprint } = await import('../ui-detector');
63
+ const result = detectUiSprint();
64
+ expect(result.isUiSprint).toBe(true);
65
+ });
66
+
67
+ it('should return false for style files NOT in view directories', async () => {
68
+ mockExecSync.mockReturnValue('src/index.css\n');
69
+ const { detectUiSprint } = await import('../ui-detector');
70
+ const result = detectUiSprint();
71
+ expect(result.isUiSprint).toBe(false);
72
+ });
73
+
74
+ it('should return true for mixed changes (backend + UI)', async () => {
75
+ mockExecSync.mockReturnValue('src/auth.ts\nviews/login.html\n');
76
+ const { detectUiSprint } = await import('../ui-detector');
77
+ const result = detectUiSprint();
78
+ expect(result.isUiSprint).toBe(true);
79
+ expect(result.matchedFiles).toContain('views/login.html');
80
+ });
81
+
82
+ it('should return true for deleted UI files', async () => {
83
+ mockExecSync.mockReturnValue('views/old.html\n');
84
+ const { detectUiSprint } = await import('../ui-detector');
85
+ const result = detectUiSprint();
86
+ expect(result.isUiSprint).toBe(true);
87
+ });
88
+
89
+ it('should handle renamed files correctly', async () => {
90
+ mockExecSync.mockReturnValue('views/a.html → views/b.html\n');
91
+ const { detectUiSprint } = await import('../ui-detector');
92
+ const result = detectUiSprint();
93
+ expect(result.isUiSprint).toBe(true);
94
+ expect(result.matchedFiles).toContain('views/b.html');
95
+ });
96
+
97
+ it('should return false for pure documentation changes', async () => {
98
+ mockExecSync.mockReturnValue('docs/README.md\n');
99
+ const { detectUiSprint } = await import('../ui-detector');
100
+ const result = detectUiSprint();
101
+ expect(result.isUiSprint).toBe(false);
102
+ });
103
+
104
+ it('should use main as default base branch', async () => {
105
+ mockExecSync.mockReturnValue('');
106
+ const { detectUiSprint } = await import('../ui-detector');
107
+ detectUiSprint();
108
+ expect(mockExecSync).toHaveBeenCalledWith(
109
+ expect.stringContaining('git diff --name-only main..HEAD'),
110
+ expect.any(Object)
111
+ );
112
+ });
113
+
114
+ it('should handle git command failure gracefully', async () => {
115
+ mockExecSync.mockImplementation(() => {
116
+ throw new Error('git not available');
117
+ });
118
+ const { detectUiSprint } = await import('../ui-detector');
119
+ const result = detectUiSprint();
120
+ expect(result.isUiSprint).toBe(false);
121
+ });
122
+
123
+ it('should handle multiple UI files across different types', async () => {
124
+ mockExecSync.mockReturnValue('views/index.njk\nsrc/components/Button.tsx\nsrc/auth.ts\n');
125
+ const { detectUiSprint } = await import('../ui-detector');
126
+ const result = detectUiSprint();
127
+ expect(result.isUiSprint).toBe(true);
128
+ expect(result.matchedFiles.length).toBe(2);
129
+ });
130
+ });
131
+
132
+ describe('parseRenamedFile', () => {
133
+ it('should extract new path from renamed file', async () => {
134
+ const { parseRenamedFile } = await import('../ui-detector');
135
+ expect(parseRenamedFile('views/a.html → views/b.html')).toBe('views/b.html');
136
+ });
137
+
138
+ it('should return original path for non-renamed files', async () => {
139
+ const { parseRenamedFile } = await import('../ui-detector');
140
+ expect(parseRenamedFile('src/auth.ts')).toBe('src/auth.ts');
141
+ });
142
+ });
143
+
144
+ describe('getFileExtension', () => {
145
+ it('should extract file extension', async () => {
146
+ const { getFileExtension } = await import('../ui-detector');
147
+ expect(getFileExtension('src/auth.ts')).toBe('.ts');
148
+ expect(getFileExtension('views/index.njk')).toBe('.njk');
149
+ expect(getFileExtension('styles/main.css')).toBe('.css');
150
+ });
151
+
152
+ it('should return empty string for files without extension', async () => {
153
+ const { getFileExtension } = await import('../ui-detector');
154
+ expect(getFileExtension('Makefile')).toBe('');
155
+ });
156
+ });
157
+
158
+ describe('hasUiPathPattern', () => {
159
+ it('should return true for views/ directory', async () => {
160
+ const { hasUiPathPattern } = await import('../ui-detector');
161
+ expect(hasUiPathPattern('views/index.html')).toBe(true);
162
+ });
163
+
164
+ it('should return true for components/ directory', async () => {
165
+ const { hasUiPathPattern } = await import('../ui-detector');
166
+ expect(hasUiPathPattern('src/components/button.tsx')).toBe(true);
167
+ });
168
+
169
+ it('should return false for non-UI paths', async () => {
170
+ const { hasUiPathPattern } = await import('../ui-detector');
171
+ expect(hasUiPathPattern('src/utils/helper.ts')).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe('getFileMatchRules', () => {
176
+ it('should return template rule for .html files', async () => {
177
+ const { getFileMatchRules } = await import('../ui-detector');
178
+ const rules = getFileMatchRules('views/index.html');
179
+ expect(rules).toContain('template-.html');
180
+ });
181
+
182
+ it('should return component rule for .tsx in components/', async () => {
183
+ const { getFileMatchRules } = await import('../ui-detector');
184
+ const rules = getFileMatchRules('src/components/Button.tsx');
185
+ expect(rules).toContain('component-.tsx');
186
+ });
187
+
188
+ it('should return empty for .tsx outside UI directories', async () => {
189
+ const { getFileMatchRules } = await import('../ui-detector');
190
+ const rules = getFileMatchRules('src/hooks/useAuth.tsx');
191
+ expect(rules).toEqual([]);
192
+ });
193
+
194
+ it('should return style rule for .css in views/', async () => {
195
+ const { getFileMatchRules } = await import('../ui-detector');
196
+ const rules = getFileMatchRules('views/styles/main.css');
197
+ expect(rules).toContain('style-.css');
198
+ });
199
+ });
200
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @test uninstall-skill
3
+ * @intent Verify uninstallSkill() correctly removes skills, cleans cache, and updates config
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ describe('uninstall-skill', () => {
10
+ let tmpHome;
11
+ let originalHome;
12
+ let logSpy;
13
+ let errorSpy;
14
+
15
+ beforeEach(() => {
16
+ originalHome = process.env.HOME;
17
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-un-'));
18
+ process.env.HOME = tmpHome;
19
+ vi.resetModules();
20
+ delete require.cache[require.resolve('../uninstall-skill')];
21
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
22
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.env.HOME = originalHome;
27
+ fs.rmSync(tmpHome, { recursive: true, force: true });
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ function skillsDir() {
32
+ return path.join(tmpHome, '.config', 'opencode', 'skills');
33
+ }
34
+
35
+ function configDir() {
36
+ return path.join(tmpHome, '.config', 'xp-gate');
37
+ }
38
+
39
+ function cacheDir() {
40
+ return path.join(configDir(), 'cache');
41
+ }
42
+
43
+ function makeInstalledSkill(name) {
44
+ const dir = path.join(skillsDir(), name);
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), 'content');
47
+ return dir;
48
+ }
49
+
50
+ it('returns 1 + console.error when name is empty', async () => {
51
+ const { uninstallSkill } = require('../uninstall-skill');
52
+ const result = await uninstallSkill();
53
+ expect(result).toBe(1);
54
+ expect(errorSpy).toHaveBeenCalledWith('Error: Skill name required');
55
+ expect(errorSpy).toHaveBeenCalledWith('Usage: xp-gate uninstall-skill <name> [--force]');
56
+ });
57
+
58
+ it('returns 1 + console.error when name is empty string', async () => {
59
+ const { uninstallSkill } = require('../uninstall-skill');
60
+ const result = await uninstallSkill('');
61
+ expect(result).toBe(1);
62
+ expect(errorSpy).toHaveBeenCalled();
63
+ });
64
+
65
+ it('returns 1 when skill is not installed', async () => {
66
+ const { uninstallSkill } = require('../uninstall-skill');
67
+ const result = await uninstallSkill('not-installed');
68
+ expect(result).toBe(1);
69
+ expect(errorSpy).toHaveBeenCalledWith('Error: not-installed is not installed');
70
+ });
71
+
72
+ it('returns 0 and prompts (no deletion) when force=false', async () => {
73
+ const dir = makeInstalledSkill('foo');
74
+ const { uninstallSkill } = require('../uninstall-skill');
75
+ const result = await uninstallSkill('foo');
76
+ expect(result).toBe(0);
77
+ expect(fs.existsSync(dir)).toBe(true); // NOT deleted
78
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Uninstall foo?'));
79
+ expect(logSpy).toHaveBeenCalledWith('Use --force to skip confirmation');
80
+ });
81
+
82
+ it('returns 0 and removes skill dir when force=true', async () => {
83
+ const dir = makeInstalledSkill('foo');
84
+ const { uninstallSkill } = require('../uninstall-skill');
85
+ const result = await uninstallSkill('foo', { force: true });
86
+ expect(result).toBe(0);
87
+ expect(fs.existsSync(dir)).toBe(false);
88
+ expect(logSpy).toHaveBeenCalledWith('Removing foo...');
89
+ expect(logSpy).toHaveBeenCalledWith('✓ foo uninstalled');
90
+ });
91
+
92
+ it('removes cache file (foo.tgz) when force=true', async () => {
93
+ makeInstalledSkill('foo');
94
+ fs.mkdirSync(cacheDir(), { recursive: true });
95
+ const cacheFile = path.join(cacheDir(), 'foo.tgz');
96
+ fs.writeFileSync(cacheFile, 'cached-tgz-data');
97
+
98
+ const { uninstallSkill } = require('../uninstall-skill');
99
+ const result = await uninstallSkill('foo', { force: true });
100
+ expect(result).toBe(0);
101
+ expect(fs.existsSync(cacheFile)).toBe(false);
102
+ });
103
+
104
+ it('does not error when no cache file exists', async () => {
105
+ makeInstalledSkill('foo');
106
+ // No cache dir created
107
+ const { uninstallSkill } = require('../uninstall-skill');
108
+ const result = await uninstallSkill('foo', { force: true });
109
+ expect(result).toBe(0);
110
+ });
111
+
112
+ it('removes skill entry from installedSkills in config when force=true', async () => {
113
+ makeInstalledSkill('foo');
114
+ fs.mkdirSync(configDir(), { recursive: true });
115
+ const configFile = path.join(configDir(), 'xp-gate.json');
116
+ fs.writeFileSync(
117
+ configFile,
118
+ JSON.stringify({
119
+ installedSkills: { foo: { version: '1.0.0' }, bar: { version: '2.0.0' } },
120
+ otherSetting: true,
121
+ })
122
+ );
123
+
124
+ const { uninstallSkill } = require('../uninstall-skill');
125
+ const result = await uninstallSkill('foo', { force: true });
126
+ expect(result).toBe(0);
127
+
128
+ const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
129
+ expect(written.installedSkills).not.toHaveProperty('foo');
130
+ expect(written.installedSkills).toHaveProperty('bar');
131
+ expect(written.otherSetting).toBe(true);
132
+ });
133
+
134
+ it('does not write config when no installedSkills key present', async () => {
135
+ makeInstalledSkill('foo');
136
+ fs.mkdirSync(configDir(), { recursive: true });
137
+ const configFile = path.join(configDir(), 'xp-gate.json');
138
+ fs.writeFileSync(configFile, JSON.stringify({ otherSetting: 'unchanged' }));
139
+
140
+ const { uninstallSkill } = require('../uninstall-skill');
141
+ const result = await uninstallSkill('foo', { force: true });
142
+ expect(result).toBe(0);
143
+
144
+ const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
145
+ expect(written.otherSetting).toBe('unchanged');
146
+ expect(written).not.toHaveProperty('installedSkills');
147
+ });
148
+
149
+ it('handles missing config file gracefully (getConfig returns {})', async () => {
150
+ makeInstalledSkill('foo');
151
+ // No config file
152
+ const { uninstallSkill } = require('../uninstall-skill');
153
+ const result = await uninstallSkill('foo', { force: true });
154
+ expect(result).toBe(0);
155
+ // No config file should be created (since config = {} has no installedSkills)
156
+ expect(fs.existsSync(path.join(configDir(), 'xp-gate.json'))).toBe(false);
157
+ });
158
+
159
+ it('handles malformed config JSON gracefully (catch swallows + returns {})', async () => {
160
+ makeInstalledSkill('foo');
161
+ fs.mkdirSync(configDir(), { recursive: true });
162
+ fs.writeFileSync(path.join(configDir(), 'xp-gate.json'), '{invalid json');
163
+
164
+ const { uninstallSkill } = require('../uninstall-skill');
165
+ const result = await uninstallSkill('foo', { force: true });
166
+ expect(result).toBe(0);
167
+ });
168
+
169
+ it('removes cache file + skill dir + config entry in one call (full path)', async () => {
170
+ const dir = makeInstalledSkill('full');
171
+ fs.mkdirSync(cacheDir(), { recursive: true });
172
+ const cacheFile = path.join(cacheDir(), 'full.tgz');
173
+ fs.writeFileSync(cacheFile, 'data');
174
+ const configFile = path.join(configDir(), 'xp-gate.json');
175
+ fs.writeFileSync(
176
+ configFile,
177
+ JSON.stringify({ installedSkills: { full: { version: '1.0.0' } } })
178
+ );
179
+
180
+ const { uninstallSkill } = require('../uninstall-skill');
181
+ const result = await uninstallSkill('full', { force: true });
182
+
183
+ expect(result).toBe(0);
184
+ expect(fs.existsSync(dir)).toBe(false);
185
+ expect(fs.existsSync(cacheFile)).toBe(false);
186
+ const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
187
+ expect(written.installedSkills).not.toHaveProperty('full');
188
+ });
189
+ });