codemini-cli 0.5.10 → 0.5.11
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/OPERATIONS.md +242 -242
- package/README.md +588 -588
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-CANOG7Xg.js} +1 -1
- package/codemini-web/dist/assets/{index-BK75hMb2.js → index-B71xykPM.js} +108 -108
- package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Z_w7M93P.js +1 -0
- package/codemini-web/dist/index.html +23 -23
- package/codemini-web/lib/approval-manager.js +32 -32
- package/codemini-web/lib/runtime-bridge.js +17 -11
- package/codemini-web/server.js +534 -205
- package/deployment.md +212 -212
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +77 -77
- package/skills/codemini.skills.json +40 -40
- package/skills/grill-me/SKILL.md +30 -30
- package/skills/superpowers-lite/SKILL.md +82 -82
- package/src/cli.js +74 -74
- package/src/commands/chat.js +210 -210
- package/src/commands/run.js +313 -313
- package/src/commands/skill.js +438 -304
- package/src/commands/web.js +57 -57
- package/src/core/agent-loop.js +980 -980
- package/src/core/ast.js +309 -307
- package/src/core/chat-runtime.js +6261 -6253
- package/src/core/command-evaluator.js +72 -72
- package/src/core/command-loader.js +311 -311
- package/src/core/command-policy.js +301 -301
- package/src/core/command-risk.js +156 -156
- package/src/core/config-store.js +289 -289
- package/src/core/constants.js +18 -1
- package/src/core/context-compact.js +365 -365
- package/src/core/default-system-prompt.js +114 -107
- package/src/core/dream-audit.js +105 -105
- package/src/core/dream-consolidate.js +229 -229
- package/src/core/dream-evaluator.js +185 -185
- package/src/core/fff-adapter.js +383 -383
- package/src/core/memory-store.js +543 -543
- package/src/core/project-index.js +737 -548
- package/src/core/project-instructions.js +98 -98
- package/src/core/provider/anthropic.js +514 -514
- package/src/core/provider/openai-compatible.js +501 -501
- package/src/core/reflect-skill.js +178 -178
- package/src/core/reply-language.js +40 -40
- package/src/core/session-store.js +474 -474
- package/src/core/shell-profile.js +237 -237
- package/src/core/shell.js +323 -323
- package/src/core/soul.js +69 -69
- package/src/core/system-prompt-composer.js +52 -52
- package/src/core/tool-args.js +199 -154
- package/src/core/tool-output.js +184 -184
- package/src/core/tool-result-store.js +206 -206
- package/src/core/tools.js +3024 -2893
- package/src/core/version.js +11 -11
- package/src/tui/chat-app.js +5171 -5171
- package/src/tui/tool-activity/presenters/misc.js +30 -30
- package/src/tui/tool-activity/presenters/system.js +20 -20
- package/templates/project-requirements/report-shell.html +582 -582
- package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.js +0 -1
package/src/core/fff-adapter.js
CHANGED
|
@@ -1,383 +1,383 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { LANGUAGE_FILE_TYPES } from './constants.js';
|
|
3
|
-
import { getPackageInfo } from './version.js';
|
|
4
|
-
|
|
5
|
-
const DEFAULT_COMMAND = 'fff-mcp';
|
|
6
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
7
|
-
|
|
8
|
-
function clampNumber(value, min, max, fallback) {
|
|
9
|
-
const num = Number(value);
|
|
10
|
-
if (!Number.isFinite(num)) return fallback;
|
|
11
|
-
return Math.min(max, Math.max(min, num));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function encodeMessage(payload) {
|
|
15
|
-
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
16
|
-
return Buffer.concat([
|
|
17
|
-
Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf8'),
|
|
18
|
-
body
|
|
19
|
-
]);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function createMessageParser(onMessage) {
|
|
23
|
-
let buffer = Buffer.alloc(0);
|
|
24
|
-
return (chunk) => {
|
|
25
|
-
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
26
|
-
while (buffer.length > 0) {
|
|
27
|
-
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
28
|
-
if (headerEnd < 0) return;
|
|
29
|
-
const headerText = buffer.slice(0, headerEnd).toString('utf8');
|
|
30
|
-
const match = headerText.match(/Content-Length:\s*(\d+)/i);
|
|
31
|
-
if (!match) {
|
|
32
|
-
buffer = Buffer.alloc(0);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
const bodyLength = Number(match[1]);
|
|
36
|
-
const totalLength = headerEnd + 4 + bodyLength;
|
|
37
|
-
if (buffer.length < totalLength) return;
|
|
38
|
-
const body = buffer.slice(headerEnd + 4, totalLength).toString('utf8');
|
|
39
|
-
buffer = buffer.slice(totalLength);
|
|
40
|
-
try {
|
|
41
|
-
onMessage(JSON.parse(body));
|
|
42
|
-
} catch {
|
|
43
|
-
// Ignore malformed frames.
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
class FffMcpClient {
|
|
50
|
-
constructor({ workspaceRoot, command, timeoutMs }) {
|
|
51
|
-
this.workspaceRoot = workspaceRoot;
|
|
52
|
-
this.command = command;
|
|
53
|
-
this.timeoutMs = timeoutMs;
|
|
54
|
-
this.child = null;
|
|
55
|
-
this.pending = new Map();
|
|
56
|
-
this.nextId = 1;
|
|
57
|
-
this.connectPromise = null;
|
|
58
|
-
this.connected = false;
|
|
59
|
-
this.closed = false;
|
|
60
|
-
this.parser = createMessageParser((message) => this.handleMessage(message));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
handleMessage(message) {
|
|
64
|
-
if (!message || typeof message !== 'object') return;
|
|
65
|
-
if (typeof message.id !== 'number') return;
|
|
66
|
-
const pending = this.pending.get(message.id);
|
|
67
|
-
if (!pending) return;
|
|
68
|
-
this.pending.delete(message.id);
|
|
69
|
-
if (message.error) {
|
|
70
|
-
pending.reject(new Error(String(message.error?.message || 'Unknown MCP error')));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
pending.resolve(message.result);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async connect() {
|
|
77
|
-
if (this.connected) return;
|
|
78
|
-
if (this.connectPromise) return this.connectPromise;
|
|
79
|
-
this.connectPromise = this.start();
|
|
80
|
-
try {
|
|
81
|
-
await this.connectPromise;
|
|
82
|
-
this.connected = true;
|
|
83
|
-
} finally {
|
|
84
|
-
this.connectPromise = null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async start() {
|
|
89
|
-
if (this.closed) {
|
|
90
|
-
throw new Error('FFF MCP client already disposed');
|
|
91
|
-
}
|
|
92
|
-
this.child = spawn(this.command, [], {
|
|
93
|
-
cwd: this.workspaceRoot,
|
|
94
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
this.child.stdout.on('data', this.parser);
|
|
98
|
-
this.child.stderr.on('data', () => {});
|
|
99
|
-
this.child.on('error', (error) => {
|
|
100
|
-
this.rejectAll(error);
|
|
101
|
-
});
|
|
102
|
-
this.child.on('exit', (code) => {
|
|
103
|
-
this.connected = false;
|
|
104
|
-
this.child = null;
|
|
105
|
-
if (!this.closed && code !== 0) {
|
|
106
|
-
this.rejectAll(new Error(`FFF MCP exited with code ${code}`));
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
await this.sendRequest('initialize', {
|
|
111
|
-
protocolVersion: '2024-11-05',
|
|
112
|
-
capabilities: {},
|
|
113
|
-
clientInfo: getPackageInfo()
|
|
114
|
-
});
|
|
115
|
-
this.sendNotification('notifications/initialized', {});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
rejectAll(error) {
|
|
119
|
-
for (const { reject, timer } of this.pending.values()) {
|
|
120
|
-
clearTimeout(timer);
|
|
121
|
-
reject(error);
|
|
122
|
-
}
|
|
123
|
-
this.pending.clear();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
sendNotification(method, params) {
|
|
127
|
-
if (!this.child?.stdin) throw new Error('FFF MCP client is not connected');
|
|
128
|
-
this.child.stdin.write(
|
|
129
|
-
encodeMessage({
|
|
130
|
-
jsonrpc: '2.0',
|
|
131
|
-
method,
|
|
132
|
-
params
|
|
133
|
-
})
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
sendRequest(method, params) {
|
|
138
|
-
if (!this.child?.stdin) {
|
|
139
|
-
return Promise.reject(new Error('FFF MCP client is not connected'));
|
|
140
|
-
}
|
|
141
|
-
return new Promise((resolve, reject) => {
|
|
142
|
-
const id = this.nextId++;
|
|
143
|
-
const timer = setTimeout(() => {
|
|
144
|
-
this.pending.delete(id);
|
|
145
|
-
reject(new Error(`FFF MCP request timed out after ${this.timeoutMs}ms`));
|
|
146
|
-
}, this.timeoutMs);
|
|
147
|
-
this.pending.set(id, {
|
|
148
|
-
resolve: (result) => {
|
|
149
|
-
clearTimeout(timer);
|
|
150
|
-
resolve(result);
|
|
151
|
-
},
|
|
152
|
-
reject: (error) => {
|
|
153
|
-
clearTimeout(timer);
|
|
154
|
-
reject(error);
|
|
155
|
-
},
|
|
156
|
-
timer
|
|
157
|
-
});
|
|
158
|
-
this.child.stdin.write(
|
|
159
|
-
encodeMessage({
|
|
160
|
-
jsonrpc: '2.0',
|
|
161
|
-
id,
|
|
162
|
-
method,
|
|
163
|
-
params
|
|
164
|
-
})
|
|
165
|
-
);
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async callTool(name, args) {
|
|
170
|
-
await this.connect();
|
|
171
|
-
return this.sendRequest('tools/call', {
|
|
172
|
-
name,
|
|
173
|
-
arguments: args
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async dispose() {
|
|
178
|
-
this.closed = true;
|
|
179
|
-
this.connected = false;
|
|
180
|
-
if (this.child?.stdin) {
|
|
181
|
-
try {
|
|
182
|
-
this.child.stdin.end();
|
|
183
|
-
} catch {}
|
|
184
|
-
}
|
|
185
|
-
if (this.child && !this.child.killed) {
|
|
186
|
-
this.child.kill();
|
|
187
|
-
}
|
|
188
|
-
this.child = null;
|
|
189
|
-
this.rejectAll(new Error('FFF MCP client disposed'));
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function extractTextContent(result) {
|
|
194
|
-
const content = Array.isArray(result?.content) ? result.content : [];
|
|
195
|
-
return content
|
|
196
|
-
.filter((item) => item?.type === 'text' && typeof item?.text === 'string')
|
|
197
|
-
.map((item) => item.text)
|
|
198
|
-
.join('\n')
|
|
199
|
-
.trim();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function stripFffFileSuffix(line) {
|
|
203
|
-
return String(line || '')
|
|
204
|
-
.replace(/\s+-\s+(?:hot|warm|frequent)(?:\s+git:[a-z_]+)?$/i, '')
|
|
205
|
-
.replace(/\s+git:[a-z_]+$/i, '')
|
|
206
|
-
.trim();
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function parseFindFilesOutput(text) {
|
|
210
|
-
const lines = String(text || '')
|
|
211
|
-
.split(/\r?\n/)
|
|
212
|
-
.map((line) => line.trimEnd())
|
|
213
|
-
.filter(Boolean);
|
|
214
|
-
const matches = [];
|
|
215
|
-
for (const line of lines) {
|
|
216
|
-
if (
|
|
217
|
-
line.startsWith('→ ') ||
|
|
218
|
-
line.startsWith('cursor:') ||
|
|
219
|
-
/^\d+\/\d+\s+matches$/i.test(line) ||
|
|
220
|
-
/^0 results\b/i.test(line)
|
|
221
|
-
) {
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
const normalized = stripFffFileSuffix(line);
|
|
225
|
-
if (normalized) matches.push(normalized);
|
|
226
|
-
}
|
|
227
|
-
return matches;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function parseGrepOutput(text, fallbackPattern = '') {
|
|
231
|
-
const lines = String(text || '')
|
|
232
|
-
.split(/\r?\n/)
|
|
233
|
-
.map((line) => line.trimEnd())
|
|
234
|
-
.filter(Boolean);
|
|
235
|
-
const matches = [];
|
|
236
|
-
let currentPath = '';
|
|
237
|
-
for (const line of lines) {
|
|
238
|
-
if (
|
|
239
|
-
line.startsWith('→ ') ||
|
|
240
|
-
line.startsWith('cursor:') ||
|
|
241
|
-
/^! regex failed:/i.test(line) ||
|
|
242
|
-
/^\d+\/\d+\s+matches shown$/i.test(line) ||
|
|
243
|
-
/^0 (?:exact )?matches\b/i.test(line) ||
|
|
244
|
-
/^Auto-broadened to\b/i.test(line)
|
|
245
|
-
) {
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
const sectionMatch = line.match(/^\s*(\d+)\s*[:|-]\s*(.*)$/);
|
|
249
|
-
if (sectionMatch && currentPath) {
|
|
250
|
-
const [, lineNumber, preview] = sectionMatch;
|
|
251
|
-
matches.push({
|
|
252
|
-
path: currentPath,
|
|
253
|
-
line: Number(lineNumber),
|
|
254
|
-
column: 1,
|
|
255
|
-
preview: String(preview || '').trim()
|
|
256
|
-
});
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
const fileCandidate = stripFffFileSuffix(line);
|
|
260
|
-
if (fileCandidate && !/^\d/.test(fileCandidate)) {
|
|
261
|
-
currentPath = fileCandidate;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return {
|
|
265
|
-
pattern: fallbackPattern,
|
|
266
|
-
matches,
|
|
267
|
-
truncated: /cursor:/i.test(text)
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function normalizePathPrefix(value) {
|
|
272
|
-
const text = String(value || '').trim().replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
273
|
-
if (!text || text === '.') return '';
|
|
274
|
-
return text.endsWith('/') ? text : `${text}/`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function buildGrepQuery(pattern, args = {}) {
|
|
278
|
-
const pieces = [];
|
|
279
|
-
const pathPrefix = normalizePathPrefix(args.path);
|
|
280
|
-
if (pathPrefix) pieces.push(pathPrefix);
|
|
281
|
-
const fileTypes = Array.isArray(args.file_types) ? args.file_types : [];
|
|
282
|
-
const language = String(args.language || '').trim().toLowerCase();
|
|
283
|
-
const mergedTypes = [...new Set([...fileTypes, ...(LANGUAGE_FILE_TYPES[language] || [])])];
|
|
284
|
-
if (mergedTypes.length === 1) {
|
|
285
|
-
pieces.push(`*.${mergedTypes[0]}`);
|
|
286
|
-
} else if (mergedTypes.length > 1) {
|
|
287
|
-
pieces.push(`*.{${mergedTypes.join(',')}}`);
|
|
288
|
-
}
|
|
289
|
-
pieces.push(String(pattern || '').trim());
|
|
290
|
-
return pieces.filter(Boolean).join(' ');
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function buildImmediateItems(relativePath, filePaths, includeHidden = false) {
|
|
294
|
-
const prefix = normalizePathPrefix(relativePath);
|
|
295
|
-
const directories = new Set();
|
|
296
|
-
const files = new Set();
|
|
297
|
-
for (const filePath of filePaths) {
|
|
298
|
-
const normalized = String(filePath || '').replace(/\\/g, '/');
|
|
299
|
-
if (!normalized.startsWith(prefix)) continue;
|
|
300
|
-
const remainder = normalized.slice(prefix.length);
|
|
301
|
-
if (!remainder) continue;
|
|
302
|
-
const [head, ...rest] = remainder.split('/');
|
|
303
|
-
if (!head) continue;
|
|
304
|
-
if (!includeHidden && head.startsWith('.')) continue;
|
|
305
|
-
if (rest.length > 0) {
|
|
306
|
-
directories.add(head);
|
|
307
|
-
} else {
|
|
308
|
-
files.add(head);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
const dirItems = [...directories].sort((a, b) => a.localeCompare(b)).map((name) => ({
|
|
312
|
-
name,
|
|
313
|
-
path: `${prefix}${name}`.replace(/\/$/, ''),
|
|
314
|
-
type: 'dir'
|
|
315
|
-
}));
|
|
316
|
-
const fileItems = [...files].sort((a, b) => a.localeCompare(b)).map((name) => ({
|
|
317
|
-
name,
|
|
318
|
-
path: `${prefix}${name}`,
|
|
319
|
-
type: 'file'
|
|
320
|
-
}));
|
|
321
|
-
return [...dirItems, ...fileItems];
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function createFffAdapter({ workspaceRoot, config }) {
|
|
325
|
-
const command = String(config?.search?.fff_command || config?.tooling?.fff_command || DEFAULT_COMMAND).trim() || DEFAULT_COMMAND;
|
|
326
|
-
const timeoutMs = clampNumber(
|
|
327
|
-
config?.search?.fff_timeout_ms || config?.tooling?.fff_timeout_ms,
|
|
328
|
-
1_000,
|
|
329
|
-
120_000,
|
|
330
|
-
DEFAULT_TIMEOUT_MS
|
|
331
|
-
);
|
|
332
|
-
const client = new FffMcpClient({ workspaceRoot, command, timeoutMs });
|
|
333
|
-
|
|
334
|
-
return {
|
|
335
|
-
async connect() {
|
|
336
|
-
await client.connect();
|
|
337
|
-
},
|
|
338
|
-
|
|
339
|
-
async grep(args) {
|
|
340
|
-
const pattern = String(args?.pattern || '').trim();
|
|
341
|
-
if (!pattern) return null;
|
|
342
|
-
const result = await client.callTool('grep', {
|
|
343
|
-
query: buildGrepQuery(pattern, args),
|
|
344
|
-
max_results: clampNumber(args?.max_results, 1, 200, 50)
|
|
345
|
-
});
|
|
346
|
-
return parseGrepOutput(extractTextContent(result), pattern);
|
|
347
|
-
},
|
|
348
|
-
|
|
349
|
-
async glob(args) {
|
|
350
|
-
const pattern = String(args?.pattern || '').trim();
|
|
351
|
-
if (!pattern) return null;
|
|
352
|
-
const limit = clampNumber(args?.max_results, 1, 500, 200);
|
|
353
|
-
const result = await client.callTool('find_files', {
|
|
354
|
-
query: pattern,
|
|
355
|
-
max_results: limit
|
|
356
|
-
});
|
|
357
|
-
const matches = parseFindFilesOutput(extractTextContent(result));
|
|
358
|
-
return {
|
|
359
|
-
pattern,
|
|
360
|
-
matches,
|
|
361
|
-
truncated: matches.length >= limit
|
|
362
|
-
};
|
|
363
|
-
},
|
|
364
|
-
|
|
365
|
-
async list(args) {
|
|
366
|
-
const relativePath = String(args?.path || '.').trim();
|
|
367
|
-
if (!relativePath || relativePath === '.') return null;
|
|
368
|
-
const result = await client.callTool('find_files', {
|
|
369
|
-
query: normalizePathPrefix(relativePath),
|
|
370
|
-
max_results: 500
|
|
371
|
-
});
|
|
372
|
-
const filePaths = parseFindFilesOutput(extractTextContent(result));
|
|
373
|
-
return {
|
|
374
|
-
path: relativePath,
|
|
375
|
-
items: buildImmediateItems(relativePath, filePaths, Boolean(args?.include_hidden))
|
|
376
|
-
};
|
|
377
|
-
},
|
|
378
|
-
|
|
379
|
-
async dispose() {
|
|
380
|
-
await client.dispose();
|
|
381
|
-
}
|
|
382
|
-
};
|
|
383
|
-
}
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { LANGUAGE_FILE_TYPES } from './constants.js';
|
|
3
|
+
import { getPackageInfo } from './version.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_COMMAND = 'fff-mcp';
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
function clampNumber(value, min, max, fallback) {
|
|
9
|
+
const num = Number(value);
|
|
10
|
+
if (!Number.isFinite(num)) return fallback;
|
|
11
|
+
return Math.min(max, Math.max(min, num));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function encodeMessage(payload) {
|
|
15
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
16
|
+
return Buffer.concat([
|
|
17
|
+
Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf8'),
|
|
18
|
+
body
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createMessageParser(onMessage) {
|
|
23
|
+
let buffer = Buffer.alloc(0);
|
|
24
|
+
return (chunk) => {
|
|
25
|
+
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
26
|
+
while (buffer.length > 0) {
|
|
27
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
28
|
+
if (headerEnd < 0) return;
|
|
29
|
+
const headerText = buffer.slice(0, headerEnd).toString('utf8');
|
|
30
|
+
const match = headerText.match(/Content-Length:\s*(\d+)/i);
|
|
31
|
+
if (!match) {
|
|
32
|
+
buffer = Buffer.alloc(0);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const bodyLength = Number(match[1]);
|
|
36
|
+
const totalLength = headerEnd + 4 + bodyLength;
|
|
37
|
+
if (buffer.length < totalLength) return;
|
|
38
|
+
const body = buffer.slice(headerEnd + 4, totalLength).toString('utf8');
|
|
39
|
+
buffer = buffer.slice(totalLength);
|
|
40
|
+
try {
|
|
41
|
+
onMessage(JSON.parse(body));
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore malformed frames.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class FffMcpClient {
|
|
50
|
+
constructor({ workspaceRoot, command, timeoutMs }) {
|
|
51
|
+
this.workspaceRoot = workspaceRoot;
|
|
52
|
+
this.command = command;
|
|
53
|
+
this.timeoutMs = timeoutMs;
|
|
54
|
+
this.child = null;
|
|
55
|
+
this.pending = new Map();
|
|
56
|
+
this.nextId = 1;
|
|
57
|
+
this.connectPromise = null;
|
|
58
|
+
this.connected = false;
|
|
59
|
+
this.closed = false;
|
|
60
|
+
this.parser = createMessageParser((message) => this.handleMessage(message));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
handleMessage(message) {
|
|
64
|
+
if (!message || typeof message !== 'object') return;
|
|
65
|
+
if (typeof message.id !== 'number') return;
|
|
66
|
+
const pending = this.pending.get(message.id);
|
|
67
|
+
if (!pending) return;
|
|
68
|
+
this.pending.delete(message.id);
|
|
69
|
+
if (message.error) {
|
|
70
|
+
pending.reject(new Error(String(message.error?.message || 'Unknown MCP error')));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
pending.resolve(message.result);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async connect() {
|
|
77
|
+
if (this.connected) return;
|
|
78
|
+
if (this.connectPromise) return this.connectPromise;
|
|
79
|
+
this.connectPromise = this.start();
|
|
80
|
+
try {
|
|
81
|
+
await this.connectPromise;
|
|
82
|
+
this.connected = true;
|
|
83
|
+
} finally {
|
|
84
|
+
this.connectPromise = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async start() {
|
|
89
|
+
if (this.closed) {
|
|
90
|
+
throw new Error('FFF MCP client already disposed');
|
|
91
|
+
}
|
|
92
|
+
this.child = spawn(this.command, [], {
|
|
93
|
+
cwd: this.workspaceRoot,
|
|
94
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this.child.stdout.on('data', this.parser);
|
|
98
|
+
this.child.stderr.on('data', () => {});
|
|
99
|
+
this.child.on('error', (error) => {
|
|
100
|
+
this.rejectAll(error);
|
|
101
|
+
});
|
|
102
|
+
this.child.on('exit', (code) => {
|
|
103
|
+
this.connected = false;
|
|
104
|
+
this.child = null;
|
|
105
|
+
if (!this.closed && code !== 0) {
|
|
106
|
+
this.rejectAll(new Error(`FFF MCP exited with code ${code}`));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await this.sendRequest('initialize', {
|
|
111
|
+
protocolVersion: '2024-11-05',
|
|
112
|
+
capabilities: {},
|
|
113
|
+
clientInfo: getPackageInfo()
|
|
114
|
+
});
|
|
115
|
+
this.sendNotification('notifications/initialized', {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
rejectAll(error) {
|
|
119
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
reject(error);
|
|
122
|
+
}
|
|
123
|
+
this.pending.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sendNotification(method, params) {
|
|
127
|
+
if (!this.child?.stdin) throw new Error('FFF MCP client is not connected');
|
|
128
|
+
this.child.stdin.write(
|
|
129
|
+
encodeMessage({
|
|
130
|
+
jsonrpc: '2.0',
|
|
131
|
+
method,
|
|
132
|
+
params
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
sendRequest(method, params) {
|
|
138
|
+
if (!this.child?.stdin) {
|
|
139
|
+
return Promise.reject(new Error('FFF MCP client is not connected'));
|
|
140
|
+
}
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const id = this.nextId++;
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.pending.delete(id);
|
|
145
|
+
reject(new Error(`FFF MCP request timed out after ${this.timeoutMs}ms`));
|
|
146
|
+
}, this.timeoutMs);
|
|
147
|
+
this.pending.set(id, {
|
|
148
|
+
resolve: (result) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
resolve(result);
|
|
151
|
+
},
|
|
152
|
+
reject: (error) => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
reject(error);
|
|
155
|
+
},
|
|
156
|
+
timer
|
|
157
|
+
});
|
|
158
|
+
this.child.stdin.write(
|
|
159
|
+
encodeMessage({
|
|
160
|
+
jsonrpc: '2.0',
|
|
161
|
+
id,
|
|
162
|
+
method,
|
|
163
|
+
params
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async callTool(name, args) {
|
|
170
|
+
await this.connect();
|
|
171
|
+
return this.sendRequest('tools/call', {
|
|
172
|
+
name,
|
|
173
|
+
arguments: args
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async dispose() {
|
|
178
|
+
this.closed = true;
|
|
179
|
+
this.connected = false;
|
|
180
|
+
if (this.child?.stdin) {
|
|
181
|
+
try {
|
|
182
|
+
this.child.stdin.end();
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
if (this.child && !this.child.killed) {
|
|
186
|
+
this.child.kill();
|
|
187
|
+
}
|
|
188
|
+
this.child = null;
|
|
189
|
+
this.rejectAll(new Error('FFF MCP client disposed'));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function extractTextContent(result) {
|
|
194
|
+
const content = Array.isArray(result?.content) ? result.content : [];
|
|
195
|
+
return content
|
|
196
|
+
.filter((item) => item?.type === 'text' && typeof item?.text === 'string')
|
|
197
|
+
.map((item) => item.text)
|
|
198
|
+
.join('\n')
|
|
199
|
+
.trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stripFffFileSuffix(line) {
|
|
203
|
+
return String(line || '')
|
|
204
|
+
.replace(/\s+-\s+(?:hot|warm|frequent)(?:\s+git:[a-z_]+)?$/i, '')
|
|
205
|
+
.replace(/\s+git:[a-z_]+$/i, '')
|
|
206
|
+
.trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseFindFilesOutput(text) {
|
|
210
|
+
const lines = String(text || '')
|
|
211
|
+
.split(/\r?\n/)
|
|
212
|
+
.map((line) => line.trimEnd())
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
const matches = [];
|
|
215
|
+
for (const line of lines) {
|
|
216
|
+
if (
|
|
217
|
+
line.startsWith('→ ') ||
|
|
218
|
+
line.startsWith('cursor:') ||
|
|
219
|
+
/^\d+\/\d+\s+matches$/i.test(line) ||
|
|
220
|
+
/^0 results\b/i.test(line)
|
|
221
|
+
) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const normalized = stripFffFileSuffix(line);
|
|
225
|
+
if (normalized) matches.push(normalized);
|
|
226
|
+
}
|
|
227
|
+
return matches;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseGrepOutput(text, fallbackPattern = '') {
|
|
231
|
+
const lines = String(text || '')
|
|
232
|
+
.split(/\r?\n/)
|
|
233
|
+
.map((line) => line.trimEnd())
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
const matches = [];
|
|
236
|
+
let currentPath = '';
|
|
237
|
+
for (const line of lines) {
|
|
238
|
+
if (
|
|
239
|
+
line.startsWith('→ ') ||
|
|
240
|
+
line.startsWith('cursor:') ||
|
|
241
|
+
/^! regex failed:/i.test(line) ||
|
|
242
|
+
/^\d+\/\d+\s+matches shown$/i.test(line) ||
|
|
243
|
+
/^0 (?:exact )?matches\b/i.test(line) ||
|
|
244
|
+
/^Auto-broadened to\b/i.test(line)
|
|
245
|
+
) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const sectionMatch = line.match(/^\s*(\d+)\s*[:|-]\s*(.*)$/);
|
|
249
|
+
if (sectionMatch && currentPath) {
|
|
250
|
+
const [, lineNumber, preview] = sectionMatch;
|
|
251
|
+
matches.push({
|
|
252
|
+
path: currentPath,
|
|
253
|
+
line: Number(lineNumber),
|
|
254
|
+
column: 1,
|
|
255
|
+
preview: String(preview || '').trim()
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const fileCandidate = stripFffFileSuffix(line);
|
|
260
|
+
if (fileCandidate && !/^\d/.test(fileCandidate)) {
|
|
261
|
+
currentPath = fileCandidate;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
pattern: fallbackPattern,
|
|
266
|
+
matches,
|
|
267
|
+
truncated: /cursor:/i.test(text)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizePathPrefix(value) {
|
|
272
|
+
const text = String(value || '').trim().replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
273
|
+
if (!text || text === '.') return '';
|
|
274
|
+
return text.endsWith('/') ? text : `${text}/`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildGrepQuery(pattern, args = {}) {
|
|
278
|
+
const pieces = [];
|
|
279
|
+
const pathPrefix = normalizePathPrefix(args.path);
|
|
280
|
+
if (pathPrefix) pieces.push(pathPrefix);
|
|
281
|
+
const fileTypes = Array.isArray(args.file_types) ? args.file_types : [];
|
|
282
|
+
const language = String(args.language || '').trim().toLowerCase();
|
|
283
|
+
const mergedTypes = [...new Set([...fileTypes, ...(LANGUAGE_FILE_TYPES[language] || [])])];
|
|
284
|
+
if (mergedTypes.length === 1) {
|
|
285
|
+
pieces.push(`*.${mergedTypes[0]}`);
|
|
286
|
+
} else if (mergedTypes.length > 1) {
|
|
287
|
+
pieces.push(`*.{${mergedTypes.join(',')}}`);
|
|
288
|
+
}
|
|
289
|
+
pieces.push(String(pattern || '').trim());
|
|
290
|
+
return pieces.filter(Boolean).join(' ');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function buildImmediateItems(relativePath, filePaths, includeHidden = false) {
|
|
294
|
+
const prefix = normalizePathPrefix(relativePath);
|
|
295
|
+
const directories = new Set();
|
|
296
|
+
const files = new Set();
|
|
297
|
+
for (const filePath of filePaths) {
|
|
298
|
+
const normalized = String(filePath || '').replace(/\\/g, '/');
|
|
299
|
+
if (!normalized.startsWith(prefix)) continue;
|
|
300
|
+
const remainder = normalized.slice(prefix.length);
|
|
301
|
+
if (!remainder) continue;
|
|
302
|
+
const [head, ...rest] = remainder.split('/');
|
|
303
|
+
if (!head) continue;
|
|
304
|
+
if (!includeHidden && head.startsWith('.')) continue;
|
|
305
|
+
if (rest.length > 0) {
|
|
306
|
+
directories.add(head);
|
|
307
|
+
} else {
|
|
308
|
+
files.add(head);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const dirItems = [...directories].sort((a, b) => a.localeCompare(b)).map((name) => ({
|
|
312
|
+
name,
|
|
313
|
+
path: `${prefix}${name}`.replace(/\/$/, ''),
|
|
314
|
+
type: 'dir'
|
|
315
|
+
}));
|
|
316
|
+
const fileItems = [...files].sort((a, b) => a.localeCompare(b)).map((name) => ({
|
|
317
|
+
name,
|
|
318
|
+
path: `${prefix}${name}`,
|
|
319
|
+
type: 'file'
|
|
320
|
+
}));
|
|
321
|
+
return [...dirItems, ...fileItems];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function createFffAdapter({ workspaceRoot, config }) {
|
|
325
|
+
const command = String(config?.search?.fff_command || config?.tooling?.fff_command || DEFAULT_COMMAND).trim() || DEFAULT_COMMAND;
|
|
326
|
+
const timeoutMs = clampNumber(
|
|
327
|
+
config?.search?.fff_timeout_ms || config?.tooling?.fff_timeout_ms,
|
|
328
|
+
1_000,
|
|
329
|
+
120_000,
|
|
330
|
+
DEFAULT_TIMEOUT_MS
|
|
331
|
+
);
|
|
332
|
+
const client = new FffMcpClient({ workspaceRoot, command, timeoutMs });
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
async connect() {
|
|
336
|
+
await client.connect();
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
async grep(args) {
|
|
340
|
+
const pattern = String(args?.pattern || '').trim();
|
|
341
|
+
if (!pattern) return null;
|
|
342
|
+
const result = await client.callTool('grep', {
|
|
343
|
+
query: buildGrepQuery(pattern, args),
|
|
344
|
+
max_results: clampNumber(args?.max_results, 1, 200, 50)
|
|
345
|
+
});
|
|
346
|
+
return parseGrepOutput(extractTextContent(result), pattern);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
async glob(args) {
|
|
350
|
+
const pattern = String(args?.pattern || '').trim();
|
|
351
|
+
if (!pattern) return null;
|
|
352
|
+
const limit = clampNumber(args?.max_results, 1, 500, 200);
|
|
353
|
+
const result = await client.callTool('find_files', {
|
|
354
|
+
query: pattern,
|
|
355
|
+
max_results: limit
|
|
356
|
+
});
|
|
357
|
+
const matches = parseFindFilesOutput(extractTextContent(result));
|
|
358
|
+
return {
|
|
359
|
+
pattern,
|
|
360
|
+
matches,
|
|
361
|
+
truncated: matches.length >= limit
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async list(args) {
|
|
366
|
+
const relativePath = String(args?.path || '.').trim();
|
|
367
|
+
if (!relativePath || relativePath === '.') return null;
|
|
368
|
+
const result = await client.callTool('find_files', {
|
|
369
|
+
query: normalizePathPrefix(relativePath),
|
|
370
|
+
max_results: 500
|
|
371
|
+
});
|
|
372
|
+
const filePaths = parseFindFilesOutput(extractTextContent(result));
|
|
373
|
+
return {
|
|
374
|
+
path: relativePath,
|
|
375
|
+
items: buildImmediateItems(relativePath, filePaths, Boolean(args?.include_hidden))
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
async dispose() {
|
|
380
|
+
await client.dispose();
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|