more tools

This commit is contained in:
Shivering Ocelot committed 2026-10-09 17:14:58 -05:00
1 parent 10d8737e74
commit 663449544e
1 file changed
+309 -26
+309 -26
View File
@@ -13,6 +13,10 @@ import * as fs from 'fs';
import * as path from 'path';
const MAX_READ_BYTES = 10 * 1024;
const MAX_SEARCH_FILE_BYTES = 1024 * 1024;
/** Directories never walked by the seeking tools. */
const DEFAULT_IGNORES = new Set(['.git', 'node_modules']);
/* ------------------------------------------------------------------ *
* Permissions
@@ -46,6 +50,105 @@ function isAllowed(target, allowed) {
);
}
/* ------------------------------------------------------------------ *
* Helpers
* ------------------------------------------------------------------ */
/**
* Compile a glob into an anchored regular expression.
*
* `*` any run of characters except `/`
* `?` a single character except `/`
* `**` any run of characters, including `/` (also matches none as a directory)
*
* @param {string} glob
* @returns {RegExp}
*/
function globToRegExp(glob) {
let out = '';
for (let i = 0; i < glob.length; i++) {
const char = glob[i];
if (char === '*') {
if (glob[i + 1] === '*') {
out += '.*';
i++;
if (glob[i + 1] === '/') i++; // `**/x` matches `x`
} else {
out += '[^/]*';
}
} else if (char === '?') {
out += '[^/]';
} else {
out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
}
return new RegExp(`^${out}$`);
}
/**
* Collect the absolute path of every file beneath `dir`, pruning
* ignored names and anything outside the allow-list.
*
* @param {string} dir
* @param {?string[]} allowed
* @param {string[]} out
*/
function walkFiles(dir, allowed, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (DEFAULT_IGNORES.has(entry.name)) continue;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (isAllowed(abs, allowed)) walkFiles(abs, allowed, out);
} else if (entry.isFile()) {
out.push(abs);
}
}
}
/** Read a file as UTF-8, or `null` if binary, oversized or unreadable. */
function readText(abs) {
try {
const stat = fs.statSync(abs);
if (!stat.isFile() || stat.size > MAX_SEARCH_FILE_BYTES) return null;
const buf = fs.readFileSync(abs);
if (buf.includes(0)) return null; // crude binary sniff
return buf.toString('utf8');
} catch {
return null;
}
}
/**
* Apply one search/replace edit to `content`, throwing when the search
* does not occur. Shared by `UpdateFile` and `MultiEdit`.
*
* @param {string} content
* @param {{search: string, replace?: string, regex?: boolean, global?: boolean}} edit
* @returns {string}
*/
function applyEdit(content, { search, replace = '', regex, global }) {
if (search == null || search === '') throw new Error('search must not be empty');
if (regex) {
const re = new RegExp(search, global ? 'g' : '');
if (!re.test(content)) throw new Error(`no match for /${search}/`);
return content.replace(re, replace);
}
if (!content.includes(search)) {
throw new Error(`no match for ${JSON.stringify(search)}`);
}
if (global) return content.split(search).join(replace);
const at = content.indexOf(search);
return content.slice(0, at) + replace + content.slice(at + search.length);
}
/* ------------------------------------------------------------------ *
* Tool base class
* ------------------------------------------------------------------ */
@@ -87,7 +190,7 @@ export class Tool {
}
/* ------------------------------------------------------------------ *
* Default tools
* Seeking tools
* ------------------------------------------------------------------ */
class ReadFile extends Tool {
@@ -122,6 +225,159 @@ class ReadFile extends Tool {
}
}
class ListDir extends Tool {
static scope = 'read';
static description = 'List a directory. Directories are suffixed with "/".';
static parameters = {
type: 'object',
properties: {
path: { type: 'string' },
},
required: [],
};
run({ path: dir }) {
const abs = this.resolve(dir || '.', 'read');
return fs
.readdirSync(abs, { withFileTypes: true })
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
}
}
class FindFiles extends Tool {
static scope = 'read';
static description =
'Find files by glob pattern, relative to the root. `*` and `?` stay ' +
'within a path segment, `**` crosses them.';
static parameters = {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Glob the path (or file name) must match, e.g. "src/**/*.js".',
},
path: { type: 'string', description: 'Directory to search; defaults to the root.' },
limit: { type: 'number', description: 'Maximum paths to return (default 200).' },
},
required: ['pattern'],
};
run({ pattern, path: dir = '.', limit = 200 }) {
const abs = this.resolve(dir, 'read');
const match = globToRegExp(pattern);
// A bare pattern such as "*.js" is matched against the file name.
const byName = !pattern.includes('/');
const files = [];
walkFiles(abs, this.allow, files);
const hits = files
.map((file) => path.relative(this.root, file))
.filter((rel) => match.test(byName ? path.basename(rel) : rel))
.sort();
if (hits.length > limit) {
return [...hits.slice(0, limit), `… ${hits.length - limit} more`];
}
return hits;
}
}
class SearchFiles extends Tool {
static scope = 'read';
static description =
'Search file contents with a regular expression. Returns lines as ' +
'"path:line: text".';
static parameters = {
type: 'object',
properties: {
pattern: { type: 'string', description: 'JavaScript regular expression.' },
path: { type: 'string', description: 'Directory to search; defaults to the root.' },
glob: { type: 'string', description: 'Restrict to matching files (default "**/*").' },
ignoreCase: { type: 'boolean' },
context: { type: 'number', description: 'Lines of context around each match.' },
maxResults: { type: 'number', description: 'Maximum result lines (default 100).' },
output: {
type: 'string',
enum: ['content', 'files', 'count'],
description: '"content" (default), "files" for names, "count" per file.',
},
},
required: ['pattern'],
};
run({
pattern,
path: dir = '.',
glob = '**/*',
ignoreCase,
context = 0,
maxResults = 100,
output = 'content',
}) {
const abs = this.resolve(dir, 'read');
let re;
try {
re = new RegExp(pattern, ignoreCase ? 'i' : '');
} catch (err) {
throw new Error(`invalid pattern: ${err.message}`);
}
const match = globToRegExp(glob);
const byName = !glob.includes('/');
const files = [];
walkFiles(abs, this.allow, files);
const selected = files
.map((file) => path.relative(this.root, file))
.filter((rel) => match.test(byName ? path.basename(rel) : rel))
.sort();
const results = [];
for (const rel of selected) {
if (results.length >= maxResults) break;
const text = readText(path.resolve(this.root, rel));
if (text === null) continue;
const lines = text.split('\n');
const hits = [];
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
if (hits.length === 0) continue;
if (output === 'files') {
results.push(rel);
continue;
}
if (output === 'count') {
results.push(`${rel}: ${hits.length}`);
continue;
}
const emit = new Set();
for (const i of hits) {
const from = Math.max(0, i - context);
const to = Math.min(lines.length - 1, i + context);
for (let j = from; j <= to; j++) emit.add(j);
}
for (const j of [...emit].sort((a, b) => a - b)) {
if (results.length >= maxResults) break;
results.push(`${rel}:${j + 1}: ${lines[j]}`);
}
}
if (results.length >= maxResults) results.push('… truncated');
return results.length > 0 ? results : ['no matches'];
}
}
/* ------------------------------------------------------------------ *
* Updating tools
* ------------------------------------------------------------------ */
class WriteFile extends Tool {
static scope = 'write';
static description = 'Write the entire content of a file.';
@@ -144,54 +400,73 @@ class WriteFile extends Tool {
class UpdateFile extends Tool {
static scope = 'write';
static description = 'Replace `search` with `replace` in a file.';
static description =
'Replace `search` with `replace` in a file. Fails when the search ' +
'does not occur.';
static parameters = {
type: 'object',
properties: {
path: { type: 'string' },
search: { type: 'string' },
replace: { type: 'string' },
global: { type: 'boolean' },
global: { type: 'boolean', description: 'Replace every occurrence.' },
regex: { type: 'boolean', description: 'Treat `search` as a regular expression.' },
},
required: ['path', 'search'],
};
run({ path: file, search, replace = '', global }) {
run({ path: file, search, replace = '', global, regex }) {
const abs = this.resolve(file, 'write');
const content = fs.readFileSync(abs, 'utf8');
let updated;
if (global) {
updated = content.split(search).join(replace);
} else {
const at = content.indexOf(search);
updated =
at < 0
? content
: content.slice(0, at) + replace + content.slice(at + search.length);
}
fs.writeFileSync(abs, updated);
fs.writeFileSync(abs, applyEdit(content, { search, replace, global, regex }));
return `updated ${file}`;
}
}
class ListDir extends Tool {
static scope = 'read';
static description = 'List a directory. Directories are suffixed with "/".';
class MultiEdit extends Tool {
static scope = 'write';
static description =
'Apply several search/replace edits to one file atomically: either ' +
'every edit matches and the file is written, or nothing changes.';
static parameters = {
type: 'object',
properties: {
path: { type: 'string' },
edits: {
type: 'array',
description: 'Edits applied in order, each seeing the previous result.',
items: {
type: 'object',
properties: {
search: { type: 'string' },
replace: { type: 'string' },
global: { type: 'boolean' },
regex: { type: 'boolean' },
},
required: [],
required: ['search'],
},
},
},
required: ['path', 'edits'],
};
run({ path: dir }) {
const abs = this.resolve(dir || '.', 'read');
run({ path: file, edits }) {
const abs = this.resolve(file, 'write');
const original = fs.readFileSync(abs, 'utf8');
return fs
.readdirSync(abs, { withFileTypes: true })
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
const list = Array.isArray(edits) ? edits : [];
let content = original;
list.forEach((edit, i) => {
try {
content = applyEdit(content, edit);
} catch (err) {
throw new Error(`edit ${i + 1} failed: ${err.message}`);
}
});
if (content === original) return `no changes to ${file}`;
fs.writeFileSync(abs, content);
return `updated ${file} (${list.length} edits)`;
}
}
@@ -201,7 +476,15 @@ class ListDir extends Tool {
/** Defaults, keyed by the name inferred from each class. */
const DEFAULT_TOOLS = Object.fromEntries(
[ReadFile, WriteFile, UpdateFile, ListDir].map((Cls) => [kebab(Cls.name), Cls])
[
ReadFile,
ListDir,
FindFiles,
SearchFiles,
WriteFile,
UpdateFile,
MultiEdit,
].map((Cls) => [kebab(Cls.name), Cls])
);
/**