Native file tools

Overview

mouaif ships built-in tools the model can call to read, list, search, create, and safely edit project files — read_file, list_files, search_files, write_file, and edit_file. They are wired through the same tool pipeline as shell, so the model sees them as ordinary function calls and the chat UI renders them as the same tool-call / tool-result cards. Every call is gated by the per-project authorization module and refuses any path that escapes the project root.

The tools cover the common "find the file, read the file, edit the file" loop without requiring an MCP server. They are not a replacement for shell (a model that wants to run a build, install a dep, or git diff still uses shell) and they are not a replacement for MCP (third-party tool ecosystems — Postgres, Playwright, GitHub — still come in via mcp__<server>__<tool>). They are the boring file primitives every agent needs.

Usage

Availability

Authorization modes

{
  "tools": {
    "file": {
      "enabled": true,
      "mode": "ask",
      "allowlist": ["^src/.*\\.js$", "^README\\.md$"]
    }
  }
}

The file tools

ToolPurposeRequired argsOptional args
read_fileRead a text file, or open an image (.png, .jpg, .jpeg, .gif, .webp, .bmp, .ico) and attach its pixels as an image.path (POSIX-relative)startLine, endLine (1-indexed inclusive) for slicing large files — text only
list_filesList text and image files under the project (image rows are marked (image)).—pattern (glob; a bare directory like src is treated as src/**, and matching is case-insensitive)
search_filesText search on the project search engine: ripgrep when a binary is available, a bounded JS walk when not. Honors .gitignore, skips binary files, searches extensionless files such as Dockerfile.query (ripgrep-style regex), include (optional file glob)path (scope to a directory or single file; ., ./, src, src/, src/file.js are accepted, and a misspelled or not-yet-created directory still searches its nearest existing ancestor)
write_fileCreate or overwrite a text file.path, content—
edit_fileReplace one unique block in an existing file. Line-ending differences are ignored, and formatter-only differences such as indentation, blank lines, line wrapping, and spaces around punctuation are tolerated. A block that matches on trimmed lines is also re-indented to the file's indentation depth so the replacement lands where a native edit would. A JSON-escaped block is unescaped and retried once before the match is declared missing. Changed code still fails, ambiguous matches return EMULTI_MATCH, and failed matches return ENO_MATCH with a line-numbered closest candidate.path (or file), oldText, newText—

Every tool:

{
  "fileReadMaxLines": 10000,
  "fileReadMaxImageBytes": 4194304,
  "fileListMaxEntries": 500,
  "fileSearchMaxMatches": 100,
  "fileSearchMaxBytes": 1048576,
  "fileWriteMaxBytes": 2097152
}

Result shape (sent back to the model)

The tool message the model sees is a small header followed by the body, so the model can read its own response cleanly:


<file body>

list_files prints the files as an indented tree (the default toolOutput.structure), so every shared path prefix is written once instead of on every row:

# Listing: src/**/*.js
# Count: 3

src/
  auth.js
  logout.js
  new.js

search_files nests each match under the same tree — the file's path segments once, then its line: text rows one level deeper:

# Search: function (login|logout)
# Matches: 2

src/
  auth.js
    1: export function login() {}
  logout.js
    1: export function logout() {}

The chat UI gets a richer object on the tool_result SSE event (full result, no header), so it can show the path and a one-line summary on the inline card. When a file-tool result reaches the UI as the plain-text header form above (subagent-nested results, tool-replay from the message store, or the model-facing content string), the frontend re-parses it back into the structured shape: it reads the # Count: / # Matches: (and # Skipped:) header lines and rebuilds the entries / matches arrays from the indented body, so the card's count and the collapsed "N files" / "N matches" summary are accurate and consistent with the object path. The parser also still reads the retired # dir/ grouped form, so cards replayed from transcripts written before the tree layout keep their counts.

The collapsed summary flags truncation instead of presenting a capped total as complete. A truncated list_files card reads 1000 files (capped at 1000) and a truncated search_files card reads 200 matches (capped at 200), matching the inline header the model already receives (# Count: N (capped at M) / # Matches: N (capped at M matches / B chars)). The text-reparse path extracts the same truncated + cap fields from those header lines, so subagent-nested results and message-store replays show the same annotation as the live SSE path.

Tool card previews

Expanding a file-tool card shows the payload that matters for that tool — read_file the file body (or, for a picture, the image itself), list_files the entries grouped by directory, search_files the matches, edit_file its unified diff, and write_file the content that was written.

The tool_result frame carries metadata only (relPath, chars, lines), so the write_file preview renders the call's content argument. The card resolves it from whichever side of the call/result pair it was built: