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.
Flags
| Flag | Meaning | Default |
|---|---|---|
--old <S> |
exact text to find; - reads all of stdin as the content instead of matching it literally |
— |
--new <S> |
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 <S> |
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 <target> |
insert --new after an anchor (@'regex' or #symbol); no line-number insert |
— |
--insert-before <target> |
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 <cmd|@preset> |
layer-2 checker: a real command, or one of @auto, @cargo, @go, @tsc, @py |
— |
--check-timeout <secs> |
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 | 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 '<cmd>'. 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: <cmd> 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 <dir> |
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
── <path> · <N> replacement(s) · line <n> · exact
<n> <context line>
<n>+ <inserted line>
<n>~ <replaced line>
── check: <result> · sha:<before>→<after>[ · <cost>]
+ 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:
$ 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:
$ 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:
$ 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:
$ 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:
$ 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:
$ 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:
$ 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
Back to the top of lets edit