src / fsops.ts
import * as fs from "node:fs";
import * as path from "node:path";
import { getWorkspace, resolveSafe, suggestFolders } from "./workspace";
export const MAX_READ_LINES = 400;
const DEFAULT_READ_LINES = 100;
const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".next", "build", "coverage"]);
const BIG_FILE = 2_000_000;
function relOf(abs: string): string {
return path.relative(getWorkspace(), abs).split(path.sep).join("/");
}
export function isBinary(buf: Buffer): boolean {
const n = Math.min(8192, buf.length);
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
return false;
}
/** List one directory level. Lines are "D name" or "F name", dirs first. */
export function listDir(relPath: string): string {
const abs = resolveSafe(relPath || ".");
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(abs, { withFileTypes: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
return `Folder not found: ${abs}. Check the exact spelling and case.${suggestFolders(abs)}`;
}
throw e;
}
const dirs: string[] = [];
const files: string[] = [];
for (const e of entries) {
if (e.isDirectory()) dirs.push(e.name);
else if (e.isFile()) files.push(e.name);
}
dirs.sort();
files.sort();
const lines = [...dirs.map((d) => "D " + d), ...files.map((f) => "F " + f)];
if (lines.length === 0) return "(empty directory)";
const CAP = 200;
if (lines.length > CAP) {
return lines.slice(0, CAP).join("\n") + `\n... (${lines.length - CAP} more)`;
}
return lines.join("\n");
}
/** Read a line range with 1-based numbering. */
export function readLines(relPath: string, startLine: number, maxLines: number): string {
const abs = resolveSafe(relPath);
const st = fs.statSync(abs);
if (st.size > BIG_FILE) {
return `Error: file too large (${(st.size / 1e6).toFixed(1)} MB). Use search to locate the part you need.`;
}
const buf = fs.readFileSync(abs);
if (isBinary(buf)) return "Error: binary file, not readable as text.";
let all = buf.toString("utf8").split(/\r?\n/);
if (all.length > 0 && all[all.length - 1] === "") all.pop(); // trailing newline
if (all.length === 0) return "(empty file)";
const start = Math.max(1, startLine);
if (start > all.length) return `Past end of file (${all.length} lines).`;
const take = Math.min(maxLines, all.length - start + 1);
const out = all.slice(start - 1, start - 1 + take).map((l, i) => `${start + i}\t${l}`).join("\n");
if (start + take > all.length) return out;
return `${out}\n... (more: read_file path="${relPath}" start=${start + take})`;
}
/** Create or overwrite a file, making parent folders as needed. */
export function writeFileSafe(relPath: string, content: string): string {
const abs = resolveSafe(relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, "utf8");
return `Wrote ${relOf(abs)} (${content.length} chars)`;
}
/** Replace an exact, unique text match. Empty replace deletes the match. */
export function editFileSafe(relPath: string, find: string, replace: string): string {
if (find === "") return "Error: find must not be empty.";
const abs = resolveSafe(relPath);
let text: string;
try {
text = fs.readFileSync(abs, "utf8");
} catch {
return `Error: file not found: ${relOf(abs)}`;
}
let count = 0;
let matchAt = -1;
for (let i = 0; (i = text.indexOf(find, i)) !== -1;) {
if (matchAt === -1) matchAt = i;
count++;
i += find.length;
}
if (count === 0) return `Not found in ${relPath}. Re-read the file to get the exact current text.`;
if (count > 1) {
return `Match is not unique (${count} places). Add surrounding context to find so it matches exactly once.`;
}
const updated = text.slice(0, matchAt) + replace + text.slice(matchAt + find.length);
fs.writeFileSync(abs, updated, "utf8");
return `Edited ${relOf(abs)}`;
}
/** Case-insensitive line search across the workspace (or a subfolder). */
export function searchFiles(pattern: string, baseRel: string): string {
const base = resolveSafe(baseRel || ".");
const needle = pattern.toLowerCase();
const results: string[] = [];
let filesScanned = 0;
const CAP = 100;
const walk = (dir: string, depth: number): void => {
if (depth > 40 || results.length >= CAP) return;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (results.length >= CAP) return;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name.toLowerCase())) walk(full, depth + 1);
continue;
}
if (!e.isFile()) continue;
let st: fs.Stats;
try {
st = fs.statSync(full);
} catch {
continue;
}
if (st.size > BIG_FILE) continue;
filesScanned++;
let buf: Buffer;
try {
buf = fs.readFileSync(full);
} catch {
continue;
}
if (isBinary(buf)) continue;
const lines = buf.toString("utf8").split(/\r?\n/);
for (let i = 0; i < lines.length && results.length < CAP; i++) {
if (lines[i].toLowerCase().includes(needle)) {
results.push(`${relOf(full)}:${i + 1}\t${lines[i]}`);
}
}
}
};
walk(base, 0);
if (results.length === 0) return `No matches for "${pattern}" in ${filesScanned} files.`;
const head = results.join("\n");
return `${head}\n(${results.length} match${results.length === 1 ? "" : "es"}, ${filesScanned} files scanned)`;
}
export { DEFAULT_READ_LINES };
src / fsops.ts
import * as fs from "node:fs";
import * as path from "node:path";
import { getWorkspace, resolveSafe, suggestFolders } from "./workspace";
export const MAX_READ_LINES = 400;
const DEFAULT_READ_LINES = 100;
const SKIP_DIRS = new Set([".git", "node_modules", "dist", ".next", "build", "coverage"]);
const BIG_FILE = 2_000_000;
function relOf(abs: string): string {
return path.relative(getWorkspace(), abs).split(path.sep).join("/");
}
export function isBinary(buf: Buffer): boolean {
const n = Math.min(8192, buf.length);
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
return false;
}
/** List one directory level. Lines are "D name" or "F name", dirs first. */
export function listDir(relPath: string): string {
const abs = resolveSafe(relPath || ".");
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(abs, { withFileTypes: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
return `Folder not found: ${abs}. Check the exact spelling and case.${suggestFolders(abs)}`;
}
throw e;
}
const dirs: string[] = [];
const files: string[] = [];
for (const e of entries) {
if (e.isDirectory()) dirs.push(e.name);
else if (e.isFile()) files.push(e.name);
}
dirs.sort();
files.sort();
const lines = [...dirs.map((d) => "D " + d), ...files.map((f) => "F " + f)];
if (lines.length === 0) return "(empty directory)";
const CAP = 200;
if (lines.length > CAP) {
return lines.slice(0, CAP).join("\n") + `\n... (${lines.length - CAP} more)`;
}
return lines.join("\n");
}
/** Read a line range with 1-based numbering. */
export function readLines(relPath: string, startLine: number, maxLines: number): string {
const abs = resolveSafe(relPath);
const st = fs.statSync(abs);
if (st.size > BIG_FILE) {
return `Error: file too large (${(st.size / 1e6).toFixed(1)} MB). Use search to locate the part you need.`;
}
const buf = fs.readFileSync(abs);
if (isBinary(buf)) return "Error: binary file, not readable as text.";
let all = buf.toString("utf8").split(/\r?\n/);
if (all.length > 0 && all[all.length - 1] === "") all.pop(); // trailing newline
if (all.length === 0) return "(empty file)";
const start = Math.max(1, startLine);
if (start > all.length) return `Past end of file (${all.length} lines).`;
const take = Math.min(maxLines, all.length - start + 1);
const out = all.slice(start - 1, start - 1 + take).map((l, i) => `${start + i}\t${l}`).join("\n");
if (start + take > all.length) return out;
return `${out}\n... (more: read_file path="${relPath}" start=${start + take})`;
}
/** Create or overwrite a file, making parent folders as needed. */
export function writeFileSafe(relPath: string, content: string): string {
const abs = resolveSafe(relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, "utf8");
return `Wrote ${relOf(abs)} (${content.length} chars)`;
}
/** Replace an exact, unique text match. Empty replace deletes the match. */
export function editFileSafe(relPath: string, find: string, replace: string): string {
if (find === "") return "Error: find must not be empty.";
const abs = resolveSafe(relPath);
let text: string;
try {
text = fs.readFileSync(abs, "utf8");
} catch {
return `Error: file not found: ${relOf(abs)}`;
}
let count = 0;
let matchAt = -1;
for (let i = 0; (i = text.indexOf(find, i)) !== -1;) {
if (matchAt === -1) matchAt = i;
count++;
i += find.length;
}
if (count === 0) return `Not found in ${relPath}. Re-read the file to get the exact current text.`;
if (count > 1) {
return `Match is not unique (${count} places). Add surrounding context to find so it matches exactly once.`;
}
const updated = text.slice(0, matchAt) + replace + text.slice(matchAt + find.length);
fs.writeFileSync(abs, updated, "utf8");
return `Edited ${relOf(abs)}`;
}
/** Case-insensitive line search across the workspace (or a subfolder). */
export function searchFiles(pattern: string, baseRel: string): string {
const base = resolveSafe(baseRel || ".");
const needle = pattern.toLowerCase();
const results: string[] = [];
let filesScanned = 0;
const CAP = 100;
const walk = (dir: string, depth: number): void => {
if (depth > 40 || results.length >= CAP) return;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (results.length >= CAP) return;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name.toLowerCase())) walk(full, depth + 1);
continue;
}
if (!e.isFile()) continue;
let st: fs.Stats;
try {
st = fs.statSync(full);
} catch {
continue;
}
if (st.size > BIG_FILE) continue;
filesScanned++;
let buf: Buffer;
try {
buf = fs.readFileSync(full);
} catch {
continue;
}
if (isBinary(buf)) continue;
const lines = buf.toString("utf8").split(/\r?\n/);
for (let i = 0; i < lines.length && results.length < CAP; i++) {
if (lines[i].toLowerCase().includes(needle)) {
results.push(`${relOf(full)}:${i + 1}\t${lines[i]}`);
}
}
}
};
walk(base, 0);
if (results.length === 0) return `No matches for "${pattern}" in ${filesScanned} files.`;
const head = results.join("\n");
return `${head}\n(${results.length} match${results.length === 1 ? "" : "es"}, ${filesScanned} files scanned)`;
}
export { DEFAULT_READ_LINES };