@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,185 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+
5
+ export interface InstallMcpDeps {
6
+ log?: (...args: unknown[]) => void;
7
+ error?: (...args: unknown[]) => void;
8
+ exit?: (code: number) => void;
9
+ cwd?: string;
10
+ readFileSync?: (p: string, enc: BufferEncoding) => string;
11
+ writeFileSync?: (p: string, data: string) => void;
12
+ mkdirSync?: (p: string, opts?: fs.MakeDirectoryOptions) => void;
13
+ existsSync?: (p: string) => boolean;
14
+ /** Override for selecting the client (skips interactive prompt). */
15
+ selectedClient?: string;
16
+ }
17
+
18
+ interface McpClientConfig {
19
+ label: string;
20
+ install: (
21
+ deps: Required<
22
+ Pick<
23
+ InstallMcpDeps,
24
+ | 'cwd'
25
+ | 'readFileSync'
26
+ | 'writeFileSync'
27
+ | 'mkdirSync'
28
+ | 'existsSync'
29
+ | 'log'
30
+ >
31
+ >,
32
+ ) => void;
33
+ }
34
+
35
+ /**
36
+ * Merges the dataqueue MCP server config into an existing JSON config file.
37
+ *
38
+ * @param filePath - Path to the MCP config file.
39
+ * @param serverKey - Key name for the server entry.
40
+ * @param serverConfig - Server configuration object.
41
+ * @param deps - Injectable file system functions.
42
+ */
43
+ export function upsertMcpConfig(
44
+ filePath: string,
45
+ serverKey: string,
46
+ serverConfig: Record<string, unknown>,
47
+ deps: {
48
+ readFileSync: (p: string, enc: BufferEncoding) => string;
49
+ writeFileSync: (p: string, data: string) => void;
50
+ existsSync: (p: string) => boolean;
51
+ },
52
+ ): void {
53
+ let config: Record<string, unknown> = {};
54
+
55
+ if (deps.existsSync(filePath)) {
56
+ try {
57
+ config = JSON.parse(deps.readFileSync(filePath, 'utf-8'));
58
+ } catch {
59
+ config = {};
60
+ }
61
+ }
62
+
63
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
64
+ config.mcpServers = {};
65
+ }
66
+
67
+ (config.mcpServers as Record<string, unknown>)[serverKey] = serverConfig;
68
+ deps.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
69
+ }
70
+
71
+ const MCP_SERVER_CONFIG = {
72
+ command: 'npx',
73
+ args: ['dataqueue-cli', 'mcp'],
74
+ };
75
+
76
+ const MCP_CLIENTS: Record<string, McpClientConfig> = {
77
+ '1': {
78
+ label: 'Cursor',
79
+ install: (deps) => {
80
+ const configDir = path.join(deps.cwd, '.cursor');
81
+ deps.mkdirSync(configDir, { recursive: true });
82
+ const configFile = path.join(configDir, 'mcp.json');
83
+ upsertMcpConfig(configFile, 'dataqueue', MCP_SERVER_CONFIG, deps);
84
+ deps.log(` ✓ .cursor/mcp.json`);
85
+ },
86
+ },
87
+ '2': {
88
+ label: 'Claude Code',
89
+ install: (deps) => {
90
+ const configFile = path.join(deps.cwd, '.mcp.json');
91
+ upsertMcpConfig(configFile, 'dataqueue', MCP_SERVER_CONFIG, deps);
92
+ deps.log(` ✓ .mcp.json`);
93
+ },
94
+ },
95
+ '3': {
96
+ label: 'VS Code (Copilot)',
97
+ install: (deps) => {
98
+ const configDir = path.join(deps.cwd, '.vscode');
99
+ deps.mkdirSync(configDir, { recursive: true });
100
+ const configFile = path.join(configDir, 'mcp.json');
101
+ upsertMcpConfig(configFile, 'dataqueue', MCP_SERVER_CONFIG, deps);
102
+ deps.log(` ✓ .vscode/mcp.json`);
103
+ },
104
+ },
105
+ '4': {
106
+ label: 'Windsurf',
107
+ install: (deps) => {
108
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
109
+ const configFile = path.join(
110
+ homeDir,
111
+ '.codeium',
112
+ 'windsurf',
113
+ 'mcp_config.json',
114
+ );
115
+ deps.mkdirSync(path.dirname(configFile), { recursive: true });
116
+ upsertMcpConfig(configFile, 'dataqueue', MCP_SERVER_CONFIG, deps);
117
+ deps.log(` ✓ ~/.codeium/windsurf/mcp_config.json`);
118
+ },
119
+ },
120
+ };
121
+
122
+ /**
123
+ * Installs the DataQueue MCP server config for the selected AI client.
124
+ *
125
+ * @param deps - Injectable dependencies for testing.
126
+ */
127
+ export async function runInstallMcp({
128
+ log = console.log,
129
+ error = console.error,
130
+ exit = (code: number) => process.exit(code),
131
+ cwd = process.cwd(),
132
+ readFileSync = fs.readFileSync,
133
+ writeFileSync = fs.writeFileSync,
134
+ mkdirSync = fs.mkdirSync,
135
+ existsSync = fs.existsSync,
136
+ selectedClient,
137
+ }: InstallMcpDeps = {}): Promise<void> {
138
+ log('DataQueue MCP Server Installer\n');
139
+ log('Select your AI client:\n');
140
+
141
+ for (const [key, client] of Object.entries(MCP_CLIENTS)) {
142
+ log(` ${key}) ${client.label}`);
143
+ }
144
+ log('');
145
+
146
+ let choice = selectedClient;
147
+
148
+ if (!choice) {
149
+ const rl = readline.createInterface({
150
+ input: process.stdin,
151
+ output: process.stdout,
152
+ });
153
+
154
+ choice = await new Promise<string>((resolve) => {
155
+ rl.question('Enter choice (1-4): ', (answer) => {
156
+ rl.close();
157
+ resolve(answer.trim());
158
+ });
159
+ });
160
+ }
161
+
162
+ const client = MCP_CLIENTS[choice];
163
+ if (!client) {
164
+ error(`Invalid choice: "${choice}". Expected 1-4.`);
165
+ exit(1);
166
+ return;
167
+ }
168
+
169
+ log(`\nInstalling MCP config for ${client.label}...`);
170
+
171
+ try {
172
+ client.install({
173
+ cwd,
174
+ readFileSync,
175
+ writeFileSync,
176
+ mkdirSync,
177
+ existsSync,
178
+ log,
179
+ });
180
+ log('\nDone! The MCP server will run via: npx dataqueue-cli mcp');
181
+ } catch (err) {
182
+ error('Failed to install MCP config:', err);
183
+ exit(1);
184
+ }
185
+ }
@@ -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
+ }