# lets — the complete API reference > Locate · Edit · Transform · Show, a file-operations CLI for coding agents. The product overview, then every verb, flag, exit code and JSON shape in one file, so a single fetch teaches the whole tool. > Individual pages: https://lets.mayberuk.com/index.md, https://lets.mayberuk.com/docs/show.md, https://lets.mayberuk.com/docs/find.md, https://lets.mayberuk.com/docs/edit.md, https://lets.mayberuk.com/docs/transform.md, https://lets.mayberuk.com/docs/write.md, https://lets.mayberuk.com/docs/install.md, https://lets.mayberuk.com/docs/hooks.md, https://lets.mayberuk.com/docs/guide.md, https://lets.mayberuk.com/docs/stats.md, https://lets.mayberuk.com/docs/targets.md, https://lets.mayberuk.com/docs/exit-codes.md, https://lets.mayberuk.com/docs/json.md. --- # lets Locate · Edit · Transform · Show — a file-operations CLI for coding agents (Claude Code, Codex). One Bash call reads, searches, edits or transforms several files and returns bounded, numbered output whose footer names everything it left out. One static Rust binary. No index, no daemon, no config file. ## What it is `lets` is a small command-line tool that coding agents call instead of `cat`, `grep`, `sed` and the built-in Read/Edit tools. The agent runs it through its normal shell tool, the same way it already runs `cat`. | Command | Replaces | Does | |---|---|---| | `lets show` | `cat`, `head`, `sed -n`, Read | reads files, line ranges, a function by name, a markdown section, or lines around a regex — several targets per call | | `lets find` | `grep -rn`, `rg` | searches with line numbers, grouped by file, capped at 50 hits, says when it hit the cap | | `lets edit` | Edit, `sed -i`, rewrite scripts | replaces text that must match exactly once, or inserts after an anchor; batches across files | | `lets transform` | `jq`, `yq`, `sed` on config | sets, appends or deletes keys in JSON, YAML, TOML or markdown frontmatter, keeping comments and formatting | | `lets write` | `cat > file < cap) return 8 return total(id, now) 9 } ── showed 1 target · 7 lines · ~38 tokens ``` ### Edit, and confirm it landed Before — three turns: read for the exact text, edit, re-read to check it landed. ```console $ lets edit src/usage.ts --old 'const cap = 10' --new 'const cap = 20' ── src/usage.ts · 1 replacement · line 5 · exact 3 export function usage(id: string) { 4 const now = Date.now() 5~ const cap = 20 6 if (!id) return 7 if (count(id) > cap) return ── check: structure ok · sha:75d31d847ffb→93b5daea8ace · ~45 tokens ``` `~` marks a changed line. Don't `cat` after a `lets` edit — the proof is already in the output. ### The text is there more than once Before — the Edit tool fails with `Found 3 matches`, then the agent greps to pick one. ```console $ lets edit src/usage.ts --old 'return' --new 'return undefined' src/usage.ts is ambiguous (3 candidates) src/usage.ts:6 if (!id) return src/usage.ts:7 if (count(id) > cap) return src/usage.ts:8 return total(id, now) ERROR_CODE=ambiguous ``` ### An edit that breaks the file Before — the edit lands with `sed -i`, and the break shows up several turns later in a test run, or never. ```console $ lets edit src/usage.ts --old 'return total(id, now)' --new 'return total(id, now))' ── src/usage.ts · 1 replacement · line 8 · REVERTED 6 if (!id) return 7 if (count(id) > cap) return 8~ return total(id, now)) ← parse error 9 } ── check: failed → reverted · file unchanged · sha:93b5daea8ace · ~40 tokens ERROR_CODE=check_failed ``` The file is parsed before and after the edit; a new parse error reverts it in the same call. This is a structural check, not a type check — it catches broken brackets and quotes, not wrong types. For a real project checker, pass `--check ''` (`tsc --noEmit`, `cargo check`, `go vet`): it runs once per batch and reverts if the command newly fails. See [`/docs/edit/`](/docs/edit/). ## How it reaches an agent The fix people try first is a rule in CLAUDE.md telling the agent to batch its reads. That doesn't hold: prose rules get roughly 55% compliance in practice. `lets hooks install claude-code` (or `codex`) wires the tool in at three points instead of asking nicely: - A `SessionStart` paragraph explains `lets` and its verbs at the start of every session, including after compaction, since injected context doesn't survive compaction the way a system prompt does. - A `SubagentStart` hook delivers the same paragraph to subagents, which a system-prompt append never reaches. - A `PreToolUse` hook (`lets hook classify`) inspects each shell command before it runs. A bare `cat`, `sed -n` or `grep` of a repo file is blocked with the exact `lets` command to run instead: ```console agent runs: sed -n '3,9p' src/usage.ts hook says: lets show reads several files and ranges in one call. run: lets show src/usage.ts:3-9 ``` Output piped into another program, a heredoc sent to a program's stdin, and files outside the repo all pass through untouched. If `lets` is missing or crashes, the hook allows the original command — it can never wedge an agent's turn. Full detail: [`/docs/hooks/`](/docs/hooks/). ## Measured A large-repo trial — five tasks sent as five turns of one Claude Code session, on a private 17.7k-file Go monorepo, checked against expected values derived from each task — compared a stock session (`none`) against one with `lets hooks install claude-code` (`lean`), no other change: | model | n, lean vs none | tool calls | wall time | checks passed | cost | |---|---|---|---|---|---| | Sonnet 5 | 9 vs 6 | −16% (64.6 vs 76.5) | −11% (403 s vs 451 s) | 99.1% vs 97.8% (339/342 vs 223/228) | −0.5% ($1.349 vs $1.356), cost-neutral | | Opus 5.5 | 6 vs 6 | −16% | −12% | 100% both arms | +8% mean ($1.003 vs $0.929; +4% median) | Caveat: small samples (3–9 sessions per arm), so a smaller difference than this would need many more sessions to resolve with confidence. Local latency, p50 from `just bench-gate`'s wall-clock target against the generated fixture corpus: `guide` 1.1 ms, `hook classify` 1.2 ms (1.34× `bash -n`), `show` on 200 lines 1.2 ms, `find` on 2,000 files 8.8 ms (1.34× `rg`), `edit` plus a syntax check 2.7 ms, a 10-file batch edit 24 ms, `transform` on a YAML file 3.8 ms. Measured once on the same private monorepo, outside the bench-gate harness: `show` at 2–4 ms, `find` at roughly 110 ms across the whole tree, and a symbol lookup plus an edit on its largest file (17.9k lines) at 116 ms and 183 ms. ## Install ```console $ curl -fsSL https://raw.githubusercontent.com/mayberuk/lets/main/install.sh | sh $ lets hooks install claude-code ``` Linux and macOS. MIT or Apache-2.0. Version 0.0.1. Repo: [github.com/mayberuk/lets](https://github.com/mayberuk/lets). --- This page covers the product summary, before/after pairs, hooks and the measured numbers. The full API — every verb, every flag, every exit code — is at [`/llms-full.txt`](/llms-full.txt), or browse it verb by verb starting from [`/docs/show.md`](/docs/show.md). Nothing here was cut; a markdown version of this exact page is what you're reading. ## Questions ### What is lets? lets is a file-operations CLI for coding agents: Locate, Edit, Transform, Show. One shell call reads, searches, edits or transforms several files and returns bounded, numbered output whose last line names everything it left out. ### Which agents does it work with, and how does it reach them? Claude Code and Codex. lets hooks install claude-code (or codex) wires three things: a SessionStart note explaining lets at the start of every session, a SubagentStart note that delivers the same explanation to subagents, and a PreToolUse hook that blocks a bare cat, sed -n or grep and hands back the matching lets command. Nothing is appended to the agent's system prompt, and if lets is missing or crashes, the hook lets the command through. ### What does "one call instead of three" mean? Today an agent often reads a file, greps for a line, then re-reads it to confirm an edit landed — three shell calls. lets show, find and edit each return the full answer in one call, and edit includes the changed lines and a syntax check, so there's no follow-up read. ### What happens when an edit would break the file? lets edit checks the result — a structural parse, a JSON/YAML/TOML/frontmatter check, or a command passed with --check — before it keeps the change. If the check fails, the edit is reverted, the file stays unchanged, and lets exits 3 (check_failed). ### What did the trial measure? Five tasks on a private 17.7k-file Go monorepo, a stock Claude Code session against one with lets hooks install, nothing else changed. On Sonnet 5: 16% fewer tool calls, 11% faster, 99.1% vs 97.8% checks passed, cost-neutral (within 0.5%). On Opus 5.5: 16% fewer calls, 12% faster, 100% checks passed both, cost 8% higher on average. The samples are small — 3 to 9 sessions per arm. ### What platforms and licence? Linux and macOS, as one static binary. MIT or Apache-2.0, your choice. Version 0.0.1. --- # `lets show` Reads files, line ranges, anchors and symbols. Several targets in one call, so an agent that needs two files or two functions gets both in one round trip instead of two. ``` lets show [OPTIONS] ... ``` ## What it does Renders each target as a numbered block with a header naming the range shown out of the file's total, and a footer summarizing the whole call. Replaces `cat`, `head`, `sed -n` and the Read tool. See [target grammar](/docs/targets/) (`path`, `path:40`, `path:40-80`, `path@'regex'`, `path#name`). ## Flags | Flag | Meaning | Default | |---|---|---| | `--window ` | cap lines shown per whole-file target | 200 | | `--all` | disable the window; print a cost line before the content | off | | `-A ` | lines of context after a `:line` or `@'regex'` target | — | | `-B ` | lines of context before | — | | `-C ` | lines of context on both sides | — | | `--no-numbers` | omit line numbers, for content piped onward | off | | `--json` | one JSON object on stdout | off | | `--jsonl` | one JSON object per target, plus a trailing stats/omitted record | off | | `--budget ` | shape the whole answer to ~N tokens, trimming the largest target first | unset | | `--max-bytes ` | refuse (exit 4) if content exceeds N bytes and no `--budget` given | 65536 | | `--max-file-bytes ` | files larger than this are refused | 8388608 | | `--no-ignore` | do not honor `.gitignore` (only matters when a target resolves through a directory scan) | off | | `--allow-outside` | permit a target outside the working tree | off | | `--no-check` | no effect on `show` (shared flag; `show` never runs a checker) | off | | `-q, --quiet` | (shared flag; `show` already prints only the footer plus content) | off | ## Output shape ``` ── (- of [ · window W · :x-y not shown][ · via R])[ · crlf][ · non-UTF-8 lines …] · sha:xxxxxxxxxxxx ... ── showed targets · lines[ · ] ``` - `sha:` is the first twelve hex of the file's blake3 hash. Pass it back to `edit --if sha:…` to refuse the edit if the file changed since this `show`. - An empty file is a valid target, not a missing one: it exits 0 with a zero-line block. - Invalid UTF-8 bytes are replaced lossily for display; the header names which lines held them (`· non-UTF-8 lines 3, 7`, at most five numbers then `(+N more)`). - A file whose dominant line ending is CRLF gets `· crlf` in its header, since the rendered lines otherwise hide `\r` entirely. - A line over 1,000 bytes is cut at a UTF-8 boundary and marked with `…`; the footer names how many lines this touched (`N long lines cut`). - `--json`/`--jsonl` bodies carry `omitted` (the machine-readable footer) and `stats` (`lines`, `bytes`, `tokens_est`) alongside the target-specific fields. See [--json and --jsonl](/docs/json/). ## Exit codes | Exit | Slug | Means | |---|---|---| | 0 | — | shown | | 1 | `not_found` | a target matched nothing (other targets in the same call are still shown) | | 2 | `ambiguous` | a `#name` target matched more than one symbol; every candidate listed | | 4 | `over_budget` | content exceeded `--max-bytes` and no `--budget` was given | | 6 | `outside_tree` | the target is outside the working tree; pass `--allow-outside` | | 7 | `unsupported_file` | binary, hardlinked, or a directory given as a target | | 64 | `usage` | malformed command line | ## Examples Read several files in one call, numbered, with a size estimate: ```console $ lets show src/usage.ts src/config.ts ── src/usage.ts (1-13 of 13) · sha:75d31d847ffb 1 import { usageCap } from './config' 2 3 export function usage(id: string) { 4 const now = Date.now() 5 const cap = 10 ... 13 } ── src/config.ts (1-2 of 2) · sha:4f49d457dfea 1 export const usageCap = 10 2 export const retries = 3 ── showed 2 targets · 15 lines · ~74 tokens ``` Read exactly one function, found by parsing the file, instead of guessing a line range: ```console $ lets show src/usage.ts#usage ── src/usage.ts#usage (3-9 of 13 · via tree-sitter) · sha:75d31d847ffb 3 export function usage(id: string) { 4 const now = Date.now() 5 const cap = 10 6 if (!id) return 7 if (count(id) > cap) return 8 return total(id, now) 9 } ── showed 1 target · 7 lines · ~38 tokens ``` Find a line by regex and read context around it in the same call: ```console $ lets show "src/usage.ts@'const cap'" -A 2 ── src/usage.ts@'const cap' (5-7 of 13) · sha:93b5daea8ace 5 const cap = 20 6 if (!id) return 7 if (count(id) > cap) return ── showed 1 target · 3 lines · ~16 tokens ``` A file over the default window is truncated and the footer names what was left out: ```console $ lets show big.ts ── big.ts (1-200 of 212 · window 200 · :201-212 not shown) · sha:e37232c09d01 ... ── showed 1 target · 200 lines · :201-212 not shown ``` Two missing targets alongside one that resolved — the call still exits 1, but everything found is still printed: ```console $ lets show nope1.ts small.md:3 nope2.ts ? 1 ── small.md:3 (3-3 of 15) · sha:[..] 3 One grammar every verb speaks. ── showed 1 target · 1 line · nope1.ts failed (not_found) · nope2.ts failed (not_found) nope1.ts: No such file or directory (os error 2) nope2.ts: No such file or directory (os error 2) ERROR_CODE=not_found ``` An empty file is a normal result, not an error: ```console $ lets show empty.md ── empty.md · sha:af1349b9f5f9 ── showed 1 target · 0 lines ``` --- # `lets find` Searches files or directories and prints hits as `path:line`, grouped by file. `locate` is an alias for the same command. ``` lets find [OPTIONS] [PATHS]... ``` ## What it does Regex search by default (Rust `regex` syntax), walked with the same rules as `ripgrep`: `.gitignore`, `.ignore`, the global gitignore and `.git/info/exclude` are honored, and hidden files are skipped unless asked for. Capped at 50 hits by default — over the cap, no hit lines are printed; instead a bounded map of the files holding the most hits, so the next call can narrow to one of them. Replaces `grep -rn` and `rg` run directly in the shell. ## Flags | Flag | Meaning | Default | |---|---|---| | `-F, --fixed-string` | match the pattern literally, not as a regex | off | | `-i, --ignore-case` | case-insensitive | off | | `-w, --word` | match whole words only | off | | `--cap ` | raise the hit cap (prints hits, not the over-cap map) | 50 | | `-l, --files` | list matching files only, one bare path per line, no cap | off | | `-c, --count` | print the footer first, then one ` ` row per file | off | | `-g, --glob ` (alias `--include`) | narrow the walk to paths the glob matches; footer names it | — | | `--hidden` | include hidden files and directories | off | | `-A ` | lines of context after a hit | — | | `-B ` | lines of context before | — | | `-C ` | lines of context on both sides | — | | `--no-ignore` | do not honor `.gitignore`/`.ignore`/global excludes | off | | `--allow-outside` | permit a path outside the working tree | off | | `--json` / `--jsonl` | structured output | off | | `--budget ` | shape the answer to ~N tokens | unset | | `--max-bytes ` | content budget | 65536 | | `--max-file-bytes ` | files larger than this are skipped as unreadable | 8388608 | | `-q, --quiet` | shared flag | off | Grep-compatible no-ops, accepted so a command copied from `grep` runs unchanged: `-n`, `--line-number`, `-r`, `-R`, `-E`, `-H` (find already behaves as if each were set). `-v, --invert-match` has no equivalent — find only ever prints matching lines — so it is refused at parse time, exit 64 `ERROR_CODE=usage`. ## Output shape Under the cap: ``` ── : - ── hits in files · searched files[ · ignored …][ · skipped …][ · glob …] ``` Over the cap, no hit lines print. Instead: ``` ... ── hits in files · over the 50-hit cap · narrow the pattern or the paths, or --files · top 10 files shown ``` - Matches are wrapped in `«»` so they survive a pipe. - `--files` prints one bare path per line, no header, no line numbers, then the ordinary footer. - `--count` prints the footer first, then ` ` rows, count right-aligned to the widest. - A path that does not exist is named in the footer alongside what the other paths produced: `· nope failed (not_found)`. - A file with a NUL byte, or a UTF-16 file, is not text and is not searched; it is counted in `skipped N (binary a · too large b · unreadable c)`. - A regex holding one of grep's BRE escapes (`\|`, `\(`, `\)`, `\{`, `\}`, `\+`, `\?`) that matches nothing is retried read grep-style, and the footer names the reading used: `· «A\|B» had no hits, read grep-style as «A|B»`. ## Exit codes | Exit | Slug | Means | |---|---|---| | 0 | — | hits found | | 1 | `not_found` | no hits, or a searched path does not exist | | 1 | `over_cap` | hit count exceeded the cap; the top-files map was printed instead | | 1 | `invalid_pattern` | the regex does not parse | | 4 | `over_budget` | content exceeded `--max-bytes` and no `--budget` was given | | 6 | `outside_tree` | a path is outside the working tree; pass `--allow-outside` | | 64 | `usage` | malformed flag, e.g. `-v` | ## Examples A basic search, hits wrapped so they survive a pipe: ```console $ lets find usageCap ── src/config.ts 1: export const «usageCap» = 10 ── src/usage.ts 1: import { «usageCap» } from './config' ── 2 hits in 2 files · searched 3 files · ~17 tokens ``` Two patterns at once, with context: ```console $ lets find 'Bottom line|Next' small.md ── small.md 5: ## «Bottom line» 13: ## «Next» ── 2 hits in 1 file · searched 1 file ``` Over the cap: no hits printed, a map of where they are instead, exit 1: ```console $ lets find needle many-hits.txt ? 1 64 many-hits.txt ── 64 hits in 1 file · searched 1 file · over the 50-hit cap · narrow the pattern or the paths, or --files · top 1 file shown ERROR_CODE=over_cap ``` Narrowing the walk with a glob, and the control with no glob: ```console $ lets find -g '*.ts' needle ── a.ts 1: const «needle» = 1; ── 1 hit in 1 file · searched 1 file · glob *.ts $ lets find needle ── a.ts 1: const «needle» = 1; ── b.py 1: «needle» = 1 ── 2 hits in 2 files · searched 2 files ``` `--files` and `--count` shapes: ```console $ lets find filler . --files big.ts store.go ── 2 files · searched 5 files · ignored 3 (gitignore 1 · hidden 2) · skipped 1 (binary 1) $ lets find filler . --count ── 393 hits in 2 files · ignored 3 (gitignore 1 · hidden 2) · skipped 1 (binary 1) 185 big.ts 208 store.go ``` No hits still prints a footer and exits 1, rather than staying silent: ```console $ lets find zzz_never_appears_zzz small.md ? 1 ── 0 hits in 0 files · searched 1 file no hits for «zzz_never_appears_zzz» ERROR_CODE=not_found ``` --- # `lets edit` Content-addressed, exactly-once replacement, with the post-state returned in the same call. Replaces the Edit tool, `sed -i`, and one-off Python rewrite scripts. ``` lets edit [OPTIONS] [TARGET] [MORE_TARGETS]... ``` ## What it does `--old` is matched as an exact substring that must occur exactly once in the target (or the target's line range); `--new` replaces it. The file is parsed before and after the edit and a guardrail reverts the write if the edit introduced a new parse error. The output shows the changed region with 2 lines of context and the check result, so the agent does not need to re-read the file to confirm the edit landed. See [target forms](/docs/targets/). ## Flags | Flag | Meaning | Default | |---|---|---| | `--old ` | exact text to find; `-` reads all of stdin as the content instead of matching it literally | — | | `--new ` | replacement text; `-` reads stdin (shared with `--old -` is refused, since only one flag can drain stdin) | — | | `--all` | replace every occurrence, not just the one match | off | | `--expect ` | with a `:a-b` target, replace only if the named single line is exactly `S` (whitespace-trimmed) | — | | `--expect-all` | confirm content for a multi-line range read from stdin | off | | `--insert-after ` | insert `--new` after an anchor (`@'regex'` or `#symbol`); no line-number insert | — | | `--insert-before ` | insert `--new` before an anchor | — | | `--from -` | a fence-delimited or JSONL batch on stdin, one edit per entry; `-` is the only accepted value | — | | `--if sha:<12+ hex>` | refuse (exit 5) if the file changed since the `show` that produced this hash | — | | `--normalize` | also try a match with smart quotes, en/em dashes and non-breaking spaces folded to their ASCII forms | off | | `--literal-newlines` | write `--new`'s line endings exactly as typed, instead of matching them to the target file's dominant line-ending convention | off | | `--check ` | layer-2 checker: a real command, or one of `@auto`, `@cargo`, `@go`, `@tsc`, `@py` | — | | `--check-timeout ` | how long the layer-2 checker may run before its result is `inconclusive (timed out)` | 60 | | `--no-check` | skip both the built-in structural/format check and any `--check` | off | | `--if`, `--json`, `--jsonl`, `--budget`, `--max-bytes`, `--max-file-bytes`, `--no-ignore`, `--allow-outside`, `-q/--quiet` | shared flags — see [--json and --jsonl](/docs/json/) | see `lets edit --help` | Several file targets in one call (`lets edit f1.ts f2.ts --old a --new b`), and a `--from -` batch, are both **validation-atomic**: every file is matched and pre-checked before any file is written. If one target fails to match, nothing is written to any file, and the failure names which target. ## The `--from -` batch format Fence-delimited, no escaping needed for code bodies: ``` lets edit --from - <<'LETS' @@ a.ts <<<<<<< old cap = 10 ======= new cap = 20 >>>>>>> @@ b.ts insert-after @'^import' ======= new import x from 'y' >>>>>>> LETS ``` A JSONL form is also accepted, one edit object per line: `{"file":"a.ts","old":"…","new":"…"}` or `{"file":"b.ts","insert_after":"@'^import'","new":"import x from 'y'\n"}`. Every line is validated against the edit shape before anything is written; an unknown key, a missing or non-string `new`, or a line combining `old` with `insert_after`, fails the whole batch at exit 64 with nothing written. ## Checks (the guardrail) **Layer 1, default, automatic, per file.** For JSON, YAML, TOML and markdown frontmatter, this is a real parser: `check: json ok` means the file is valid JSON. For the tree-sitter languages, it is a **structural check** — the file is parsed before and after, and any new parse-error node reverts the edit. This catches broken brackets and quotes, not wrong types. A file with no bundled grammar is named as skipped, never guessed at: `check: skipped (no grammar for .vue)`. **Layer 2, opt-in, per batch: `--check ''`.** A real checker (`tsc --noEmit`, `go vet`, `cargo check`) runs once before any write and once after the whole batch lands. Verdict is by exit code only: `0 → 0` is `check: ok`; `0 → non-zero` reverts the whole batch, exit 3; `non-zero → non-zero` is `inconclusive (failed before and after)` — kept, not called verified. `{}` in the command substitutes the list of edited files. **Presets**, so `--check` needs no config file: `@auto` walks up from the edited files to the first manifest it recognizes; `@cargo`, `@go`, `@tsc`, `@py` force one regardless of what manifest is present. | Manifest | Preset | Command | |---|---|---| | `Cargo.toml` | `@cargo` | `cargo check --workspace --quiet --all-targets` | | `go.mod` | `@go` | `go build ./...` | | `tsconfig.json` | `@tsc` | `npm run -s typecheck` (if `scripts.typecheck` exists) else `npx --no-install tsc --noEmit -p ` | | `pyproject.toml` or `setup.py` | `@py` | `python3 -m py_compile {}` | `@auto` finding no manifest is not an error: `check: skipped (@auto found no manifest)`. ## Output shape ``` ── · replacement(s) · line · exact + ~ ── check: · sha:→[ · ] ``` `+` marks an inserted line, `~` a replaced line, in the marker column right after the line number. A reverted edit prints `REVERTED` after the line/replacement count and shows the rejected line with `← parse error` (or `← invalid json`/`yaml`/`toml`), then `check: failed → reverted · file unchanged`. `--quiet` returns only the footer line. ## Exit codes | Exit | Slug | Means | |---|---|---| | 0 | — | applied | | 1 | `not_found` | `--old` (or the target) matched nothing; nearest candidate shown | | 2 | `ambiguous` | `--old` matched more than once; every candidate listed as `path:line` | | 2 | `expect_refused` | `--expect` can't confirm a multi-line range, or its content didn't match | | 3 | `check_failed` | the guardrail reverted the edit; the file is unchanged | | 5 | `changed` | the file changed since the `--if sha:…` given | | 6 | `outside_tree` | the target is outside the working tree; pass `--allow-outside` | | 7 | `unsupported_file` / `locked` / `read_only` / `io_error` | binary, non-UTF-8 region, over size; lock held by another `lets`; missing owner-write bit; other I/O failure | | 8 | `partial_batch` | a batch partly landed; the footer names which files | | 64 | `usage` | malformed command line or batch input | ## Examples Edit and get the proof back in the same call — the `~` marks the changed line, and the check runs before the output is printed, not after, in a separate call: ```console $ lets edit src/usage.ts --old 'const cap = 10' --new 'const cap = 20' ── src/usage.ts · 1 replacement · line 5 · exact 3 export function usage(id: string) { 4 const now = Date.now() 5~ const cap = 20 6 if (!id) return 7 if (count(id) > cap) return ── check: structure ok · sha:75d31d847ffb→93b5daea8ace · ~45 tokens ``` Ambiguous match — every candidate listed, nothing written: ```console $ lets edit usage.ts --old 'return' --new 'return undefined' ? 2 usage.ts is ambiguous (3 candidates) usage.ts:4 if (!id) return usage.ts:7 if (now > cap) return usage.ts:9 return total ERROR_CODE=ambiguous ``` A broken edit is parsed, caught, and reverted in the same call: ```console $ lets edit main.go --old 'func' --new 'fun' ? 3 ── main.go · 1 replacement · line 2 · REVERTED 1 package main 2~ fun usage(id string) int { ← parse error 3 cap := 10 4 return cap + len(id) ── check: failed → reverted · file unchanged · sha:db91a17c0af6 structure check failed for main.go: failed ERROR_CODE=check_failed ``` A project-level checker (`@cargo`) reverts a rename that a structural check alone would miss, because it broke a caller in a sibling crate: ```console $ lets edit a/src/lib.rs --old 'pub fn double' --new 'pub fn twice' --check @cargo ? 3 command check failed for a/src/lib.rs: `cargo check --workspace --quiet --all-targets` passed before the batch and failed after it[..] · 1 file reverted ERROR_CODE=check_failed ``` Several edits across files in one call, validated before any file is written: ```console $ lets edit --from - <<'LETS' @@ src/config.ts <<<<<<< old export const usageCap = 10 ======= new export const usageLimit = 10 >>>>>>> @@ src/usage.ts <<<<<<< old import { usageCap } from './config' ======= new import { usageLimit } from './config' >>>>>>> LETS ── src/config.ts · 1 replacement · line 1 · exact 1~ export const usageLimit = 10 2 export const retries = 3 ── src/usage.ts · 1 replacement · line 1 · exact 1~ import { usageLimit } from './config' 2 3 export function usage(id: string) { ── 2 files · 2 edits · all applied · checks: structure ok ×2 · ~60 tokens ``` A stale `--if` hash is refused rather than silently overwritten: ```console $ lets edit usage.ts --old 'const cap = 10' --new 'const cap = 20' --if sha:000000000000 ? 5 usage.ts changed since sha:000000000000 (now sha:c5525cc20b61) ERROR_CODE=changed ``` Insert text anchored on a symbol, not a guessed line number: ```console $ lets edit usage.ts --insert-before '#usage' --new '/** Returns the running total for id. */' ── usage.ts · inserted 1 line before #usage (line 3) 1 import { usageCap } from './config' 2 import { clock } from './clock' 3+ /** Returns the running total for id. */ 4 export function usage(id: string) { 5 if (!id) return ── check: structure ok · sha:c5525cc20b61→45b5b2745608 ``` --- # `lets transform` Structured edits for structured files: JSON, YAML, TOML and markdown frontmatter. Parses, mutates and re-emits, preserving comments, key order and indentation. Replaces `jq`, `yq` and `sed` run against config files. ``` lets transform [OPTIONS] [FILE] ``` ## What it does `--set`, `--delete` and `--append` take a dotted path (`a.b[0].c`) and change one value in place without rewriting the rest of the file. The same structural guardrail that `edit` runs — a real parser for these four formats — runs before and after every change and reverts on failure. ## Flags | Flag | Meaning | Default | |---|---|---| | `--set ` | set a key's value (repeatable) | — | | `--delete ` | remove a key (repeatable) | — | | `--append ` | append to an array, or append a table to a TOML array of tables | — | | `--from -` | a JSONL batch on stdin (`-` is the only accepted value), one file's operations per line, validation-atomic like `edit --from -` | — | | `--if sha:<12+ hex>` | refuse (exit 5) if the file changed since the hash was taken | — | | `--check ` | same layer-2 checker as `edit` — see `/docs/edit/#checks-the-guardrail` | — | | `--check-timeout ` | timeout before a layer-2 result is `inconclusive` | 60 | | `--no-check` | skip the structured-format check | off | | `--json`, `--jsonl`, `--budget`, `--max-bytes`, `--max-file-bytes`, `--no-ignore`, `--allow-outside`, `-q/--quiet` | shared flags | see `lets transform --help` | ## Path syntax - Dotted keys with numeric indexes: `a.b[0].c`. - **`--set` keeps the existing type.** When `path` already holds a string, a numeric-looking value is still stored as a string; when it already holds a number or bool, the value is parsed as JSON. A brand-new key's number is written byte-for-byte as typed (`--set v=3.10` writes `3.10`, not `3.1`), since a version string and a decimal look identical on the command line. - **A quoted key is one literal key, not a path.** `--set '"editor.formatOnSave"=true'` sets the single key literally named `editor.formatOnSave`, distinct from the nested path `editor.formatOnSave`. - **Attribute selectors** — `plugins[name=gitty].version` — select the array element whose named field equals the given value. `lets` resolves the selector to a plain numeric index before any format-specific parser sees the path, and the footer names the resolution: `plugins[name=gitty] → plugins[2]`. No match is exit 1, listing the values the field actually held; more than one match is exit 2. - **`--append 'bin[]={"name":"b","path":"b.rs"}'`** on a TOML array of tables appends a new `[[bin]]` table after the last one, keeping existing formatting. ## Output shape ``` ── · · · line(s) ~ + - (deleted) ── check: ok · sha:→ ``` ## Exit codes Same table as `edit` ([exit codes](/docs/exit-codes/)), plus this verb's own reason for `unsupported_file`: a file that is not JSON, YAML, TOML or markdown-with-frontmatter, or a key that exists but can't be changed in place (a tagged YAML node, or one reached through an alias). | Exit | Slug | Means | |---|---|---| | 0 | — | applied | | 1 | `not_found` | the path, or an attribute selector's value, matched nothing | | 2 | `ambiguous` | an attribute selector matched more than one element | | 3 | `check_failed` | the guardrail reverted the change | | 5 | `changed` | the file changed since the `--if sha:…` given | | 6 | `outside_tree` | the target is outside the working tree | | 7 | `unsupported_file` | not a structured format, or an in-place-uneditable key | | 64 | `usage` | malformed command line, e.g. an out-of-range TOML integer | ## Examples Set two JSON values; comments and the rest of the file survive: ```console $ lets transform config.json --set features.e2e=false --set review.threads=3 ── config.json · json · set features.e2e, review.threads · lines 3, 4 1 { 2 // feature flags 3~ "features": { "e2e": false }, 4~ "review": { "threads": 3 } 5 } ── check: json ok · sha:bfe3e156b1ce→01c03fe96fd3 · ~21 tokens ``` Frontmatter is a YAML edit between the fences of a markdown file: ```console $ lets transform docs/note.md --set last_updated=2026.09.16 ── docs/note.md · frontmatter · set last_updated · line 4 2 title: Notes 3 tags: [moc] 4~ last_updated: 2026.09.16 5 --- 6 # Notes ── check: frontmatter ok · sha:5abff77ae5c2→60afc02190e6 ``` An attribute selector that matches two elements is refused, not guessed: ```console $ lets transform compose.yaml --set 'services[name=web].image=c' ? 2 services[name=web].image is ambiguous (2 candidates) compose.yaml:2 services[0].name="web" compose.yaml:4 services[1].name="web" ERROR_CODE=ambiguous ``` Append a TOML array-of-tables entry, formatting preserved: ```console $ lets transform Cargo.toml --append 'bin[]={"name":"b","path":"src/b.rs"}' ── Cargo.toml · toml · append bin · line 8 5 name = "a" 6 path = "src/a.rs" 7+ 8+ [[bin]] 9+ name = "b" 10+ path = "src/b.rs" ── check: toml ok · sha:[..]→[..] ``` Deleting a key shows it struck out with a `-` marker, then the surrounding lines: ```console $ lets transform config/settings.yaml --delete legacy.token ── config/settings.yaml · yaml · delete legacy.token · line 4 1 allow: 2 - ls 3~ legacy: {} 4- token: abc123 # remove me (deleted) ── check: yaml ok · sha:8246dbee199f→72dd850c63b5 ``` A YAML node that can't be edited in place (it's tagged) is refused rather than corrupted: ```console $ lets transform tags.yaml --append tags=wiki ? 7 tags.yaml is unsupported: tags cannot be changed in place (a tagged YAML node) ERROR_CODE=unsupported_file ``` --- # `lets write` Creates a file from stdin. The hook-visible replacement for `cat > file <<'EOF'`. ``` lets write [OPTIONS] ``` ## What it does Writes stdin to a new file, reports lines and bytes written and the new `sha:`, and runs the same layer-1 check `edit` runs on creation. It refuses to silently overwrite an existing file, and refuses to silently create an empty file — both need an explicit flag, because a heredoc typo that would clobber a file with `cat >` should not clobber one here either. ## Flags | Flag | Meaning | Default | |---|---|---| | `--force` | overwrite an existing file | off | | `--empty` | allow writing a file from empty stdin | off | | `--json`, `--jsonl` | structured output | off | | `--budget ` | shape the answer to ~N tokens | unset | | `--max-bytes ` | content budget | 65536 | | `--max-file-bytes ` | refuse content over this size | 8388608 | | `--no-ignore` | shared flag; not meaningful for a single new path | off | | `--allow-outside` | permit a path outside the working tree | off | | `--no-check` | skip the layer-1 check on the new file | off | | `-q, --quiet` | shared flag | off | ## Output shape ``` ── · created · lines · bytes · sha: ── check: ``` An overwrite (`--force`) prints `overwritten` and both the old and new `sha:`, joined by `→`. ## Exit codes | Exit | Slug | Means | |---|---|---| | 0 | — | created (or overwritten, with `--force`) | | 1 | `exists` | the file already exists; pass `--force` | | 1 | `empty_input` | stdin was empty; pass `--empty` | | 6 | `outside_tree` | the path is outside the working tree; pass `--allow-outside` | | 7 | `io_error` | any other I/O failure | ## Examples Create a file, checked on creation: ```console $ lets write scripts/new-check.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail EOF ── scripts/new-check.sh · created · 2 lines · 38 bytes · sha:[..] ── check: structure ok ``` Refuses to clobber an existing file: ```console $ lets write scripts/new-check.sh <<'EOF' #!/usr/bin/env bash echo hi EOF ? 1 scripts/new-check.sh exists (2 lines, sha:[..]) · pass --force to overwrite ERROR_CODE=exists ``` `--force` overwrites, and the header shows both hashes: ```console $ lets write scripts/new-check.sh --force <<'EOF' #!/usr/bin/env bash echo hi EOF ── scripts/new-check.sh · overwritten · 2 lines · 28 bytes · sha:954b9f40bbee→b53701120a2e ── check: structure ok ``` An empty file needs `--empty`, or it's refused as likely a mistake: ```console $ lets write f.txt ? 1 f.txt: refuses empty stdin without --empty ERROR_CODE=empty_input $ lets write f.txt --empty ── f.txt · created · 0 lines · 0 bytes · sha:[..] ── check: skipped (no grammar for .txt) ``` --- # Install and update ```console $ curl -fsSL https://raw.githubusercontent.com/mayberuk/lets/main/install.sh | sh ``` `install.sh` detects an existing `lets`, updates it in place if it's this project's build, and offers to wire up agent hooks. It refuses, naming the one it found, if a different `lets` is already first on `PATH`. Its own flags: ``` Usage: install.sh [--version vX.Y.Z] [--hooks=claude-code,codex | --no-hooks] [--check] [--uninstall] [--yes] Installs into $LETS_BIN_DIR, default ~/.local/bin. ``` - `--version vX.Y.Z` installs a specific release instead of the latest. - `--hooks=claude-code,codex` installs the named agent hooks non-interactively; `--no-hooks` skips hook setup entirely. With neither given, the script asks interactively (see [the hooks reference](/docs/hooks/) for what each hook does). - `--check` reports whether a newer release exists (via `lets update --check`) without installing anything. - `--uninstall` removes any agent hooks this script installed, then deletes the binary. - `--yes` assumes yes to every interactive prompt, for non-interactive installs. Linux and macOS. Or fetch a release binary directly, the `dist`-generated one-liner used by `install.sh` itself under the hood: ```console $ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/mayberuk/lets/releases/latest/download/lets-installer.sh | sh ``` ## `lets update` ``` lets update [OPTIONS] ``` | Flag | Meaning | Default | |---|---|---| | `--check` | report whether a newer release exists, without installing it | off | | `--force` | reinstall the latest release even when this one is already it | off | `update` refuses, naming the one it found, when a different `lets` comes first on `PATH`. `update --check` only compares versions against the latest release and never touches `PATH`, so it does not refuse this way. | Exit | Slug | Means | |---|---|---| | 0 | — | up to date, or updated | | 1 | `update_available` | `--check` found a newer release; nothing was installed | | 1 | `path_conflict` | a different `lets` comes first on `PATH`; named in the message | | 1 | `no_repository` | this build names no release repository | | 7 | `update_failed` | no release for this platform, the download failed, or the installer exited non-zero | ```console $ lets update --check lets 0.0.1 is the latest release $ lets update --check ? 1 lets 0.0.1 · latest 999.0.0 lets 0.0.1 · latest 999.0.0 · run `lets update` ERROR_CODE=update_available $ lets update replaced with the latest release ``` ## `lets version` ```console $ lets version lets 0.0.1 $ lets version --json {"version":"0.0.1"} ``` `--json`/`--jsonl` carry the version string alone, with no `omitted`/`stats` — see [--json and --jsonl](/docs/json/). ## Agent hooks `install.sh` can wire up an agent's own settings during install (`--hooks=`/`--no-hooks`/`--yes` above), or it can be done separately at any time with `lets hooks install claude-code` or `lets hooks install codex`. See [the hooks reference](/docs/hooks/) for what each hook does and how to uninstall it. ## Uninstall ```console $ curl -fsSL https://raw.githubusercontent.com/mayberuk/lets/main/install.sh | sh -s -- --uninstall ``` Removes any agent hooks `install.sh` added, then deletes the binary from `$LETS_BIN_DIR`. --- # `lets hooks` and `lets hook classify` How `lets` reaches an agent without asking it to remember a prose rule. Two commands: `lets hooks install|uninstall ` wires the integration into the agent's own settings; `lets hook classify` is the program those hooks call on every shell command. ``` lets hooks install lets hooks uninstall lets hook classify ``` ## What `hooks install` does **`claude-code`** merges three hooks into `~/.claude/settings.json`: - A `SessionStart` hook (matcher `startup|resume|clear|compact|fork`) that prints a short paragraph explaining `lets` and its verbs at the start of every session — including after compaction, since injected context does not survive compaction the way a system prompt does. - A `SubagentStart` hook that delivers the same paragraph as `additionalContext`, since `--append-system-prompt` does not reach a non-fork subagent. - A `PreToolUse` hook on `Bash`, matcher covering every shell call, that pipes the command to `lets hook classify` and blocks only a bare read it can classify with confidence **and** that has a runnable `lets` replacement — a `cat`, `sed -n` or `grep` of a repo file whose stdout goes to the tool result, not into a pipe or a subshell, plus a bare `sed -i 's/OLD/NEW/g'` substitution against an in-tree file, replaced with `lets edit --old '' --new '' --all`. **`codex`** merges a `PreToolUse` entry into `hooks.json` (default `$CODEX_HOME/hooks.json`, or `~/.codex/hooks.json`) and prints one paragraph to add to `~/.codex/AGENTS.md` by hand — Codex has no session-start hook to inject a paragraph automatically the way Claude Code does. Both installers only ever write into the agent's own settings; nothing modifies your shell profile. Installing twice is a no-op — the settings file is byte-identical across a reinstall. `hooks uninstall` removes exactly what `hooks install` added and leaves any of the user's own hooks in the same file untouched. ## What `lets hook classify` does Reads one `PreToolUse` JSON event (`session_id`, `cwd`, `hook_event_name`, `tool_name`, `tool_input.command`) on stdin. For an allowed command it prints nothing and exits 0 — the command runs unmodified. For a blocked command it prints one JSON line on stdout naming the `lets` replacement, in the shape Claude Code's `PreToolUse` hook contract expects (`hookSpecificOutput.permissionDecision: "deny"`). The classifier parses the command with a real bash grammar, walks pipelines, `&&`/`;` lists and substitutions, and blocks only what it can translate with confidence. It fails open: `lets` missing, crashing, or mid-update degrades every hook to allow, never an error that could wedge an agent's turn. What passes unblocked, deliberately: output piped into another program (`cat f | jq`, `cat f | wc`), a command substitution or process substitution (`$(cat f)`, `<(cat f)`), a heredoc sent to another program's stdin, and any path outside the working tree. A `sed -i` substitution is blocked only in its narrowest form: a bare `sed -i 's/OLD/NEW/g'` (the `g` flag is required), every target path in-tree, non-glob, resolvable, and neither a directory nor duplicated, and a substitution that is literal — no regex metacharacters, no line-range prefix. Anything looser (a different `-i` suffix, a non-global substitution, a regex with `.*` or `&`) passes through unblocked, since a wrong translation is worse than none. ## Flags Both `hooks install`/`hooks uninstall` and `hook classify` take only the shared flags (`--json`, `--jsonl`, `--budget`, `--max-bytes`, `--max-file-bytes`, `--no-ignore`, `--allow-outside`, `--no-check`, `-q/--quiet`); `hook classify` takes its event as stdin, not as a flag. ## Exit codes | Exit | Slug | Means | |---|---|---| | 0 | — | installed, uninstalled, or nothing to remove | | 1 | `path_conflict` | a different `lets` comes first on `PATH`; named in the message, nothing written | | 1 | `not_on_path` | no `lets` reachable on `PATH` at all, so a hook could not run it | | 7 | `io_error` | the settings file could not be read or written | ## Examples Installing for Claude Code adds all three hooks in one call: ```console $ lets hooks install claude-code added the PreToolUse hook added the SubagentStart hook added the SessionStart hook ``` A second install is a no-op, reported as such, and the settings file does not change: ```console $ lets hooks install claude-code the PreToolUse hook was already installed the SubagentStart hook was already installed the SessionStart hook was already installed ``` Installing when a different `lets` shadows this one on `PATH` refuses and names it: ```console $ lets hooks install claude-code ? 1 a different `lets` comes first on PATH at [CWD]/fake-bin/lets — this one is [..]/lets · remove the other, or put this one's directory ahead of it on PATH ERROR_CODE=path_conflict ``` `lets hook classify` blocking a bare `cat`, then the replacement it named: ```console $ printf '{"session_id":"s","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat src/usage.ts"}}' "$(pwd)" | lets hook classify {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"lets show reads several files and ranges in one call.\nrun: lets show src/usage.ts"}} $ lets show src/usage.ts ── src/usage.ts (1-9 of 9) · sha:9925e474e391 1 const cap = 10 ... ── showed 1 target · 9 lines ``` `lets hook classify` letting a piped `cat` through untouched — its stdout never reaches the tool result, so there is nothing to replace: ```console $ printf '{"session_id":"s","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat config/app.json | wc -l"}}' "$(pwd)" | lets hook classify ``` (stdout is empty, exit code 0 — the command runs unmodified.) Installing for Codex adds the `PreToolUse` hook and prints the one line to add by hand: ```console $ lets hooks install codex added the PreToolUse hook For reading, finding and editing files, use `lets` (run `lets guide` once) instead of `cat`, `grep` or `sed -n`. It reads several files or ranges in one call, returns bounded numbered output, and its edits return the changed region — so do not follow a `lets` call with a `cat` or `sed -n` to check the result. add this to ~/.codex/AGENTS.md by hand ``` --- # `lets guide` ``` lets guide ``` Prints one screen: every verb, the target grammar, and the exit-code shorthand, so an agent (or a person) reads it once and has the whole surface. This is the same text a `SessionStart` hook installed by `lets hooks install claude-code` shows at the start of every session — see [the hooks reference](/docs/hooks/). ```console $ lets guide lets — Locate · Edit · Transform · Show one call, bounded output, post-state returned show ... read files, ranges, anchors, symbols — several per call find [path]... search; hits print as path:line; capped at 50, says so -F/-i/-w · -A/-B/-C context · --files/-l · --count/-c grep's -n/--line-number -r -R -E -H are accepted as no-ops edit --old --new exact-once replace; --all; --insert-after; --from - for batches transform --set k=v --append k=v; JSON/YAML/TOML/frontmatter keys, formatting preserved write < stdin create a file; refuses to overwrite without --force targets f.ts f.ts:40-80 "f.ts@'regex'" -A 20 f.ts#funcName f.md#'Heading' exits 0 done · 1 none/over cap · 2 ambiguous · 3 check failed (reverted) · 4 over budget 5 changed since --if · 6 outside tree · 7 unsupported file · 8 batch partly written after an edit the changed region is in the output — do not cat or sed -n to check it ``` `lets guide --json` carries the same text under one key, with no `omitted`/`stats` — there is nothing to omit and no file-derived cost to report: `{"guide":"lets — Locate · Edit · Transform · Show ..."}`. See [--json and --jsonl](/docs/json/). --- # `lets stats` ``` lets stats [OPTIONS] ``` Scans Claude Code session transcripts (`.jsonl` files) and reports how much `lets` and its hook were used — no transcript text, path or content leaves the scan; only counts and verb names reach the report. ## Flags | Flag | Meaning | Default | |---|---|---| | `--dir ` | directory to scan for `.jsonl` transcripts | `~/.claude/projects` | | `--since ` | only scan files modified within this window, e.g. `7d` or `12h` — days or hours only | unset (all files) | | `--json` / `--jsonl` | print the raw `StatsReport` object instead of a text table | off | | `--budget`, `--max-bytes`, `--max-file-bytes`, `--no-ignore`, `--allow-outside`, `--no-check`, `-q/--quiet` | shared flags; `stats` has no files to check or bound, so most have no effect | see `lets stats --help` | ## What it counts | Field | Means | |---|---| | `sessions` | number of transcript files scanned | | `bash_calls` | total `Bash` tool calls seen | | `lets_calls` | `Bash` calls whose command was a `lets` invocation, by verb | | `hook_blocks` | `Bash` tool results that are a hook denial (text starting `run: `) | | `blocks_followed` | of those blocks, how many were followed by a `Bash` call matching the suggested verb | | `calls_saved` | sum of `targets - 1` over every multi-target `lets` call — `show a.ts b.ts c.ts` saves 2 | | `read_calls` / `read_bytes` | `Read` tool calls and the bytes they returned | | `skipped` | malformed or non-UTF-8 lines, shown only when nonzero (`malformed_lines`, `non_utf8_lines`, `unreadable_files`, `walk_errors`) | ## Output shape Text table: ```console $ lets stats --dir . sessions 1 bash calls 5 lets calls 3 edit 2 show 1 hook blocks 1 blocks followed 1 calls saved 1 read calls 1 read bytes 10 ``` `--json` prints the `StatsReport` struct directly, not wrapped in the `{"…":…, "omitted":[],"stats":{...}}` envelope other verbs use — there is no per-call cost to report, since `stats` is itself the report: ```console $ lets stats --dir . --json {"sessions":1,"bash_calls":1,"lets_calls":{"find":1},"hook_blocks":0,"blocks_followed":0,"calls_saved":0,"read_calls":1,"read_bytes":3} ``` With skips: ```console $ lets stats --dir . sessions 1 bash calls 1 lets calls 1 show 1 hook blocks 0 blocks followed 0 calls saved 0 read calls 0 read bytes 0 ── skipped: 1 non-UTF-8 line $ lets stats --dir . --json {"sessions":1,"bash_calls":1,"lets_calls":{"show":1},"hook_blocks":0,"blocks_followed":0,"calls_saved":0,"read_calls":0,"read_bytes":0,"skipped":{"non_utf8_lines":1}} ``` ## `LETS_NO_STATS` and `LETS_TOKEN_RATIO` These two env vars govern a different, unrelated field: the per-call `stats.tokens_est` estimate that `show`, `find`, `edit`, `transform` and `write` carry in their own output (bytes ÷ 4, or ÷ `LETS_TOKEN_RATIO` if set; `null` under `LETS_NO_STATS=1`). They do not affect what `lets stats` itself counts — the name is a coincidence, not a shared mechanism. See [--json and --jsonl](/docs/json/) for `stats.tokens_est`. --- # Target grammar One grammar, spoken by `show`, `edit` and `transform`. `find` takes a plain path or directory, not a target, but every `find` hit prints as `path:line`, which is itself a valid target for a following `show` or `edit` call. | Form | Means | |---|---| | `path` | whole file, windowed to `--window` lines (`show`) | | `path:40` | one line; add `-A`/`-B`/`-C` for context on `show` | | `path:40-80` | a line range; `edit` accepts a range only with `--expect`, `--expect-all` or `--if` | | `path@'regex'` | the first line matching the regex, with `-A`/`-B`/`-C` context; `path@'regex'+2` is the second match | | `path#name` | the enclosing symbol: function, method, type, class, or a markdown heading | | `path#Outer.inner` | a nested symbol | ## File names win over metacharacters A target string that names an existing file, whole, is a path — checked before any metacharacter is parsed. `lets show 'C#.md'` opens the file `C#.md`; only when no such file exists is `#` read as the symbol separator. Verified: ```console $ lets show 'C#.md' ── C#.md (1-3 of 3) · sha:[..] 1 one 2 two 3 three ── showed 1 target · 3 lines $ lets show 'C#.md:2' ── C#.md:2 (2-2 of 2) · sha:[..] 2 b ── showed 1 target · 1 line ``` When the whole string is not an existing file, `lets` looks for the longest prefix that ends right before a `#`, `@` or `:`, names an existing file, and leaves a well-formed suffix (`#name`, `@'regex'[+N]`, `:N` or `:N-M`). Only that remainder is read as the suffix. When no prefix qualifies, the string parses by metacharacter as the table above shows, so `a.rs#main` is the symbol `main` in `a.rs`. ## Symbol resolution (`#name`) Tried in order: 1. **tree-sitter**, for Go, TypeScript, TSX, JavaScript, Python, Rust, Markdown, JSON, YAML, TOML, Shell, C, C++, C#, Java, PHP, Ruby and Swift. `.jsonc` files resolve `#symbol` with the JSON grammar. The footer names this resolver `via tree-sitter`. 2. **A plaintext heuristic**, for any other text file. `#name` finds lines where `name` is a whole word directly after one of the keywords `fn func function def class struct interface enum type fun trait impl object module sub proc record const let val var`, or directly before an opening `(`. The span is found by brace matching first, then by indentation, then falls back to the single matching line. The footer names this resolver `via heuristic (plaintext)`. Kotlin (`.kt`) always uses this path — there is no tree-sitter grammar crate for it that both passes the project's compatibility test and builds. 3. **`@'regex'`** is always available as a fallback for any file. More than one candidate line for a symbol is exit 2 with every candidate listed as `path:line`; no candidate is exit 1 with a hint to use `@'regex'` instead. ```console $ lets show 'store.go#Open' ? 2 store.go#Open is ambiguous (2 candidates) store.go:44 func Open(path string) (*Store, error) { store.go:213 func (s *Store) Open(ctx context.Context) error { ERROR_CODE=ambiguous $ lets show 'greet.kt#greet' ── greet.kt#greet (3-5 of 7 · via heuristic (plaintext)) · sha:[..] 3 fun greet(name: String): String { 4 return "hi $name" 5 } ── showed 1 target · 3 lines ``` Because the end of a plaintext span is a guess rather than a parsed boundary, `edit --insert-after` on a plaintext symbol is refused (`ERROR_CODE=guessed_span`) unless the span came from brace matching; `--insert-before` is unaffected, since it only needs the start. ## A target that doesn't parse gets a diagnosis, not a bare "not found" `lets` recognizes four common habits typed against a file that does exist, and suggests the form it would have accepted, exit 1 `ERROR_CODE=not_found`: | Typed | Reads as | Suggestion | |---|---|---| | `path:40,60` | the `sed` comma habit | `path:40-60` | | `path:40:60` | the colon habit / grep's `file:line:col` | `path:40-60` | | `path:name` | — | `path#name` | | `path@word` (shell stripped the quotes) | — | `path@'word'` | ```console $ lets show a.ts:40,60 ? 1 a.ts:40,60: no such file · did you mean a.ts:40-60 ERROR_CODE=not_found $ lets show a.ts@cap ? 1 a.ts@cap: no such file · did you mean "a.ts@'cap'" ERROR_CODE=not_found ``` When no prefix of the string names a file at all, the message is the plain one: `nope.ts: No such file or directory (os error 2)`. A directory given as a target is not a target: exit 7 `ERROR_CODE=unsupported_file`, naming `lets find` as the way to search it. ## `@'regex'` with no match falls back to `find`'s reading A `@'regex'` that matches no line is retried with the same grep-style second reading `find` gives a pattern that matched nothing (see [lets find](/docs/find/)): `show f.go@'A\|B'` shows the first line matching `A|B`, and the footer names the reading used. ## `find` output is made of targets Every `find` hit prints as `path:line`, so the next call needs no read — pass that string straight to `show` or `edit`. --- # Exit codes Every non-zero exit writes one diagnostic line per failure to stderr, and the last line is always `ERROR_CODE=`. Branch on the exit code (or the slug), not on the message text — the message can change wording; the slug does not. Content and a non-zero exit are not exclusive: a partly successful `show` or a batch that landed some of its edits prints what it did, then still exits non-zero, so stdout stays worth reading even on failure. | Exit | Slug | Means | Seen from | |---|---|---|---| | 0 | — | done | all verbs | | 1 | `not_found` | a target, `--old`, or a pattern matched nothing; the nearest candidate is shown where one exists | `show`, `edit`, `transform`, `find` | | 1 | `over_cap` | `find` had more than 50 hits (or `--cap`'s value) and printed the top-files map instead | `find` | | 1 | `invalid_pattern` | the regex does not parse | `find` | | 1 | `exists` | `write` would overwrite an existing file; pass `--force` | `write` | | 1 | `empty_input` | `write` got empty stdin; pass `--empty` | `write` | | 1 | `empty_file` | `edit`'s `--old` matched nothing because the target file is empty; write it first with `lets write --force` | `edit` | | 1 | `mixed_endings` | `--old` spans more than one line and the file mixes CRLF and LF; match one line at a time, or type `\r\n` literally | `edit` | | 1 | `no_grammar` | a `#symbol` target on a file whose extension has no bundled grammar and no plaintext match; the message names the extension and a fallback target form | `show`, `edit`, `transform` | | 1 | `update_available` | `update --check` found a newer release; nothing was installed | `update` | | 1 | `path_conflict` | `hooks install` or `update`: a different `lets` comes first on `PATH`; named in the message, nothing written | `hooks install`, `update` | | 1 | `not_on_path` | `hooks install`: no `lets` reachable on `PATH` at all | `hooks install` | | 1 | `no_repository` | `update`: this build names no release repository | `update` | | 1 | `guessed_span` | `edit --insert-after` targeted a plaintext-heuristic symbol whose span end is a guess, not a parsed boundary; use `--insert-before` or anchor on the last line | `edit` | | 2 | `ambiguous` | the target, `--old`, or an attribute selector matched more than once; every candidate listed as `path:line`, at most 20 with `(+N more)` | `show`, `edit`, `transform` | | 2 | `expect_refused` | `--expect` can't confirm a multi-line range, or its content didn't match what's on disk | `edit` | | 3 | `check_failed` | the guardrail failed after the edit; the file was reverted and is unchanged | `edit`, `transform` | | 4 | `over_budget` | content exceeded `--max-bytes` and no `--budget` was given | `show`, `find` | | 5 | `changed` | the file changed since the `--if sha:…` it was given | `edit`, `transform` | | 6 | `outside_tree` | a target or write is outside the working tree; pass `--allow-outside` | `show`, `find`, `edit`, `transform`, `write` | | 7 | `unsupported_file` | binary, hardlinked, non-UTF-8 in the matched region, over `--max-file-bytes`, a directory given as a target, or (for `transform`) not a structured format, or a key that can't be changed in place | `show`, `edit`, `transform` | | 7 | `locked` | another `lets` process holds the file's lock past the 2-second retry | `edit`, `transform`, `write` | | 7 | `read_only` | the file lacks the owner-write bit; `chmod u+w` is the fix named in the message | `edit`, `transform`, `write` | | 7 | `io_error` | any other I/O failure; a stdout write that fails because the reader closed the pipe is exit 0, not this | `edit`, `transform`, `write`, `hooks` | | 7 | `update_failed` | `update`: no release for this platform, the download failed, or the installer exited non-zero | `update` | | 8 | `partial_batch` | some files of a batch landed; the footer names which | `edit`, `transform` | | 64 | `usage` | the command line, or a batch's `--from -` input, is malformed — an unknown JSONL key, a short `--if`, an unrecognized `--check` preset, `find -v`, two conflicting stdin flags | all verbs | A missing file reaches exit 1 (`not_found`) whichever way it failed, so a caller can branch on the code alone without parsing the message. Exit 64 is reserved for a malformed request — never exit 2, which is reserved for an ambiguity or a content refusal about what's already on disk. `update --check` (report only, no `not_on_path`/`path_conflict` refusal applies since nothing is written) and `hooks uninstall` (exit 7 `io_error` if the settings file can't be read or written, leaving it untouched) are not in this table's "seen from" column separately; they share the exits above their sibling command uses. --- # `--json` and `--jsonl` Every verb that reports on files takes `--json` (one object on stdout) or `--jsonl` (one object per target, hit, file, count row or edit, followed by one trailing object carrying `stats` and `omitted`). The same information renders as text, `--json` or `--jsonl` — a field exists in all three or in none. Shapes below are copied from real `lets` 0.0.1 output (`LETS_NO_STATS=1`, which is why `tokens_est` reads `null`). ## `show --json` ```json {"targets":[{"target":"usage.ts","path":"usage.ts","start":1,"end":9,"total":9, "sha":"14dc685cb937","lines":[{"number":1,"marker":"none","text":"import { usageCap } from './config'"}]}], "omitted":[],"stats":{"lines":9,"bytes":189,"tokens_est":null}} ``` A `#symbol` target adds a `"resolver"` key (`"tree-sitter"` or `"via heuristic (plaintext)"` text, depending on the verb). `--jsonl` prints one such object per target, no `targets` wrapper, then `{"stats":{...},"omitted":[]}`. ## `find --json` ```json {"targets":[{"target":"usage.ts","path":"usage.ts", "lines":[{"number":1,"marker":"hit","text":"import { «usageCap» } from './config'"}]}], "omitted":[],"stats":{"lines":1,"bytes":40,"tokens_est":null}} ``` `--files` is `{"files":["usage.ts"],"omitted":[],"stats":{...}}`. `--count` is `{"counts":[{"count":1,"path":"usage.ts"}],"omitted":[],"stats":{...}}`. Over the cap, `targets` is empty and the top-files map appears under its own key, named in `omitted` too: ```json {"targets":[], "omitted":[{"hit_cap":{"hits":60,"cap":50}},{"top_files":{"shown":1}}], "stats":{"lines":0,"bytes":0,"tokens_est":null}, "top_files":[{"count":60,"path":"many.txt"}]} ``` ## `edit --json`, one file ```json {"path":"usage.ts","replacements":1,"lines":[5],"match":"exact", "region":{"start":3,"end":7,"lines":[ {"number":3,"marker":"none","text":"export function usage(id: string) {"}, {"number":5,"marker":"replaced","text":" const cap = 20"}]}, "check":{"layer":"structure","status":"ok","errors_before":0,"errors_after":0}, "sha":{"before":"14dc685cb937","after":"9d77b7d8a9f6"}, "omitted":[],"stats":{"lines":5,"bytes":178,"tokens_est":null}} ``` An insert carries `"inserted"` and `"anchor"` in place of `"replacements"`. A line's `"marker"` is `"none"`, `"replaced"`, or (for an insert) `"inserted"`. `--jsonl` on a multi-file batch prints one such object per file, then the trailing `{"stats":...,"omitted":[]}` record. ## `transform --json` ```json {"path":"config.json","format":"json","operations":[{"op":"set","key":"review.threads"}], "lines":[6], "region":{"start":4,"end":8,"lines":[{"number":6,"marker":"replaced","text":" \"threads\": 3"}]}, "check":{"layer":"json","status":"ok","errors_before":0,"errors_after":0}, "sha":{"before":"5e9ec954d099","after":"13adcd49eab3"}, "omitted":[],"stats":{"lines":5,"bytes":42,"tokens_est":null}} ``` ## `write --json` ```json {"path":"new.txt","outcome":"created","lines":1,"bytes":6,"sha":"8e4c7c1b99db", "omitted":[{"check_skipped":{"reason":"no grammar for .txt"}}], "stats":{"lines":1,"bytes":6,"tokens_est":null}} ``` `"outcome"` is `"created"`, `"overwritten"`, or (on a failed call that still had something to report, such as a refused overwrite) `"exists"`. ## `guide --json` and `version --json` These two carry their text alone, with no `omitted`/`stats` — there is nothing to omit and no file-derived cost to report: ```json {"guide":"lets — Locate · Edit · Transform · Show ..."} {"version":"0.0.1"} ``` ## The `omitted` array The machine-readable half of the footer: a unit omission is a bare string (e.g. `"normalized"`), one with detail is a single-key object. `[]` means nothing was left out. Observed shapes: | Shape | Means | |---|---| | `{"hit_cap":{"hits":60,"cap":50}}` | `find` was over its cap | | `{"top_files":{"shown":10}}` | the over-cap map showed this many files | | `{"check_skipped":{"reason":"no grammar for .txt"}}` | no checker ran for this file type | | `"normalized"` | a `--normalize` match was used | | `{"partial_batch":{"written":["src/a.ts"]}}` | some files of a batch landed before a failure | ## The error shape A call that fails with no other stdout to report — every target missing, a batch that never got to write anything — still produces one JSON object, so a `--json` caller never has to fall back to parsing stderr: ```json {"error":{"slug":"not_found","message":"--old not found in usage.ts\n nearest: line 5\t const cap = 20"}, "omitted":[],"stats":{"lines":0,"bytes":0,"tokens_est":null}} ``` `error.slug` is the same slug `ERROR_CODE=` prints on stderr — see [exit codes](/docs/exit-codes/) for the full table. A call that produces some real output alongside a partial failure keeps its normal shape instead of this one. ## `stats.tokens_est` Bytes ÷ 4, always an estimate. `LETS_TOKEN_RATIO` overrides the divisor; `LETS_NO_STATS=1` makes it `null` instead, for a caller diffing two runs that doesn't want the estimate to be part of the diff.