@nicnocquee/dataqueue 1.33.0 → 1.35.0-beta.20260224075710

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 (54) hide show
  1. package/ai/build-docs-content.ts +96 -0
  2. package/ai/build-llms-full.ts +42 -0
  3. package/ai/docs-content.json +290 -0
  4. package/ai/rules/advanced.md +170 -0
  5. package/ai/rules/basic.md +159 -0
  6. package/ai/rules/react-dashboard.md +87 -0
  7. package/ai/skills/dataqueue-advanced/SKILL.md +370 -0
  8. package/ai/skills/dataqueue-core/SKILL.md +235 -0
  9. package/ai/skills/dataqueue-react/SKILL.md +201 -0
  10. package/dist/cli.cjs +577 -32
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.d.cts +52 -2
  13. package/dist/cli.d.ts +52 -2
  14. package/dist/cli.js +575 -32
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.cjs +937 -108
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +358 -11
  19. package/dist/index.d.ts +358 -11
  20. package/dist/index.js +937 -108
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp-server.cjs +186 -0
  23. package/dist/mcp-server.cjs.map +1 -0
  24. package/dist/mcp-server.d.cts +32 -0
  25. package/dist/mcp-server.d.ts +32 -0
  26. package/dist/mcp-server.js +175 -0
  27. package/dist/mcp-server.js.map +1 -0
  28. package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
  29. package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
  30. package/package.json +10 -4
  31. package/src/backend.ts +36 -3
  32. package/src/backends/postgres.ts +344 -42
  33. package/src/backends/redis-scripts.ts +173 -8
  34. package/src/backends/redis.test.ts +668 -0
  35. package/src/backends/redis.ts +244 -15
  36. package/src/cli.test.ts +65 -0
  37. package/src/cli.ts +56 -19
  38. package/src/db-util.ts +1 -1
  39. package/src/index.test.ts +811 -12
  40. package/src/index.ts +106 -14
  41. package/src/install-mcp-command.test.ts +216 -0
  42. package/src/install-mcp-command.ts +185 -0
  43. package/src/install-rules-command.test.ts +218 -0
  44. package/src/install-rules-command.ts +233 -0
  45. package/src/install-skills-command.test.ts +176 -0
  46. package/src/install-skills-command.ts +124 -0
  47. package/src/mcp-server.test.ts +162 -0
  48. package/src/mcp-server.ts +231 -0
  49. package/src/processor.ts +133 -49
  50. package/src/queue.test.ts +477 -0
  51. package/src/queue.ts +20 -3
  52. package/src/supervisor.test.ts +340 -0
  53. package/src/supervisor.ts +177 -0
  54. package/src/types.ts +318 -3
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import {
3
+ runInstallRules,
4
+ upsertMarkedSection,
5
+ InstallRulesDeps,
6
+ } from './install-rules-command.js';
7
+
8
+ describe('upsertMarkedSection', () => {
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ });
12
+
13
+ it('creates file with markers when file does not exist', () => {
14
+ // Setup
15
+ const deps = {
16
+ existsSync: vi.fn(() => false),
17
+ readFileSync: vi.fn(),
18
+ writeFileSync: vi.fn(),
19
+ };
20
+
21
+ // Act
22
+ upsertMarkedSection('/path/file.md', 'content here', deps);
23
+
24
+ // Assert
25
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
26
+ '/path/file.md',
27
+ expect.stringContaining('<!-- DATAQUEUE RULES START -->'),
28
+ );
29
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
30
+ '/path/file.md',
31
+ expect.stringContaining('content here'),
32
+ );
33
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
34
+ '/path/file.md',
35
+ expect.stringContaining('<!-- DATAQUEUE RULES END -->'),
36
+ );
37
+ });
38
+
39
+ it('replaces existing marked section', () => {
40
+ // Setup
41
+ const existing =
42
+ 'before\n<!-- DATAQUEUE RULES START -->\nold content\n<!-- DATAQUEUE RULES END -->\nafter';
43
+ const deps = {
44
+ existsSync: vi.fn(() => true),
45
+ readFileSync: vi.fn(() => existing),
46
+ writeFileSync: vi.fn(),
47
+ };
48
+
49
+ // Act
50
+ upsertMarkedSection('/path/file.md', 'new content', deps);
51
+
52
+ // Assert
53
+ const written = deps.writeFileSync.mock.calls[0][1] as string;
54
+ expect(written).toContain('before\n');
55
+ expect(written).toContain('new content');
56
+ expect(written).not.toContain('old content');
57
+ expect(written).toContain('\nafter');
58
+ });
59
+
60
+ it('appends to file when no markers exist', () => {
61
+ // Setup
62
+ const deps = {
63
+ existsSync: vi.fn(() => true),
64
+ readFileSync: vi.fn(() => '# Existing content'),
65
+ writeFileSync: vi.fn(),
66
+ };
67
+
68
+ // Act
69
+ upsertMarkedSection('/path/file.md', 'new content', deps);
70
+
71
+ // Assert
72
+ const written = deps.writeFileSync.mock.calls[0][1] as string;
73
+ expect(written).toContain('# Existing content');
74
+ expect(written).toContain('new content');
75
+ });
76
+ });
77
+
78
+ describe('runInstallRules', () => {
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ function makeDeps(
84
+ overrides: Partial<InstallRulesDeps> = {},
85
+ ): InstallRulesDeps {
86
+ return {
87
+ log: vi.fn(),
88
+ error: vi.fn(),
89
+ exit: vi.fn(),
90
+ cwd: '/project',
91
+ readFileSync: vi.fn(() => '# Rule content'),
92
+ writeFileSync: vi.fn(),
93
+ appendFileSync: vi.fn(),
94
+ mkdirSync: vi.fn(),
95
+ existsSync: vi.fn(() => false),
96
+ rulesSourceDir: '/pkg/ai/rules',
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ it('installs rules for Cursor (option 1)', async () => {
102
+ // Setup
103
+ const deps = makeDeps({ selectedClient: '1' });
104
+
105
+ // Act
106
+ await runInstallRules(deps);
107
+
108
+ // Assert
109
+ expect(deps.mkdirSync).toHaveBeenCalledWith('/project/.cursor/rules', {
110
+ recursive: true,
111
+ });
112
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
113
+ expect.stringContaining('.cursor/rules/dataqueue-basic.mdc'),
114
+ expect.any(String),
115
+ );
116
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
117
+ expect.stringContaining('.cursor/rules/dataqueue-advanced.mdc'),
118
+ expect.any(String),
119
+ );
120
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
121
+ expect.stringContaining('.cursor/rules/dataqueue-react-dashboard.mdc'),
122
+ expect.any(String),
123
+ );
124
+ });
125
+
126
+ it('installs rules for Claude Code (option 2)', async () => {
127
+ // Setup
128
+ const deps = makeDeps({ selectedClient: '2' });
129
+
130
+ // Act
131
+ await runInstallRules(deps);
132
+
133
+ // Assert
134
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
135
+ '/project/CLAUDE.md',
136
+ expect.stringContaining('<!-- DATAQUEUE RULES START -->'),
137
+ );
138
+ });
139
+
140
+ it('installs rules for AGENTS.md (option 3)', async () => {
141
+ // Setup
142
+ const deps = makeDeps({ selectedClient: '3' });
143
+
144
+ // Act
145
+ await runInstallRules(deps);
146
+
147
+ // Assert
148
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
149
+ '/project/AGENTS.md',
150
+ expect.stringContaining('<!-- DATAQUEUE RULES START -->'),
151
+ );
152
+ });
153
+
154
+ it('installs rules for GitHub Copilot (option 4)', async () => {
155
+ // Setup
156
+ const deps = makeDeps({ selectedClient: '4' });
157
+
158
+ // Act
159
+ await runInstallRules(deps);
160
+
161
+ // Assert
162
+ expect(deps.mkdirSync).toHaveBeenCalledWith('/project/.github', {
163
+ recursive: true,
164
+ });
165
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
166
+ '/project/.github/copilot-instructions.md',
167
+ expect.stringContaining('<!-- DATAQUEUE RULES START -->'),
168
+ );
169
+ });
170
+
171
+ it('installs rules for Windsurf (option 5)', async () => {
172
+ // Setup
173
+ const deps = makeDeps({ selectedClient: '5' });
174
+
175
+ // Act
176
+ await runInstallRules(deps);
177
+
178
+ // Assert
179
+ expect(deps.writeFileSync).toHaveBeenCalledWith(
180
+ '/project/CONVENTIONS.md',
181
+ expect.stringContaining('<!-- DATAQUEUE RULES START -->'),
182
+ );
183
+ });
184
+
185
+ it('exits with error for invalid choice', async () => {
186
+ // Setup
187
+ const deps = makeDeps({ selectedClient: '99' });
188
+
189
+ // Act
190
+ await runInstallRules(deps);
191
+
192
+ // Assert
193
+ expect(deps.error).toHaveBeenCalledWith(
194
+ expect.stringContaining('Invalid choice'),
195
+ );
196
+ expect(deps.exit).toHaveBeenCalledWith(1);
197
+ });
198
+
199
+ it('handles install errors', async () => {
200
+ // Setup
201
+ const deps = makeDeps({
202
+ selectedClient: '1',
203
+ readFileSync: vi.fn(() => {
204
+ throw new Error('file not found');
205
+ }),
206
+ });
207
+
208
+ // Act
209
+ await runInstallRules(deps);
210
+
211
+ // Assert
212
+ expect(deps.error).toHaveBeenCalledWith(
213
+ 'Failed to install rules:',
214
+ expect.any(Error),
215
+ );
216
+ expect(deps.exit).toHaveBeenCalledWith(1);
217
+ });
218
+ });
@@ -0,0 +1,233 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import readline from 'readline';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ export interface InstallRulesDeps {
10
+ log?: (...args: unknown[]) => void;
11
+ error?: (...args: unknown[]) => void;
12
+ exit?: (code: number) => void;
13
+ cwd?: string;
14
+ readFileSync?: (p: string, enc: BufferEncoding) => string;
15
+ writeFileSync?: (p: string, data: string) => void;
16
+ appendFileSync?: (p: string, data: string) => void;
17
+ mkdirSync?: (p: string, opts?: fs.MakeDirectoryOptions) => void;
18
+ existsSync?: (p: string) => boolean;
19
+ rulesSourceDir?: string;
20
+ /** Override for selecting the client (skips interactive prompt). */
21
+ selectedClient?: string;
22
+ }
23
+
24
+ const RULE_FILES = ['basic.md', 'advanced.md', 'react-dashboard.md'];
25
+
26
+ interface ClientConfig {
27
+ label: string;
28
+ install: (
29
+ deps: Required<
30
+ Pick<
31
+ InstallRulesDeps,
32
+ | 'cwd'
33
+ | 'readFileSync'
34
+ | 'writeFileSync'
35
+ | 'appendFileSync'
36
+ | 'mkdirSync'
37
+ | 'existsSync'
38
+ | 'log'
39
+ | 'rulesSourceDir'
40
+ >
41
+ >,
42
+ ) => void;
43
+ }
44
+
45
+ const MARKER_START = '<!-- DATAQUEUE RULES START -->';
46
+ const MARKER_END = '<!-- DATAQUEUE RULES END -->';
47
+
48
+ /**
49
+ * Appends or replaces a marked section in a file.
50
+ *
51
+ * @param filePath - Path to the file to update.
52
+ * @param content - Content to insert between markers.
53
+ * @param deps - Injectable file system functions.
54
+ */
55
+ export function upsertMarkedSection(
56
+ filePath: string,
57
+ content: string,
58
+ deps: {
59
+ readFileSync: (p: string, enc: BufferEncoding) => string;
60
+ writeFileSync: (p: string, data: string) => void;
61
+ existsSync: (p: string) => boolean;
62
+ },
63
+ ): void {
64
+ const block = `${MARKER_START}\n${content}\n${MARKER_END}`;
65
+
66
+ if (!deps.existsSync(filePath)) {
67
+ deps.writeFileSync(filePath, block + '\n');
68
+ return;
69
+ }
70
+
71
+ const existing = deps.readFileSync(filePath, 'utf-8');
72
+ const startIdx = existing.indexOf(MARKER_START);
73
+ const endIdx = existing.indexOf(MARKER_END);
74
+
75
+ if (startIdx !== -1 && endIdx !== -1) {
76
+ const before = existing.slice(0, startIdx);
77
+ const after = existing.slice(endIdx + MARKER_END.length);
78
+ deps.writeFileSync(filePath, before + block + after);
79
+ } else {
80
+ deps.writeFileSync(filePath, existing.trimEnd() + '\n\n' + block + '\n');
81
+ }
82
+ }
83
+
84
+ function getAllRulesContent(
85
+ rulesSourceDir: string,
86
+ readFileSync: (p: string, enc: BufferEncoding) => string,
87
+ ): string {
88
+ return RULE_FILES.map((f) =>
89
+ readFileSync(path.join(rulesSourceDir, f), 'utf-8'),
90
+ ).join('\n\n');
91
+ }
92
+
93
+ const CLIENTS: Record<string, ClientConfig> = {
94
+ '1': {
95
+ label: 'Cursor',
96
+ install: (deps) => {
97
+ const rulesDir = path.join(deps.cwd, '.cursor', 'rules');
98
+ deps.mkdirSync(rulesDir, { recursive: true });
99
+
100
+ for (const file of RULE_FILES) {
101
+ const src = deps.readFileSync(
102
+ path.join(deps.rulesSourceDir, file),
103
+ 'utf-8',
104
+ );
105
+ const destName = `dataqueue-${file.replace(/\.md$/, '.mdc')}`;
106
+ deps.writeFileSync(path.join(rulesDir, destName), src);
107
+ deps.log(` ✓ .cursor/rules/${destName}`);
108
+ }
109
+ },
110
+ },
111
+ '2': {
112
+ label: 'Claude Code',
113
+ install: (deps) => {
114
+ const content = getAllRulesContent(
115
+ deps.rulesSourceDir,
116
+ deps.readFileSync,
117
+ );
118
+ const filePath = path.join(deps.cwd, 'CLAUDE.md');
119
+ upsertMarkedSection(filePath, content, deps);
120
+ deps.log(` ✓ CLAUDE.md`);
121
+ },
122
+ },
123
+ '3': {
124
+ label: 'AGENTS.md (Codex, Jules, OpenCode)',
125
+ install: (deps) => {
126
+ const content = getAllRulesContent(
127
+ deps.rulesSourceDir,
128
+ deps.readFileSync,
129
+ );
130
+ const filePath = path.join(deps.cwd, 'AGENTS.md');
131
+ upsertMarkedSection(filePath, content, deps);
132
+ deps.log(` ✓ AGENTS.md`);
133
+ },
134
+ },
135
+ '4': {
136
+ label: 'GitHub Copilot',
137
+ install: (deps) => {
138
+ const content = getAllRulesContent(
139
+ deps.rulesSourceDir,
140
+ deps.readFileSync,
141
+ );
142
+ deps.mkdirSync(path.join(deps.cwd, '.github'), { recursive: true });
143
+ const filePath = path.join(
144
+ deps.cwd,
145
+ '.github',
146
+ 'copilot-instructions.md',
147
+ );
148
+ upsertMarkedSection(filePath, content, deps);
149
+ deps.log(` ✓ .github/copilot-instructions.md`);
150
+ },
151
+ },
152
+ '5': {
153
+ label: 'Windsurf',
154
+ install: (deps) => {
155
+ const content = getAllRulesContent(
156
+ deps.rulesSourceDir,
157
+ deps.readFileSync,
158
+ );
159
+ const filePath = path.join(deps.cwd, 'CONVENTIONS.md');
160
+ upsertMarkedSection(filePath, content, deps);
161
+ deps.log(` ✓ CONVENTIONS.md`);
162
+ },
163
+ },
164
+ };
165
+
166
+ /**
167
+ * Installs DataQueue agent rules for the selected AI client.
168
+ *
169
+ * @param deps - Injectable dependencies for testing.
170
+ */
171
+ export async function runInstallRules({
172
+ log = console.log,
173
+ error = console.error,
174
+ exit = (code: number) => process.exit(code),
175
+ cwd = process.cwd(),
176
+ readFileSync = fs.readFileSync,
177
+ writeFileSync = fs.writeFileSync,
178
+ appendFileSync = fs.appendFileSync,
179
+ mkdirSync = fs.mkdirSync,
180
+ existsSync = fs.existsSync,
181
+ rulesSourceDir = path.join(__dirname, '../ai/rules'),
182
+ selectedClient,
183
+ }: InstallRulesDeps = {}): Promise<void> {
184
+ log('DataQueue Agent Rules Installer\n');
185
+ log('Select your AI client:\n');
186
+
187
+ for (const [key, client] of Object.entries(CLIENTS)) {
188
+ log(` ${key}) ${client.label}`);
189
+ }
190
+ log('');
191
+
192
+ let choice = selectedClient;
193
+
194
+ if (!choice) {
195
+ const rl = readline.createInterface({
196
+ input: process.stdin,
197
+ output: process.stdout,
198
+ });
199
+
200
+ choice = await new Promise<string>((resolve) => {
201
+ rl.question('Enter choice (1-5): ', (answer) => {
202
+ rl.close();
203
+ resolve(answer.trim());
204
+ });
205
+ });
206
+ }
207
+
208
+ const client = CLIENTS[choice];
209
+ if (!client) {
210
+ error(`Invalid choice: "${choice}". Expected 1-5.`);
211
+ exit(1);
212
+ return;
213
+ }
214
+
215
+ log(`\nInstalling rules for ${client.label}...`);
216
+
217
+ try {
218
+ client.install({
219
+ cwd,
220
+ readFileSync,
221
+ writeFileSync,
222
+ appendFileSync,
223
+ mkdirSync,
224
+ existsSync,
225
+ log,
226
+ rulesSourceDir,
227
+ });
228
+ log('\nDone!');
229
+ } catch (err) {
230
+ error('Failed to install rules:', err);
231
+ exit(1);
232
+ }
233
+ }
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import {
3
+ runInstallSkills,
4
+ detectAiTools,
5
+ InstallSkillsDeps,
6
+ } from './install-skills-command.js';
7
+
8
+ describe('detectAiTools', () => {
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ });
12
+
13
+ it('detects Cursor when .cursor directory exists', () => {
14
+ // Setup
15
+ const existsSync = vi.fn((p: string) => p.endsWith('.cursor'));
16
+
17
+ // Act
18
+ const tools = detectAiTools('/project', existsSync);
19
+
20
+ // Assert
21
+ expect(tools).toEqual([{ name: 'Cursor', targetDir: '.cursor/skills' }]);
22
+ });
23
+
24
+ it('detects multiple AI tools', () => {
25
+ // Setup
26
+ const existsSync = vi.fn(() => true);
27
+
28
+ // Act
29
+ const tools = detectAiTools('/project', existsSync);
30
+
31
+ // Assert
32
+ expect(tools).toHaveLength(3);
33
+ expect(tools.map((t) => t.name)).toEqual([
34
+ 'Cursor',
35
+ 'Claude Code',
36
+ 'GitHub Copilot',
37
+ ]);
38
+ });
39
+
40
+ it('returns empty array when no tools detected', () => {
41
+ // Setup
42
+ const existsSync = vi.fn(() => false);
43
+
44
+ // Act
45
+ const tools = detectAiTools('/project', existsSync);
46
+
47
+ // Assert
48
+ expect(tools).toEqual([]);
49
+ });
50
+ });
51
+
52
+ describe('runInstallSkills', () => {
53
+ afterEach(() => {
54
+ vi.restoreAllMocks();
55
+ });
56
+
57
+ function makeDeps(
58
+ overrides: Partial<InstallSkillsDeps> = {},
59
+ ): InstallSkillsDeps {
60
+ return {
61
+ log: vi.fn(),
62
+ error: vi.fn(),
63
+ exit: vi.fn(),
64
+ cwd: '/project',
65
+ existsSync: vi.fn((p: string) => p.endsWith('.cursor')),
66
+ mkdirSync: vi.fn(),
67
+ copyFileSync: vi.fn(),
68
+ readdirSync: vi.fn(() => ['SKILL.md']),
69
+ skillsSourceDir: '/pkg/ai/skills',
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ it('installs skills for detected AI tools', () => {
75
+ // Setup
76
+ const deps = makeDeps();
77
+
78
+ // Act
79
+ runInstallSkills(deps);
80
+
81
+ // Assert
82
+ expect(deps.mkdirSync).toHaveBeenCalledTimes(3);
83
+ expect(deps.copyFileSync).toHaveBeenCalledTimes(3);
84
+ expect(deps.log).toHaveBeenCalledWith(
85
+ expect.stringContaining('Done! Installed 3 skill(s)'),
86
+ );
87
+ });
88
+
89
+ it('creates .cursor/skills as default when no AI tools detected', () => {
90
+ // Setup
91
+ const deps = makeDeps({
92
+ existsSync: vi.fn(() => false),
93
+ });
94
+
95
+ // Act
96
+ runInstallSkills(deps);
97
+
98
+ // Assert
99
+ expect(deps.log).toHaveBeenCalledWith(
100
+ expect.stringContaining('Creating .cursor/skills/'),
101
+ );
102
+ expect(deps.mkdirSync).toHaveBeenCalled();
103
+ });
104
+
105
+ it('copies each SKILL.md file to the target directory', () => {
106
+ // Setup
107
+ const deps = makeDeps();
108
+
109
+ // Act
110
+ runInstallSkills(deps);
111
+
112
+ // Assert
113
+ expect(deps.copyFileSync).toHaveBeenCalledWith(
114
+ '/pkg/ai/skills/dataqueue-core/SKILL.md',
115
+ '/project/.cursor/skills/dataqueue-core/SKILL.md',
116
+ );
117
+ expect(deps.copyFileSync).toHaveBeenCalledWith(
118
+ '/pkg/ai/skills/dataqueue-advanced/SKILL.md',
119
+ '/project/.cursor/skills/dataqueue-advanced/SKILL.md',
120
+ );
121
+ expect(deps.copyFileSync).toHaveBeenCalledWith(
122
+ '/pkg/ai/skills/dataqueue-react/SKILL.md',
123
+ '/project/.cursor/skills/dataqueue-react/SKILL.md',
124
+ );
125
+ });
126
+
127
+ it('handles copy errors gracefully', () => {
128
+ // Setup
129
+ const deps = makeDeps({
130
+ copyFileSync: vi.fn(() => {
131
+ throw new Error('permission denied');
132
+ }),
133
+ });
134
+
135
+ // Act
136
+ runInstallSkills(deps);
137
+
138
+ // Assert
139
+ expect(deps.error).toHaveBeenCalledWith(
140
+ expect.stringContaining('Failed to install'),
141
+ expect.any(Error),
142
+ );
143
+ });
144
+
145
+ it('exits with code 1 when all installs fail', () => {
146
+ // Setup
147
+ const deps = makeDeps({
148
+ readdirSync: vi.fn(() => {
149
+ throw new Error('not found');
150
+ }),
151
+ });
152
+
153
+ // Act
154
+ runInstallSkills(deps);
155
+
156
+ // Assert
157
+ expect(deps.error).toHaveBeenCalledWith('No skills were installed.');
158
+ expect(deps.exit).toHaveBeenCalledWith(1);
159
+ });
160
+
161
+ it('installs for multiple detected tools', () => {
162
+ // Setup
163
+ const deps = makeDeps({
164
+ existsSync: vi.fn(() => true),
165
+ });
166
+
167
+ // Act
168
+ runInstallSkills(deps);
169
+
170
+ // Assert
171
+ expect(deps.mkdirSync).toHaveBeenCalledTimes(9);
172
+ expect(deps.log).toHaveBeenCalledWith(
173
+ expect.stringContaining('Cursor, Claude Code, GitHub Copilot'),
174
+ );
175
+ });
176
+ });