more tools
This commit is contained in:
1 parent
10d8737e74
commit
663449544e
1 file changed
+309
-26
+309
-26
@@ -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])
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user