@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.
- package/ai/build-docs-content.ts +96 -0
- package/ai/build-llms-full.ts +42 -0
- package/ai/docs-content.json +290 -0
- package/ai/rules/advanced.md +170 -0
- package/ai/rules/basic.md +159 -0
- package/ai/rules/react-dashboard.md +87 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +370 -0
- package/ai/skills/dataqueue-core/SKILL.md +235 -0
- package/ai/skills/dataqueue-react/SKILL.md +201 -0
- package/dist/cli.cjs +577 -32
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +52 -2
- package/dist/cli.d.ts +52 -2
- package/dist/cli.js +575 -32
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +937 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +358 -11
- package/dist/index.d.ts +358 -11
- package/dist/index.js +937 -108
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +186 -0
- package/dist/mcp-server.cjs.map +1 -0
- package/dist/mcp-server.d.cts +32 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +175 -0
- package/dist/mcp-server.js.map +1 -0
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
- package/package.json +10 -4
- package/src/backend.ts +36 -3
- package/src/backends/postgres.ts +344 -42
- package/src/backends/redis-scripts.ts +173 -8
- package/src/backends/redis.test.ts +668 -0
- package/src/backends/redis.ts +244 -15
- package/src/cli.test.ts +65 -0
- package/src/cli.ts +56 -19
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +811 -12
- package/src/index.ts +106 -14
- package/src/install-mcp-command.test.ts +216 -0
- package/src/install-mcp-command.ts +185 -0
- package/src/install-rules-command.test.ts +218 -0
- package/src/install-rules-command.ts +233 -0
- package/src/install-skills-command.test.ts +176 -0
- package/src/install-skills-command.ts +124 -0
- package/src/mcp-server.test.ts +162 -0
- package/src/mcp-server.ts +231 -0
- package/src/processor.ts +133 -49
- package/src/queue.test.ts +477 -0
- package/src/queue.ts +20 -3
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- 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
|
+
});
|