@nicnocquee/dataqueue 1.33.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.
@@ -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
+ });
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * DataQueue MCP Server — exposes documentation search over stdio.
5
+ * Run via: dataqueue-cli mcp
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { z } from 'zod';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+
18
+ interface DocPage {
19
+ slug: string;
20
+ title: string;
21
+ description: string;
22
+ content: string;
23
+ }
24
+
25
+ /** @internal Loads docs-content.json from the ai/ directory bundled with the package. */
26
+ export function loadDocsContent(
27
+ docsPath: string = path.join(__dirname, '../ai/docs-content.json'),
28
+ ): DocPage[] {
29
+ const raw = fs.readFileSync(docsPath, 'utf-8');
30
+ return JSON.parse(raw) as DocPage[];
31
+ }
32
+
33
+ /** @internal Scores a doc page against a search query using simple term matching. */
34
+ export function scorePageForQuery(page: DocPage, queryTerms: string[]): number {
35
+ const titleLower = page.title.toLowerCase();
36
+ const descLower = page.description.toLowerCase();
37
+ const contentLower = page.content.toLowerCase();
38
+
39
+ let score = 0;
40
+ for (const term of queryTerms) {
41
+ if (titleLower.includes(term)) score += 10;
42
+ if (descLower.includes(term)) score += 5;
43
+
44
+ const contentMatches = contentLower.split(term).length - 1;
45
+ score += Math.min(contentMatches, 10);
46
+ }
47
+ return score;
48
+ }
49
+
50
+ /** @internal Extracts a relevant excerpt around the first match of any query term. */
51
+ export function extractExcerpt(
52
+ content: string,
53
+ queryTerms: string[],
54
+ maxLength = 500,
55
+ ): string {
56
+ const lower = content.toLowerCase();
57
+ let earliestIndex = -1;
58
+
59
+ for (const term of queryTerms) {
60
+ const idx = lower.indexOf(term);
61
+ if (idx !== -1 && (earliestIndex === -1 || idx < earliestIndex)) {
62
+ earliestIndex = idx;
63
+ }
64
+ }
65
+
66
+ if (earliestIndex === -1) {
67
+ return content.slice(0, maxLength);
68
+ }
69
+
70
+ const start = Math.max(0, earliestIndex - 100);
71
+ const end = Math.min(content.length, start + maxLength);
72
+ let excerpt = content.slice(start, end);
73
+
74
+ if (start > 0) excerpt = '...' + excerpt;
75
+ if (end < content.length) excerpt = excerpt + '...';
76
+
77
+ return excerpt;
78
+ }
79
+
80
+ /**
81
+ * Creates and starts the DataQueue MCP server over stdio.
82
+ *
83
+ * @param deps - Injectable dependencies for testing.
84
+ */
85
+ export async function startMcpServer(
86
+ deps: {
87
+ docsPath?: string;
88
+ transport?: InstanceType<typeof StdioServerTransport>;
89
+ } = {},
90
+ ): Promise<McpServer> {
91
+ const pages = loadDocsContent(deps.docsPath);
92
+
93
+ const server = new McpServer({
94
+ name: 'dataqueue-docs',
95
+ version: '1.0.0',
96
+ });
97
+
98
+ server.resource('llms-txt', 'dataqueue://llms.txt', async () => {
99
+ const llmsPath = path.join(
100
+ __dirname,
101
+ '../ai/skills/dataqueue-core/SKILL.md',
102
+ );
103
+ let content: string;
104
+ try {
105
+ content = fs.readFileSync(llmsPath, 'utf-8');
106
+ } catch {
107
+ content = pages
108
+ .map((p) => `## ${p.title}\n\nSlug: ${p.slug}\n\n${p.description}`)
109
+ .join('\n\n');
110
+ }
111
+ return { contents: [{ uri: 'dataqueue://llms.txt', text: content }] };
112
+ });
113
+
114
+ server.tool(
115
+ 'list-doc-pages',
116
+ 'List all available DataQueue documentation pages with titles and descriptions.',
117
+ {},
118
+ async () => {
119
+ const listing = pages.map((p) => ({
120
+ slug: p.slug,
121
+ title: p.title,
122
+ description: p.description,
123
+ }));
124
+ return {
125
+ content: [
126
+ { type: 'text' as const, text: JSON.stringify(listing, null, 2) },
127
+ ],
128
+ };
129
+ },
130
+ );
131
+
132
+ server.tool(
133
+ 'get-doc-page',
134
+ 'Fetch a specific DataQueue doc page by slug. Returns full page content as markdown.',
135
+ {
136
+ slug: z
137
+ .string()
138
+ .describe('The doc page slug, e.g. "usage/add-job" or "api/job-queue"'),
139
+ },
140
+ async ({ slug }) => {
141
+ const page = pages.find((p) => p.slug === slug);
142
+ if (!page) {
143
+ return {
144
+ content: [
145
+ {
146
+ type: 'text' as const,
147
+ text: `Page not found: "${slug}". Use list-doc-pages to see available slugs.`,
148
+ },
149
+ ],
150
+ isError: true,
151
+ };
152
+ }
153
+ const header = page.description
154
+ ? `# ${page.title}\n\n> ${page.description}\n\n`
155
+ : `# ${page.title}\n\n`;
156
+ return {
157
+ content: [{ type: 'text' as const, text: header + page.content }],
158
+ };
159
+ },
160
+ );
161
+
162
+ server.tool(
163
+ 'search-docs',
164
+ 'Full-text search across all DataQueue documentation pages. Returns matching sections with page titles and content excerpts.',
165
+ {
166
+ query: z
167
+ .string()
168
+ .describe('Search query, e.g. "cron scheduling" or "waitForToken"'),
169
+ },
170
+ async ({ query }) => {
171
+ const queryTerms = query
172
+ .toLowerCase()
173
+ .split(/\s+/)
174
+ .filter((t) => t.length > 1);
175
+
176
+ if (queryTerms.length === 0) {
177
+ return {
178
+ content: [
179
+ { type: 'text' as const, text: 'Please provide a search query.' },
180
+ ],
181
+ isError: true,
182
+ };
183
+ }
184
+
185
+ const scored = pages
186
+ .map((page) => ({
187
+ page,
188
+ score: scorePageForQuery(page, queryTerms),
189
+ }))
190
+ .filter((r) => r.score > 0)
191
+ .sort((a, b) => b.score - a.score)
192
+ .slice(0, 5);
193
+
194
+ if (scored.length === 0) {
195
+ return {
196
+ content: [
197
+ {
198
+ type: 'text' as const,
199
+ text: `No results for "${query}". Try different keywords or use list-doc-pages to browse.`,
200
+ },
201
+ ],
202
+ };
203
+ }
204
+
205
+ const results = scored.map((r) => {
206
+ const excerpt = extractExcerpt(r.page.content, queryTerms);
207
+ return `## ${r.page.title} (${r.page.slug})\n\n${r.page.description}\n\n${excerpt}`;
208
+ });
209
+
210
+ return {
211
+ content: [{ type: 'text' as const, text: results.join('\n\n---\n\n') }],
212
+ };
213
+ },
214
+ );
215
+
216
+ const transport = deps.transport ?? new StdioServerTransport();
217
+ await server.connect(transport);
218
+ return server;
219
+ }
220
+
221
+ const isDirectRun =
222
+ process.argv[1] &&
223
+ (process.argv[1].endsWith('/mcp-server.js') ||
224
+ process.argv[1].endsWith('/mcp-server.cjs'));
225
+
226
+ if (isDirectRun) {
227
+ startMcpServer().catch((err) => {
228
+ console.error('Failed to start MCP server:', err);
229
+ process.exit(1);
230
+ });
231
+ }