@blockrun/franklin 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +256 -0
- package/dist/agent/commands.d.ts +27 -0
- package/dist/agent/commands.js +659 -0
- package/dist/agent/compact.d.ts +31 -0
- package/dist/agent/compact.js +366 -0
- package/dist/agent/context.d.ts +11 -0
- package/dist/agent/context.js +184 -0
- package/dist/agent/error-classifier.d.ts +10 -0
- package/dist/agent/error-classifier.js +61 -0
- package/dist/agent/llm.d.ts +63 -0
- package/dist/agent/llm.js +448 -0
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +346 -0
- package/dist/agent/optimize.d.ts +53 -0
- package/dist/agent/optimize.js +262 -0
- package/dist/agent/permissions.d.ts +39 -0
- package/dist/agent/permissions.js +226 -0
- package/dist/agent/reduce.d.ts +49 -0
- package/dist/agent/reduce.js +317 -0
- package/dist/agent/streaming-executor.d.ts +36 -0
- package/dist/agent/streaming-executor.js +149 -0
- package/dist/agent/tokens.d.ts +53 -0
- package/dist/agent/tokens.js +185 -0
- package/dist/agent/types.d.ts +125 -0
- package/dist/agent/types.js +5 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +27 -0
- package/dist/commands/balance.d.ts +1 -0
- package/dist/commands/balance.js +40 -0
- package/dist/commands/config.d.ts +14 -0
- package/dist/commands/config.js +107 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +117 -0
- package/dist/commands/history.d.ts +5 -0
- package/dist/commands/history.js +31 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +92 -0
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/models.d.ts +1 -0
- package/dist/commands/models.js +56 -0
- package/dist/commands/plugin.d.ts +14 -0
- package/dist/commands/plugin.js +176 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +106 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +292 -0
- package/dist/commands/stats.d.ts +10 -0
- package/dist/commands/stats.js +94 -0
- package/dist/commands/uninit.d.ts +1 -0
- package/dist/commands/uninit.js +63 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +179 -0
- package/dist/mcp/client.d.ts +44 -0
- package/dist/mcp/client.js +147 -0
- package/dist/mcp/config.d.ts +20 -0
- package/dist/mcp/config.js +138 -0
- package/dist/plugin-sdk/channel.d.ts +100 -0
- package/dist/plugin-sdk/channel.js +10 -0
- package/dist/plugin-sdk/index.d.ts +14 -0
- package/dist/plugin-sdk/index.js +9 -0
- package/dist/plugin-sdk/plugin.d.ts +87 -0
- package/dist/plugin-sdk/plugin.js +7 -0
- package/dist/plugin-sdk/search.d.ts +13 -0
- package/dist/plugin-sdk/search.js +4 -0
- package/dist/plugin-sdk/tracker.d.ts +27 -0
- package/dist/plugin-sdk/tracker.js +5 -0
- package/dist/plugin-sdk/workflow.d.ts +126 -0
- package/dist/plugin-sdk/workflow.js +11 -0
- package/dist/plugins/registry.d.ts +33 -0
- package/dist/plugins/registry.js +155 -0
- package/dist/plugins/runner.d.ts +21 -0
- package/dist/plugins/runner.js +453 -0
- package/dist/plugins-bundled/social/index.d.ts +10 -0
- package/dist/plugins-bundled/social/index.js +363 -0
- package/dist/plugins-bundled/social/plugin.json +14 -0
- package/dist/plugins-bundled/social/prompts.d.ts +19 -0
- package/dist/plugins-bundled/social/prompts.js +67 -0
- package/dist/plugins-bundled/social/types.d.ts +58 -0
- package/dist/plugins-bundled/social/types.js +16 -0
- package/dist/pricing.d.ts +21 -0
- package/dist/pricing.js +91 -0
- package/dist/proxy/fallback.d.ts +38 -0
- package/dist/proxy/fallback.js +144 -0
- package/dist/proxy/server.d.ts +18 -0
- package/dist/proxy/server.js +576 -0
- package/dist/proxy/sse-translator.d.ts +29 -0
- package/dist/proxy/sse-translator.js +270 -0
- package/dist/router/index.d.ts +22 -0
- package/dist/router/index.js +269 -0
- package/dist/session/search.d.ts +33 -0
- package/dist/session/search.js +229 -0
- package/dist/session/storage.d.ts +48 -0
- package/dist/session/storage.js +173 -0
- package/dist/stats/insights.d.ts +55 -0
- package/dist/stats/insights.js +195 -0
- package/dist/stats/tracker.d.ts +54 -0
- package/dist/stats/tracker.js +165 -0
- package/dist/tools/askuser.d.ts +6 -0
- package/dist/tools/askuser.js +76 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +336 -0
- package/dist/tools/edit.d.ts +5 -0
- package/dist/tools/edit.js +148 -0
- package/dist/tools/glob.d.ts +5 -0
- package/dist/tools/glob.js +158 -0
- package/dist/tools/grep.d.ts +5 -0
- package/dist/tools/grep.js +194 -0
- package/dist/tools/imagegen.d.ts +6 -0
- package/dist/tools/imagegen.js +172 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.js +90 -0
- package/dist/tools/subagent.d.ts +5 -0
- package/dist/tools/subagent.js +116 -0
- package/dist/tools/task.d.ts +5 -0
- package/dist/tools/task.js +91 -0
- package/dist/tools/webfetch.d.ts +5 -0
- package/dist/tools/webfetch.js +166 -0
- package/dist/tools/websearch.d.ts +5 -0
- package/dist/tools/websearch.js +103 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +114 -0
- package/dist/ui/app.d.ts +26 -0
- package/dist/ui/app.js +545 -0
- package/dist/ui/model-picker.d.ts +14 -0
- package/dist/ui/model-picker.js +161 -0
- package/dist/ui/terminal.d.ts +35 -0
- package/dist/ui/terminal.js +337 -0
- package/dist/wallet/manager.d.ts +10 -0
- package/dist/wallet/manager.js +23 -0
- package/package.json +79 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit capability — targeted string replacement in files.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { partiallyReadFiles } from './read.js';
|
|
7
|
+
/**
|
|
8
|
+
* Normalize curly/smart quotes to straight quotes.
|
|
9
|
+
* Claude Code does this to handle API-sanitized strings and editor paste artifacts.
|
|
10
|
+
*/
|
|
11
|
+
function normalizeQuotes(str) {
|
|
12
|
+
return str
|
|
13
|
+
.replace(/[\u201C\u201D]/g, '"') // " " → "
|
|
14
|
+
.replace(/[\u2018\u2019]/g, "'"); // ' ' → '
|
|
15
|
+
}
|
|
16
|
+
async function execute(input, ctx) {
|
|
17
|
+
const { file_path: filePath, old_string: oldStr, new_string: newStr, replace_all: replaceAll } = input;
|
|
18
|
+
if (!filePath) {
|
|
19
|
+
return { output: 'Error: file_path is required', isError: true };
|
|
20
|
+
}
|
|
21
|
+
if (oldStr === undefined || oldStr === null) {
|
|
22
|
+
return { output: 'Error: old_string is required', isError: true };
|
|
23
|
+
}
|
|
24
|
+
if (newStr === undefined || newStr === null) {
|
|
25
|
+
return { output: 'Error: new_string is required', isError: true };
|
|
26
|
+
}
|
|
27
|
+
if (oldStr === newStr) {
|
|
28
|
+
return { output: 'Error: old_string and new_string are identical', isError: true };
|
|
29
|
+
}
|
|
30
|
+
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.workingDir, filePath);
|
|
31
|
+
// Warn if the file was only partially read — editing without full context risks mistakes
|
|
32
|
+
const isPartial = partiallyReadFiles.has(resolved);
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(resolved)) {
|
|
35
|
+
return { output: `Error: file not found: ${resolved}`, isError: true };
|
|
36
|
+
}
|
|
37
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
38
|
+
// Try exact match first, then quote-normalized fallback
|
|
39
|
+
let effectiveOldStr = oldStr;
|
|
40
|
+
if (!content.includes(oldStr)) {
|
|
41
|
+
const normalized = normalizeQuotes(oldStr);
|
|
42
|
+
const contentNormalized = normalizeQuotes(content);
|
|
43
|
+
if (normalized !== oldStr && contentNormalized.includes(normalized)) {
|
|
44
|
+
// Find the original text in content that corresponds to the normalized match
|
|
45
|
+
const idx = contentNormalized.indexOf(normalized);
|
|
46
|
+
effectiveOldStr = content.slice(idx, idx + normalized.length);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!content.includes(effectiveOldStr)) {
|
|
50
|
+
// Find lines containing fragments of old_string for helpful context
|
|
51
|
+
const lines = content.split('\n');
|
|
52
|
+
const searchTerms = oldStr.split('\n').map(l => l.trim()).filter(l => l.length > 3);
|
|
53
|
+
const matchedLines = [];
|
|
54
|
+
if (searchTerms.length > 0) {
|
|
55
|
+
for (let i = 0; i < lines.length && matchedLines.length < 5; i++) {
|
|
56
|
+
if (searchTerms.some(term => lines[i].includes(term))) {
|
|
57
|
+
matchedLines.push({ num: i + 1, text: lines[i] });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let hint;
|
|
62
|
+
if (matchedLines.length > 0) {
|
|
63
|
+
const preview = matchedLines.map(m => `${m.num}\t${m.text}`).join('\n');
|
|
64
|
+
hint = `\n\nSimilar lines found:\n${preview}\n\nCheck for whitespace or formatting differences.`;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const preview = lines.slice(0, 10).map((l, i) => `${i + 1}\t${l}`).join('\n');
|
|
68
|
+
hint = `\n\nFirst 10 lines of file:\n${preview}`;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
output: `Error: old_string not found in ${resolved}.${hint}`,
|
|
72
|
+
isError: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
let updated;
|
|
76
|
+
let matchCount;
|
|
77
|
+
if (replaceAll) {
|
|
78
|
+
matchCount = content.split(effectiveOldStr).length - 1;
|
|
79
|
+
updated = content.split(effectiveOldStr).join(newStr);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const firstIdx = content.indexOf(effectiveOldStr);
|
|
83
|
+
const secondIdx = content.indexOf(effectiveOldStr, firstIdx + 1);
|
|
84
|
+
if (secondIdx !== -1) {
|
|
85
|
+
const positions = [];
|
|
86
|
+
let searchFrom = 0;
|
|
87
|
+
while (true) {
|
|
88
|
+
const idx = content.indexOf(effectiveOldStr, searchFrom);
|
|
89
|
+
if (idx === -1)
|
|
90
|
+
break;
|
|
91
|
+
const lineNum = content.slice(0, idx).split('\n').length;
|
|
92
|
+
positions.push(lineNum);
|
|
93
|
+
searchFrom = idx + 1;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
output: `Error: old_string matches ${positions.length} locations (lines: ${positions.join(', ')}). ` +
|
|
97
|
+
`Provide more context to make it unique, or use replace_all: true.`,
|
|
98
|
+
isError: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
matchCount = 1;
|
|
102
|
+
updated = content.slice(0, firstIdx) + newStr + content.slice(firstIdx + effectiveOldStr.length);
|
|
103
|
+
}
|
|
104
|
+
fs.writeFileSync(resolved, updated, 'utf-8');
|
|
105
|
+
// File has been modified — remove from partial-read tracking so next read is fresh
|
|
106
|
+
partiallyReadFiles.delete(resolved);
|
|
107
|
+
// Build a concise diff preview
|
|
108
|
+
const oldLines = effectiveOldStr.split('\n');
|
|
109
|
+
const newLines = newStr.split('\n');
|
|
110
|
+
let diffPreview = '';
|
|
111
|
+
if (oldLines.length <= 5 && newLines.length <= 5) {
|
|
112
|
+
const removed = oldLines.map(l => `- ${l}`).join('\n');
|
|
113
|
+
const added = newLines.map(l => `+ ${l}`).join('\n');
|
|
114
|
+
diffPreview = `\n${removed}\n${added}`;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
diffPreview = ` (${oldLines.length} lines → ${newLines.length} lines)`;
|
|
118
|
+
}
|
|
119
|
+
const partialWarning = isPartial
|
|
120
|
+
? '\nNote: file was only partially read before this edit.'
|
|
121
|
+
: '';
|
|
122
|
+
return {
|
|
123
|
+
output: `Updated ${resolved} — ${matchCount} replacement${matchCount > 1 ? 's' : ''} made.${diffPreview}${partialWarning}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
128
|
+
return { output: `Error editing file: ${msg}`, isError: true };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export const editCapability = {
|
|
132
|
+
spec: {
|
|
133
|
+
name: 'Edit',
|
|
134
|
+
description: 'Replace exact string in a file. old_string must be unique (or use replace_all).',
|
|
135
|
+
input_schema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
file_path: { type: 'string', description: 'Absolute path' },
|
|
139
|
+
old_string: { type: 'string', description: 'Text to find' },
|
|
140
|
+
new_string: { type: 'string', description: 'Replacement text' },
|
|
141
|
+
replace_all: { type: 'boolean', description: 'Replace all occurrences' },
|
|
142
|
+
},
|
|
143
|
+
required: ['file_path', 'old_string', 'new_string'],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
execute,
|
|
147
|
+
concurrent: false,
|
|
148
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob capability — file pattern matching using native fs.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const MAX_RESULTS = 200;
|
|
7
|
+
const MAX_OUTPUT_CHARS = 12_000; // ~3,000 tokens — prevents huge glob results from blowing up context
|
|
8
|
+
/**
|
|
9
|
+
* Simple glob matcher supporting *, **, and ? wildcards.
|
|
10
|
+
* No external dependencies.
|
|
11
|
+
*/
|
|
12
|
+
function globMatch(pattern, text) {
|
|
13
|
+
const regexStr = pattern
|
|
14
|
+
.replace(/\\/g, '/')
|
|
15
|
+
.split('**/')
|
|
16
|
+
.map(segment => segment
|
|
17
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
18
|
+
.replace(/\*/g, '[^/]*')
|
|
19
|
+
.replace(/\?/g, '[^/]'))
|
|
20
|
+
.join('(?:.*/)?');
|
|
21
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
22
|
+
return regex.test(text.replace(/\\/g, '/'));
|
|
23
|
+
}
|
|
24
|
+
function walkDirectory(dir, baseDir, pattern, results, depth, visited) {
|
|
25
|
+
if (depth > 50 || results.length >= MAX_RESULTS)
|
|
26
|
+
return;
|
|
27
|
+
// Symlink loop protection
|
|
28
|
+
const visitedSet = visited ?? new Set();
|
|
29
|
+
let realDir;
|
|
30
|
+
try {
|
|
31
|
+
realDir = fs.realpathSync(dir);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (visitedSet.has(realDir))
|
|
37
|
+
return;
|
|
38
|
+
visitedSet.add(realDir);
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return; // Permission denied or similar
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (results.length >= MAX_RESULTS)
|
|
48
|
+
break;
|
|
49
|
+
// Skip hidden dirs and common large dirs
|
|
50
|
+
const isDir = entry.isDirectory() || (entry.isSymbolicLink() && isSymlinkDir(path.join(dir, entry.name)));
|
|
51
|
+
if (entry.name.startsWith('.') && isDir)
|
|
52
|
+
continue;
|
|
53
|
+
if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git')
|
|
54
|
+
continue;
|
|
55
|
+
const fullPath = path.join(dir, entry.name);
|
|
56
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
57
|
+
if (entry.isFile() || (entry.isSymbolicLink() && !isDir)) {
|
|
58
|
+
if (globMatch(pattern, relativePath)) {
|
|
59
|
+
results.push(fullPath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (isDir) {
|
|
63
|
+
// Recurse for ** patterns; for patterns with /, only recurse if current dir is on the path
|
|
64
|
+
if (pattern.includes('**')) {
|
|
65
|
+
walkDirectory(fullPath, baseDir, pattern, results, depth + 1, visitedSet);
|
|
66
|
+
}
|
|
67
|
+
else if (pattern.includes('/')) {
|
|
68
|
+
// Check if this directory could be part of the pattern path
|
|
69
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
70
|
+
const patternDir = pattern.split('/').slice(0, -1).join('/');
|
|
71
|
+
if (patternDir.startsWith(relativePath) || relativePath.startsWith(patternDir)) {
|
|
72
|
+
walkDirectory(fullPath, baseDir, pattern, results, depth + 1, visitedSet);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isSymlinkDir(p) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.statSync(p).isDirectory();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function execute(input, ctx) {
|
|
87
|
+
const { pattern, path: searchPath } = input;
|
|
88
|
+
if (!pattern) {
|
|
89
|
+
return { output: 'Error: pattern is required', isError: true };
|
|
90
|
+
}
|
|
91
|
+
const baseDir = searchPath
|
|
92
|
+
? (path.isAbsolute(searchPath) ? searchPath : path.resolve(ctx.workingDir, searchPath))
|
|
93
|
+
: ctx.workingDir;
|
|
94
|
+
if (!fs.existsSync(baseDir)) {
|
|
95
|
+
return { output: `Error: directory not found: ${baseDir}`, isError: true };
|
|
96
|
+
}
|
|
97
|
+
const results = [];
|
|
98
|
+
walkDirectory(baseDir, baseDir, pattern, results, 0);
|
|
99
|
+
// Sort by modification time (most recent first)
|
|
100
|
+
const withMtime = results.map(f => {
|
|
101
|
+
try {
|
|
102
|
+
return { path: f, mtime: fs.statSync(f).mtimeMs };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { path: f, mtime: 0 };
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
109
|
+
// Convert to relative paths to save tokens (same as Claude Code)
|
|
110
|
+
const sorted = withMtime.map(f => {
|
|
111
|
+
const rel = path.relative(ctx.workingDir, f.path);
|
|
112
|
+
return rel.startsWith('..') ? f.path : rel;
|
|
113
|
+
});
|
|
114
|
+
if (sorted.length === 0) {
|
|
115
|
+
// Suggest recursive pattern if user used non-recursive glob
|
|
116
|
+
const hint = !pattern.includes('**') && !pattern.includes('/')
|
|
117
|
+
? ` Try "**/${pattern}" for recursive search.`
|
|
118
|
+
: '';
|
|
119
|
+
return { output: `No files matched pattern "${pattern}" in ${baseDir}.${hint}` };
|
|
120
|
+
}
|
|
121
|
+
let output = sorted.join('\n');
|
|
122
|
+
if (sorted.length >= MAX_RESULTS) {
|
|
123
|
+
output += `\n\n... (limited to ${MAX_RESULTS} results. Use a more specific pattern to narrow results.)`;
|
|
124
|
+
}
|
|
125
|
+
// Cap total output length to prevent context bloat
|
|
126
|
+
if (output.length > MAX_OUTPUT_CHARS) {
|
|
127
|
+
const lines = output.split('\n');
|
|
128
|
+
let trimmed = '';
|
|
129
|
+
let count = 0;
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
if ((trimmed + line).length > MAX_OUTPUT_CHARS)
|
|
132
|
+
break;
|
|
133
|
+
trimmed += (trimmed ? '\n' : '') + line;
|
|
134
|
+
count++;
|
|
135
|
+
}
|
|
136
|
+
const remaining = lines.length - count;
|
|
137
|
+
if (remaining > 0) {
|
|
138
|
+
output = `${trimmed}\n... (${remaining} more paths not shown — use a more specific pattern)`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { output };
|
|
142
|
+
}
|
|
143
|
+
export const globCapability = {
|
|
144
|
+
spec: {
|
|
145
|
+
name: 'Glob',
|
|
146
|
+
description: 'Find files by glob pattern (e.g. "**/*.ts", "src/**/*.tsx"). Returns up to 500 paths sorted by modification time. Skips node_modules, .git, hidden dirs.',
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
pattern: { type: 'string', description: 'Glob pattern to match files (e.g. "**/*.ts")' },
|
|
151
|
+
path: { type: 'string', description: 'Directory to search in. Defaults to working directory.' },
|
|
152
|
+
},
|
|
153
|
+
required: ['pattern'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
execute,
|
|
157
|
+
concurrent: true,
|
|
158
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grep capability — search file contents using ripgrep or native fallback.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const MAX_GREP_OUTPUT_CHARS = 16_000; // ~4,000 tokens — prevents huge grep results
|
|
8
|
+
let _hasRipgrep = null;
|
|
9
|
+
function hasRipgrep() {
|
|
10
|
+
if (_hasRipgrep !== null)
|
|
11
|
+
return _hasRipgrep;
|
|
12
|
+
try {
|
|
13
|
+
execSync('rg --version', { stdio: 'pipe' });
|
|
14
|
+
_hasRipgrep = true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
_hasRipgrep = false;
|
|
18
|
+
}
|
|
19
|
+
return _hasRipgrep;
|
|
20
|
+
}
|
|
21
|
+
async function execute(input, ctx) {
|
|
22
|
+
const opts = input;
|
|
23
|
+
if (!opts.pattern) {
|
|
24
|
+
return { output: 'Error: pattern is required', isError: true };
|
|
25
|
+
}
|
|
26
|
+
const searchPath = opts.path
|
|
27
|
+
? (path.isAbsolute(opts.path) ? opts.path : path.resolve(ctx.workingDir, opts.path))
|
|
28
|
+
: ctx.workingDir;
|
|
29
|
+
if (!fs.existsSync(searchPath)) {
|
|
30
|
+
return { output: `Error: path not found: ${searchPath}`, isError: true };
|
|
31
|
+
}
|
|
32
|
+
const mode = opts.output_mode || 'files_with_matches';
|
|
33
|
+
const limit = opts.head_limit ?? 250;
|
|
34
|
+
if (hasRipgrep()) {
|
|
35
|
+
return runRipgrep(opts, searchPath, mode, limit, ctx.workingDir);
|
|
36
|
+
}
|
|
37
|
+
return runNativeGrep(opts, searchPath, mode, limit, ctx.workingDir);
|
|
38
|
+
}
|
|
39
|
+
function toRelative(absPath, cwd) {
|
|
40
|
+
const rel = path.relative(cwd, absPath);
|
|
41
|
+
return rel.startsWith('..') ? absPath : rel;
|
|
42
|
+
}
|
|
43
|
+
function runRipgrep(opts, searchPath, mode, limit, cwd) {
|
|
44
|
+
const args = [];
|
|
45
|
+
// Limit line length to prevent base64/minified content from cluttering output
|
|
46
|
+
args.push('--max-columns', '500');
|
|
47
|
+
switch (mode) {
|
|
48
|
+
case 'files_with_matches':
|
|
49
|
+
args.push('-l');
|
|
50
|
+
break;
|
|
51
|
+
case 'count':
|
|
52
|
+
args.push('-c');
|
|
53
|
+
break;
|
|
54
|
+
case 'content':
|
|
55
|
+
args.push('-n');
|
|
56
|
+
if (opts.context && opts.context > 0) {
|
|
57
|
+
args.push(`-C${opts.context}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
if (opts.before_context && opts.before_context > 0)
|
|
61
|
+
args.push(`-B${opts.before_context}`);
|
|
62
|
+
if (opts.after_context && opts.after_context > 0)
|
|
63
|
+
args.push(`-A${opts.after_context}`);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (opts.case_insensitive)
|
|
68
|
+
args.push('-i');
|
|
69
|
+
if (opts.multiline)
|
|
70
|
+
args.push('-U', '--multiline-dotall');
|
|
71
|
+
if (opts.glob)
|
|
72
|
+
args.push(`--glob=${opts.glob}`);
|
|
73
|
+
// Always exclude common noise + lock files (huge, rarely useful)
|
|
74
|
+
args.push('--glob=!node_modules', '--glob=!.git', '--glob=!dist', '--glob=!*.lock', '--glob=!package-lock.json', '--glob=!pnpm-lock.yaml');
|
|
75
|
+
args.push('--', opts.pattern);
|
|
76
|
+
args.push(searchPath);
|
|
77
|
+
try {
|
|
78
|
+
const result = execFileSync('rg', args, {
|
|
79
|
+
encoding: 'utf-8',
|
|
80
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
81
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
82
|
+
});
|
|
83
|
+
const lines = result.split('\n').filter(Boolean);
|
|
84
|
+
const limited = limit > 0 ? lines.slice(0, limit) : lines;
|
|
85
|
+
// Convert absolute paths to relative paths to save tokens (same as Claude Code)
|
|
86
|
+
const relativized = limited.map(line => {
|
|
87
|
+
// Lines: /abs/path or /abs/path:rest (content mode)
|
|
88
|
+
const colonIdx = line.indexOf(':');
|
|
89
|
+
if (colonIdx > 0 && line.startsWith('/')) {
|
|
90
|
+
const filePart = line.slice(0, colonIdx);
|
|
91
|
+
return toRelative(filePart, cwd) + line.slice(colonIdx);
|
|
92
|
+
}
|
|
93
|
+
return line.startsWith('/') ? toRelative(line, cwd) : line;
|
|
94
|
+
});
|
|
95
|
+
let output = relativized.join('\n');
|
|
96
|
+
if (lines.length > limited.length) {
|
|
97
|
+
output += `\n\n... (${lines.length - limited.length} more results, use head_limit to see more)`;
|
|
98
|
+
}
|
|
99
|
+
// Cap total output to prevent context bloat
|
|
100
|
+
if (output.length > MAX_GREP_OUTPUT_CHARS) {
|
|
101
|
+
output = output.slice(0, MAX_GREP_OUTPUT_CHARS) + `\n... (output capped at ${MAX_GREP_OUTPUT_CHARS / 1000}KB — use more specific pattern or head_limit)`;
|
|
102
|
+
}
|
|
103
|
+
return { output: output || 'No matches found' };
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const exitErr = err;
|
|
107
|
+
if (exitErr.status === 1) {
|
|
108
|
+
return { output: 'No matches found' };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
output: `Grep error: ${exitErr.stderr || err.message}`,
|
|
112
|
+
isError: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function runNativeGrep(opts, searchPath, mode, limit, cwd) {
|
|
117
|
+
const args = ['-r', '-n'];
|
|
118
|
+
if (opts.case_insensitive)
|
|
119
|
+
args.push('-i');
|
|
120
|
+
switch (mode) {
|
|
121
|
+
case 'files_with_matches':
|
|
122
|
+
args.push('-l');
|
|
123
|
+
break;
|
|
124
|
+
case 'count':
|
|
125
|
+
args.push('-c');
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (opts.glob) {
|
|
129
|
+
// Native grep --include doesn't support ** or path separators
|
|
130
|
+
// Extract file extension pattern for best compatibility
|
|
131
|
+
const nativeGlob = opts.glob
|
|
132
|
+
.replace(/^\*\*\//, '') // Strip leading **/
|
|
133
|
+
.replace(/^.*\//, '') // Strip path prefix (src/ etc.)
|
|
134
|
+
.replace(/\*\*/, '*'); // Convert ** to * for flat matching
|
|
135
|
+
args.push(`--include=${nativeGlob}`);
|
|
136
|
+
}
|
|
137
|
+
args.push('--exclude-dir=node_modules', '--exclude-dir=.git', '--exclude-dir=dist', '--exclude=*.lock', '--exclude=package-lock.json', '--exclude=pnpm-lock.yaml');
|
|
138
|
+
args.push('-e', opts.pattern, searchPath);
|
|
139
|
+
try {
|
|
140
|
+
const result = execFileSync('grep', args, {
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
143
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
+
});
|
|
145
|
+
const lines = result.split('\n').filter(Boolean);
|
|
146
|
+
const limited = limit > 0 ? lines.slice(0, limit) : lines;
|
|
147
|
+
const relativized = limited.map(line => {
|
|
148
|
+
const colonIdx = line.indexOf(':');
|
|
149
|
+
if (colonIdx > 0 && line.startsWith('/')) {
|
|
150
|
+
return toRelative(line.slice(0, colonIdx), cwd) + line.slice(colonIdx);
|
|
151
|
+
}
|
|
152
|
+
return line.startsWith('/') ? toRelative(line, cwd) : line;
|
|
153
|
+
});
|
|
154
|
+
let output = relativized.join('\n');
|
|
155
|
+
if (lines.length > limited.length) {
|
|
156
|
+
output += `\n\n... (${lines.length - limited.length} more results)`;
|
|
157
|
+
}
|
|
158
|
+
if (output.length > MAX_GREP_OUTPUT_CHARS) {
|
|
159
|
+
output = output.slice(0, MAX_GREP_OUTPUT_CHARS) + `\n... (output capped at ${MAX_GREP_OUTPUT_CHARS / 1000}KB)`;
|
|
160
|
+
}
|
|
161
|
+
return { output: output || 'No matches found' };
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const exitErr = err;
|
|
165
|
+
if (exitErr.status === 1) {
|
|
166
|
+
return { output: 'No matches found' };
|
|
167
|
+
}
|
|
168
|
+
return { output: `Grep error: ${err.message}`, isError: true };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export const grepCapability = {
|
|
172
|
+
spec: {
|
|
173
|
+
name: 'Grep',
|
|
174
|
+
description: 'Search file contents by regex. Default output: file paths. output_mode "content" returns matching lines. Skips node_modules/.git/dist.',
|
|
175
|
+
input_schema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
pattern: { type: 'string', description: 'Regex pattern' },
|
|
179
|
+
path: { type: 'string', description: 'File or dir to search (default: cwd)' },
|
|
180
|
+
glob: { type: 'string', description: 'File filter e.g. "*.ts"' },
|
|
181
|
+
output_mode: { type: 'string', description: '"content" | "files_with_matches" | "count". Default: files_with_matches' },
|
|
182
|
+
context: { type: 'number', description: 'Context lines around match' },
|
|
183
|
+
before_context: { type: 'number', description: 'Lines before match' },
|
|
184
|
+
after_context: { type: 'number', description: 'Lines after match' },
|
|
185
|
+
case_insensitive: { type: 'boolean' },
|
|
186
|
+
head_limit: { type: 'number', description: 'Max results (default 250)' },
|
|
187
|
+
multiline: { type: 'boolean', description: 'Match across lines' },
|
|
188
|
+
},
|
|
189
|
+
required: ['pattern'],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
execute,
|
|
193
|
+
concurrent: true,
|
|
194
|
+
};
|