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';
|
import * as path from 'path';
|
||||||
|
|
||||||
const MAX_READ_BYTES = 10 * 1024;
|
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
|
* 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
|
* Tool base class
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
@@ -87,7 +190,7 @@ export class Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
* Default tools
|
* Seeking tools
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
class ReadFile extends Tool {
|
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 {
|
class WriteFile extends Tool {
|
||||||
static scope = 'write';
|
static scope = 'write';
|
||||||
static description = 'Write the entire content of a file.';
|
static description = 'Write the entire content of a file.';
|
||||||
@@ -144,54 +400,73 @@ class WriteFile extends Tool {
|
|||||||
|
|
||||||
class UpdateFile extends Tool {
|
class UpdateFile extends Tool {
|
||||||
static scope = 'write';
|
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 = {
|
static parameters = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string' },
|
path: { type: 'string' },
|
||||||
search: { type: 'string' },
|
search: { type: 'string' },
|
||||||
replace: { 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'],
|
required: ['path', 'search'],
|
||||||
};
|
};
|
||||||
|
|
||||||
run({ path: file, search, replace = '', global }) {
|
run({ path: file, search, replace = '', global, regex }) {
|
||||||
const abs = this.resolve(file, 'write');
|
const abs = this.resolve(file, 'write');
|
||||||
const content = fs.readFileSync(abs, 'utf8');
|
const content = fs.readFileSync(abs, 'utf8');
|
||||||
|
fs.writeFileSync(abs, applyEdit(content, { search, replace, global, regex }));
|
||||||
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);
|
|
||||||
return `updated ${file}`;
|
return `updated ${file}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ListDir extends Tool {
|
class MultiEdit extends Tool {
|
||||||
static scope = 'read';
|
static scope = 'write';
|
||||||
static description = 'List a directory. Directories are suffixed with "/".';
|
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 = {
|
static parameters = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string' },
|
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 }) {
|
run({ path: file, edits }) {
|
||||||
const abs = this.resolve(dir || '.', 'read');
|
const abs = this.resolve(file, 'write');
|
||||||
|
const original = fs.readFileSync(abs, 'utf8');
|
||||||
|
|
||||||
return fs
|
const list = Array.isArray(edits) ? edits : [];
|
||||||
.readdirSync(abs, { withFileTypes: true })
|
let content = original;
|
||||||
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
|
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. */
|
/** Defaults, keyed by the name inferred from each class. */
|
||||||
const DEFAULT_TOOLS = Object.fromEntries(
|
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