From 9ed88646a860bb778c89792ed21cfe95e0503ec9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 8 Jan 2026 13:44:34 +0100 Subject: [PATCH] feat(coding-agent): add pluggable operations for remote tool execution Adds optional operations parameter to create*Tool functions enabling delegation to remote systems (SSH, containers, etc.): - ReadOperations: readFile, access, detectImageMimeType - WriteOperations: writeFile, mkdir - EditOperations: readFile, writeFile, access - BashOperations: exec (with streaming, signal, timeout) Add ssh.ts example demonstrating --ssh flag for remote execution. Built-in renderers used automatically for overrides without custom renderers. fixes #564 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/extensions.md | 33 ++ .../examples/extensions/README.md | 1 + .../coding-agent/examples/extensions/ssh.ts | 194 +++++++++++ packages/coding-agent/src/core/tools/bash.ts | 302 +++++++++++------- packages/coding-agent/src/core/tools/edit.ts | 37 ++- packages/coding-agent/src/core/tools/find.ts | 120 +++++-- packages/coding-agent/src/core/tools/grep.ts | 56 +++- packages/coding-agent/src/core/tools/index.ts | 20 +- packages/coding-agent/src/core/tools/ls.ts | 200 +++++++----- packages/coding-agent/src/core/tools/read.ts | 34 +- packages/coding-agent/src/core/tools/write.ts | 31 +- packages/coding-agent/src/index.ts | 14 + 13 files changed, 782 insertions(+), 264 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/ssh.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 20536cc1..9e804db4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added - `--no-tools` flag to disable all built-in tools, allowing extension-only tool setups ([#557](https://github.com/badlogic/pi-mono/pull/557) by [@cv](https://github.com/cv)) +- Pluggable operations for built-in tools enabling remote execution via SSH or other transports ([#564](https://github.com/badlogic/pi-mono/issues/564)). Interfaces: `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` +- `setActiveTools()` in ExtensionAPI for dynamic tool management +- Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult` +- `ssh.ts` example: remote tool execution via `--ssh user@host:/path` ### Fixed diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 94d321ed..bb494ce8 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -972,6 +972,39 @@ Built-in tool implementations: - [find.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails` - [ls.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails` +### Remote Execution + +Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.): + +```typescript +import { createReadTool, createBashTool, type ReadOperations } from "@mariozechner/pi-coding-agent"; + +// Create tool with custom operations +const remoteRead = createReadTool(cwd, { + operations: { + readFile: (path) => sshExec(remote, `cat ${path}`), + access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), + } +}); + +// Register, checking flag at execution time +pi.registerTool({ + ...remoteRead, + async execute(id, params, onUpdate, _ctx, signal) { + const ssh = getSshConfig(); + if (ssh) { + const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) }); + return tool.execute(id, params, signal, onUpdate); + } + return localRead.execute(id, params, signal, onUpdate); + }, +}); +``` + +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` + +See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. + ### Output Truncation **Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause: diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 7fdf2014..9f33f011 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -31,6 +31,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `hello.ts` | Minimal custom tool example | | `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions | | `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) | +| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations | | `subagent/` | Delegate tasks to specialized subagents with isolated context windows | ### Commands & UI diff --git a/packages/coding-agent/examples/extensions/ssh.ts b/packages/coding-agent/examples/extensions/ssh.ts new file mode 100644 index 00000000..d269443e --- /dev/null +++ b/packages/coding-agent/examples/extensions/ssh.ts @@ -0,0 +1,194 @@ +/** + * SSH Remote Execution Example + * + * Demonstrates delegating tool operations to a remote machine via SSH. + * When --ssh is provided, read/write/edit/bash run on the remote. + * + * Usage: + * pi -e ./ssh.ts --ssh user@host + * pi -e ./ssh.ts --ssh user@host:/remote/path + * + * Requirements: + * - SSH key-based auth (no password prompts) + * - bash on remote + */ + +import { spawn } from "node:child_process"; +import { basename } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + type BashOperations, + createBashTool, + createEditTool, + createReadTool, + createWriteTool, + type EditOperations, + type ReadOperations, + type WriteOperations, +} from "@mariozechner/pi-coding-agent"; + +function sshExec(remote: string, command: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("ssh", [remote, command], { stdio: ["ignore", "pipe", "pipe"] }); + const chunks: Buffer[] = []; + const errChunks: Buffer[] = []; + child.stdout.on("data", (data) => chunks.push(data)); + child.stderr.on("data", (data) => errChunks.push(data)); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`SSH failed (${code}): ${Buffer.concat(errChunks).toString()}`)); + } else { + resolve(Buffer.concat(chunks)); + } + }); + }); +} + +function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string): ReadOperations { + const toRemote = (p: string) => p.replace(localCwd, remoteCwd); + return { + readFile: (p) => sshExec(remote, `cat ${JSON.stringify(toRemote(p))}`), + access: (p) => sshExec(remote, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}), + detectImageMimeType: async (p) => { + try { + const r = await sshExec(remote, `file --mime-type -b ${JSON.stringify(toRemote(p))}`); + const m = r.toString().trim(); + return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m) ? m : null; + } catch { + return null; + } + }, + }; +} + +function createRemoteWriteOps(remote: string, remoteCwd: string, localCwd: string): WriteOperations { + const toRemote = (p: string) => p.replace(localCwd, remoteCwd); + return { + writeFile: async (p, content) => { + const b64 = Buffer.from(content).toString("base64"); + await sshExec(remote, `echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`); + }, + mkdir: (dir) => sshExec(remote, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}), + }; +} + +function createRemoteEditOps(remote: string, remoteCwd: string, localCwd: string): EditOperations { + const r = createRemoteReadOps(remote, remoteCwd, localCwd); + const w = createRemoteWriteOps(remote, remoteCwd, localCwd); + return { readFile: r.readFile, access: r.access, writeFile: w.writeFile }; +} + +function createRemoteBashOps(remote: string, remoteCwd: string, localCwd: string): BashOperations { + const toRemote = (p: string) => p.replace(localCwd, remoteCwd); + return { + exec: (command, cwd, { onData, signal, timeout }) => + new Promise((resolve, reject) => { + const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`; + const child = spawn("ssh", [remote, cmd], { stdio: ["ignore", "pipe", "pipe"] }); + let timedOut = false; + const timer = timeout + ? setTimeout(() => { + timedOut = true; + child.kill(); + }, timeout * 1000) + : undefined; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.on("error", (e) => { + if (timer) clearTimeout(timer); + reject(e); + }); + const onAbort = () => child.kill(); + signal?.addEventListener("abort", onAbort, { once: true }); + child.on("close", (code) => { + if (timer) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + if (signal?.aborted) reject(new Error("aborted")); + else if (timedOut) reject(new Error(`timeout:${timeout}`)); + else resolve({ exitCode: code }); + }); + }), + }; +} + +export default function (pi: ExtensionAPI) { + pi.registerFlag("ssh", { description: "SSH remote: user@host or user@host:/path", type: "string" }); + + const localCwd = process.cwd(); + const localRead = createReadTool(localCwd); + const localWrite = createWriteTool(localCwd); + const localEdit = createEditTool(localCwd); + const localBash = createBashTool(localCwd); + + const getSsh = () => { + const arg = pi.getFlag("ssh") as string | undefined; + if (!arg) return null; + const [remote, path] = arg.includes(":") ? arg.split(":") : [arg, `~/${basename(localCwd)}`]; + return { remote, remoteCwd: path }; + }; + + pi.registerTool({ + ...localRead, + async execute(id, params, onUpdate, _ctx, signal) { + const ssh = getSsh(); + if (ssh) { + const tool = createReadTool(localCwd, { + operations: createRemoteReadOps(ssh.remote, ssh.remoteCwd, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + } + return localRead.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localWrite, + async execute(id, params, onUpdate, _ctx, signal) { + const ssh = getSsh(); + if (ssh) { + const tool = createWriteTool(localCwd, { + operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + } + return localWrite.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localEdit, + async execute(id, params, onUpdate, _ctx, signal) { + const ssh = getSsh(); + if (ssh) { + const tool = createEditTool(localCwd, { + operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + } + return localEdit.execute(id, params, signal, onUpdate); + }, + }); + + pi.registerTool({ + ...localBash, + async execute(id, params, onUpdate, _ctx, signal) { + const ssh = getSsh(); + if (ssh) { + const tool = createBashTool(localCwd, { + operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd), + }); + return tool.execute(id, params, signal, onUpdate); + } + return localBash.execute(id, params, signal, onUpdate); + }, + }); + + pi.on("session_start", async (_event, ctx) => { + const ssh = getSsh(); + if (ssh) { + ctx.ui.setStatus("ssh", `SSH: ${ssh.remote}:${ssh.remoteCwd}`); + ctx.ui.notify(`SSH mode: ${ssh.remote}:${ssh.remoteCwd}`, "info"); + } + }); +} diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index a4da4c2b..6d2aa2e8 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -26,7 +26,120 @@ export interface BashToolDetails { fullOutputPath?: string; } -export function createBashTool(cwd: string): AgentTool { +/** + * Pluggable operations for the bash tool. + * Override these to delegate command execution to remote systems (e.g., SSH). + */ +export interface BashOperations { + /** + * Execute a command and stream output. + * @param command - The command to execute + * @param cwd - Working directory + * @param options - Execution options + * @returns Promise resolving to exit code (null if killed) + */ + exec: ( + command: string, + cwd: string, + options: { + onData: (data: Buffer) => void; + signal?: AbortSignal; + timeout?: number; + }, + ) => Promise<{ exitCode: number | null }>; +} + +/** + * Default bash operations using local shell + */ +const defaultBashOperations: BashOperations = { + exec: (command, cwd, { onData, signal, timeout }) => { + return new Promise((resolve, reject) => { + const { shell, args } = getShellConfig(); + + if (!existsSync(cwd)) { + reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); + return; + } + + const child = spawn(shell, [...args, command], { + cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let timedOut = false; + + // Set timeout if provided + let timeoutHandle: NodeJS.Timeout | undefined; + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) { + killProcessTree(child.pid); + } + }, timeout * 1000); + } + + // Stream stdout and stderr + if (child.stdout) { + child.stdout.on("data", onData); + } + if (child.stderr) { + child.stderr.on("data", onData); + } + + // Handle shell spawn errors + child.on("error", (err) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + reject(err); + }); + + // Handle abort signal - kill entire process tree + const onAbort = () => { + if (child.pid) { + killProcessTree(child.pid); + } + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + // Handle process exit + child.on("close", (code) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + + if (signal?.aborted) { + reject(new Error("aborted")); + return; + } + + if (timedOut) { + reject(new Error(`timeout:${timeout}`)); + return; + } + + resolve({ exitCode: code }); + }); + }); + }, +}; + +export interface BashToolOptions { + /** Custom operations for command execution. Default: local shell */ + operations?: BashOperations; +} + +export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { + const ops = options?.operations ?? defaultBashOperations; + return { name: "bash", label: "bash", @@ -39,18 +152,6 @@ export function createBashTool(cwd: string): AgentTool { onUpdate?, ) => { return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(); - - if (!existsSync(cwd)) { - throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); - } - - const child = spawn(shell, [...args, command], { - cwd, - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - // We'll stream to a temp file if output gets large let tempFilePath: string | undefined; let tempFileStream: ReturnType | undefined; @@ -62,17 +163,6 @@ export function createBashTool(cwd: string): AgentTool { // Keep more than we need so we have enough for truncation const maxChunksBytes = DEFAULT_MAX_BYTES * 2; - let timedOut = false; - - // Set timeout if provided - let timeoutHandle: NodeJS.Timeout | undefined; - if (timeout !== undefined && timeout > 0) { - timeoutHandle = setTimeout(() => { - timedOut = true; - onAbort(); - }, timeout * 1000); - } - const handleData = (data: Buffer) => { totalBytes += data.length; @@ -116,109 +206,75 @@ export function createBashTool(cwd: string): AgentTool { } }; - // Collect stdout and stderr together - if (child.stdout) { - child.stdout.on("data", handleData); - } - if (child.stderr) { - child.stderr.on("data", handleData); - } - - // Handle shell spawn errors to prevent session from crashing - child.on("error", (err) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - if (signal) { - signal.removeEventListener("abort", onAbort); - } - reject(err); - }); - - // Handle process exit - child.on("close", (code) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - // Close temp file stream - if (tempFileStream) { - tempFileStream.end(); - } - - // Combine all buffered chunks - const fullBuffer = Buffer.concat(chunks); - const fullOutput = fullBuffer.toString("utf-8"); - - if (signal?.aborted) { - let output = fullOutput; - if (output) output += "\n\n"; - output += "Command aborted"; - reject(new Error(output)); - return; - } - - if (timedOut) { - let output = fullOutput; - if (output) output += "\n\n"; - output += `Command timed out after ${timeout} seconds`; - reject(new Error(output)); - return; - } - - // Apply tail truncation - const truncation = truncateTail(fullOutput); - let outputText = truncation.content || "(no output)"; - - // Build details with truncation info - let details: BashToolDetails | undefined; - - if (truncation.truncated) { - details = { - truncation, - fullOutputPath: tempFilePath, - }; - - // Build actionable notice - const startLine = truncation.totalLines - truncation.outputLines + 1; - const endLine = truncation.totalLines; - - if (truncation.lastLinePartial) { - // Edge case: last line alone > 30KB - const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8")); - outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`; - } else if (truncation.truncatedBy === "lines") { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`; - } else { - outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`; + ops.exec(command, cwd, { onData: handleData, signal, timeout }) + .then(({ exitCode }) => { + // Close temp file stream + if (tempFileStream) { + tempFileStream.end(); } - } - if (code !== 0 && code !== null) { - outputText += `\n\nCommand exited with code ${code}`; - reject(new Error(outputText)); - } else { - resolve({ content: [{ type: "text", text: outputText }], details }); - } - }); + // Combine all buffered chunks + const fullBuffer = Buffer.concat(chunks); + const fullOutput = fullBuffer.toString("utf-8"); - // Handle abort signal - kill entire process tree - const onAbort = () => { - if (child.pid) { - killProcessTree(child.pid); - } - }; + // Apply tail truncation + const truncation = truncateTail(fullOutput); + let outputText = truncation.content || "(no output)"; - if (signal) { - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort, { once: true }); - } - } + // Build details with truncation info + let details: BashToolDetails | undefined; + + if (truncation.truncated) { + details = { + truncation, + fullOutputPath: tempFilePath, + }; + + // Build actionable notice + const startLine = truncation.totalLines - truncation.outputLines + 1; + const endLine = truncation.totalLines; + + if (truncation.lastLinePartial) { + // Edge case: last line alone > 30KB + const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8")); + outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`; + } else if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`; + } else { + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`; + } + } + + if (exitCode !== 0 && exitCode !== null) { + outputText += `\n\nCommand exited with code ${exitCode}`; + reject(new Error(outputText)); + } else { + resolve({ content: [{ type: "text", text: outputText }], details }); + } + }) + .catch((err: Error) => { + // Close temp file stream + if (tempFileStream) { + tempFileStream.end(); + } + + // Combine all buffered chunks for error output + const fullBuffer = Buffer.concat(chunks); + let output = fullBuffer.toString("utf-8"); + + if (err.message === "aborted") { + if (output) output += "\n\n"; + output += "Command aborted"; + reject(new Error(output)); + } else if (err.message.startsWith("timeout:")) { + const timeoutSecs = err.message.split(":")[1]; + if (output) output += "\n\n"; + output += `Command timed out after ${timeoutSecs} seconds`; + reject(new Error(output)); + } else { + reject(err); + } + }); }); }, }; diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 3360308c..0658117a 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -1,7 +1,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; -import { access, readFile, writeFile } from "fs/promises"; +import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; import { detectLineEnding, generateDiffString, normalizeToLF, restoreLineEndings, stripBom } from "./edit-diff.js"; import { resolveToCwd } from "./path-utils.js"; @@ -18,7 +18,33 @@ export interface EditToolDetails { firstChangedLine?: number; } -export function createEditTool(cwd: string): AgentTool { +/** + * Pluggable operations for the edit tool. + * Override these to delegate file editing to remote systems (e.g., SSH). + */ +export interface EditOperations { + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Check if file is readable and writable (throw if not) */ + access: (absolutePath: string) => Promise; +} + +const defaultEditOperations: EditOperations = { + readFile: (path) => fsReadFile(path), + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), + access: (path) => fsAccess(path, constants.R_OK | constants.W_OK), +}; + +export interface EditToolOptions { + /** Custom operations for file editing. Default: local filesystem */ + operations?: EditOperations; +} + +export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool { + const ops = options?.operations ?? defaultEditOperations; + return { name: "edit", label: "edit", @@ -59,7 +85,7 @@ export function createEditTool(cwd: string): AgentTool { try { // Check if file exists try { - await access(absolutePath, constants.R_OK | constants.W_OK); + await ops.access(absolutePath); } catch { if (signal) { signal.removeEventListener("abort", onAbort); @@ -74,7 +100,8 @@ export function createEditTool(cwd: string): AgentTool { } // Read the file - const rawContent = await readFile(absolutePath, "utf-8"); + const buffer = await ops.readFile(absolutePath); + const rawContent = buffer.toString("utf-8"); // Check if aborted after reading if (aborted) { @@ -144,7 +171,7 @@ export function createEditTool(cwd: string): AgentTool { } const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding); - await writeFile(absolutePath, finalContent, "utf-8"); + await ops.writeFile(absolutePath, finalContent); // Check if aborted after writing if (aborted) { diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 07c7694b..a0a78bce 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -23,7 +23,33 @@ export interface FindToolDetails { resultLimitReached?: number; } -export function createFindTool(cwd: string): AgentTool { +/** + * Pluggable operations for the find tool. + * Override these to delegate file search to remote systems (e.g., SSH). + */ +export interface FindOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Find files matching glob pattern. Returns relative paths. */ + glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise | string[]; +} + +const defaultFindOperations: FindOperations = { + exists: existsSync, + glob: (_pattern, _searchCwd, _options) => { + // This is a placeholder - actual fd execution happens in execute + return []; + }, +}; + +export interface FindToolOptions { + /** Custom operations for find. Default: local filesystem + fd */ + operations?: FindOperations; +} + +export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool { + const customOps = options?.operations; + return { name: "find", label: "find", @@ -45,26 +71,86 @@ export function createFindTool(cwd: string): AgentTool { (async () => { try { - // Ensure fd is available + const searchPath = resolveToCwd(searchDir || ".", cwd); + const effectiveLimit = limit ?? DEFAULT_LIMIT; + const ops = customOps ?? defaultFindOperations; + + // If custom operations provided with glob, use that + if (customOps?.glob) { + if (!(await ops.exists(searchPath))) { + reject(new Error(`Path not found: ${searchPath}`)); + return; + } + + const results = await ops.glob(pattern, searchPath, { + ignore: ["**/node_modules/**", "**/.git/**"], + limit: effectiveLimit, + }); + + signal?.removeEventListener("abort", onAbort); + + if (results.length === 0) { + resolve({ + content: [{ type: "text", text: "No files found matching pattern" }], + details: undefined, + }); + return; + } + + // Relativize paths + const relativized = results.map((p) => { + if (p.startsWith(searchPath)) { + return p.slice(searchPath.length + 1); + } + return path.relative(searchPath, p); + }); + + const resultLimitReached = relativized.length >= effectiveLimit; + const rawOutput = relativized.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + + let resultOutput = truncation.content; + const details: FindToolDetails = {}; + const notices: string[] = []; + + if (resultLimitReached) { + notices.push(`${effectiveLimit} results limit reached`); + details.resultLimitReached = effectiveLimit; + } + + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }); + return; + } + + // Default: use fd const fdPath = await ensureTool("fd", true); if (!fdPath) { reject(new Error("fd is not available and could not be downloaded")); return; } - const searchPath = resolveToCwd(searchDir || ".", cwd); - const effectiveLimit = limit ?? DEFAULT_LIMIT; - // Build fd arguments const args: string[] = [ - "--glob", // Use glob pattern - "--color=never", // No ANSI colors - "--hidden", // Search hidden files (but still respect .gitignore) + "--glob", + "--color=never", + "--hidden", "--max-results", String(effectiveLimit), ]; - // Include .gitignore files (root + nested) so fd respects them even outside git repos + // Include .gitignore files const gitignoreFiles = new Set(); const rootGitignore = path.join(searchPath, ".gitignore"); if (existsSync(rootGitignore)) { @@ -89,13 +175,11 @@ export function createFindTool(cwd: string): AgentTool { args.push("--ignore-file", gitignorePath); } - // Pattern and path args.push(pattern, searchPath); - // Run fd const result = spawnSync(fdPath, args, { encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, // 10MB + maxBuffer: 10 * 1024 * 1024, }); signal?.removeEventListener("abort", onAbort); @@ -109,7 +193,6 @@ export function createFindTool(cwd: string): AgentTool { if (result.status !== 0) { const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`; - // fd returns non-zero for some errors but may still have partial output if (!output) { reject(new Error(errorMsg)); return; @@ -129,14 +212,12 @@ export function createFindTool(cwd: string): AgentTool { for (const rawLine of lines) { const line = rawLine.replace(/\r$/, "").trim(); - if (!line) { - continue; - } + if (!line) continue; const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\"); let relativePath = line; if (line.startsWith(searchPath)) { - relativePath = line.slice(searchPath.length + 1); // +1 for the / + relativePath = line.slice(searchPath.length + 1); } else { relativePath = path.relative(searchPath, line); } @@ -148,17 +229,12 @@ export function createFindTool(cwd: string): AgentTool { relativized.push(relativePath); } - // Check if we hit the result limit const resultLimitReached = relativized.length >= effectiveLimit; - - // Apply byte truncation (no line limit since we already have result limit) const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); let resultOutput = truncation.content; const details: FindToolDetails = {}; - - // Build notices const notices: string[] = []; if (resultLimitReached) { diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 5402bd83..b0844817 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -2,7 +2,7 @@ import { createInterface } from "node:readline"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { spawn } from "child_process"; -import { readFileSync, type Stats, statSync } from "fs"; +import { readFileSync, statSync } from "fs"; import path from "path"; import { ensureTool } from "../../utils/tools-manager.js"; import { resolveToCwd } from "./path-utils.js"; @@ -37,7 +37,30 @@ export interface GrepToolDetails { linesTruncated?: boolean; } -export function createGrepTool(cwd: string): AgentTool { +/** + * Pluggable operations for the grep tool. + * Override these to delegate search to remote systems (e.g., SSH). + */ +export interface GrepOperations { + /** Check if path is a directory. Throws if path doesn't exist. */ + isDirectory: (absolutePath: string) => Promise | boolean; + /** Read file contents for context lines */ + readFile: (absolutePath: string) => Promise | string; +} + +const defaultGrepOperations: GrepOperations = { + isDirectory: (p) => statSync(p).isDirectory(), + readFile: (p) => readFileSync(p, "utf-8"), +}; + +export interface GrepToolOptions { + /** Custom operations for grep. Default: local filesystem + ripgrep */ + operations?: GrepOperations; +} + +export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool { + const customOps = options?.operations; + return { name: "grep", label: "grep", @@ -87,15 +110,15 @@ export function createGrepTool(cwd: string): AgentTool { } const searchPath = resolveToCwd(searchDir || ".", cwd); - let searchStat: Stats; + const ops = customOps ?? defaultGrepOperations; + + let isDirectory: boolean; try { - searchStat = statSync(searchPath); + isDirectory = await ops.isDirectory(searchPath); } catch (_err) { settle(() => reject(new Error(`Path not found: ${searchPath}`))); return; } - - const isDirectory = searchStat.isDirectory(); const contextValue = context && context > 0 ? context : 0; const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); @@ -110,11 +133,11 @@ export function createGrepTool(cwd: string): AgentTool { }; const fileCache = new Map(); - const getFileLines = (filePath: string): string[] => { + const getFileLines = async (filePath: string): Promise => { let lines = fileCache.get(filePath); if (!lines) { try { - const content = readFileSync(filePath, "utf-8"); + const content = await ops.readFile(filePath); lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); } catch { lines = []; @@ -173,9 +196,9 @@ export function createGrepTool(cwd: string): AgentTool { stderr += chunk.toString(); }); - const formatBlock = (filePath: string, lineNumber: number): string[] => { + const formatBlock = async (filePath: string, lineNumber: number): Promise => { const relativePath = formatPath(filePath); - const lines = getFileLines(filePath); + const lines = await getFileLines(filePath); if (!lines.length) { return [`${relativePath}:${lineNumber}: (unable to read file)`]; } @@ -205,6 +228,9 @@ export function createGrepTool(cwd: string): AgentTool { return block; }; + // Collect matches during streaming, format after + const matches: Array<{ filePath: string; lineNumber: number }> = []; + rl.on("line", (line) => { if (!line.trim() || matchCount >= effectiveLimit) { return; @@ -223,7 +249,7 @@ export function createGrepTool(cwd: string): AgentTool { const lineNumber = event.data?.line_number; if (filePath && typeof lineNumber === "number") { - outputLines.push(...formatBlock(filePath, lineNumber)); + matches.push({ filePath, lineNumber }); } if (matchCount >= effectiveLimit) { @@ -238,7 +264,7 @@ export function createGrepTool(cwd: string): AgentTool { settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`))); }); - child.on("close", (code) => { + child.on("close", async (code) => { cleanup(); if (aborted) { @@ -259,6 +285,12 @@ export function createGrepTool(cwd: string): AgentTool { return; } + // Format matches (async to support remote file reading) + for (const match of matches) { + const block = await formatBlock(match.filePath, match.lineNumber); + outputLines.push(...block); + } + // Apply byte truncation (no line limit since we already have match limit) const rawOutput = outputLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index c617f8be..462d0410 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,9 +1,15 @@ -export { type BashToolDetails, bashTool, createBashTool } from "./bash.js"; -export { createEditTool, editTool } from "./edit.js"; -export { createFindTool, type FindToolDetails, findTool } from "./find.js"; -export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js"; -export { createLsTool, type LsToolDetails, lsTool } from "./ls.js"; -export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read.js"; +export { type BashOperations, type BashToolDetails, type BashToolOptions, bashTool, createBashTool } from "./bash.js"; +export { createEditTool, type EditOperations, type EditToolDetails, type EditToolOptions, editTool } from "./edit.js"; +export { createFindTool, type FindOperations, type FindToolDetails, type FindToolOptions, findTool } from "./find.js"; +export { createGrepTool, type GrepOperations, type GrepToolDetails, type GrepToolOptions, grepTool } from "./grep.js"; +export { createLsTool, type LsOperations, type LsToolDetails, type LsToolOptions, lsTool } from "./ls.js"; +export { + createReadTool, + type ReadOperations, + type ReadToolDetails, + type ReadToolOptions, + readTool, +} from "./read.js"; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, @@ -14,7 +20,7 @@ export { truncateLine, truncateTail, } from "./truncate.js"; -export { createWriteTool, writeTool } from "./write.js"; +export { createWriteTool, type WriteOperations, type WriteToolOptions, writeTool } from "./write.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { bashTool, createBashTool } from "./bash.js"; diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index ca27bfe4..abd9b208 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -17,7 +17,33 @@ export interface LsToolDetails { entryLimitReached?: number; } -export function createLsTool(cwd: string): AgentTool { +/** + * Pluggable operations for the ls tool. + * Override these to delegate directory listing to remote systems (e.g., SSH). + */ +export interface LsOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Get file/directory stats. Throws if not found. */ + stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean }; + /** Read directory entries */ + readdir: (absolutePath: string) => Promise | string[]; +} + +const defaultLsOperations: LsOperations = { + exists: existsSync, + stat: statSync, + readdir: readdirSync, +}; + +export interface LsToolOptions { + /** Custom operations for directory listing. Default: local filesystem */ + operations?: LsOperations; +} + +export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool { + const ops = options?.operations ?? defaultLsOperations; + return { name: "ls", label: "ls", @@ -37,100 +63,102 @@ export function createLsTool(cwd: string): AgentTool { const onAbort = () => reject(new Error("Operation aborted")); signal?.addEventListener("abort", onAbort, { once: true }); - try { - const dirPath = resolveToCwd(path || ".", cwd); - const effectiveLimit = limit ?? DEFAULT_LIMIT; - - // Check if path exists - if (!existsSync(dirPath)) { - reject(new Error(`Path not found: ${dirPath}`)); - return; - } - - // Check if path is a directory - const stat = statSync(dirPath); - if (!stat.isDirectory()) { - reject(new Error(`Not a directory: ${dirPath}`)); - return; - } - - // Read directory entries - let entries: string[]; + (async () => { try { - entries = readdirSync(dirPath); - } catch (e: any) { - reject(new Error(`Cannot read directory: ${e.message}`)); - return; - } + const dirPath = resolveToCwd(path || ".", cwd); + const effectiveLimit = limit ?? DEFAULT_LIMIT; - // Sort alphabetically (case-insensitive) - entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - - // Format entries with directory indicators - const results: string[] = []; - let entryLimitReached = false; - - for (const entry of entries) { - if (results.length >= effectiveLimit) { - entryLimitReached = true; - break; + // Check if path exists + if (!(await ops.exists(dirPath))) { + reject(new Error(`Path not found: ${dirPath}`)); + return; } - const fullPath = nodePath.join(dirPath, entry); - let suffix = ""; + // Check if path is a directory + const stat = await ops.stat(dirPath); + if (!stat.isDirectory()) { + reject(new Error(`Not a directory: ${dirPath}`)); + return; + } + // Read directory entries + let entries: string[]; try { - const entryStat = statSync(fullPath); - if (entryStat.isDirectory()) { - suffix = "/"; - } - } catch { - // Skip entries we can't stat - continue; + entries = await ops.readdir(dirPath); + } catch (e: any) { + reject(new Error(`Cannot read directory: ${e.message}`)); + return; } - results.push(entry + suffix); + // Sort alphabetically (case-insensitive) + entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + // Format entries with directory indicators + const results: string[] = []; + let entryLimitReached = false; + + for (const entry of entries) { + if (results.length >= effectiveLimit) { + entryLimitReached = true; + break; + } + + const fullPath = nodePath.join(dirPath, entry); + let suffix = ""; + + try { + const entryStat = await ops.stat(fullPath); + if (entryStat.isDirectory()) { + suffix = "/"; + } + } catch { + // Skip entries we can't stat + continue; + } + + results.push(entry + suffix); + } + + signal?.removeEventListener("abort", onAbort); + + if (results.length === 0) { + resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined }); + return; + } + + // Apply byte truncation (no line limit since we already have entry limit) + const rawOutput = results.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + + let output = truncation.content; + const details: LsToolDetails = {}; + + // Build notices + const notices: string[] = []; + + if (entryLimitReached) { + notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`); + details.entryLimitReached = effectiveLimit; + } + + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }); + } catch (e: any) { + signal?.removeEventListener("abort", onAbort); + reject(e); } - - signal?.removeEventListener("abort", onAbort); - - if (results.length === 0) { - resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined }); - return; - } - - // Apply byte truncation (no line limit since we already have entry limit) - const rawOutput = results.join("\n"); - const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); - - let output = truncation.content; - const details: LsToolDetails = {}; - - // Build notices - const notices: string[] = []; - - if (entryLimitReached) { - notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`); - details.entryLimitReached = effectiveLimit; - } - - if (truncation.truncated) { - notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); - details.truncation = truncation; - } - - if (notices.length > 0) { - output += `\n\n[${notices.join(". ")}]`; - } - - resolve({ - content: [{ type: "text", text: output }], - details: Object.keys(details).length > 0 ? details : undefined, - }); - } catch (e: any) { - signal?.removeEventListener("abort", onAbort); - reject(e); - } + })(); }); }, }; diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index e7ba44fb..dd0eb6fc 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -2,7 +2,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; -import { access, readFile } from "fs/promises"; +import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { resolveReadPath } from "./path-utils.js"; @@ -18,13 +18,36 @@ export interface ReadToolDetails { truncation?: TruncationResult; } +/** + * Pluggable operations for the read tool. + * Override these to delegate file reading to remote systems (e.g., SSH). + */ +export interface ReadOperations { + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; + /** Check if file is readable (throw if not) */ + access: (absolutePath: string) => Promise; + /** Detect image MIME type, return null/undefined for non-images */ + detectImageMimeType?: (absolutePath: string) => Promise; +} + +const defaultReadOperations: ReadOperations = { + readFile: (path) => fsReadFile(path), + access: (path) => fsAccess(path, constants.R_OK), + detectImageMimeType: detectSupportedImageMimeTypeFromFile, +}; + export interface ReadToolOptions { /** Whether to auto-resize images to 2000x2000 max. Default: true */ autoResizeImages?: boolean; + /** Custom operations for file reading. Default: local filesystem */ + operations?: ReadOperations; } export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { const autoResizeImages = options?.autoResizeImages ?? true; + const ops = options?.operations ?? defaultReadOperations; + return { name: "read", label: "read", @@ -61,14 +84,14 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo (async () => { try { // Check if file exists - await access(absolutePath, constants.R_OK); + await ops.access(absolutePath); // Check if aborted before reading if (aborted) { return; } - const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); + const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined; // Read the file based on type let content: (TextContent | ImageContent)[]; @@ -76,7 +99,7 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo if (mimeType) { // Read as image (binary) - const buffer = await readFile(absolutePath); + const buffer = await ops.readFile(absolutePath); const base64 = buffer.toString("base64"); if (autoResizeImages) { @@ -101,7 +124,8 @@ export function createReadTool(cwd: string, options?: ReadToolOptions): AgentToo } } else { // Read as text - const textContent = await readFile(absolutePath, "utf-8"); + const buffer = await ops.readFile(absolutePath); + const textContent = buffer.toString("utf-8"); const allLines = textContent.split("\n"); const totalFileLines = allLines.length; diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 02317b70..a9248ca1 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -1,6 +1,6 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import { mkdir, writeFile } from "fs/promises"; +import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; import { dirname } from "path"; import { resolveToCwd } from "./path-utils.js"; @@ -9,7 +9,30 @@ const writeSchema = Type.Object({ content: Type.String({ description: "Content to write to the file" }), }); -export function createWriteTool(cwd: string): AgentTool { +/** + * Pluggable operations for the write tool. + * Override these to delegate file writing to remote systems (e.g., SSH). + */ +export interface WriteOperations { + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Create directory (recursively) */ + mkdir: (dir: string) => Promise; +} + +const defaultWriteOperations: WriteOperations = { + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), + mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}), +}; + +export interface WriteToolOptions { + /** Custom operations for file writing. Default: local filesystem */ + operations?: WriteOperations; +} + +export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool { + const ops = options?.operations ?? defaultWriteOperations; + return { name: "write", label: "write", @@ -48,7 +71,7 @@ export function createWriteTool(cwd: string): AgentTool { (async () => { try { // Create parent directories if needed - await mkdir(dir, { recursive: true }); + await ops.mkdir(dir); // Check if aborted before writing if (aborted) { @@ -56,7 +79,7 @@ export function createWriteTool(cwd: string): AgentTool { } // Write the file - await writeFile(absolutePath, content, "utf-8"); + await ops.writeFile(absolutePath, content); // Check if aborted after writing if (aborted) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8923565e..a8b1c70d 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -176,19 +176,31 @@ export { } from "./core/skills.js"; // Tools export { + type BashOperations, type BashToolDetails, + type BashToolOptions, bashTool, codingTools, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, + type EditOperations, + type EditToolDetails, + type EditToolOptions, editTool, + type FindOperations, type FindToolDetails, + type FindToolOptions, findTool, formatSize, + type GrepOperations, type GrepToolDetails, + type GrepToolOptions, grepTool, + type LsOperations, type LsToolDetails, + type LsToolOptions, lsTool, + type ReadOperations, type ReadToolDetails, type ReadToolOptions, readTool, @@ -198,6 +210,8 @@ export { truncateHead, truncateLine, truncateTail, + type WriteOperations, + type WriteToolOptions, writeTool, } from "./core/tools/index.js"; // Main entry point