[Read in Portuguese / Leia em Português](README.pt-BR.md)
# atomwrite
- Atomic file operations for LLM agents: one CLI, all-or-nothing writes
[](https://docs.rs/atomwrite)
[](https://crates.io/crates/atomwrite)
[](https://crates.io/crates/atomwrite)
[](https://github.com/danilo-aguiar-br/atomwrite/blob/main/Cargo.toml)
[](https://crates.io/crates/atomwrite)
[](https://www.rust-lang.org)
- Agent-readable summary: [llms.txt](llms.txt) and [llms-full.txt](llms-full.txt)
- Jump to: [Installation](#installation), [Quick Start](#quick-start), [Global Flags](#global-flags), [Commands](#commands-47-total--ssot), [Configuration](#configuration), [Exit Codes](#exit-codes)
## What Is It
- A single Rust binary that handles every file operation an LLM agent needs
- 47 subcommands (SSOT: clap `Commands` / `agent-surface`), all listed with an example in [Commands](#commands-47-total--ssot)
- Every write is atomic: tempfile, fsync, rename, fsync directory
- Every response is NDJSON: one JSON object per line, machine-readable by default
- Every file gets a BLAKE3 checksum: detect drift, verify integrity, enable optimistic locking
## Installation
### From crates.io
```bash
cargo install atomwrite
```
### Slim and full builds
- Features: `core`, `ast`, `lang-rust`, `lang-ts`, `lang-py`, `lang-go`, `lang-full`, `watch`, `full` (default = `core` + `ast` + `lang-rust` + `lang-ts` + `lang-py`; `watch` is opt-in; `semantic-search` is always compiled)
- Slim build (core only, 26,560,352 bytes measured on 2026-11-04, DERIVADO 26560352 / 1048576 = 25.33 MiB, above the local size-gate of 15 MiB): `cargo install --path . --locked --force --no-default-features --features core`
- Full/default build (AST languages, 71,084,440 bytes measured on 2026-11-04, DERIVADO 71084440 / 1048576 = 67.79 MiB): `cargo install --path . --locked --force`
- Build with `watch`: `cargo install --path . --locked --force --features watch` (the default binary exits 78 on `watch` with a rebuild hint)
- Slim build: `outline` and `sparse outline` exit 78 with a rebuild hint (measured), and `query`, `scope`, `transform` and `write --syntax-check` have stubs for the same case (read from `src/commands/*_stub.rs` and `src/syntax_check_stub.rs`, the slim build was not executed to confirm)
- `cargo xtask check-features` checks the `core` and `core,watch` sets
- The features `i18n-cjk`, `i18n-rtl`, `i18n-europe`, `i18n-full` and `slow-tests` are placeholders or test switches: do not enable them
- The 5-8 MB target of the product plan applies to the core profile, not to the default AST build: the measured core build (25.33 MiB) is above that target and above the 15 MiB gate
### From source
```bash
git clone https://github.com/danilo-aguiar-br/atomwrite.git
cd atomwrite
cargo build --release
```
### Shell completions
```bash
# Bash
atomwrite completions bash > ~/.local/share/bash-completion/completions/atomwrite
# Zsh
atomwrite completions zsh > ~/.zfunc/_atomwrite
# Fish
atomwrite completions fish > ~/.config/fish/completions/atomwrite.fish
```
## Quick Start
```bash
# Write a file atomically from stdin
echo "hello world" | atomwrite --workspace . write src/hello.txt
# Read it back with checksum
atomwrite --workspace . read src/hello.txt
# Search across a directory
atomwrite --workspace . search 'hello' src/
# Replace text with atomic writes
atomwrite --workspace . replace 'hello' 'world' src/
# Evaluate math and unit conversions
atomwrite calc "2 hours + 30 minutes to seconds"
# Set a value in a TOML file (preserves comments)
atomwrite --workspace . set Cargo.toml package.version 0.2.0
# Walk the AST of a Rust file (downloads the parser on first use, see Network And Privacy)
atomwrite --workspace . query src/main.rs --kinds
# Extract the outline of a Python file
atomwrite --workspace . outline src/app.py
# Real tree-sitter syntax check before writing (exit 88 on a syntax error)
echo "fn broken(" | atomwrite --workspace . write --syntax-check src/x.rs
# Report stale WAL sidecars and estimate the reclaim
atomwrite --workspace . wal-stats
# Reap terminal journals older than the threshold
atomwrite --workspace . wal-heal . --threshold-secs 3600
```
## What Is New In v0.1.38 (2026-11-04)
- `read --format raw` stops at 24,000 bytes and prints one stderr line with the next `--lines`, because the Bash tool already cut the stream at 30,000 characters without saying so
- `--all` removes the cap, and the raw path streams with `BufReader` instead of holding the file in memory
- `--number` prints `N:text`, `--lines` is repeatable with `--range` as an alias, and several windows and several files arrive in one call with `==> path <==` headers
- `--symbol NAME` and `--line N --enclosing` select by syntax tree, and `--no-comments` drops comment and blank lines before the window is counted
- `search --format text` writes one `path:line:col:text` per match under a byte cap, and a tree where every file was binary exits `89 ALL_SKIPPED_BINARY` instead of `NO_MATCHES`
- `list --format text` writes one path per line, and `total_bytes` counts files only
- `outline --format tsv` writes `line<TAB>kind<TAB>name` with the path once, lists Markdown headings and setext sections, parses raw bytes, and publishes `parse_errors` with `first_error_line`
- `outline` reads a Markdown heading as a `section` that runs to the end of its own section, and `read --symbol Part` returns that whole section with its sub-sections
- `apply` matches whole lines, refuses a repeated SEARCH with exit `65 MATCH_AMBIGUOUS` unless `--all` is given, and refuses a block that lost `=======` or `>>>>>>> REPLACE` by naming its index
- `apply --show` returns the changed lines with their 1-based numbers, which removes the re-read that confirmed the swap
- `apply` runs the syntax check before and after the swap and refuses only when the parse-error count rises, with `--no-syntax-check` as the opt-out
- `apply --format unified` accepts `@@ ... @@` and a bare `@@`, so a hunk without numbers matches by context and more than one match is refused with the line of each one
- `diff` reports 1-based `line` plus `old_line` and `new_line`, and `--select` is honored on the terminal summary
- `delete` publishes `backups_kept`, `backup_bytes` and `dirs_left` when a backup or a leftover exists
- The failure envelope carries `ok`, `code` and `exit`, and `error: true` left it because it duplicated `ok: false` plus `code`, cost 13 bytes on every failure (179 to 166 bytes on the same path) and no caller branched on it alone
- The lock targets the inode with `std::fs::File::try_lock`, and `delete` takes the same lock before it hashes and unlinks
- `delete -r <dir>` owns the whole named tree, including hidden, ignored, linked, fifo, socket and device entries, and a link is never followed
- `target_resolved` is always an array in invocation order, because a path may contain the comma that used to join several targets
- `read --format raw` on a single target honors `--max-filesize` and exits 65 `FILE_TOO_LARGE` above the ceiling
- A file whose image signature is damaged fails `read` with exit 65 `INVALID_INPUT` instead of returning its bytes as text
- `output.profile = "lean"` drops the digests, the byte counts and the timing from a successful mutation record, which took one `apply` on a three-line file from 433 bytes to 196
- `config` lists 43 keys, and `atomwrite commands --brief` prints one line per subcommand in 5 020 bytes against 67 492 for the full tree
- Pin agents to `^0.1.38`. 47 subcommands (SSOT)
## Read Expansion In v0.1.37 (2026-10-31)
- `read` takes one or more paths: explicit paths are read in argv order, and an operand carrying `*`, `?` or `[` is a pattern the binary expands itself against one level of its own directory
- Pattern order is the `LC_ALL=C` shell order, taken from the encoded bytes of each name, so one code path sorts on all three targets, and only Linux was executed, while macOS and Windows stay unvalidated
- `--format raw` concatenates every target with no header, no separator and no newline of its own, so `atomwrite read --format raw '*.rs' | cmp - <(LC_ALL=C cat *.rs)` exits 0 with an empty diff
- The batch copies in 64 KiB blocks, so memory stays flat instead of holding the sum of every file, and `--timeout-secs` still cuts the copy with exit 124 because every block advances the cancel check
- A target that fails prints one line on stderr and the run continues with the next one, and the final status is the semantic code of the first failure: 4 for a missing path, 13 for a permission, 126 outside the `--workspace`
- `read --format raw` errors now go to stderr and never to stdout, because that channel promised bytes and a JSON envelope there was the defect
- `--lines`, `--line`, `--context`, `--head`, `--tail`, `--grep`, `--stat` and `--verify-checksum` need a single target: naming several exits 65 with a message that names the flag
- `**` is out of scope: a pattern with `**` or with more than one separator exits 65 instead of matching something else in silence
- The default NDJSON format is unchanged, and one target in `--format raw` is byte-identical to 0.1.37
- `[profile.dev] opt-level = 1` cut the dev-profile fuzzy cost from 18533 ms to 4232 ms on the same 15 MB corpus
- Pin agents to `^0.1.37`. 47 subcommands (SSOT)
## What Is New In v0.1.37 (2026-10-31)
- Risk assessment now runs in every verb that writes: `edit`, `edit-loop`, `replace`, `case`, `transform` and `codemod` publish `risk_assessment` when the payload matches a content-risk pattern, exactly as `write` already did. The size barriers stay out of these verbs on purpose, because they rewrite a region and a shorter result is the operation working
- A grammar this host cannot provide is exit 69 `GRAMMAR_UNAVAILABLE` with class `precondition_failed`, never 255 `INTERNAL_ERROR`. `atomwrite doctor` reports the new `grammar_cache` check with how many of the 23 grammars the AST verbs can need are already cached
- `write.content_risk_patterns` is reachable with `config get` and `config set` as a list value, so the risk patterns are XDG configuration instead of a compiled literal (43 keys)
- `log.file` and `log.rotation` are accepted in the workspace scope too, and the `[log]` section of `.atomwrite.toml` now starts the log sink instead of being validated and discarded
- `batch --transaction` publishes one verdict: the `rollback` event carries `ok: false` with the `code` and `exit` of the failure that caused it, and the transaction locks every target in path order before the snapshot
- `--version` no longer appends `-dirty`, so an untracked file cannot change the string
## What Is New In v0.1.37 (2026-10-15 draft)
- Full history lives in [CHANGELOG.md](CHANGELOG.md), the upgrade steps live in [docs/MIGRATION.md](docs/MIGRATION.md)
- New `config` verb (the 47th subcommand): `config path|list|get|set|unset|doctor` reads and writes 37 persistent tuning keys, see [Configuration](#configuration)
- New `extract-artifact` verb: bounded signal from PDF, OOXML and image headers, never raw bytes
- Global stream shaping: `--sort-by`, `--dedupe`, `--aggregate`, `--join`, `--diff-against`, `--redact`, `--normalize-encoding`, `--truncate-content`, `--max-output-bytes`, see [Global Flags](#global-flags)
- The terminal success event carries `ok: true` (the per-item events of a stream, such as `entry` and `match`, do not), and a failing run ends in exactly one terminal event with `ok: false`
- Verbs that write take an explicit path (exit 2 without one), and the terminal event carries `target_resolved` and `target_source: "argv"`
- Every terminal event also carries `workspace_source`: `argv` when the root came from `--workspace`, and `cwd` when it came from the process working directory
- `edit-loop` is all-or-nothing: a pair with no match exits 65 and writes nothing, while `--partial` writes the matches and exits 87; the checksum it reads under the lock is the write base, so a concurrent edit exits 82 `STATE_DRIFT`
- Atomic writes take an automatic advisory lock (waits up to 5 s, exit 83), and `search`, `write`, `edit` and the other stdin verbs obey SIGTERM, SIGHUP and the deadline (exit 143, 129, 124)
- `watch` left the default feature set, and `--version` prints `atomwrite 0.1.37 (<sha>) <target triple>` inside a tracked checkout and `atomwrite 0.1.37 (no-git) <target triple>` outside one
- Changes that can break a consumer:
- top-level JSON fields whose value was `null` are no longer emitted: test for an absent key, not for `null`
- `--max-output-bytes` now requires `--allow-truncate`, and each flag alone exits 2
- `semantic-merge` with a conflict exits 84 (`MERGE_CONFLICT`), and `apply` with a unified diff that does not match the file exits 65 and writes nothing
- an invalid regex or glob exits 65, not 255, and `get`, `set` and `del` on an invalid TOML or JSON file exit 65
- `case`, `transform`, `scope` and `codemod` can exit 82 (`STATE_DRIFT`) when a file changed during the run
- `sparse list` applies `--include` and `--exclude`, and `search.max_inflight_bytes` starts at 4 MiB
- the Rust library API changed, see [Library API](#library-api)
- Pin agents to `^0.1.37`. 47 subcommands (SSOT). CLI + XDG only (no product `ATOMWRITE_*` knobs; no product telemetry). Error envelopes always emit, with two deliberate exceptions: a broken pipe and a checksum divergence already published as one `hash_verify` event
## Why
- LLM agents juggle dozens of shell commands to manipulate files
- A crash mid-write can corrupt a file written in place
- Parsing unstructured CLI output wastes tokens and causes hallucinations
- Agents need checksums to detect concurrent edits but rarely compute them
- atomwrite solves all four problems with one `cargo install`
## Objectives
- Spend fewer tokens: one NDJSON object per call replaces the shell pipeline, and `commands --brief` answers in 5 020 bytes where the full tree costs 67 492
- Lower the financial cost, because the bill follows the tokens and the round trips, and one call replaces the several shell calls a file task needs
- Reduce errors with an atomic write and a BLAKE3 checksum on every response, so a stale or partial result is caught instead of assumed
- Reduce hallucinations with a `type` discriminator, a closed schema per verb and an exit code that names the failure
- Reduce the time spent on each task, because the read, the search and the write run in one process instead of a chain of shell tools
- Reduce latency with local execution and no network on the file path, measured at medians of 5.6 ms on `read` and 6.7 ms on `write` for small files
- Multiply the speed of operations with `batch`, `edit-loop`, `replace`, `case`, `transform` and `codemod` settling many files in one call
- Cut the lead time between the request and the verified result, because the response already carries the checksum, the `target_resolved` and the next step
- Make the agent faster, more effective and more assertive, since the exit code, the `code` and the `suggestion` say what to do next without a retry guess
## Superpowers
### Atomic Writes
- Uses tempfile + fsync + rename + directory fsync on every write
- A reader sees the old file or the new file, never a half-written one
- Durability is set by `write --durability full|fast|auto` (default `auto`); a power cut was not simulated in the tests
- Optional real syntax check via tree-sitter (`--syntax-check` flag on `write`, exit 88)
- Optional WAL sidecar for crash recovery (`.atomwrite.journal.<target>.atomwrite.journal.json`)
### NDJSON Output
- stdout is ALWAYS structured JSON, one object per line
- Every object carries a `"type"` discriminator field
- Agents parse output without regex or brittle text scraping
- Errors also emit JSON on stdout with `ok: false` plus `code`, `exit`, `message` and `suggestion`
### BLAKE3 Checksums
- Every `read` and `write` response includes a BLAKE3 hash
- Use `--expect-checksum` for optimistic locking on concurrent edits
- Detect state drift before applying changes
### Parallel Search
- Built on the ripgrep engine for file content search
- Respects `.gitignore` automatically
- Returns structured matches with file, line, column, and context
### AST-Aware Transforms
- `transform` and `codemod` rewrite by syntax tree through ast-grep 0.43, which supports 28 languages (measured: each of the 28 names is accepted, `zig` and `erlang` exit 65 `unsupported language`)
- `query`, `outline` and `--syntax-check` use `tree-sitter-language-pack` 1.22.1, whose manifest lists 372 languages (measured on 2026-10-31 against the manifest of that version)
- Automatic language detection by file extension covers 23 languages (`src/syntax_check.rs`); `--language NAME` accepts a parser that is already in the local cache
- Refactor code by syntax tree, not fragile regex
### Grammatical Scoping
- Select AST categories like comments, functions, classes, and strings
- Apply actions: delete, uppercase, lowercase, titlecase, squeeze, or replace
- Covers Rust, Python, JavaScript, TypeScript, and Go with prepared queries
- Use `--pattern` for custom AST patterns beyond the built-in queries
### Batch Operations
- Execute write, replace, delete, edit, hash, move, and copy operations from an NDJSON manifest
- Use `--transaction` for all-or-nothing execution with automatic rollback
- All operations in a batch share the same atomic guarantees
### Structured Config Editing
- `set` writes a value at a dotted path in TOML or JSON files (preserves comments via `toml_edit`)
- `get` reads a value at a dotted path with auto-detected format
- `del` removes a key (with `--force-missing` to treat missing keys as no-op success)
- `case` renames identifiers inside files via `heck` (snake, camel, pascal, kebab, screaming-snake)
## Usage
- All output goes to stdout as NDJSON
- Logs go to stderr: warnings and errors always, info and debug with `-v` / `-vv`
- Always pass `--workspace <DIR>` to restrict operations to a project root
- Use `--dry-run` before destructive operations
- Use `--expect-checksum <HASH>` for optimistic locking
- Use `--locale <TAG>` or `atomwrite locale --set` / `locale --clear` for UI locale (`en`, `pt-BR`); NDJSON codes stay English
- Configuration is CLI flags + XDG / `.atomwrite.toml` only, see [Configuration](#configuration)
- Pipe stdin for `write` and `batch` commands
- Agent-native stdout globals (all subcommands): `--select` / `--fields` (CSV top-level keys), `--filter KEY=VAL` (repeatable AND), `--limit N` (long-only: there is no global `-n`, and `replace` owns short `-n`)
- Error NDJSON always has `ok: false` and usually `match_count`: parse `exit` / `code` first; success shaping via `--filter`/`--limit` never drops error envelopes
## Global Flags
- 33 flags are accepted before or after the subcommand (source: `atomwrite commands --include-globals`); a hidden `--json` is also accepted and ignored because the output is always NDJSON
- The short help `-h` of a subcommand lists only that verb's own flags, and its footer line points to `atomwrite --help` for the 33 globals
- Aliases: `--max-concurrency` for `-j` / `--threads`, `--timeout` for `--timeout-secs`, `--fields` for `--select`
- Flags named like a global but local to one command (for example `search --max-filesize`, 10 MiB by default) are listed in `atomwrite <command> --help`
| Flag | Default | What it does |
|---|---|---|
| `-v`, `--verbose` | off | raises log verbosity on stderr (`-v` info, `-vv` debug, `-vvv` trace) |
| `-q`, `--quiet` | off | lowers it (`-q` error, `-qq` off) |
| `--workspace <DIR>` | current directory | root of the path jail, always pass it |
| `--config <PATH>` | none | explicit `.atomwrite.toml`, fails if missing or malformed; replaces both files in `config get` and `config list` |
| `--color <WHEN>` | `auto` | `auto`, `always` or `never` |
| `--no-color` | off | disables colored output |
| `--ready-file <PATH>` | none | writes the PID after the signal handlers are installed (test harness only) |
| `--no-gitignore` | off | does not respect `.gitignore` files |
| `--hidden` | off | includes hidden files and directories |
| `--follow-symlinks` | off | follows symbolic links |
| `-j`, `--threads <N>` | automatic | worker bound; omitted or `0` = the smaller of logical CPUs, 16 and the RAM cap; an explicit `N` is honoured, also above 16 |
| `--max-filesize <BYTES>` | 1 GiB | rejects a larger file (`read`, `edit`, `hash` and `write` exit 65 `FILE_TOO_LARGE`), and on `write` it also bounds the stdin payload, so the flag names one ceiling and the payload and the target obey the same one; only `search` skips silently, with its own local 10 MiB limit counted in `files_skipped` |
| `--timeout-secs <N>` | 120 | global deadline, `0` disables it, exit 124 with a `cancelled` event that has `deadline_exceeded: true` |
| `--no-progress` | off | disables NDJSON progress events on long operations |
| `--json-schema` | off | emits the JSON Schema of the command output and exits |
| `--print-schema [NAME]` | none | lists the 55 schema names, or emits one schema; an unknown name exits 65 |
| `--non-interactive` | off | declares agent-hosted execution: never prompts, never reads a confirmation from stdin |
| `--confirm-side-effect` | off | acknowledges a side effect where a command contract asks for it; on a verb that writes nothing it is rejected with exit 65 `INVALID_INPUT` instead of being a silent no-op |
| `--allow-truncate` | off | opts in to value truncation, required by `--max-output-bytes` |
| `--locale <TAG>` | resolved | `en` or `pt-BR`; precedence: this flag, the saved preference, the OS language, `en` |
| `--no-auto-heal` | off | skips the startup `wal-heal` pass (3600 s threshold, 100 ms budget) |
| `--select <KEYS>` | all keys | keeps only the listed top-level keys (CSV) |
| `--filter <KEY=VALUE>` | none | keeps events whose key equals the value (`!=` and `==` work; repeatable, AND); never drops an error envelope |
| `--limit <N>` | none | work budget: the target list is cut before the side effect and the walk stops at the record that reaches the cap, so `--limit 1` touches exactly one target and a huge tree is not read to the end; and the terminal summary carries `walker_stopped_early: true` only when the walk was actually cut before the tree ended |
| `--sort-by <KEY>` | arrival order | sorts events by `path-line`, `line-path` or any key (buffers until the end) |
| `--dedupe <KEYS>` | off | drops events whose key tuple (CSV) was already seen |
| `--aggregate <KEY[:OP[:FIELD]]>` | off | groups buffered events by `KEY`; `OP` is `count`, `sum`, `min` or `max` |
| `--join <KEY:SUFFIX>` | off | reads the NDJSON table from stdin once (up to 16 MiB) and attaches the matching rows as a `SUFFIX` array to every data event that has `KEY`; events without `KEY` or without a match, failure events and the summary stay as they were; runs before `--select`, `--filter` and `--limit` (with `--sort-by`, `--dedupe` or `--aggregate`: after the sort, before the dedupe); exits 65 `INVALID_INPUT` for a TTY stdin, a table above 16 MiB, a line that is not a JSON object, a bad format, a `KEY` with no row holding a string value, or a command that already reads stdin (`write`, `edit`, `edit-loop`, `apply`, `extract`, `batch` without `--file`, `hash --stdin`, `calc` or `regex` reading stdin, `diff` with `-`) |
| `--truncate-content <KEY:LEN>` | off | cuts one string key to `LEN` characters |
| `--redact` | off | replaces secret-shaped values with `[redacted]` |
| `--normalize-encoding` | off | normalizes every string value to Unicode NFC |
| `--diff-against <PATH>` | off | compares the events against a baseline NDJSON file inside the jail and emits one `stream_diff` event |
| `--max-output-bytes <N>` | off | cuts every string value, `checksum` included; needs `--allow-truncate` and each flag alone exits 2 |
```bash
# Stream shaping examples
atomwrite --workspace . --sort-by path-line search 'TODO' src/
atomwrite --workspace . --aggregate type:count list src/ --depth 1
atomwrite --workspace . --dedupe kind list src/ --depth 1
atomwrite --workspace . --truncate-content content:20 read src/main.rs
atomwrite --workspace . --redact read src/main.rs
atomwrite --workspace . --normalize-encoding read src/main.rs
atomwrite --workspace . list src/ --depth 1 > base.ndjson
atomwrite --workspace . --diff-against base.ndjson list src/ --depth 1
atomwrite --workspace . --max-output-bytes 16 --allow-truncate --select path,checksum read src/lib.rs
atomwrite --workspace . --non-interactive read src/lib.rs
printf '{"path":"src/lib.rs","owner":"alice"}\n' | atomwrite --workspace . --join path:meta list src/ --depth 1
```
## Commands (47 total — SSOT)
### Core I/O
- `read` — read files with metadata, checksum and a bounded body: `--format raw` or `ndjson`, `--head`, `--tail`, `--lines` and `--range` (repeatable), `--number`, `--symbol NAME`, `--line N --enclosing`, `--no-comments`, `--all`, and `--pages`, `--max-tokens`, `--crop` or `--ocr` for a PDF or a picture
- `write` — create or overwrite files atomically via stdin (large existing targets need `--ack-overwrite`)
- `edit` — surgically edit by line number, text marker, or exact match
- `edit-loop` — apply N `old`/`new` pairs from NDJSON stdin in one atomic write
- `delete` — delete files, copying one only when the call asks (`--backup` or `[defaults].backup = true`; `--plan` plan-only; `--confirm`/`--yes` rejected)
- `copy` — copy files with checksum verification
- `move` — move or rename files atomically (EXDEV copy-fallback)
- `apply` — apply patches from stdin (unified diff, search/replace, full, markdown); the target is optional so one call patches several files, `--show` returns the changed lines with their numbers, and `--no-syntax-check` opts out of the pre/post parse
- `scratch` — create agent staging dir under workspace (never OS `/tmp`)
- `verify` — verify file integrity by comparing BLAKE3 checksum
### Search and replace
- `search` — search file contents in parallel (ripgrep engine); `--format text` prints one `path:line:col:text` per match under a byte cap, `-e`/`--regex` repeats a pattern and `search.format` makes the text format the default
- `replace` — replace text across files with atomic writes
- `transform` — AST refactoring via ast-grep (28 languages)
- `codemod` — multi-rule codemod campaign (YAML rules, delegates to transform)
- `semantic-search` — offline token Jaccard search (no network, no embeddings API)
- `semantic-merge` — three-way line-based merge (not AST/embedding) for multi-agent writes
### Inspection
- `hash` — calculate BLAKE3 checksums
- `stat` — metadata alias for `read --stat` (size, mode, checksum; no body)
- `count` — count lines, matches, or files by extension
- `diff` — compare two files or a file against stdin (unified or stat), reporting a 1-based `line` plus the separate `old_line` and `new_line`
- `list` — list the project file structure with metadata (one NDJSON record per entry, or one path per line with `--format text`, where `total_bytes` counts files only)
- `sparse` — budgeted sparse list/read/outline (cap files and bytes for agent context)
- `extract` — extract fields from NDJSON input via pipe (positional fields or local `--field`; never local `extract --select`: global `--select` only projects output keys)
- `extract-artifact` — bounded signal from PDF, OOXML and image headers (never raw artifact bytes)
- `scope` — grammatical scoping (delete all comments, etc.)
- `regex` — generate regex from examples
- `calc` — math and unit conversions
- `doctor` — diagnose environment and dependencies
- `locale` — show resolved UI locale / persist preference (XDG)
- `commands` — emit full command tree as JSON for agent discovery, and `--brief` prints one line per subcommand
- `config` — persistent tuning keys: `config path|list|get|set|unset|doctor`, 43 keys, `--scope global|workspace` required on `set` and `unset` (and on `path`)
- `agent-surface` — agent tool surface manifest (integration by subprocess, CLI with NDJSON output)
- `watch` — watch filesystem events (needs a build with `--features watch`; the default binary exits 78)
- `completions` — generate shell completions (bash, zsh, fish, elvish, powershell)
### Backup and recovery
- `backup` — create timestamped backups with BLAKE3 checksums
- `rollback` — restore from a previous backup
- `prune-backups` — prune `.bak.YYYYMMDD_HHMMSS` backups by age or count
- `batch` — NDJSON-driven batch operations (transactional)
- `recipe` — named multi-step recipes (`list` / `run`)
### Structured config editors
- `set <PATH> <KEY_PATH> <VALUE>` — write a value at a dotted path in a TOML or JSON file, preserving comments and key order via `toml_edit`
- `get <PATH> <KEY_PATH>` — read a value at a dotted path, NDJSON `{"type":"get","key_path","value","found","format"}`
- `del <PATH> <KEY_PATH>` — remove a key, `--force-missing` treats a missing key as a no-op success
- `case <FILES...> --subvert OLD NEW --to <style>` — rename identifiers inside the given FILES via `heck` (styles: `snake`, `camel`, `pascal`, `kebab`, `screaming-snake`); a directory argument expands through the shared walker and honours `--include` and `--exclude`, and a directory that yields no file exits 65 instead of succeeding with nothing renamed
### AST tools (via tree-sitter-language-pack)
- `query <PATH> [--kinds|--query <KIND>|-Q <KIND>|--tree] [--positions]` — walk a tree-sitter AST and emit nodes as NDJSON
- `outline <PATH> [--kind <KIND>] [--positions] [--format tsv]` — extract high-level structure (functions, classes, structs, enums, traits, modules, Markdown headings) as NDJSON or as `line<TAB>kind<TAB>name`; needs a file, not a directory, and publishes `parse_errors` with `first_error_line`
- `write --syntax-check` — real syntax check via tree-sitter, exit 88 with first error line, column, kind and message
### WAL cleanup
- `wal-stats` — telemetry snapshot: total journals, by-state breakdown, oldest age, total size, by-directory breakdown, auto-heal recommendation, estimated reclaim bytes
- `wal-heal <PATH> [--threshold-secs N] [--max-duration-ms N]` — reap stale terminal (committed or aborted) journals older than the threshold, and also remove a `Started` journal whose owning PID no longer exists, reported as `dead_owner_removed`
### Common patterns for each subcommand
```bash
# read (bounded at read.max_output_bytes; --all removes the cap)
atomwrite --workspace . read src/main.rs
atomwrite --workspace . read --head 20 src/main.rs
atomwrite --workspace . read --format raw --lines 1:3 src/main.rs
atomwrite --workspace . read src/main.rs --number --lines 1:20
atomwrite --workspace . read src/main.rs --symbol main
atomwrite --workspace . read src/main.rs --line 42 --enclosing
atomwrite --workspace . read --format raw --lines 1:5 --lines 40:60 src/main.rs
atomwrite --workspace . read src/main.rs --grep 'unimplemented' --no-comments
# write
echo "fn main() {}" | atomwrite --workspace . write src/main.rs
# edit
atomwrite --workspace . edit src/main.rs --old "old text" --new "new text"
# edit-loop (N old/new pairs, one atomic write)
printf '%s\n' '{"old":"foo","new":"bar"}' '{"old":"baz","new":"qux"}' \
| atomwrite --workspace . edit-loop src/version.rs
# scratch: stage under .atomwrite/scratch/, never OS /tmp outside --workspace, for --old-file/--new-file
atomwrite --workspace . scratch init .atomwrite/scratch
atomwrite --workspace . edit src/main.rs \
--old-file .atomwrite/scratch/old.txt \
--new-file .atomwrite/scratch/new.txt
# verify
atomwrite --workspace . verify src/main.rs abc123def456
# search (search.format = text makes the cheap shape the default)
atomwrite --workspace . search 'TODO' src/ --include '*.rs'
atomwrite --workspace . search 'TODO' src/ --format text --limit 20
atomwrite --workspace . search -e 'TODO' -e 'FIXME' src/ --format text
# replace
atomwrite --workspace . replace 'old_name' 'new_name' src/ --include '*.rs'
# hash
atomwrite --workspace . hash src/main.rs src/lib.rs
# delete
atomwrite --workspace . delete src/temp.rs --backup
atomwrite --workspace . delete --plan src/temp.rs
# count
atomwrite --workspace . count src/ --by-extension
# diff
atomwrite --workspace . diff src/old.rs src/new.rs --unified
# move
atomwrite --workspace . move src/old.rs src/new.rs
# copy
atomwrite --workspace . copy src/template.rs src/new_module.rs
# list
atomwrite --workspace . list src/ --depth 2
atomwrite --workspace . list src/ --format text --depth 2
# extract: positional fields or local --field; NEVER local extract --select
atomwrite --workspace . search 'TODO' src/ | atomwrite extract path line
atomwrite --workspace . search 'TODO' src/ | atomwrite extract --field path --field line
# thin agent shape (global --select projects output keys only):
atomwrite --workspace . search 'TODO' src/ --select path,line --limit 20
# extract-artifact (PDF, OOXML or image header; an image here)
atomwrite --workspace . extract-artifact logo.png
# calc
atomwrite calc "2 GiB to bytes"
atomwrite calc "sqrt(144) + 3^2"
# regex
atomwrite regex "2024-01-15" "2025-12-31" "2026-06-01"
# transform
atomwrite --workspace . transform -p 'println!($$$ARGS)' -r 'tracing::info!($$$ARGS)' -l rust src/
# scope
atomwrite --workspace . scope src/ --lang rust --query comments --delete --dry-run
# batch
cat manifest.ndjson | atomwrite --workspace . batch --transaction
# backup
atomwrite --workspace . backup src/config.toml
# rollback
atomwrite --workspace . rollback src/config.toml
# prune-backups
atomwrite --workspace . prune-backups --max-age-secs 86400 src/config.toml
atomwrite --workspace . prune-backups --max-count 3 --dry-run src/config.toml
# apply: the target may come from the patch, so one call patches several files
echo "new content" | atomwrite --workspace . apply src/file.txt --format full
printf '<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n' | atomwrite --workspace . apply --show src/file.txt
# set
atomwrite --workspace . set Cargo.toml package.version 0.2.0
# get
atomwrite --workspace . get Cargo.toml package.version
# del
atomwrite --workspace . del Cargo.toml package.metadata.boring
# case: a file or a directory (the directory is expanded through the walker)
atomwrite --workspace . case src/main.rs --subvert user_id UserId --to pascal
# query
atomwrite --workspace . query src/main.rs --kinds
atomwrite --workspace . query src/main.rs -Q function_item --positions
# outline
atomwrite --workspace . outline src/app.py
atomwrite --workspace . outline src/app.py --format tsv
# wal-stats
atomwrite --workspace . wal-stats
# wal-heal
atomwrite --workspace . wal-heal . --threshold-secs 3600 --max-duration-ms 100
# stat
atomwrite --workspace . stat src/lib.rs
# sparse
atomwrite --workspace . sparse list --max-files 100 --max-bytes 1048576 src/
# semantic-merge (line-based, not AST; exit 84 when there are conflicts)
atomwrite --workspace . semantic-merge \
--base base.rs --ours ours.rs --theirs theirs.rs --output src/merged.rs
# recipe
atomwrite --workspace . recipe list
atomwrite --workspace . recipe run --name search-replace-verify \
--path src --pattern unwrap --replacement expect --fuzzy auto --dry-run
# agent-surface
atomwrite --workspace . agent-surface
# watch (needs a build with --features watch; the default binary exits 78)
atomwrite --workspace . watch . --debounce-ms 200 --max-events 20
# codemod
atomwrite --workspace . codemod --rules rules.yaml --dry-run src/
# semantic-search
atomwrite --workspace . semantic-search "fn main println" src/ --k 10
# doctor
atomwrite --workspace . doctor
atomwrite --workspace . doctor --strict
# locale
atomwrite locale
atomwrite locale --set pt-BR
atomwrite locale --clear
# commands (full clap tree for agent discovery; --brief fits one screen)
atomwrite commands
atomwrite commands --include-globals
atomwrite commands --brief
# completions
atomwrite completions bash
# config (persistent tuning keys, see Configuration)
atomwrite --workspace . config list
atomwrite --workspace . config get fuzzy.threshold
atomwrite --workspace . config set fuzzy.threshold 0.9 --scope workspace
atomwrite --workspace . config unset fuzzy.threshold --scope workspace
```
- `codemod --rules` reads a YAML list of items with `language`, `pattern`, `rewrite` and an optional `id`; the ast-grep style `rule:` and `fix:` form exits 65
- `prune-backups --dry-run` takes no value: omit the flag to apply the pruning
## Local Flags Per Command
- Each command accepts the local flags listed here on top of the 33 global flags in the Global Flags section
- `agent-surface`: `--format`
- `apply`: `--strict-atomic`, `--format`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--dry-run`, `--ack-overwrite`, `--allow-shrink`, `--show`, `--all`, `--no-syntax-check`
- `backup`: `--output-dir`, `--retention`, `--dry-run`
- `batch`: `--strict-atomic`, `--dry-run`, `--file`, `--transaction`, `--input-schema`, `--batch-size`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`
- `calc`: `--stdin`
- `case`: `--strict-atomic`, `-g/--include`, `--exclude`, `--subvert`, `--to`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--preserve-timestamps`, `--dry-run`
- `codemod`: `--strict-atomic`, `--rules`, `--dry-run`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`
- `commands`: `--include-globals`, `--brief`
- `completions`: `--install`
- `copy`: `--strict-atomic`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `-f/--force`, `-r/--recursive`, `--preserve`, `--dry-run`, `--no-reflink`, `--preserve-xattr`
- `count`: `--by-extension`, `--by-size`, `--top`, `-g/--include`, `--exclude`
- `del`: `--strict-atomic`, `--dry-run`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--preserve-timestamps`, `--force-missing`
- `delete`: `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `-r/--recursive`, `-g/--include`, `--exclude`, `--older-than`, `--plan`, `--dry-run`, `--confirm`, `-y/--yes`
- `diff`: `--unified`, `--stat`, `-C/--context`, `--algorithm`
- `doctor`: `--strict`
- `edit`: `--after-line`, `--before-line`, `--range`, `--delete-range`, `--after-match`, `--before-match`, `--between`, `--old`, `--new`, `--old-file`, `--new-file`, `--fuzzy`, `--multi`, `--expect-checksum`, `--line-ending`, `--dry-run`, `--preserve-timestamps`, `--partial`, `--fuzzy-threshold`, `--replace-all`, `--wal-policy`, `--strict-atomic`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--allow-sequential-drift`
- `edit-loop`: `--strict-atomic`, `--allow-sequential-drift`, `--partial`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--syntax-check`, `--line-ending`
- `extract`: `--field`, `-d/--delimiter`, `--stdin`
- `extract-artifact`: `--max-sample-rows`, `--max-text-chars`
- `hash`: `--verify`, `--stdin`, `-r/--recursive`, `--exclude`
- `list`: `-d/--depth`, `-l/--long`, `--count-by-ext`, `--format`, `--all`, `-g/--include`, `--exclude`
- `locale`: `--set`, `--clear`
- `move`: `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `-f/--force`, `--dry-run`, `--preserve-hardlinks`
- `outline`: `--language`, `--kind`, `--positions`, `--format`
- `prune-backups`: `--max-age-secs`, `--max-count`, `-r/--recursive`, `--dry-run`
- `query`: `--language`, `-Q/--query`, `--tree`, `--kinds`, `--positions`
- `read`: `--lines`, `--all`, `--number`, `--no-comments`, `--symbol`, `--enclosing`, `--line`, `-C/--context`, `--head`, `--tail`, `--stat`, `--format`, `--bytes`, `--verify-checksum`, `--grep`, `--crop`, `--max-tokens`, `--force`, `--pages`, `--page`, `--dpi`, `--ocr`, `--high-res`
- `regex`: `--stdin`, `-d/--digits`, `-w/--words`, `-s/--spaces`, `-r/--repetitions`, `-i/--case-insensitive`, `--no-anchors`
- `replace`: `--strict-atomic`, `--regex`, `-w/--word`, `-F/--literal`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `-g/--include`, `--exclude`, `--preview`, `-n/--max-replacements`, `--expect-checksum`, `--dry-run`, `--preserve-case`, `--fuzzy`, `--fuzzy-threshold`, `--progress-every`, `--preserve-timestamps`
- `rollback`: `--strict-atomic`, `--timestamp`, `--latest`, `--verify`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--dry-run`
- `scope`: `-l/--language`, `--query`, `--pattern`, `--delete`, `--action`, `--replace-with`, `-g/--include`, `--exclude`, `--dry-run`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--strict-atomic`
- `search`: `-e/--regex`, `-F/--fixed`, `-w/--word`, `-i/--case-insensitive`, `-S/--smart-case`, `-C/--context`, `-g/--include`, `--exclude`, `-c/--count`, `-l/--files`, `-m/--max-count`, `-U/--multiline`, `--invert`, `--sort`, `--include-fifo`, `--max-filesize`, `--max-columns`, `--binary`, `--no-begin-end`, `-P/--pcre2`, `--target`, `--offset`, `--limit`, `--format`
- `semantic-merge`: `--strict-atomic`, `--base`, `--ours`, `--theirs`, `--output`, `--fail-on-conflict`, `--write-conflict-markers`, `--no-conflict-markers`, `--expect-checksum`, `--dry-run`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`
- `semantic-search`: `--k`, `--min-score`, `--index-dir`, `--include-backups`
- `set`: `--strict-atomic`, `--dry-run`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--preserve-timestamps`
- `stat`: `--lines`, `--all`, `--number`, `--no-comments`, `--symbol`, `--enclosing`, `--line`, `-C/--context`, `--head`, `--tail`, `--stat`, `--format`, `--bytes`, `--verify-checksum`, `--grep`, `--crop`, `--max-tokens`, `--force`, `--pages`, `--page`, `--dpi`, `--ocr`, `--high-res`
- `transform`: `--strict-atomic`, `-p/--pattern`, `-r/--rewrite`, `-l/--language`, `-g/--include`, `--exclude`, `--dry-run`, `--rules`, `--inline-rules`, `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--verify-parse`
- `wal-heal`: `--threshold-secs`, `--max-duration-ms`, `--dry-run`
- `wal-stats`: `--dry-run`
- `watch`: `--debounce-ms`, `--max-events`, `--idle-exit-ms`, `--checksum`, `--gitignore`
- `write`: `--backup`, `--no-backup`, `--keep-backup`, `--retention`, `--max-size`, `--append`, `--prepend`, `--syntax-check`, `--durability`, `--expect-checksum`, `--allow-shrink`, `--line-ending`, `--allow-empty-stdin`, `--no-checksum-when-empty`, `--wal-policy`, `--strict-atomic`, `--preserve-timestamps`, `--require-backup`, `--confirm`, `--require-large-ack`, `--ack-overwrite`, `--auto-rotate`, `--risk-threshold`, `--dry-run`
- `config`, `get`, `recipe`, `scratch`, `sparse` and `verify` take no local flag, and their options come from the global set and from their own subcommands
## Configuration
- Precedence: CLI flags, then the workspace `.atomwrite.toml`, then the global XDG `config.toml` (the two files are merged key by key), then the built-in defaults
- `config path|list|get|set|unset|doctor` is the only way to change a persistent tuning key
- `config set KEY VALUE --scope global|workspace` and `config unset KEY --scope global|workspace` require `--scope` (exit 2 without it, nothing is touched), and `config path` takes it too
- `config list` and `config get KEY` take no `--scope`: they show `value`, `origin` (`default`, `global`, `workspace` or `explicit` with `--config PATH`) and `default`
- Scope `global` is the XDG `config.toml` (mode 0600), scope `workspace` is `.atomwrite.toml` in the workspace root
- `set` keeps comments, validates the whole file and writes by tempfile-fsync-rename without a backup; the terminal event carries `target_resolved` and `target_source: "argv"`
- An unknown key or a value out of range exits 65 and writes nothing; a file that is not valid TOML is never rewritten (`set` and `unset` exit 78); `config` works even with a broken `config.toml`
- A flag still wins over the configuration (for example `extract-artifact --max-text-chars`)
| Key | Type | Default | Accepted | Scopes |
|---|---|---|---|---|
| `fuzzy.mode` | string | `auto` | `auto`, `aggressive`, `off` | global, workspace |
| `fuzzy.threshold` | float | 0.7 | 0.0 to 1.0 | global, workspace |
| `fuzzy.threshold_aggressive` | float | 0.5 | 0.0 to 1.0 | global, workspace |
| `fuzzy.threshold_context` | float | 0.8 | 0.0 to 1.0 | global, workspace |
| `fuzzy.threshold_jw` | float | 0.85 | 0.0 to 1.0 | global, workspace |
| `fuzzy.soft_floor_delta` | float | 0.05 | 0.0 to 1.0 | global, workspace |
| `fuzzy.max_content_bytes` | integer | 268435456 (256 MiB) | 1 or more | global, workspace |
| `write.confirm_large_bytes` | integer | 102400 (100 KiB) | 1 or more | global, workspace |
| `log.file` | boolean | `false` | `true`, `false` | global, workspace |
| `log.rotation` | string | `daily` | `daily`, `hourly` | global, workspace |
| `storage.home` | string | none | a directory | global only |
| `locale` | string | none (resolved) | `en`, `pt-BR` | global only |
| `concurrency.max_threads` | integer | 16 | 1 to 256 | global, workspace |
| `concurrency.ram_fraction_percent` | integer | 50 | 1 to 100 | global, workspace |
| `search.max_inflight_bytes` | integer | 8388608 (8 MiB) | 4194304 (4 MiB) to 1073741824 (1 GiB) | global, workspace |
| `summary.errors_max_entries` | integer | 50 | 1 to 1000 | global, workspace |
| `lock.acquire_timeout_ms` | integer | 5000 | 100 to 3600000 | global, workspace |
| `retry.base_ms` | integer | 50 | 1 to 60000, not above `retry.max_ms` | global, workspace |
| `retry.max_ms` | integer | 400 | 1 to 60000 | global, workspace |
| `retry.max_elapsed_ms` | integer | 2000 | 1 to 3600000 | global, workspace |
| `extract.max_sample_rows` | integer | 10 | 0 to 100000 | global, workspace |
| `extract.max_text_chars` | integer | 400 | 1 to 10000000 | global, workspace |
| `extract.max_sheets` | integer | 32 | 1 to 10000 | global, workspace |
| `extract.max_slides` | integer | 64 | 1 to 10000 | global, workspace |
| `read.max_output_bytes` | integer | 24000 | 1 or more | global, workspace |
| `read.number` | boolean | `false` | `true`, `false` | global, workspace |
| `search.format` | string | `ndjson` | `ndjson`, `text` | global, workspace |
| `output.profile` | string | `full` | `full`, `lean` | global, workspace |
| `apply.max_ellipsis_lines` | integer | 200 | 1 or more | global, workspace |
| `delete.backup` | boolean | absent | `true`, `false` | global, workspace |
- `read.max_output_bytes` is the ceiling `read` honors before it prints the next `--lines` on stderr and marks the NDJSON event with `truncated: true` plus the `next` hint, and `--all` is the per-call way past it
- `read.number` makes `--number` the default, and `search.format` makes `--format text` the default, while an explicit flag still wins
- `output.profile = "lean"` drops the digests, the byte counts and the timing from a successful mutation record, and `full` is the default
- `apply.max_ellipsis_lines` bounds the span a `...` line may match inside a SEARCH block
- `delete.backup` is absent by default and inherits `defaults.backup`, because a rewrite copies and a delete does not unless the call asks
- A scope outside the last column exits 65 (for example `config set storage.home /srv --scope workspace`)
- These fields are `config` keys like every row above, and each answers `config get`: `defaults.backup`, `defaults.retention`, `defaults.line_ending`, `defaults.max_filesize`, `write.shrink_block_percent`, `write.auto_rotate_max_age_secs`, `write.content_risk_patterns`, `watch.idle_exit_ms`, `watch.debounce_ms`, `search.context`, `search.smart_case`, `search.max_filesize` and `search.max_columns`; an unknown section in `.atomwrite.toml` makes `read` exit 78 (measured)
- Files on Linux: global config `$XDG_CONFIG_HOME/atomwrite/config.toml`, saved locale `$XDG_CONFIG_HOME/atomwrite/locale`, log `$XDG_STATE_HOME/atomwrite/logs/atomwrite.<date>.jsonl` (only with `log.file = true`, file mode 0600, directory 0700), parsers `$XDG_CACHE_HOME/tree-sitter-language-pack/v1.22.1/`
- macOS and Windows resolve the same files through the `directories` crate; those paths were read from the code and not run there
- `storage.home` moves `config` and `state` under `{home}/config` and `{home}/state`
```bash
atomwrite --workspace . config get fuzzy.max_content_bytes
atomwrite --workspace . config set fuzzy.max_content_bytes 1000000 --scope global
```
## Environment Variables
- Product policy: atomwrite does not use product environment variables for runtime knobs (workspace, backup, locale, log filter, home override, WAL, etc.)
- Configure via CLI flags and XDG storage / `.atomwrite.toml` (and optional `--config`)
- `doctor` reads no environment variable: host detection (WSL, container, Kubernetes, Termux, Flatpak, Snap) uses file probes only, and it is not a configuration API
- Dependencies read some variables on their own, never as atomwrite configuration: the OS-language crate (`LANG`, `LC_ALL`, `LC_MESSAGES`, `LANGUAGE`), the XDG crates (`HOME`, `XDG_*`) and the parser download client (`ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`); see ADR-0057 in [docs/decisions/](docs/decisions/README.md)
- Shell-level variables used by your agent harness (PATH, etc.) are outside atomwrite product config
## Network And Privacy
- No telemetry: atomwrite sends nothing about you or your files
- The only network traffic is the parser download used by `query`, `outline` and `write --syntax-check` (the last one read from the source, not run); `transform`, `scope` and `codemod` use ast-grep parsers compiled into the binary
- On the first use of a language, the binary downloads the parser bundle for the platform from `https://github.com/xberg-io/tree-sitter-language-pack/releases/download/v1.22.1/` (for Linux x86_64 the file `parsers-linux-x86_64.tar.zst`, 23031807 bytes in the manifest, about 22 MiB, plus 3.9 MiB of extracted parser libraries for the six languages this machine used so far); the manifest carries a `sha256` for each platform bundle
- The cache lives at `$XDG_CACHE_HOME/tree-sitter-language-pack/v1.22.1/` (`bundles`, `libs`, `manifest.json`), and the download happens only while the needed parser is missing there
- The download client honours the proxy variables listed in [Environment Variables](#environment-variables)
- Offline use: fill the cache directory on a host with network access and copy it to the offline host
- A panic writes a crash report file `report-<uuid>.toml` in the OS temporary directory (through the `human-panic` crate) and prints its path; nothing is transmitted, and you choose whether to send it
- Details: [PRIVACY.md](PRIVACY.md)
## Exit Codes
- Error envelopes on stdout are always NDJSON with `ok: false` (and usually `match_count`); read `exit` / `code` before treating a line as success
- The list below is derived from the error enum and checked against `agent-surface` and `--help`
<!-- exit-codes:begin -->
- `0` SUCCESS: success
- `1` NO_MATCHES: no matches found (search, replace, transform, scope; not a crash)
- `1` DOCTOR_CHECKS_FAILED: `doctor --strict` found failed or warned checks (the report event carries every check)
- `2` ARGUMENT_PARSE_ERROR: command line rejected by the parser
- `2` UNSUPPORTED_COMBINATION: two flags this verb refuses to combine (read `flag` and `other` in the envelope)
- `4` FILE_NOT_FOUND: file not found
- `4` GLOB_NO_MATCHES: pattern expanded to no file
- `13` PERMISSION_DENIED: permission denied
- `28` DISK_FULL: disk full (no space left on device)
- `30` QUOTA_EXCEEDED: quota exceeded
- `65` INVALID_INPUT: invalid input (bad arguments or malformed data, including empty stdin without `--allow-empty-stdin` and a file above `[fuzzy].max_content_bytes` under fuzzy)
- `65` MATCH_FAILED: pattern did not match, or an `edit-loop` pair has no match (read `best_candidate`)
- `65` MATCH_AMBIGUOUS: pattern matched more than once (read `best_candidate`)
- `65` BINARY_FILE: binary content rejected
- `65` FILE_TOO_LARGE: file above the size limit
- `69` GRAMMAR_UNAVAILABLE: the tree-sitter grammar for a language is not on this host (class `precondition_failed`, retryable `false`)
- `73` CROSS_DEVICE: cross-device rename (filesystem boundary)
- `74` IO_ERROR: I/O error
- `78` CONFIG_INVALID: configuration invalid, or a verb that needs a feature missing from this build (slim)
- `79` SYMLINK_BLOCKED: symlink refused by the jail; pass `--follow-symlinks` to resolve it and check the target against the workspace
- `80` TRANSACTION_ROLLBACK_FAILED: batch transaction rollback failed (backups kept)
- `81` CHECKSUM_VERIFY_FAILED: checksum verify failed
- `82` STATE_DRIFT: state drift (checksum mismatch, optimistic lock failed)
- `83` LOCK_TIMEOUT: lock not acquired within `lock.acquire_timeout_ms` (default 5000)
- `84` MERGE_CONFLICT: `semantic-merge` found conflicts
- `85` FIFO_DETECTED: FIFO detected (named pipe cannot be atomically written)
- `86` DEVICE_FILE: device file detected (block or character device)
- `87` EDIT_LOOP_PARTIAL: `edit-loop --partial` wrote the matching pairs and some pairs missed
- `88` SYNTAX_ERROR_DETECTED: syntax error detected (`--syntax-check` failed)
- `89` ALL_SKIPPED_BINARY: every visited file was binary and was skipped, so nothing was searched (`search`; pass --binary to search it)
- `91` EXDEV_FALLBACK_DISABLED: EXDEV fallback disabled (cross-device copy-fallback is not allowed)
- `92` COPY_BACK_BLAKE3_FAILED: copy-back BLAKE3 verification failed
- `93` ORPHAN_JOURNAL: orphan journal detected
- `124` TIMEOUT: global `--timeout-secs` deadline exceeded (default 120; pass `0` to disable)
- `126` WORKSPACE_JAIL: workspace jail violated (path escapes workspace)
- `128` IMMUTABLE_FILE: file immutable (cannot modify)
- `129` SIGHUP: terminal hangup (SIGHUP)
- `130` CANCELLED: run cancelled before it finished (status follows the cause)
- `130` SIGINT: interrupted by SIGINT
- `141` BROKEN_PIPE: broken pipe (SIGPIPE or a `BrokenPipe` write error)
- `143` SIGTERM: terminated by SIGTERM
- `255` INTERNAL_ERROR: internal error
<!-- exit-codes:end -->
## Signal Handling
- Unix: SIGINT (Ctrl+C), SIGTERM and SIGHUP are intercepted for a cooperative shutdown with exit 130, 143 and 129; the run ends with a `cancelled` event that carries `signal` and `deadline_exceeded`
- Unix: SIGPIPE has a handler, it is neither ignored nor left at its default action; the handler marks a cooperative shutdown with exit 141
- The default SIGPIPE action killed the process before any destructor ran and left lock sidecars, journals and tempfiles of the writes in flight; with the handler every `Drop` runs and nothing is left behind
- A write that fails because the reader closed exits 141 (`BROKEN_PIPE`) with no panic and no crash report: `completions`, `--print-schema`, `--help` and every verb (fixed in 0.1.37, test `cli_v0137_broken_pipe_outputs`)
- Very short output that fits in the pipe buffer (for example `--version | head -1`) can finish before the reader closes and then exits 0
- A write that hits the closed pipe fails with `BrokenPipe` and exits 141 as well, and a parent that ignores SIGPIPE gets the same exit 141
- A repeated SIGPIPE never forces an exit; a repeated SIGINT, SIGTERM or SIGHUP does
- Windows: Ctrl+C intercepted via console handler; there is no SIGPIPE, and `BrokenPipe` from `std` follows the same path (not validated there)
- First signal: sets the shutdown flag and prints a message on stderr in the active locale (`atomwrite: shutting down...` with `--locale en`)
- Second signal: immediate process termination via `_exit` (Unix) or `exit` (Windows)
- Walker threads (search, replace, transform, scope) stop between files
- Batch operations stop between operations
- Stdin verbs (`write`, `edit` and the others) obey the signals and the deadline while stdin is still open: SIGTERM exits 143 (measured)
## Error Handling
- All errors emit a JSON object on stdout with `ok: false` plus `code`, `exit`, `message` and `suggestion`
- Error fields: `code`, `exit`, `path` when there is one, `suggestion` and `workspace`
- `-v` restores the diagnostic prose the default envelope drops: `message`, `error_class`, `retryable` and `suggestion_lang`
- Every summary that publishes `files_failed` adds `partial: true` and `errors[]` beside it (each entry has `path`, `code` and `message`, `path` is `<walk>` when the walk failed without naming a file, at most 50), and that covers `search`, `transform`, `replace`, `scope`, `count`, `list`, `sparse`, `semantic-search` and `case`
- Error classes: `permanent`, `transient`, `conflict`, `precondition_failed`
- 38 error variants in `src/error/variants.inc.rs`, counted with `rg -c '#\[error' src/error/variants.inc.rs`
- Transient and conflict errors set `retryable: true`
- The `suggestion` field provides actionable recovery guidance for agents in the active locale, and `suggestion_lang` names that locale under `-v`
- See `docs/schemas/error-output.schema.json` for the full contract
## Performance
- Single binary; on Linux it links dynamically against the system C library and nothing else (`ldd` on the v0.1.37 build lists `libgcc_s.so.1`, `libm.so.6` and `libc.so.6`)
- Release builds use LTO, single codegen unit, and symbol stripping
- Memory-mapped file reads via `memmap2` for large files
- Parallel multi-file work is the default modus operandi: `ignore::WalkParallel` / `collect_files_parallel` (search content + `--target files`, replace, count, transform, scope, recursive discovery) + `rayon` (hash, backup, multi-path + recursive delete/copy, case multi-path, semantic-search scoring, non-txn batch, prune multi-target, sparse read, diff dual-read)
- Worker bound: `--threads` / `-j` / `--max-concurrency` (default = the smaller of the logical CPUs, 16 threads and the RAM cap; an explicit `-j N` is honoured as asked; see `src/concurrency.rs`)
- Small-file latency: medians of 5.6 ms (`read`) and 6.7 ms (`write`) in one session of 20 runs, and 9.4 ms and 9.7 ms in another of 30 runs, whole process included; machine: Intel Xeon E5-2699 v3 2.30 GHz with 72 logical CPUs, Linux, working directory on tmpfs, measured on 2026-10-19 while other jobs ran on the same host
## Known Limits
- `replace --expect-checksum` compares the checksum under the lock, so a writer that lands between the check and the write is detected as exit 82 `STATE_DRIFT`; verbs that read, change and write back (`edit`, `edit-loop`, `replace`, `set`, `del`, `config set`, `apply`, `write --append`) serialize under the lock
- A target name long enough to push the sidecar past 255 bytes gets a short BLAKE3 digest as the sidecar name, so a 240-byte target locks instead of failing with `ENAMETOOLONG`; a 244-byte target was proven with `replace`
- The lock is taken on the target's own inode, so a file the caller owns inside a read-only directory is written in place instead of refused with exit 13
- The `.<target>.atomwrite.lock` sidecar is best effort and its failure no longer aborts the write, because an older release that holds only that file still meets the inode lock
- The RAM ceiling for the automatic worker count reads `MemAvailable` from `/proc/meminfo` on Linux only and ignores container (cgroup) limits
- Without `--sort-by`, the order of `search` results varies from run to run because the walkers run in parallel
- `case` expands a directory argument through the shared walker and honours `--include` and `--exclude`; a directory that yields no file exits 65 instead of succeeding with nothing renamed
- `query`, `outline` and `write --syntax-check` detect only 23 languages by extension; `--language NAME` works for a parser already extracted in the local cache, and exits 65 `unsupported language override` for a language not yet used
- `extract-artifact` emits a single `artifact` event and no summary, so a successful run has no event with `ok: true`
- `locale --set` and `locale --clear` print the resolved language from before the change; a following `atomwrite locale` shows the new state
- macOS and Windows were not run; the Unix-only code has `cfg(windows)` pairs that were only compiled with `cargo check` from Linux
## Library API
- The supported interface of atomwrite is the CLI and its NDJSON contract; the crate also ships a Rust library target that is not covered by that promise
- The public Rust API of v0.1.37 changed against the published 0.1.36 in ways SemVer treats as breaking: `cargo semver-checks` 0.50.0 reported 10 failed major checks (removed `locale::Idioma` and `locale::{negotiate_to_idioma,resolved_idioma}`, removed `LocaleSource::EnvVar`, removed `signal::reset_sigpipe`, four removed lock constants, `LockGuard` no longer `Send` and `Sync`, new `Commands` variants and new public struct fields without `#[non_exhaustive]`, and `watch` no longer a default feature)
- Pin `atomwrite` as a library dependency to an exact version (`=0.1.38`) and read [CHANGELOG.md](CHANGELOG.md) before every upgrade
## Troubleshooting FAQ
### atomwrite write hangs with no output
- Ensure you are piping content to stdin
- `write` reads from stdin and waits for EOF
- Example: `echo "content" | atomwrite --workspace . write file.txt`
### search returns exit code 1
- Exit code 1 means zero matches were found
- This is expected behavior, not an error
- Check the pattern and target path
### search with a pattern that starts with a hyphen
- A pattern that starts with `-` goes after `--`, the way `rg` and `grep` do it
- `atomwrite --workspace . search -F -- '-deprecated' src/` searches for that literal text
- Without `--` the token is a flag the verb does not have, so the run exits 2 and prints the parser tip `to pass '-Z' as a value, use '-- -Z'`
### search, replace and transform exit 4 although they printed matches
- A root named in the invocation that does not exist fails the run even when the other roots matched, in `search`, `replace` and `transform`
- The matches still reach stdout, and the summary carries `ok: false`, `code: FILE_NOT_FOUND`, `files_failed` and the path in `errors[]`
- A file that fails during the walk is the other case: `partial: true` joins the summary, and the run keeps exit 0 in `search`, `replace`, `transform`, `scope`, `case`, `sparse`, `semantic-search` and `backup`, while `count`, `list`, `hash`, `copy`, `delete` and `prune-backups` exit with the first failure because their answer is a total over the tree; the contract is ADR-0077 (`docs/decisions/0077-answer-family-and-walk-honesty.md`)
### cross-device rename fails with exit 73
- The source and destination are on different filesystems
- atomwrite falls back to copy+delete for `move` across devices
- Use `copy` followed by `delete` as an alternative
### checksum mismatch with exit 82
- Another process modified the file between read and write
- Re-read the file to get the current checksum
- Retry the operation with the updated `--expect-checksum`
### workspace jail violated with exit 126
- The target path resolves outside the `--workspace` boundary
- Verify the path does not contain `..` traversals or symlinks escaping the workspace
### syntax check failed with exit 88
- The real tree-sitter syntax check found a syntax error in the file
- Inspect the first error line/column/kind/message in the JSON error envelope
- Fix the syntax and retry, or remove `--syntax-check` to bypass
### empty stdin rejected with exit 65
- The guard rejected 0 bytes from stdin as a likely upstream-pipeline failure
- Pass `--allow-empty-stdin` to confirm the empty input is intentional
- Or pipe real content: `echo "x" | atomwrite --workspace . write file`
### edit hangs (or fails with exit 65) on --after-line/--after-match/--between/--multi
- These modes read replacement content from stdin, same as `write`
- A terminal stdin fails fast with exit 65 `INVALID_INPUT` instead of blocking forever
- Pipe content: `echo "new text" | atomwrite --workspace . edit file.rs --after-line 10`
- Or use `--new-file <PATH>` where the mode accepts it
- `--after-match`/`--before-match`/`--between`/`--after-line`/`--before-line`/`--range`/`--delete-range` reject `--old`/`--new`/`--old-file`/`--new-file` at parse time (exit 2): these modes only take content from stdin
### watch exits 78
- The default binary is built without the `watch` feature
- Rebuild with `cargo install --path . --locked --force --features watch`
## MSRV And Targets
- Minimum supported Rust version: 1.90 (edition 2024); `rust-toolchain.toml` pins channel 1.94 for development
- Locales: `en` and `pt-BR`
- Linux x86_64 is exercised end to end
- Windows is checked with `cargo check --target x86_64-pc-windows-gnu` from a Linux host only
- macOS was not exercised on a real Mac
## Documentation
- [docs/INSTALL.md](docs/INSTALL.md): install, features and build profiles
- [docs/HOW_TO_USE.md](docs/HOW_TO_USE.md): full usage guide
- [docs/COOKBOOK.md](docs/COOKBOOK.md): task recipes
- [docs/AGENTS.md](docs/AGENTS.md): contract for LLM agents
- [docs/CROSS_PLATFORM.md](docs/CROSS_PLATFORM.md): platform notes
- [docs/MIGRATION.md](docs/MIGRATION.md): upgrade paths between versions
- [docs/TESTING.md](docs/TESTING.md): how the test suite is organized
- [INTEGRATIONS.md](INTEGRATIONS.md): using atomwrite from agents and scripts
- [PRIVACY.md](PRIVACY.md): what the binary reads, writes and downloads
- [llms.txt](llms.txt) and [llms-full.txt](llms-full.txt): summaries for LLM context windows
## Architecture
- See [ARCHITECTURE.md](ARCHITECTURE.md) for module map, data flow, and design decisions
- See [docs/decisions/](docs/decisions/README.md) for the index of 77 ADRs (0019 to 0095; 3 more live in `docs/adr/`)
- Verification is running the program, not a test suite: every claim in this README is backed by a real invocation of the binary, and `docs/TESTING.md` describes the harness for those cases a maintainer chooses to keep
- See [docs/schemas/](docs/schemas/README.md) for the JSON Schema contracts of the NDJSON output, where 54 files describe the 55 names `atomwrite --print-schema` lists
## Contributing
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, local DoD, PR process, and release notes
- See [CONTRIBUTING.pt-BR.md](CONTRIBUTING.pt-BR.md) for the Portuguese guide
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md) / [Código de Conduta](CODE_OF_CONDUCT.pt-BR.md)
## Security
- See [SECURITY.md](SECURITY.md) for vulnerability reporting
- See [SECURITY.md](SECURITY.md) section "Known Security Advisories" for resolved and active advisories
- See [PRIVACY.md](PRIVACY.md) for data handling
## Changelog
- See [CHANGELOG.md](CHANGELOG.md) for English release history
- See [CHANGELOG.pt-BR.md](CHANGELOG.pt-BR.md) for Portuguese release history
## License
- Licensed under MIT OR Apache-2.0
- See [LICENSE-MIT](LICENSE-MIT) and [LICENSE-APACHE](LICENSE-APACHE) for details