nex-code 0.3.4 → 0.3.7
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/README.md +34 -12
- package/dist/bundle.js +505 -0
- package/dist/nex-code.js +485 -0
- package/package.json +8 -6
- package/bin/nex-code.js +0 -99
- package/cli/agent.js +0 -835
- package/cli/compactor.js +0 -85
- package/cli/context-engine.js +0 -507
- package/cli/context.js +0 -98
- package/cli/costs.js +0 -290
- package/cli/diff.js +0 -366
- package/cli/file-history.js +0 -94
- package/cli/format.js +0 -211
- package/cli/fuzzy-match.js +0 -270
- package/cli/git.js +0 -211
- package/cli/hooks.js +0 -173
- package/cli/index.js +0 -1289
- package/cli/mcp.js +0 -284
- package/cli/memory.js +0 -170
- package/cli/ollama.js +0 -130
- package/cli/permissions.js +0 -124
- package/cli/picker.js +0 -201
- package/cli/planner.js +0 -282
- package/cli/providers/anthropic.js +0 -333
- package/cli/providers/base.js +0 -116
- package/cli/providers/gemini.js +0 -239
- package/cli/providers/local.js +0 -249
- package/cli/providers/ollama.js +0 -228
- package/cli/providers/openai.js +0 -237
- package/cli/providers/registry.js +0 -454
- package/cli/render.js +0 -495
- package/cli/safety.js +0 -241
- package/cli/session.js +0 -133
- package/cli/skills.js +0 -412
- package/cli/spinner.js +0 -371
- package/cli/sub-agent.js +0 -425
- package/cli/tasks.js +0 -179
- package/cli/tool-tiers.js +0 -164
- package/cli/tool-validator.js +0 -138
- package/cli/tools.js +0 -1050
- package/cli/ui.js +0 -93
package/cli/tools.js
DELETED
|
@@ -1,1050 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cli/tools.js — Tool Definitions + Implementations
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { execSync, execFileSync } = require('child_process');
|
|
8
|
-
const axios = require('axios');
|
|
9
|
-
const { isForbidden, isDangerous, confirm } = require('./safety');
|
|
10
|
-
const { showClaudeDiff, showClaudeNewFile, showEditDiff, confirmFileChange } = require('./diff');
|
|
11
|
-
const { C, Spinner, getToolSpinnerText } = require('./ui');
|
|
12
|
-
const { isGitRepo, getCurrentBranch, getStatus, getDiff } = require('./git');
|
|
13
|
-
const { recordChange } = require('./file-history');
|
|
14
|
-
const { fuzzyFindText, findMostSimilar } = require('./fuzzy-match');
|
|
15
|
-
|
|
16
|
-
const CWD = process.cwd();
|
|
17
|
-
|
|
18
|
-
// ─── Auto-Fix Helpers ─────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Auto-fix file path: try to find the correct file when path doesn't exist.
|
|
22
|
-
* Strategies:
|
|
23
|
-
* 1. Normalize path (remove double slashes, expand ~)
|
|
24
|
-
* 2. Try basename glob (e.g. "src/comp/Button.tsx" → glob for "Button.tsx")
|
|
25
|
-
* 3. Try with/without common extensions (.js, .ts, .jsx, .tsx, .mjs)
|
|
26
|
-
* @param {string} originalPath - The path that wasn't found
|
|
27
|
-
* @returns {{ fixedPath: string|null, message: string }}
|
|
28
|
-
*/
|
|
29
|
-
function autoFixPath(originalPath) {
|
|
30
|
-
if (!originalPath) return { fixedPath: null, message: '' };
|
|
31
|
-
|
|
32
|
-
// Strategy 1: normalize path issues
|
|
33
|
-
let normalized = originalPath
|
|
34
|
-
.replace(/\/+/g, '/') // double slashes
|
|
35
|
-
.replace(/^~\//, `${require('os').homedir()}/`); // expand ~
|
|
36
|
-
const np = resolvePath(normalized);
|
|
37
|
-
if (np && fs.existsSync(np)) {
|
|
38
|
-
return { fixedPath: np, message: `(auto-fixed path: ${originalPath} → ${normalized})` };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Strategy 2: try with/without extensions
|
|
42
|
-
const extVariants = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.json'];
|
|
43
|
-
const hasExt = path.extname(originalPath);
|
|
44
|
-
if (!hasExt) {
|
|
45
|
-
for (const ext of extVariants) {
|
|
46
|
-
const withExt = resolvePath(originalPath + ext);
|
|
47
|
-
if (withExt && fs.existsSync(withExt)) {
|
|
48
|
-
return { fixedPath: withExt, message: `(auto-fixed: added ${ext} extension)` };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// Try stripping extension and trying others
|
|
53
|
-
if (hasExt) {
|
|
54
|
-
const base = originalPath.replace(/\.[^.]+$/, '');
|
|
55
|
-
for (const ext of extVariants) {
|
|
56
|
-
if (ext === hasExt) continue;
|
|
57
|
-
const alt = resolvePath(base + ext);
|
|
58
|
-
if (alt && fs.existsSync(alt)) {
|
|
59
|
-
return { fixedPath: alt, message: `(auto-fixed: ${hasExt} → ${ext})` };
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Strategy 3: glob for basename in project directory
|
|
65
|
-
const basename = path.basename(originalPath);
|
|
66
|
-
if (basename && basename.length > 2) {
|
|
67
|
-
try {
|
|
68
|
-
const walkForFile = (dir, target, depth = 0) => {
|
|
69
|
-
if (depth > 5) return [];
|
|
70
|
-
const matches = [];
|
|
71
|
-
let entries;
|
|
72
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
73
|
-
for (const e of entries) {
|
|
74
|
-
if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
|
|
75
|
-
const full = path.join(dir, e.name);
|
|
76
|
-
if (e.isDirectory()) {
|
|
77
|
-
matches.push(...walkForFile(full, target, depth + 1));
|
|
78
|
-
} else if (e.name === target) {
|
|
79
|
-
matches.push(full);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return matches;
|
|
83
|
-
};
|
|
84
|
-
const found = walkForFile(CWD, basename);
|
|
85
|
-
if (found.length === 1) {
|
|
86
|
-
return { fixedPath: found[0], message: `(auto-fixed: found ${basename} at ${path.relative(CWD, found[0])})` };
|
|
87
|
-
}
|
|
88
|
-
if (found.length > 1 && found.length <= 5) {
|
|
89
|
-
const relative = found.map(f => path.relative(CWD, f));
|
|
90
|
-
return { fixedPath: null, message: `File not found. Did you mean one of:\n${relative.map(r => ` - ${r}`).join('\n')}` };
|
|
91
|
-
}
|
|
92
|
-
} catch { /* glob failed, skip */ }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return { fixedPath: null, message: '' };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Enrich bash error output with actionable hints.
|
|
100
|
-
* @param {string} errorOutput - Raw stderr/stdout from bash
|
|
101
|
-
* @param {string} command - The command that was run
|
|
102
|
-
* @returns {string} Enriched error message
|
|
103
|
-
*/
|
|
104
|
-
function enrichBashError(errorOutput, command) {
|
|
105
|
-
const hints = [];
|
|
106
|
-
|
|
107
|
-
// Command not found
|
|
108
|
-
if (/command not found|not recognized/i.test(errorOutput)) {
|
|
109
|
-
const cmdMatch = command.match(/^(\S+)/);
|
|
110
|
-
const cmd = cmdMatch ? cmdMatch[1] : '';
|
|
111
|
-
if (/^(npx|npm|node|yarn|pnpm|bun)$/.test(cmd)) {
|
|
112
|
-
hints.push('HINT: Node.js/npm may not be in PATH. Check your Node.js installation.');
|
|
113
|
-
} else if (/^(python|python3|pip|pip3)$/.test(cmd)) {
|
|
114
|
-
hints.push('HINT: Python may not be installed. Try: brew install python3 (macOS) or apt install python3 (Linux)');
|
|
115
|
-
} else {
|
|
116
|
-
hints.push(`HINT: "${cmd}" is not installed. Try installing it with your package manager.`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Module not found (Node.js)
|
|
121
|
-
if (/Cannot find module|MODULE_NOT_FOUND/i.test(errorOutput)) {
|
|
122
|
-
const modMatch = errorOutput.match(/Cannot find module '([^']+)'/);
|
|
123
|
-
const mod = modMatch ? modMatch[1] : '';
|
|
124
|
-
if (mod && !mod.startsWith('.') && !mod.startsWith('/')) {
|
|
125
|
-
hints.push(`HINT: Missing npm package "${mod}". Run: npm install ${mod}`);
|
|
126
|
-
} else {
|
|
127
|
-
hints.push('HINT: Module not found. Check the import path or run npm install.');
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Permission denied
|
|
132
|
-
if (/permission denied|EACCES/i.test(errorOutput)) {
|
|
133
|
-
hints.push('HINT: Permission denied. Check file permissions or try a different approach.');
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Port already in use
|
|
137
|
-
if (/EADDRINUSE|address already in use/i.test(errorOutput)) {
|
|
138
|
-
const portMatch = errorOutput.match(/port (\d+)|:(\d+)/);
|
|
139
|
-
const port = portMatch ? (portMatch[1] || portMatch[2]) : '';
|
|
140
|
-
hints.push(`HINT: Port ${port || ''} is already in use. Kill the process or use a different port.`);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Syntax error
|
|
144
|
-
if (/SyntaxError|Unexpected token/i.test(errorOutput)) {
|
|
145
|
-
hints.push('HINT: Syntax error in the code. Check the file at the line number shown above.');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// TypeScript errors
|
|
149
|
-
if (/TS\d{4}:/i.test(errorOutput)) {
|
|
150
|
-
hints.push('HINT: TypeScript compilation error. Fix the type issue at the indicated line.');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Jest/test failures
|
|
154
|
-
if (/Test Suites:.*failed|Tests:.*failed/i.test(errorOutput)) {
|
|
155
|
-
hints.push('HINT: Test failures detected. Read the error output above to identify failing tests.');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Git errors
|
|
159
|
-
if (/fatal: not a git repository/i.test(errorOutput)) {
|
|
160
|
-
hints.push('HINT: Not inside a git repository. Run git init or cd to a git project.');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (hints.length === 0) return errorOutput;
|
|
164
|
-
return errorOutput + '\n\n' + hints.join('\n');
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Auto-apply a close edit match instead of erroring.
|
|
169
|
-
* If findMostSimilar returns a match with distance ≤ AUTO_APPLY_THRESHOLD,
|
|
170
|
-
* use the similar text as the old_text.
|
|
171
|
-
*
|
|
172
|
-
* @param {string} content - File content
|
|
173
|
-
* @param {string} oldText - The old_text that wasn't found
|
|
174
|
-
* @param {string} newText - The new_text replacement
|
|
175
|
-
* @returns {{ autoFixed: boolean, matchText: string, content: string, distance: number, line: number }|null}
|
|
176
|
-
*/
|
|
177
|
-
function autoFixEdit(content, oldText, newText) {
|
|
178
|
-
const similar = findMostSimilar(content, oldText);
|
|
179
|
-
if (!similar) return null;
|
|
180
|
-
|
|
181
|
-
// Auto-apply threshold: ≤ 5% of target length or ≤ 3 chars difference
|
|
182
|
-
const threshold = Math.max(3, Math.ceil(oldText.length * 0.05));
|
|
183
|
-
if (similar.distance > threshold) return null;
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
autoFixed: true,
|
|
187
|
-
matchText: similar.text,
|
|
188
|
-
content: content.split(similar.text).join(newText),
|
|
189
|
-
distance: similar.distance,
|
|
190
|
-
line: similar.line,
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Auto-checkpoint: tag last known state before first agent edit
|
|
195
|
-
let _checkpointCreated = false;
|
|
196
|
-
function ensureCheckpoint() {
|
|
197
|
-
if (_checkpointCreated) return;
|
|
198
|
-
_checkpointCreated = true;
|
|
199
|
-
try {
|
|
200
|
-
// Only in git repos with changes
|
|
201
|
-
const isGit = execSync('git rev-parse --is-inside-work-tree', { cwd: CWD, encoding: 'utf-8', stdio: 'pipe' }).trim() === 'true';
|
|
202
|
-
if (!isGit) return;
|
|
203
|
-
execSync('git stash push -m "nex-code-checkpoint" --include-untracked', { cwd: CWD, encoding: 'utf-8', stdio: 'pipe', timeout: 10000 });
|
|
204
|
-
execSync('git stash pop', { cwd: CWD, encoding: 'utf-8', stdio: 'pipe', timeout: 10000 });
|
|
205
|
-
execSync('git tag -f nex-checkpoint', { cwd: CWD, encoding: 'utf-8', stdio: 'pipe', timeout: 5000 });
|
|
206
|
-
} catch { /* not critical */ }
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Sensitive paths that should never be accessed by file tools
|
|
210
|
-
const SENSITIVE_PATHS = [
|
|
211
|
-
/\.ssh\//i, /\.gnupg\//i, /\.aws\//i, /\.config\/gcloud/i,
|
|
212
|
-
/\/etc\/shadow/, /\/etc\/passwd/, /\/etc\/sudoers/,
|
|
213
|
-
/\.env(?:\.|$)/, /credentials/i, /\.npmrc$/,
|
|
214
|
-
/\.docker\/config\.json/, /\.kube\/config/,
|
|
215
|
-
];
|
|
216
|
-
|
|
217
|
-
function resolvePath(p) {
|
|
218
|
-
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(CWD, p);
|
|
219
|
-
// Block access to sensitive paths
|
|
220
|
-
for (const pat of SENSITIVE_PATHS) {
|
|
221
|
-
if (pat.test(resolved)) return null;
|
|
222
|
-
}
|
|
223
|
-
return resolved;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ─── Tool Definitions (Ollama format) ─────────────────────────
|
|
227
|
-
const TOOL_DEFINITIONS = [
|
|
228
|
-
{
|
|
229
|
-
type: 'function',
|
|
230
|
-
function: {
|
|
231
|
-
name: 'bash',
|
|
232
|
-
description:
|
|
233
|
-
'Execute a bash command in the project directory. Timeout: 90s. Use for: running tests, installing packages, git commands, build tools, starting servers. Do NOT use bash for file operations when a dedicated tool exists — use read_file instead of cat, edit_file instead of sed, glob instead of find, grep instead of grep/rg. Always quote paths with spaces. Prefer specific commands over rm -rf. Destructive or dangerous commands require user confirmation.',
|
|
234
|
-
parameters: {
|
|
235
|
-
type: 'object',
|
|
236
|
-
properties: { command: { type: 'string', description: 'The bash command to execute' } },
|
|
237
|
-
required: ['command'],
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
type: 'function',
|
|
243
|
-
function: {
|
|
244
|
-
name: 'read_file',
|
|
245
|
-
description: "Read a file's contents with line numbers. Always read a file BEFORE editing it to see exact content. Use line_start/line_end for large files to read specific sections. Prefer this over bash cat/head/tail.",
|
|
246
|
-
parameters: {
|
|
247
|
-
type: 'object',
|
|
248
|
-
properties: {
|
|
249
|
-
path: { type: 'string', description: 'File path (relative or absolute)' },
|
|
250
|
-
line_start: { type: 'number', description: 'Start line (1-based, optional)' },
|
|
251
|
-
line_end: { type: 'number', description: 'End line (1-based, optional)' },
|
|
252
|
-
},
|
|
253
|
-
required: ['path'],
|
|
254
|
-
},
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
{
|
|
258
|
-
type: 'function',
|
|
259
|
-
function: {
|
|
260
|
-
name: 'write_file',
|
|
261
|
-
description: 'Create a new file or completely overwrite an existing file. For targeted changes to existing files, prefer edit_file or patch_file instead — they only send the diff and are safer. Only use write_file when creating new files or when the entire content needs to be replaced.',
|
|
262
|
-
parameters: {
|
|
263
|
-
type: 'object',
|
|
264
|
-
properties: {
|
|
265
|
-
path: { type: 'string', description: 'File path' },
|
|
266
|
-
content: { type: 'string', description: 'Full file content' },
|
|
267
|
-
},
|
|
268
|
-
required: ['path', 'content'],
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
{
|
|
273
|
-
type: 'function',
|
|
274
|
-
function: {
|
|
275
|
-
name: 'edit_file',
|
|
276
|
-
description: 'Replace specific text in a file. IMPORTANT: old_text must match the file content EXACTLY — including all whitespace, indentation (tabs vs spaces), and newlines. Always read_file first to see the exact content before editing. If old_text is not found, the edit fails. For multiple changes to the same file, prefer patch_file instead.',
|
|
277
|
-
parameters: {
|
|
278
|
-
type: 'object',
|
|
279
|
-
properties: {
|
|
280
|
-
path: { type: 'string', description: 'File path' },
|
|
281
|
-
old_text: { type: 'string', description: 'Exact text to find (must match file content precisely)' },
|
|
282
|
-
new_text: { type: 'string', description: 'Replacement text' },
|
|
283
|
-
},
|
|
284
|
-
required: ['path', 'old_text', 'new_text'],
|
|
285
|
-
},
|
|
286
|
-
},
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
type: 'function',
|
|
290
|
-
function: {
|
|
291
|
-
name: 'list_directory',
|
|
292
|
-
description: 'List files and directories in a tree view. Use this to understand project structure. For finding specific files by pattern, prefer glob instead.',
|
|
293
|
-
parameters: {
|
|
294
|
-
type: 'object',
|
|
295
|
-
properties: {
|
|
296
|
-
path: { type: 'string', description: 'Directory path' },
|
|
297
|
-
max_depth: { type: 'number', description: 'Max depth (default: 2)' },
|
|
298
|
-
pattern: { type: 'string', description: "File filter glob (e.g. '*.js')" },
|
|
299
|
-
},
|
|
300
|
-
required: ['path'],
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
{
|
|
305
|
-
type: 'function',
|
|
306
|
-
function: {
|
|
307
|
-
name: 'search_files',
|
|
308
|
-
description: 'Search for a text pattern across files (regex). Returns matching lines with file paths. For simple content search, grep is equivalent. For finding files by name, use glob instead.',
|
|
309
|
-
parameters: {
|
|
310
|
-
type: 'object',
|
|
311
|
-
properties: {
|
|
312
|
-
path: { type: 'string', description: 'Directory to search' },
|
|
313
|
-
pattern: { type: 'string', description: 'Search pattern (regex)' },
|
|
314
|
-
file_pattern: { type: 'string', description: "File filter (e.g. '*.js')" },
|
|
315
|
-
},
|
|
316
|
-
required: ['path', 'pattern'],
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
|
-
{
|
|
321
|
-
type: 'function',
|
|
322
|
-
function: {
|
|
323
|
-
name: 'glob',
|
|
324
|
-
description: "Find files matching a glob pattern. Fast file search by name/extension. Use this to find files before reading them. Examples: '**/*.test.js' (all test files), 'src/**/*.ts' (all TypeScript in src). Prefer this over bash find/ls.",
|
|
325
|
-
parameters: {
|
|
326
|
-
type: 'object',
|
|
327
|
-
properties: {
|
|
328
|
-
pattern: { type: 'string', description: "Glob pattern (e.g. '**/*.ts', 'src/**/*.test.js')" },
|
|
329
|
-
path: { type: 'string', description: 'Base directory (default: project root)' },
|
|
330
|
-
},
|
|
331
|
-
required: ['pattern'],
|
|
332
|
-
},
|
|
333
|
-
},
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
type: 'function',
|
|
337
|
-
function: {
|
|
338
|
-
name: 'grep',
|
|
339
|
-
description: 'Search file contents with regex. Returns matching lines with file paths and line numbers. Use this to find where functions/variables/classes are defined or used. Prefer this over bash grep/rg.',
|
|
340
|
-
parameters: {
|
|
341
|
-
type: 'object',
|
|
342
|
-
properties: {
|
|
343
|
-
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
|
344
|
-
path: { type: 'string', description: 'Directory or file to search (default: project root)' },
|
|
345
|
-
include: { type: 'string', description: "File filter (e.g. '*.js', '*.ts')" },
|
|
346
|
-
ignore_case: { type: 'boolean', description: 'Case-insensitive search' },
|
|
347
|
-
},
|
|
348
|
-
required: ['pattern'],
|
|
349
|
-
},
|
|
350
|
-
},
|
|
351
|
-
},
|
|
352
|
-
{
|
|
353
|
-
type: 'function',
|
|
354
|
-
function: {
|
|
355
|
-
name: 'patch_file',
|
|
356
|
-
description: 'Apply multiple text replacements to a file atomically. All patches are validated before any are applied — if one fails, none are written. Prefer this over multiple edit_file calls when making several changes to the same file. Like edit_file, all old_text values must match exactly.',
|
|
357
|
-
parameters: {
|
|
358
|
-
type: 'object',
|
|
359
|
-
properties: {
|
|
360
|
-
path: { type: 'string', description: 'File path' },
|
|
361
|
-
patches: {
|
|
362
|
-
type: 'array',
|
|
363
|
-
description: 'Array of { old_text, new_text } replacements to apply in order',
|
|
364
|
-
items: {
|
|
365
|
-
type: 'object',
|
|
366
|
-
properties: {
|
|
367
|
-
old_text: { type: 'string', description: 'Text to find' },
|
|
368
|
-
new_text: { type: 'string', description: 'Replacement text' },
|
|
369
|
-
},
|
|
370
|
-
required: ['old_text', 'new_text'],
|
|
371
|
-
},
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
required: ['path', 'patches'],
|
|
375
|
-
},
|
|
376
|
-
},
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
type: 'function',
|
|
380
|
-
function: {
|
|
381
|
-
name: 'web_fetch',
|
|
382
|
-
description: 'Fetch content from a URL and return text. HTML tags are stripped. Use for reading documentation, API responses, or web pages. Will not work with authenticated/private URLs.',
|
|
383
|
-
parameters: {
|
|
384
|
-
type: 'object',
|
|
385
|
-
properties: {
|
|
386
|
-
url: { type: 'string', description: 'URL to fetch' },
|
|
387
|
-
max_length: { type: 'number', description: 'Max response length in chars (default: 10000)' },
|
|
388
|
-
},
|
|
389
|
-
required: ['url'],
|
|
390
|
-
},
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
{
|
|
394
|
-
type: 'function',
|
|
395
|
-
function: {
|
|
396
|
-
name: 'web_search',
|
|
397
|
-
description: 'Search the web using DuckDuckGo. Returns titles and URLs. Use to find documentation, solutions, or current information beyond your knowledge cutoff.',
|
|
398
|
-
parameters: {
|
|
399
|
-
type: 'object',
|
|
400
|
-
properties: {
|
|
401
|
-
query: { type: 'string', description: 'Search query' },
|
|
402
|
-
max_results: { type: 'number', description: 'Max results (default: 5)' },
|
|
403
|
-
},
|
|
404
|
-
required: ['query'],
|
|
405
|
-
},
|
|
406
|
-
},
|
|
407
|
-
},
|
|
408
|
-
{
|
|
409
|
-
type: 'function',
|
|
410
|
-
function: {
|
|
411
|
-
name: 'ask_user',
|
|
412
|
-
description: 'Ask the user a question and wait for their response. Use when requirements are ambiguous, you need to choose between approaches, or a decision has significant impact. Do not ask unnecessary questions — proceed if the intent is clear.',
|
|
413
|
-
parameters: {
|
|
414
|
-
type: 'object',
|
|
415
|
-
properties: {
|
|
416
|
-
question: { type: 'string', description: 'The question to ask the user' },
|
|
417
|
-
},
|
|
418
|
-
required: ['question'],
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
},
|
|
422
|
-
{
|
|
423
|
-
type: 'function',
|
|
424
|
-
function: {
|
|
425
|
-
name: 'git_status',
|
|
426
|
-
description: 'Get git status: current branch, changed files, staged/unstaged state. Use before git operations to understand the current state.',
|
|
427
|
-
parameters: {
|
|
428
|
-
type: 'object',
|
|
429
|
-
properties: {},
|
|
430
|
-
required: [],
|
|
431
|
-
},
|
|
432
|
-
},
|
|
433
|
-
},
|
|
434
|
-
{
|
|
435
|
-
type: 'function',
|
|
436
|
-
function: {
|
|
437
|
-
name: 'git_diff',
|
|
438
|
-
description: 'Get git diff for changed files. Shows additions and deletions.',
|
|
439
|
-
parameters: {
|
|
440
|
-
type: 'object',
|
|
441
|
-
properties: {
|
|
442
|
-
staged: { type: 'boolean', description: 'Show only staged changes (default: false)' },
|
|
443
|
-
file: { type: 'string', description: 'Diff specific file only (optional)' },
|
|
444
|
-
},
|
|
445
|
-
required: [],
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
},
|
|
449
|
-
{
|
|
450
|
-
type: 'function',
|
|
451
|
-
function: {
|
|
452
|
-
name: 'git_log',
|
|
453
|
-
description: 'Show recent git commits (short format).',
|
|
454
|
-
parameters: {
|
|
455
|
-
type: 'object',
|
|
456
|
-
properties: {
|
|
457
|
-
count: { type: 'number', description: 'Number of commits to show (default: 10)' },
|
|
458
|
-
file: { type: 'string', description: 'Show commits for specific file (optional)' },
|
|
459
|
-
},
|
|
460
|
-
required: [],
|
|
461
|
-
},
|
|
462
|
-
},
|
|
463
|
-
},
|
|
464
|
-
{
|
|
465
|
-
type: 'function',
|
|
466
|
-
function: {
|
|
467
|
-
name: 'task_list',
|
|
468
|
-
description: 'Create and manage a task list for complex multi-step tasks. Use for tasks with 3+ steps to track progress. Actions: create (new list with tasks), update (mark task in_progress/done/failed), get (view current list). Always update task status as you work.',
|
|
469
|
-
parameters: {
|
|
470
|
-
type: 'object',
|
|
471
|
-
properties: {
|
|
472
|
-
action: { type: 'string', enum: ['create', 'update', 'get'], description: 'Action to perform' },
|
|
473
|
-
name: { type: 'string', description: 'Task list name (for create)' },
|
|
474
|
-
tasks: {
|
|
475
|
-
type: 'array',
|
|
476
|
-
description: 'Array of tasks to create (for create)',
|
|
477
|
-
items: {
|
|
478
|
-
type: 'object',
|
|
479
|
-
properties: {
|
|
480
|
-
description: { type: 'string', description: 'Task description' },
|
|
481
|
-
depends_on: { type: 'array', items: { type: 'string' }, description: 'IDs of prerequisite tasks' },
|
|
482
|
-
},
|
|
483
|
-
required: ['description'],
|
|
484
|
-
},
|
|
485
|
-
},
|
|
486
|
-
task_id: { type: 'string', description: 'Task ID to update (for update)' },
|
|
487
|
-
status: { type: 'string', enum: ['in_progress', 'done', 'failed'], description: 'New status (for update)' },
|
|
488
|
-
result: { type: 'string', description: 'Result summary (for update, optional)' },
|
|
489
|
-
},
|
|
490
|
-
required: ['action'],
|
|
491
|
-
},
|
|
492
|
-
},
|
|
493
|
-
},
|
|
494
|
-
{
|
|
495
|
-
type: 'function',
|
|
496
|
-
function: {
|
|
497
|
-
name: 'spawn_agents',
|
|
498
|
-
description: 'Run multiple independent sub-agents in parallel (max 5). Each agent has its own conversation context. Use when 2+ tasks can run simultaneously — e.g. reading multiple files, analyzing separate modules, independent research. Do NOT use for tasks that depend on each other or modify the same file. Keep task descriptions specific and self-contained.',
|
|
499
|
-
parameters: {
|
|
500
|
-
type: 'object',
|
|
501
|
-
properties: {
|
|
502
|
-
agents: {
|
|
503
|
-
type: 'array',
|
|
504
|
-
description: 'Array of agent definitions to run in parallel (max 5)',
|
|
505
|
-
items: {
|
|
506
|
-
type: 'object',
|
|
507
|
-
properties: {
|
|
508
|
-
task: { type: 'string', description: 'Task description for the agent' },
|
|
509
|
-
context: { type: 'string', description: 'Additional context (optional)' },
|
|
510
|
-
max_iterations: { type: 'number', description: 'Max iterations (default: 10, max: 15)' },
|
|
511
|
-
model: { type: 'string', description: 'Override model for this agent (provider:model, e.g. "anthropic:claude-haiku"). Auto-selected if omitted.' },
|
|
512
|
-
},
|
|
513
|
-
required: ['task'],
|
|
514
|
-
},
|
|
515
|
-
},
|
|
516
|
-
},
|
|
517
|
-
required: ['agents'],
|
|
518
|
-
},
|
|
519
|
-
},
|
|
520
|
-
},
|
|
521
|
-
];
|
|
522
|
-
|
|
523
|
-
// ─── Tool Implementations ─────────────────────────────────────
|
|
524
|
-
async function _executeToolInner(name, args, options = {}) {
|
|
525
|
-
switch (name) {
|
|
526
|
-
case 'bash': {
|
|
527
|
-
const cmd = args.command;
|
|
528
|
-
const forbidden = isForbidden(cmd);
|
|
529
|
-
if (forbidden) return `BLOCKED: Command matches forbidden pattern: ${forbidden}`;
|
|
530
|
-
|
|
531
|
-
if (isDangerous(cmd) && !options.autoConfirm) {
|
|
532
|
-
console.log(`\n${C.yellow} ⚠ Dangerous command: ${cmd}${C.reset}`);
|
|
533
|
-
const ok = await confirm(' Execute?');
|
|
534
|
-
if (!ok) return 'CANCELLED: User declined to execute this command.';
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const bashSpinner = options.silent ? null : new Spinner(`Running: ${cmd.substring(0, 60)}${cmd.length > 60 ? '...' : ''}`);
|
|
538
|
-
if (bashSpinner) bashSpinner.start();
|
|
539
|
-
try {
|
|
540
|
-
const out = execSync(cmd, {
|
|
541
|
-
cwd: CWD,
|
|
542
|
-
timeout: 90000,
|
|
543
|
-
encoding: 'utf-8',
|
|
544
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
545
|
-
});
|
|
546
|
-
if (bashSpinner) bashSpinner.stop();
|
|
547
|
-
return out || '(no output)';
|
|
548
|
-
} catch (e) {
|
|
549
|
-
if (bashSpinner) bashSpinner.stop();
|
|
550
|
-
const rawError = (e.stderr || e.stdout || e.message || '').toString().substring(0, 5000);
|
|
551
|
-
const enriched = enrichBashError(rawError, cmd);
|
|
552
|
-
return `EXIT ${e.status || 1}\n${enriched}`;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
case 'read_file': {
|
|
557
|
-
let fp = resolvePath(args.path);
|
|
558
|
-
if (!fp) return `ERROR: Access denied — path outside project: ${args.path}`;
|
|
559
|
-
if (!fs.existsSync(fp)) {
|
|
560
|
-
// Auto-fix: try to find the file
|
|
561
|
-
const fix = autoFixPath(args.path);
|
|
562
|
-
if (fix.fixedPath) {
|
|
563
|
-
fp = fix.fixedPath;
|
|
564
|
-
console.log(`${C.dim} ✓ auto-fixed path: ${args.path} → ${path.relative(CWD, fp)}${C.reset}`);
|
|
565
|
-
} else {
|
|
566
|
-
return `ERROR: File not found: ${args.path}${fix.message ? '\n' + fix.message : ''}`;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Binary file detection: check first 8KB for null bytes
|
|
571
|
-
const buf = Buffer.alloc(8192);
|
|
572
|
-
const fd = fs.openSync(fp, 'r');
|
|
573
|
-
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
|
574
|
-
fs.closeSync(fd);
|
|
575
|
-
for (let b = 0; b < bytesRead; b++) {
|
|
576
|
-
if (buf[b] === 0) return `ERROR: ${fp} is a binary file (not readable as text)`;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const content = fs.readFileSync(fp, 'utf-8');
|
|
580
|
-
if (!content && fs.statSync(fp).size > 0) return `WARNING: ${fp} is empty or unreadable`;
|
|
581
|
-
const lines = content.split('\n');
|
|
582
|
-
const start = (args.line_start || 1) - 1;
|
|
583
|
-
const end = args.line_end || lines.length;
|
|
584
|
-
return lines
|
|
585
|
-
.slice(start, end)
|
|
586
|
-
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
587
|
-
.join('\n');
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
case 'write_file': {
|
|
591
|
-
ensureCheckpoint();
|
|
592
|
-
const fp = resolvePath(args.path);
|
|
593
|
-
if (!fp) return `ERROR: Access denied — path outside project: ${args.path}`;
|
|
594
|
-
const exists = fs.existsSync(fp);
|
|
595
|
-
let oldContent = null;
|
|
596
|
-
|
|
597
|
-
if (!options.autoConfirm) {
|
|
598
|
-
if (exists) {
|
|
599
|
-
oldContent = fs.readFileSync(fp, 'utf-8');
|
|
600
|
-
showClaudeDiff(fp, oldContent, args.content);
|
|
601
|
-
const ok = await confirmFileChange('Overwrite');
|
|
602
|
-
if (!ok) return 'CANCELLED: User declined to overwrite file.';
|
|
603
|
-
} else {
|
|
604
|
-
showClaudeNewFile(fp, args.content);
|
|
605
|
-
const ok = await confirmFileChange('Create');
|
|
606
|
-
if (!ok) return 'CANCELLED: User declined to create file.';
|
|
607
|
-
}
|
|
608
|
-
} else if (exists) {
|
|
609
|
-
oldContent = fs.readFileSync(fp, 'utf-8');
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
const dir = path.dirname(fp);
|
|
613
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
614
|
-
fs.writeFileSync(fp, args.content, 'utf-8');
|
|
615
|
-
recordChange('write_file', fp, oldContent, args.content);
|
|
616
|
-
return `Written: ${fp} (${args.content.length} chars)`;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
case 'edit_file': {
|
|
620
|
-
ensureCheckpoint();
|
|
621
|
-
let fp = resolvePath(args.path);
|
|
622
|
-
if (!fp) return `ERROR: Access denied — path outside project: ${args.path}`;
|
|
623
|
-
if (!fs.existsSync(fp)) {
|
|
624
|
-
// Auto-fix: try to find the file
|
|
625
|
-
const fix = autoFixPath(args.path);
|
|
626
|
-
if (fix.fixedPath) {
|
|
627
|
-
fp = fix.fixedPath;
|
|
628
|
-
console.log(`${C.dim} ✓ auto-fixed path: ${args.path} → ${path.relative(CWD, fp)}${C.reset}`);
|
|
629
|
-
} else {
|
|
630
|
-
return `ERROR: File not found: ${args.path}${fix.message ? '\n' + fix.message : ''}`;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
const content = fs.readFileSync(fp, 'utf-8');
|
|
634
|
-
|
|
635
|
-
let matchText = args.old_text;
|
|
636
|
-
let fuzzyMatched = false;
|
|
637
|
-
let autoFixed = false;
|
|
638
|
-
|
|
639
|
-
if (!content.includes(args.old_text)) {
|
|
640
|
-
// Try fuzzy whitespace-normalized match
|
|
641
|
-
const fuzzyResult = fuzzyFindText(content, args.old_text);
|
|
642
|
-
if (fuzzyResult) {
|
|
643
|
-
matchText = fuzzyResult;
|
|
644
|
-
fuzzyMatched = true;
|
|
645
|
-
console.log(`${C.dim} ✓ fuzzy whitespace match applied${C.reset}`);
|
|
646
|
-
} else {
|
|
647
|
-
// Try auto-fix: apply close matches automatically (≤5% distance)
|
|
648
|
-
const fix = autoFixEdit(content, args.old_text, args.new_text);
|
|
649
|
-
if (fix) {
|
|
650
|
-
if (!options.autoConfirm) {
|
|
651
|
-
showClaudeDiff(fp, content, fix.content);
|
|
652
|
-
const ok = await confirmFileChange(`Apply (auto-fix, line ${fix.line}, distance ${fix.distance})`);
|
|
653
|
-
if (!ok) return 'CANCELLED: User declined to apply edit.';
|
|
654
|
-
}
|
|
655
|
-
fs.writeFileSync(fp, fix.content, 'utf-8');
|
|
656
|
-
recordChange('edit_file', fp, content, fix.content);
|
|
657
|
-
const matchPreview = fix.matchText.length > 80
|
|
658
|
-
? fix.matchText.substring(0, 77) + '...'
|
|
659
|
-
: fix.matchText;
|
|
660
|
-
console.log(`${C.dim} ✓ auto-fixed edit: line ${fix.line}, distance ${fix.distance}${C.reset}`);
|
|
661
|
-
return `Edited: ${fp} (auto-fixed, line ${fix.line}, distance ${fix.distance}, matched: "${matchPreview}")`;
|
|
662
|
-
}
|
|
663
|
-
// Provide helpful error with most similar text
|
|
664
|
-
const similar = findMostSimilar(content, args.old_text);
|
|
665
|
-
if (similar) {
|
|
666
|
-
return `ERROR: old_text not found in ${fp}\nMost similar text (line ${similar.line}, distance ${similar.distance}):\n${similar.text}`;
|
|
667
|
-
}
|
|
668
|
-
return `ERROR: old_text not found in ${fp}`;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (!options.autoConfirm) {
|
|
673
|
-
const preview = content.split(matchText).join(args.new_text);
|
|
674
|
-
showClaudeDiff(fp, content, preview);
|
|
675
|
-
const label = fuzzyMatched ? 'Apply (fuzzy match)' : 'Apply';
|
|
676
|
-
const ok = await confirmFileChange(label);
|
|
677
|
-
if (!ok) return 'CANCELLED: User declined to apply edit.';
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Use split/join for literal replacement (no regex interpretation)
|
|
681
|
-
const updated = content.split(matchText).join(args.new_text);
|
|
682
|
-
fs.writeFileSync(fp, updated, 'utf-8');
|
|
683
|
-
recordChange('edit_file', fp, content, updated);
|
|
684
|
-
return fuzzyMatched ? `Edited: ${fp} (fuzzy match)` : `Edited: ${fp}`;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
case 'list_directory': {
|
|
688
|
-
let dp = resolvePath(args.path);
|
|
689
|
-
if (!dp) return `ERROR: Access denied — path outside project: ${args.path}`;
|
|
690
|
-
if (!fs.existsSync(dp)) {
|
|
691
|
-
// Auto-fix: normalize path
|
|
692
|
-
const normalized = args.path.replace(/\/+/g, '/').replace(/^~\//, `${require('os').homedir()}/`);
|
|
693
|
-
const np = resolvePath(normalized);
|
|
694
|
-
if (np && fs.existsSync(np)) {
|
|
695
|
-
dp = np;
|
|
696
|
-
} else {
|
|
697
|
-
return `ERROR: Directory not found: ${args.path}`;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
const depth = args.max_depth || 2;
|
|
701
|
-
let pattern = null;
|
|
702
|
-
if (args.pattern) {
|
|
703
|
-
try {
|
|
704
|
-
// Escape regex specials, convert glob * to .*
|
|
705
|
-
const safe = args.pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
706
|
-
pattern = new RegExp(`^${safe}$`);
|
|
707
|
-
} catch {
|
|
708
|
-
return `ERROR: Invalid pattern: ${args.pattern}`;
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
const result = [];
|
|
712
|
-
|
|
713
|
-
const walk = (dir, level, prefix) => {
|
|
714
|
-
if (level > depth) return;
|
|
715
|
-
let entries;
|
|
716
|
-
try {
|
|
717
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
718
|
-
} catch {
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
entries = entries.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules');
|
|
722
|
-
for (const entry of entries) {
|
|
723
|
-
if (pattern && !entry.isDirectory() && !pattern.test(entry.name)) continue;
|
|
724
|
-
const marker = entry.isDirectory() ? '/' : '';
|
|
725
|
-
result.push(`${prefix}${entry.name}${marker}`);
|
|
726
|
-
if (entry.isDirectory()) walk(path.join(dir, entry.name), level + 1, prefix + ' ');
|
|
727
|
-
}
|
|
728
|
-
};
|
|
729
|
-
|
|
730
|
-
walk(dp, 1, '');
|
|
731
|
-
return result.join('\n') || '(empty)';
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
case 'search_files': {
|
|
735
|
-
const dp = resolvePath(args.path);
|
|
736
|
-
if (!dp) return `ERROR: Access denied — path outside project: ${args.path}`;
|
|
737
|
-
const grepArgs = ['-rn'];
|
|
738
|
-
if (args.file_pattern) grepArgs.push(`--include=${args.file_pattern}`);
|
|
739
|
-
grepArgs.push(args.pattern, dp);
|
|
740
|
-
try {
|
|
741
|
-
const out = execFileSync('grep', grepArgs, {
|
|
742
|
-
cwd: CWD, timeout: 30000, encoding: 'utf-8', maxBuffer: 2 * 1024 * 1024,
|
|
743
|
-
});
|
|
744
|
-
const lines = out.split('\n').slice(0, 50).join('\n');
|
|
745
|
-
return lines || '(no matches)';
|
|
746
|
-
} catch {
|
|
747
|
-
return '(no matches)';
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
case 'glob': {
|
|
752
|
-
const GLOB_LIMIT = 200;
|
|
753
|
-
const basePath = args.path ? resolvePath(args.path) : CWD;
|
|
754
|
-
const pattern = args.pattern;
|
|
755
|
-
// Pure Node.js glob: convert glob pattern to regex and walk directory
|
|
756
|
-
const globToRegex = (g) => {
|
|
757
|
-
const escaped = g
|
|
758
|
-
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
759
|
-
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
760
|
-
.replace(/\*/g, '[^/]*')
|
|
761
|
-
.replace(/__DOUBLESTAR__/g, '.*')
|
|
762
|
-
.replace(/\?/g, '.');
|
|
763
|
-
return new RegExp(`^${escaped}$`);
|
|
764
|
-
};
|
|
765
|
-
const namePattern = pattern.replace(/\*\*\//g, '').replace(/\//g, '');
|
|
766
|
-
const nameRegex = globToRegex(namePattern);
|
|
767
|
-
const fullRegex = globToRegex(pattern);
|
|
768
|
-
const matches = [];
|
|
769
|
-
let truncated = false;
|
|
770
|
-
const walkGlob = (dir, rel) => {
|
|
771
|
-
if (matches.length >= GLOB_LIMIT) { truncated = true; return; }
|
|
772
|
-
let entries;
|
|
773
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
774
|
-
for (const e of entries) {
|
|
775
|
-
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
776
|
-
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
777
|
-
if (e.isDirectory()) {
|
|
778
|
-
walkGlob(path.join(dir, e.name), relPath);
|
|
779
|
-
} else if (fullRegex.test(relPath) || nameRegex.test(e.name)) {
|
|
780
|
-
matches.push(path.join(basePath, relPath));
|
|
781
|
-
}
|
|
782
|
-
if (matches.length >= GLOB_LIMIT) { truncated = true; return; }
|
|
783
|
-
}
|
|
784
|
-
};
|
|
785
|
-
walkGlob(basePath, '');
|
|
786
|
-
if (matches.length === 0) return '(no matches)';
|
|
787
|
-
const result = matches.join('\n');
|
|
788
|
-
return truncated ? `${result}\n\n⚠ Results truncated at ${GLOB_LIMIT}. Use a more specific pattern to narrow results.` : result;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
case 'grep': {
|
|
792
|
-
const searchPath = args.path ? resolvePath(args.path) : CWD;
|
|
793
|
-
const grepArgs2 = ['-rn', '-E']; // Extended regex (supports |, +, etc.)
|
|
794
|
-
if (args.ignore_case) grepArgs2.push('-i');
|
|
795
|
-
if (args.include) grepArgs2.push(`--include=${args.include}`);
|
|
796
|
-
grepArgs2.push('--exclude-dir=node_modules', '--exclude-dir=.git', '--exclude-dir=coverage');
|
|
797
|
-
grepArgs2.push(args.pattern, searchPath);
|
|
798
|
-
try {
|
|
799
|
-
const out = execFileSync('grep', grepArgs2, {
|
|
800
|
-
cwd: CWD, timeout: 30000, encoding: 'utf-8', maxBuffer: 2 * 1024 * 1024,
|
|
801
|
-
});
|
|
802
|
-
const lines = out.split('\n').slice(0, 100).join('\n');
|
|
803
|
-
return lines.trim() || '(no matches)';
|
|
804
|
-
} catch (e) {
|
|
805
|
-
// exit 1 = no matches (normal), exit 2 = regex error
|
|
806
|
-
if (e.status === 2) {
|
|
807
|
-
return `ERROR: Invalid regex pattern: ${args.pattern}`;
|
|
808
|
-
}
|
|
809
|
-
return '(no matches)';
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
case 'patch_file': {
|
|
814
|
-
ensureCheckpoint();
|
|
815
|
-
let fp = resolvePath(args.path);
|
|
816
|
-
if (!fp) return `ERROR: Access denied — path outside project: ${args.path}`;
|
|
817
|
-
if (!fs.existsSync(fp)) {
|
|
818
|
-
// Auto-fix: try to find the file
|
|
819
|
-
const fix = autoFixPath(args.path);
|
|
820
|
-
if (fix.fixedPath) {
|
|
821
|
-
fp = fix.fixedPath;
|
|
822
|
-
console.log(`${C.dim} ✓ auto-fixed path: ${args.path} → ${path.relative(CWD, fp)}${C.reset}`);
|
|
823
|
-
} else {
|
|
824
|
-
return `ERROR: File not found: ${args.path}${fix.message ? '\n' + fix.message : ''}`;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const patches = args.patches;
|
|
829
|
-
if (!Array.isArray(patches) || patches.length === 0) return 'ERROR: No patches provided';
|
|
830
|
-
|
|
831
|
-
let content = fs.readFileSync(fp, 'utf-8');
|
|
832
|
-
|
|
833
|
-
// Validate all patches first (exact → fuzzy → auto-fix → error)
|
|
834
|
-
const resolvedPatches = [];
|
|
835
|
-
let anyFuzzy = false;
|
|
836
|
-
let anyAutoFixed = false;
|
|
837
|
-
for (let i = 0; i < patches.length; i++) {
|
|
838
|
-
const { old_text, new_text } = patches[i];
|
|
839
|
-
if (content.includes(old_text)) {
|
|
840
|
-
resolvedPatches.push({ old_text, new_text });
|
|
841
|
-
} else {
|
|
842
|
-
const fuzzyResult = fuzzyFindText(content, old_text);
|
|
843
|
-
if (fuzzyResult) {
|
|
844
|
-
resolvedPatches.push({ old_text: fuzzyResult, new_text });
|
|
845
|
-
anyFuzzy = true;
|
|
846
|
-
} else {
|
|
847
|
-
// Auto-fix: try close match (≤5% distance)
|
|
848
|
-
const similar = findMostSimilar(content, old_text);
|
|
849
|
-
if (similar) {
|
|
850
|
-
const threshold = Math.max(3, Math.ceil(old_text.length * 0.05));
|
|
851
|
-
if (similar.distance <= threshold) {
|
|
852
|
-
resolvedPatches.push({ old_text: similar.text, new_text });
|
|
853
|
-
anyAutoFixed = true;
|
|
854
|
-
} else {
|
|
855
|
-
return `ERROR: Patch ${i + 1} old_text not found in ${fp}\nMost similar text (line ${similar.line}, distance ${similar.distance}):\n${similar.text}`;
|
|
856
|
-
}
|
|
857
|
-
} else {
|
|
858
|
-
return `ERROR: Patch ${i + 1} old_text not found in ${fp}`;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Apply to a copy first (atomic — validate all patches succeed before writing)
|
|
865
|
-
let preview = content;
|
|
866
|
-
for (const { old_text, new_text } of resolvedPatches) {
|
|
867
|
-
preview = preview.split(old_text).join(new_text);
|
|
868
|
-
}
|
|
869
|
-
if (!options.autoConfirm) {
|
|
870
|
-
showClaudeDiff(fp, content, preview);
|
|
871
|
-
const label = anyFuzzy ? 'Apply patches (fuzzy match)' : 'Apply patches';
|
|
872
|
-
const ok = await confirmFileChange(label);
|
|
873
|
-
if (!ok) return 'CANCELLED: User declined to apply patches.';
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Write the fully-validated preview (atomic — no partial application)
|
|
877
|
-
fs.writeFileSync(fp, preview, 'utf-8');
|
|
878
|
-
recordChange('patch_file', fp, content, preview);
|
|
879
|
-
const suffix = anyAutoFixed ? ' (auto-fixed)' : anyFuzzy ? ' (fuzzy match)' : '';
|
|
880
|
-
return `Patched: ${fp} (${patches.length} replacements)${suffix}`;
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
case 'web_fetch': {
|
|
884
|
-
const url = args.url;
|
|
885
|
-
const maxLen = args.max_length || 10000;
|
|
886
|
-
try {
|
|
887
|
-
const resp = await axios.get(url, {
|
|
888
|
-
timeout: 15000,
|
|
889
|
-
maxContentLength: 1048576,
|
|
890
|
-
responseType: 'text',
|
|
891
|
-
headers: { 'User-Agent': 'nex-code/0.2.0' },
|
|
892
|
-
});
|
|
893
|
-
const out = typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data);
|
|
894
|
-
// Strip HTML tags for cleaner output
|
|
895
|
-
const text = out.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
896
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
897
|
-
.replace(/<[^>]+>/g, ' ')
|
|
898
|
-
.replace(/\s+/g, ' ')
|
|
899
|
-
.trim();
|
|
900
|
-
return text.substring(0, maxLen) || '(empty response)';
|
|
901
|
-
} catch (e) {
|
|
902
|
-
return `ERROR: Failed to fetch ${url}: ${e.message}`;
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
case 'web_search': {
|
|
907
|
-
const maxResults = args.max_results || 5;
|
|
908
|
-
try {
|
|
909
|
-
const resp = await axios.get('https://html.duckduckgo.com/html/', {
|
|
910
|
-
params: { q: args.query },
|
|
911
|
-
timeout: 10000,
|
|
912
|
-
responseType: 'text',
|
|
913
|
-
headers: { 'User-Agent': 'nex-code/0.2.0' },
|
|
914
|
-
});
|
|
915
|
-
const out = resp.data;
|
|
916
|
-
// Parse results from DuckDuckGo HTML
|
|
917
|
-
const results = [];
|
|
918
|
-
const regex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
919
|
-
let match;
|
|
920
|
-
while ((match = regex.exec(out)) !== null && results.length < maxResults) {
|
|
921
|
-
const href = match[1].replace(/.*uddg=/, '').split('&')[0];
|
|
922
|
-
const title = match[2].replace(/<[^>]+>/g, '').trim();
|
|
923
|
-
try {
|
|
924
|
-
results.push({ title, url: decodeURIComponent(href) });
|
|
925
|
-
} catch {
|
|
926
|
-
results.push({ title, url: href });
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
if (results.length === 0) return '(no results)';
|
|
930
|
-
return results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join('\n\n');
|
|
931
|
-
} catch {
|
|
932
|
-
return 'ERROR: Web search failed';
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
case 'ask_user': {
|
|
937
|
-
const question = args.question;
|
|
938
|
-
return new Promise((resolve) => {
|
|
939
|
-
const rl = require('readline').createInterface({
|
|
940
|
-
input: process.stdin,
|
|
941
|
-
output: process.stdout,
|
|
942
|
-
});
|
|
943
|
-
console.log(`\n${C.cyan}${C.bold} ? ${question}${C.reset}`);
|
|
944
|
-
rl.question(`${C.cyan} > ${C.reset}`, (answer) => {
|
|
945
|
-
rl.close();
|
|
946
|
-
resolve(answer.trim() || '(no response)');
|
|
947
|
-
});
|
|
948
|
-
});
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
case 'git_status': {
|
|
952
|
-
if (!isGitRepo()) return 'ERROR: Not a git repository';
|
|
953
|
-
const branch = getCurrentBranch() || '(detached)';
|
|
954
|
-
const status = getStatus();
|
|
955
|
-
if (status.length === 0) return `Branch: ${branch}\nClean working tree (no changes)`;
|
|
956
|
-
const lines = [`Branch: ${branch}`, `Changed files (${status.length}):`];
|
|
957
|
-
for (const s of status) {
|
|
958
|
-
const label = s.status === 'M' ? 'modified' : s.status === 'A' ? 'added' : s.status === 'D' ? 'deleted' : s.status === '??' ? 'untracked' : s.status;
|
|
959
|
-
lines.push(` ${label}: ${s.file}`);
|
|
960
|
-
}
|
|
961
|
-
return lines.join('\n');
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
case 'git_diff': {
|
|
965
|
-
if (!isGitRepo()) return 'ERROR: Not a git repository';
|
|
966
|
-
let diff;
|
|
967
|
-
if (args.file) {
|
|
968
|
-
const gitArgs = ['diff'];
|
|
969
|
-
if (args.staged) gitArgs.push('--cached');
|
|
970
|
-
gitArgs.push('--', args.file);
|
|
971
|
-
try {
|
|
972
|
-
diff = execFileSync('git', gitArgs, { cwd: CWD, encoding: 'utf-8', timeout: 15000, stdio: 'pipe' }).trim();
|
|
973
|
-
} catch { diff = ''; }
|
|
974
|
-
} else {
|
|
975
|
-
diff = getDiff(!!args.staged);
|
|
976
|
-
}
|
|
977
|
-
return diff || '(no diff)';
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
case 'git_log': {
|
|
981
|
-
if (!isGitRepo()) return 'ERROR: Not a git repository';
|
|
982
|
-
const count = Math.min(args.count || 10, 50);
|
|
983
|
-
const gitLogArgs = ['log', '--oneline', `-${count}`];
|
|
984
|
-
if (args.file) gitLogArgs.push('--', args.file);
|
|
985
|
-
try {
|
|
986
|
-
const out = execFileSync('git', gitLogArgs, { cwd: CWD, encoding: 'utf-8', timeout: 15000, stdio: 'pipe' }).trim();
|
|
987
|
-
return out || '(no commits)';
|
|
988
|
-
} catch {
|
|
989
|
-
return '(no commits)';
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
case 'task_list': {
|
|
994
|
-
const { createTasks, updateTask, getTaskList, renderTaskList, hasActiveTasks } = require('./tasks');
|
|
995
|
-
const { getActiveTaskProgress } = require('./ui');
|
|
996
|
-
const liveDisplay = getActiveTaskProgress();
|
|
997
|
-
switch (args.action) {
|
|
998
|
-
case 'create': {
|
|
999
|
-
if (!args.name || !args.tasks) return 'ERROR: task_list create requires name and tasks';
|
|
1000
|
-
const created = createTasks(args.name, args.tasks);
|
|
1001
|
-
if (!liveDisplay) console.log('\n' + renderTaskList());
|
|
1002
|
-
return `Created task list "${args.name}" with ${created.length} tasks:\n` +
|
|
1003
|
-
created.map(t => ` ${t.id}: ${t.description}`).join('\n');
|
|
1004
|
-
}
|
|
1005
|
-
case 'update': {
|
|
1006
|
-
if (!args.task_id || !args.status) return 'ERROR: task_list update requires task_id and status';
|
|
1007
|
-
const updated = updateTask(args.task_id, args.status, args.result);
|
|
1008
|
-
if (!updated) return `ERROR: Task not found: ${args.task_id}`;
|
|
1009
|
-
if (!liveDisplay) console.log('\n' + renderTaskList());
|
|
1010
|
-
return `Updated ${args.task_id}: ${args.status}${args.result ? ' — ' + args.result : ''}`;
|
|
1011
|
-
}
|
|
1012
|
-
case 'get': {
|
|
1013
|
-
const list = getTaskList();
|
|
1014
|
-
if (list.tasks.length === 0) return 'No active tasks';
|
|
1015
|
-
if (!liveDisplay) console.log('\n' + renderTaskList());
|
|
1016
|
-
return JSON.stringify(list, null, 2);
|
|
1017
|
-
}
|
|
1018
|
-
default:
|
|
1019
|
-
return `ERROR: Unknown task_list action: ${args.action}. Use: create, update, get`;
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
case 'spawn_agents': {
|
|
1024
|
-
const { executeSpawnAgents } = require('./sub-agent');
|
|
1025
|
-
return executeSpawnAgents(args);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
default:
|
|
1029
|
-
return `ERROR: Unknown tool: ${name}`;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// ─── Spinner Wrapper ──────────────────────────────────────────
|
|
1034
|
-
async function executeTool(name, args, options = {}) {
|
|
1035
|
-
const spinnerText = options.silent ? null : getToolSpinnerText(name, args);
|
|
1036
|
-
if (!spinnerText) return _executeToolInner(name, args, options);
|
|
1037
|
-
|
|
1038
|
-
const spinner = new Spinner(spinnerText);
|
|
1039
|
-
spinner.start();
|
|
1040
|
-
try {
|
|
1041
|
-
const result = await _executeToolInner(name, args, options);
|
|
1042
|
-
spinner.stop();
|
|
1043
|
-
return result;
|
|
1044
|
-
} catch (err) {
|
|
1045
|
-
spinner.stop();
|
|
1046
|
-
throw err;
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
module.exports = { TOOL_DEFINITIONS, executeTool, resolvePath, autoFixPath, autoFixEdit, enrichBashError };
|