# File tools (/docs/coding-agent/file-tools)



`@pleach/coding-agent/tools` ships eight tool factories the coding-agent
loop drives. Each takes a `SandboxClient` (the 3-method contract from
`@pleach/coding-agent/sandbox`) and returns a `PluginToolDefinition`
from `@pleach/core` — the canonical `defineTool` shape.

The factories do NOT import `@pleach/sandbox` directly. They are
vendor-neutral by construction: anything they need flows through the
3-method `SandboxClient`, which any vendor adapter satisfies.

## What ships [#what-ships]

```ts
import {
  createReadFileTool,
  createWriteFileTool,
  createApplyDiffTool,
  createSearchFilesTool,
  createListFilesTool,
  createRunTestsTool,
  createGitCloneTool,
  createGitDiffTool,
} from "@pleach/coding-agent/tools";
```

Five read-side primitives + three exec-side primitives. Eight total.

| Tool           | Class | What it does                                                                            |
| -------------- | ----- | --------------------------------------------------------------------------------------- |
| `read_file`    | read  | Read a workspace file. Returns `{ content, bytes }`.                                    |
| `write_file`   | read  | Write a workspace file. Returns `{ bytesWritten }`.                                     |
| `apply_diff`   | read  | Apply a unified diff via POSIX `patch -p0`. Returns `{ hunksApplied, conflicts }`.      |
| `search_files` | read  | Pattern-match across the workspace. Uses `rg` when available, falls back to `grep -rn`. |
| `list_files`   | read  | Enumerate via `find` with `-printf '%y\t%s\t%p\n'`.                                     |
| `run_tests`    | exec  | Run a test command; parse jest / vitest / pytest / cargo / go test output.              |
| `git_clone`    | exec  | `git clone --depth 1 <url>` + resolve commit SHA + branch.                              |
| `git_diff`     | exec  | `git diff` + `--numstat`; returns raw diff text + per-file counts.                      |

The "read-side" labels mean the tool reads from or writes to the
sandbox workspace — they're still load-bearing for the agent's
behavior. The "exec-side" labels mean the tool drives non-trivial
process execution (long-running tests, git operations) and surfaces
exit codes / timing back to the LLM.

## Wiring against a sandbox client [#wiring-against-a-sandbox-client]

```ts
import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime";
import {
  createPooledSandboxProvider,
  createSandboxComposite,
} from "@pleach/coding-agent/sandbox";
import {
  createReadFileTool,
  createWriteFileTool,
  createRunTestsTool,
  createGitCloneTool,
} from "@pleach/coding-agent/tools";
import { createHttpStreamSandboxProvider } from "@pleach/sandbox";

const adapter = createHttpStreamSandboxProvider({
  baseURL: process.env.MODAL_SANDBOX_BASE_URL!,
  auth: async () => ({
    headers: { Authorization: `Bearer ${process.env.MODAL_TOKEN!}` },
  }),
});

const provider = createPooledSandboxProvider({ adapter });
const session = await provider.acquire();

const tools = [
  createReadFileTool(session.client),
  createWriteFileTool(session.client),
  createRunTestsTool(session.client),
  createGitCloneTool(session.client),
  // ...four more
];

const runtime = createCodingAgentRuntime({
  sandboxProvider: provider,
  // ...rest of config; runtime registers the tools
});
```

The tools close over `session.client` — when the session changes (a
fresh acquire after a release), reconstruct the tools.

## `read_file` [#read_file]

```ts
const readFile = createReadFileTool(session.client);
```

| Input                                             | Output                               |
| ------------------------------------------------- | ------------------------------------ |
| `{ path: string, encoding?: "utf8" \| "base64" }` | `{ content: string, bytes: number }` |

`bytes` is the byte length of the decoded payload — utf-8 byte length
for text; raw byte length of the binary payload for base64. The tool
computes byte counts locally (`Buffer.byteLength`) so the
`SandboxClient` contract doesn't need to surface byte counts (many
vendor adapters don't).

## `write_file` [#write_file]

```ts
const writeFile = createWriteFileTool(session.client);
```

| Input                                                              | Output                     |
| ------------------------------------------------------------------ | -------------------------- |
| `{ path: string, content: string, encoding?: "utf8" \| "base64" }` | `{ bytesWritten: number }` |

`bytesWritten` is computed locally via `Buffer.byteLength` *before* the
sandbox call — the public output shape stays stable even when the
vendor adapter returns `void`.

## `apply_diff` [#apply_diff]

```ts
const applyDiff = createApplyDiffTool(session.client);
```

| Input                            | Output                                          |
| -------------------------------- | ----------------------------------------------- |
| `{ path: string, diff: string }` | `{ hunksApplied: number, conflicts: string[] }` |

Wire protocol: writes the diff to `.pleach-apply-diff.patch` in the
workspace, then runs `patch -p0 <path> < .pleach-apply-diff.patch`.

`hunksApplied` is parsed from `patch` stdout (`Hunk #N succeeded` lines);
falls back to counting `@@ ... @@` headers in the input diff when
`patch` applied silently. `conflicts` is the array of `Hunk #N FAILED`
lines from stdout + stderr. Non-empty `conflicts` indicates a
partial-success — the LLM gets to decide whether to retry with a fresh
diff or surface the rejects.

## `search_files` [#search_files]

```ts
const searchFiles = createSearchFilesTool(session.client);
```

| Input                                                     | Output                           |
| --------------------------------------------------------- | -------------------------------- |
| `{ pattern: string, glob?: string, maxMatches?: number }` | `Array<{ path, line, snippet }>` |

Probes for `rg` (`command -v rg`) and uses it when available; falls
back to `grep -rn`. Both produce the canonical `path:line:content`
format the parser consumes.

`maxMatches` defaults to `100` to keep the LLM-visible payload
tractable. Raise it when the model needs a wider sweep, but expect
context pressure to climb proportionally. The shell pipeline uses
`| head -n N` to cap upstream — the parser doesn't have to.

Pattern is treated as a regex; escape metacharacters for literal
matching.

## `list_files` [#list_files]

```ts
const listFiles = createListFilesTool(session.client);
```

| Input                                                 | Output                                          |
| ----------------------------------------------------- | ----------------------------------------------- |
| `{ dir: string, recursive?: boolean, glob?: string }` | `Array<{ path, kind: "file" \| "dir", size? }>` |

Invokes `find <dir> [-maxdepth 1] [-name <glob>] -printf '%y\t%s\t%p\n'`.

`recursive: false` (default) limits to immediate children via
`-maxdepth 1`. `glob` matches the basename only — consumers wanting
full-path glob semantics should post-filter the result.

Symlinks and devices are dropped from the result so the LLM sees a
clean two-kind axis (`file` / `dir`).

## `run_tests` [#run_tests]

```ts
const runTests = createRunTestsTool(session.client);
```

| Input                                                  | Output                                                                 |
| ------------------------------------------------------ | ---------------------------------------------------------------------- |
| `{ command?: string, cwd?: string, timeout?: number }` | `{ exitCode, stdout, stderr, durationMs, passed?, failed?, skipped? }` |

Default command is `npm test`. The output parser is a best-effort
heuristic across five runners:

* **jest / vitest** — `Tests: N failed, M skipped, K passed, T total`
* **pytest** — `===== 3 passed, 1 failed, 2 skipped in 0.05s =====`
* **cargo test** — `test result: ok. 5 passed; 0 failed; 1 ignored;`
* **go test verbose** — counts `--- PASS:` / `--- FAIL:` / `--- SKIP:`
  lines

The parser deliberately refuses to invent counts when no signature
matched. `passed` / `failed` / `skipped` are OMITTED rather than
reported as zero — by design. Downstream code should treat
`undefined` as "no signal" and not "no tests".

```ts
import { parseTestRunnerOutput } from "@pleach/coding-agent/tools";

// Exposed for callers who want to parse output without driving the tool.
const counts = parseTestRunnerOutput(stdout + "\n" + stderr);
```

## `git_clone` [#git_clone]

```ts
const gitClone = createGitCloneTool(session.client);
```

| Input                                                             | Output                        |
| ----------------------------------------------------------------- | ----------------------------- |
| `{ url: string, dest?: string, depth?: number, branch?: string }` | `{ commitSha, branch, path }` |

Defaults: destination = repo basename (strips `.git` suffix and
trailing slash); no `--depth` cap unless supplied. Clones, then runs
`git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` in the
cloned directory to surface the resolved SHA and branch.

The tool throws on any non-zero exit code — `git_clone` is a setup
step; the LLM sees an exception, not a `{ exitCode: 1, ... }` row.

## `git_diff` [#git_diff]

```ts
const gitDiff = createGitDiffTool(session.client);
```

| Input                                                 | Output                                                           |
| ----------------------------------------------------- | ---------------------------------------------------------------- |
| `{ cwd: string, paths?: string[], staged?: boolean }` | `{ diff: string, files: Array<{ path, additions, deletions }> }` |

Runs `git diff [--staged] [--numstat] [-- <paths>]` twice in parallel
— once for `--numstat` to get the per-file counts, once for the raw
diff text. Binary files surface as `additions: -1, deletions: -1`
(git emits `-` instead of numeric counts for binaries; the tool
preserves the signal as a negative sentinel rather than dropping the
file).

```ts
import { parseNumstat } from "@pleach/coding-agent/tools";

// Exposed for callers who want to parse `git diff --numstat` output
// without driving the tool.
const files = parseNumstat(numstatStdout);
```

## Output-shape stability [#output-shape-stability]

All eight tools return validated outputs through Zod schemas
([defineTool](/docs/tools)). Schema drift between an LLM-emitted
output and the declared `outputSchema` is caught at the tool boundary
— the runtime throws before downstream code sees the malformed shape.

When you wire your own variant of one of these tools (a custom
`read_file` with rate limiting, for instance), use the published
schema as the floor — match the input/output shapes and the LLM's
existing tool-use grammar continues to apply.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Sandbox providers" href="/docs/coding-agent/sandbox-providers" description="The provider + composite layer above SandboxClient." />

  <Card title="defineTool" href="/docs/tools" description="The canonical @pleach/core tool shape these factories return." />

  <Card title="Long session context" href="/docs/coding-agent/long-session" description="Roadmap — file-context budget management across long sessions." />

  <Card title="SWE-Bench recipe" href="/docs/coding-agent/swe-bench-recipe" description="Composing these tools into a SWE-Bench Lite evaluation." />
</Cards>
