@nicnocquee/dataqueue 1.32.0 → 1.34.0
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 +278 -0
- package/ai/rules/advanced.md +94 -0
- package/ai/rules/basic.md +90 -0
- package/ai/rules/react-dashboard.md +83 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +211 -0
- package/ai/skills/dataqueue-core/SKILL.md +131 -0
- package/ai/skills/dataqueue-react/SKILL.md +189 -0
- package/dist/cli.cjs +1149 -14
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +66 -1
- package/dist/cli.d.ts +66 -1
- package/dist/cli.js +1146 -13
- package/dist/cli.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/package.json +10 -4
- package/src/cli.test.ts +82 -6
- package/src/cli.ts +73 -10
- package/src/init-command.test.ts +449 -0
- package/src/init-command.ts +709 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
export interface InstallSkillsDeps {
|
|
9
|
+
log?: (...args: unknown[]) => void;
|
|
10
|
+
error?: (...args: unknown[]) => void;
|
|
11
|
+
exit?: (code: number) => void;
|
|
12
|
+
cwd?: string;
|
|
13
|
+
existsSync?: (p: string) => boolean;
|
|
14
|
+
mkdirSync?: (p: string, opts?: fs.MakeDirectoryOptions) => void;
|
|
15
|
+
copyFileSync?: (src: string, dest: string) => void;
|
|
16
|
+
readdirSync?: (p: string) => string[];
|
|
17
|
+
skillsSourceDir?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SKILL_DIRS = ['dataqueue-core', 'dataqueue-advanced', 'dataqueue-react'];
|
|
21
|
+
|
|
22
|
+
interface AiTool {
|
|
23
|
+
name: string;
|
|
24
|
+
targetDir: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detects which AI tools have config directories in the project.
|
|
29
|
+
*
|
|
30
|
+
* @param cwd - Current working directory to scan.
|
|
31
|
+
* @param existsSync - Injectable fs.existsSync.
|
|
32
|
+
* @returns Array of detected AI tools with their skills target directories.
|
|
33
|
+
*/
|
|
34
|
+
export function detectAiTools(
|
|
35
|
+
cwd: string,
|
|
36
|
+
existsSync: (p: string) => boolean = fs.existsSync,
|
|
37
|
+
): AiTool[] {
|
|
38
|
+
const tools: AiTool[] = [];
|
|
39
|
+
const checks: Array<{ name: string; indicator: string; targetDir: string }> =
|
|
40
|
+
[
|
|
41
|
+
{
|
|
42
|
+
name: 'Cursor',
|
|
43
|
+
indicator: '.cursor',
|
|
44
|
+
targetDir: '.cursor/skills',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'Claude Code',
|
|
48
|
+
indicator: '.claude',
|
|
49
|
+
targetDir: '.claude/skills',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'GitHub Copilot',
|
|
53
|
+
indicator: '.github',
|
|
54
|
+
targetDir: '.github/skills',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
for (const check of checks) {
|
|
59
|
+
if (existsSync(path.join(cwd, check.indicator))) {
|
|
60
|
+
tools.push({ name: check.name, targetDir: check.targetDir });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return tools;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Installs DataQueue skill files into detected AI tool directories.
|
|
69
|
+
*
|
|
70
|
+
* @param deps - Injectable dependencies for testing.
|
|
71
|
+
*/
|
|
72
|
+
export function runInstallSkills({
|
|
73
|
+
log = console.log,
|
|
74
|
+
error = console.error,
|
|
75
|
+
exit = (code: number) => process.exit(code),
|
|
76
|
+
cwd = process.cwd(),
|
|
77
|
+
existsSync = fs.existsSync,
|
|
78
|
+
mkdirSync = fs.mkdirSync,
|
|
79
|
+
copyFileSync = fs.copyFileSync,
|
|
80
|
+
readdirSync = fs.readdirSync,
|
|
81
|
+
skillsSourceDir = path.join(__dirname, '../ai/skills'),
|
|
82
|
+
}: InstallSkillsDeps = {}): void {
|
|
83
|
+
const tools = detectAiTools(cwd, existsSync);
|
|
84
|
+
|
|
85
|
+
if (tools.length === 0) {
|
|
86
|
+
log('No AI tool directories detected (.cursor/, .claude/, .github/).');
|
|
87
|
+
log('Creating .cursor/skills/ as the default target.');
|
|
88
|
+
tools.push({ name: 'Cursor', targetDir: '.cursor/skills' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let installed = 0;
|
|
92
|
+
|
|
93
|
+
for (const tool of tools) {
|
|
94
|
+
log(`\nInstalling skills for ${tool.name}...`);
|
|
95
|
+
|
|
96
|
+
for (const skillDir of SKILL_DIRS) {
|
|
97
|
+
const srcDir = path.join(skillsSourceDir, skillDir);
|
|
98
|
+
const destDir = path.join(cwd, tool.targetDir, skillDir);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(destDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
const files = readdirSync(srcDir);
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
copyFileSync(path.join(srcDir, file), path.join(destDir, file));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
log(` ✓ ${skillDir}`);
|
|
109
|
+
installed++;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
error(` ✗ Failed to install ${skillDir}:`, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (installed > 0) {
|
|
117
|
+
log(
|
|
118
|
+
`\nDone! Installed ${installed} skill(s) for ${tools.map((t) => t.name).join(', ')}.`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
error('No skills were installed.');
|
|
122
|
+
exit(1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
loadDocsContent,
|
|
4
|
+
scorePageForQuery,
|
|
5
|
+
extractExcerpt,
|
|
6
|
+
} from './mcp-server.js';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const DOCS_CONTENT_PATH = path.join(__dirname, '../ai/docs-content.json');
|
|
14
|
+
|
|
15
|
+
describe('loadDocsContent', () => {
|
|
16
|
+
it('loads the docs-content.json file', () => {
|
|
17
|
+
// Act
|
|
18
|
+
const pages = loadDocsContent(DOCS_CONTENT_PATH);
|
|
19
|
+
|
|
20
|
+
// Assert
|
|
21
|
+
expect(pages.length).toBeGreaterThan(0);
|
|
22
|
+
expect(pages[0]).toHaveProperty('slug');
|
|
23
|
+
expect(pages[0]).toHaveProperty('title');
|
|
24
|
+
expect(pages[0]).toHaveProperty('content');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('throws for non-existent file', () => {
|
|
28
|
+
// Act & Assert
|
|
29
|
+
expect(() => loadDocsContent('/nonexistent/path.json')).toThrow();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('scorePageForQuery', () => {
|
|
34
|
+
it('scores title matches highest', () => {
|
|
35
|
+
// Setup
|
|
36
|
+
const page = {
|
|
37
|
+
slug: 'test',
|
|
38
|
+
title: 'Cron Jobs',
|
|
39
|
+
description: 'Schedule recurring tasks',
|
|
40
|
+
content: 'Use cron expressions.',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const score = scorePageForQuery(page, ['cron']);
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(score).toBeGreaterThanOrEqual(10);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('scores description matches', () => {
|
|
51
|
+
// Setup
|
|
52
|
+
const page = {
|
|
53
|
+
slug: 'test',
|
|
54
|
+
title: 'Other Page',
|
|
55
|
+
description: 'Schedule recurring cron tasks',
|
|
56
|
+
content: 'No match in content.',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const score = scorePageForQuery(page, ['cron']);
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(score).toBeGreaterThanOrEqual(5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('scores content matches', () => {
|
|
67
|
+
// Setup
|
|
68
|
+
const page = {
|
|
69
|
+
slug: 'test',
|
|
70
|
+
title: 'Other',
|
|
71
|
+
description: 'Other',
|
|
72
|
+
content: 'Use cron for scheduling. Cron is powerful.',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Act
|
|
76
|
+
const score = scorePageForQuery(page, ['cron']);
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(score).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns 0 for no matches', () => {
|
|
83
|
+
// Setup
|
|
84
|
+
const page = {
|
|
85
|
+
slug: 'test',
|
|
86
|
+
title: 'Unrelated',
|
|
87
|
+
description: 'Nothing here',
|
|
88
|
+
content: 'No match.',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Act
|
|
92
|
+
const score = scorePageForQuery(page, ['zzzzzzz']);
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
95
|
+
expect(score).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('handles multiple query terms', () => {
|
|
99
|
+
// Setup
|
|
100
|
+
const page = {
|
|
101
|
+
slug: 'test',
|
|
102
|
+
title: 'Cron Jobs',
|
|
103
|
+
description: 'Schedule tasks',
|
|
104
|
+
content: 'timeout and cron are related.',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Act
|
|
108
|
+
const scoreMulti = scorePageForQuery(page, ['cron', 'timeout']);
|
|
109
|
+
const scoreSingle = scorePageForQuery(page, ['cron']);
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
expect(scoreMulti).toBeGreaterThan(scoreSingle);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('extractExcerpt', () => {
|
|
117
|
+
it('extracts content around the first matching term', () => {
|
|
118
|
+
// Setup
|
|
119
|
+
const content = 'A'.repeat(200) + 'target keyword here' + 'B'.repeat(200);
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
const excerpt = extractExcerpt(content, ['target']);
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
expect(excerpt).toContain('target keyword here');
|
|
126
|
+
expect(excerpt.length).toBeLessThanOrEqual(510);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns beginning of content when no match found', () => {
|
|
130
|
+
// Setup
|
|
131
|
+
const content = 'This is the beginning of the content. More content here.';
|
|
132
|
+
|
|
133
|
+
// Act
|
|
134
|
+
const excerpt = extractExcerpt(content, ['nonexistent']);
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
expect(excerpt).toBe(content);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('adds ellipsis when excerpt is truncated', () => {
|
|
141
|
+
// Setup
|
|
142
|
+
const content = 'A'.repeat(300) + 'match' + 'B'.repeat(300);
|
|
143
|
+
|
|
144
|
+
// Act
|
|
145
|
+
const excerpt = extractExcerpt(content, ['match'], 200);
|
|
146
|
+
|
|
147
|
+
// Assert
|
|
148
|
+
expect(excerpt.startsWith('...')).toBe(true);
|
|
149
|
+
expect(excerpt.endsWith('...')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('respects maxLength parameter', () => {
|
|
153
|
+
// Setup
|
|
154
|
+
const content = 'A'.repeat(1000);
|
|
155
|
+
|
|
156
|
+
// Act
|
|
157
|
+
const excerpt = extractExcerpt(content, ['nonexistent'], 100);
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
160
|
+
expect(excerpt.length).toBeLessThanOrEqual(100);
|
|
161
|
+
});
|
|
162
|
+
});
|