GitHub

README.md — Text Editor

View as markdown

One call instead of three.

lets replaces cat, grep and sed for your coding agent, and every answer says what it left out.

Locate Edit Transform Show

  1. curl -fsSL https://raw.githubusercontent.com/mayberuk/lets/main/install.sh | sh
  2. lets hooks install claude-code

Linux and macOS · MIT or Apache-2.0 · Codex: lets hooks install codex · Docs

anatomy.svg — Image Viewer

3calls
before

  1. sed -n '3,9p' src/usage.ts
  2. Edit {"old_string": "const cap = 10", …}
  3. sed -n '3,9p' src/usage.ts

1call with
lets

$ 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
  1. Where it landed: file, replacement count and line.
  2. The edit in place, two lines either side.
  3. The line as it now reads on disk, so no re-read.
  4. Parsed before and after; a break reverts in this call.
  5. Hash before and after; with --if, the next edit refuses if the file moved.
One edit, its output pulled apartReal output · 100%

anatomy.svg is minimised.

Seven turns become two.

The same rename, run by an agent with and without lets. Flip the switch.

Same task, two agents

Rename usageCap to usageLimit in two files, and confirm it.

turnslets off
2 turnswith lets

lets off: 7 turns, one command each

  1. 1
    grep -rn 'usageCap' .find the uses
  2. 2
    cat src/config.tsread the first file
  3. 3
    cat src/usage.tsread the second
  4. 4
    Edit {"file_path": "src/config.ts", …}edit
  5. 5
    Edit {"file_path": "src/usage.ts", …}edit
  6. 6
    cat src/config.tsre-read to confirm
  7. 7
    cat src/usage.tsre-read to confirm

lets on: 2 turns

  1. 1
    $ 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
  2. 2
    $ lets edit --from - <<'LETS'
    The batch: 2 edits, 12 lines
    @@ 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

The lets output is real, from a demo project. The stock turns follow habits found in 1,502 real Claude Code sessions.

Same task, two agents is minimised.

Without it, the agent finds, reads, edits and re-reads one file at a time. With it, one search and one checked batch edit.

Try it on a two-file project.

Run one of these in the terminal beside, or type your own. Each answer is output recorded from lets 0.0.1 on a demo project, replayed here.

  • lets show src/usage.ts#usageone function, found by parsing
  • lets find usageCapevery hit, grouped by file
  • lets edit src/usage.ts --old 'const cap = 10' --new 'const cap = 20'an edit, checked, with its proof
  • lets edit src/usage.ts --old 'const cap = 15' …a miss names the nearest line
  • lets edit … --new 'return total(id, now))'a broken edit puts itself back

Run the edit twice: the second time the text has changed, so lets reports the nearest line instead of guessing. reset puts the project back.

demo — Terminal (simulated)

demo $ lets show src/usage.ts#usage ── src/usage.ts#usage (3-9 of 13 · via tree-sitter) · sha:6fae9e67700e 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 demo $ 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:6fae9e67700e→06aa54dff860 · ~45 tokens This transcript is static without JavaScript; with it, the terminal takes commands.

A simulation: it replays output recorded from the real lets 0.0.1 binary on a two-file demo project. Nothing runs on your machine.

The terminal is minimised.

Each answer arrives whole.

Seven everyday jobs, compared: the turns an agent takes today on the left, the one lets call that replaces them on the right. Every right-hand pane is real output.

without lets ↔ with lets — Compare

Two files, one call.

Every line comes back numbered, and each header says which lines you see out of how many.

Without lets2 turns

  1. cat src/usage.tsread one file Made unnecessary by: ── src/usage.ts (1-13 of 13) · sha:6fae9e67700e
  2. cat src/config.tsthen the next Made unnecessary by: ── src/config.ts (1-2 of 2) · sha:4f49d457dfea

With lets1 call

$ lets show src/usage.ts src/config.ts── src/usage.ts  (1-13 of 13) · sha:6fae9e67700e
 1 	import { usageCap } from './config'
 2 	
 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 	}
10 	
11 	function total(id: string, now: number) {
12 	  return now - count(id)
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 · ~77 tokens

Just the function, found by parsing.

No grep for where it starts and no guess at where it ends.

Without lets2 turns

  1. grep -n 'function usage' src/usage.tsfind where it starts Made unnecessary by: ── src/usage.ts#usage (3-9 of 13 · via tree-sitter) · sha:6
  2. sed -n '3,20p' src/usage.tsguess where it ends Made unnecessary by: 3 export function usage(id: string) {

With lets1 call

$ lets show src/usage.ts#usage── src/usage.ts#usage  (3-9 of 13 · via tree-sitter) · sha:6fae9e67700e3 	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

Every hit, with the lines around it.

One search returns the hits and their context, grouped by file.

Without lets2 turns

  1. grep -rn usageCap .find the uses Made unnecessary by: ── src/config.ts
  2. sed -n '1,4p' src/usage.tsread around a hit Made unnecessary by: ── src/usage.ts

With lets1 call

$ lets find usageCap -C 3── src/config.ts
1:	export const «usageCap» = 10
2-	export const retries = 3── src/usage.ts
1:	import { «usageCap» } from './config'
2-	
3-	export function usage(id: string) {
4-	  const now = Date.now()── 2 hits in 2 files · searched 2 files · ~39 tokens

A miss names the nearest line.

Exit 1 shows the closest match, so the agent fixes its text without reading the whole file.

Without lets2 turns

  1. Edit {"old_string": "const cap = 15", …}String to replace not found Made unnecessary by: --old not found in src/usage.ts
  2. cat src/usage.tsre-read to see what is there Made unnecessary by: nearest: line 5 const cap = 10

With lets1 call

$ lets edit src/usage.ts --old 'const cap = 15' --new 'const cap = 20'--old not found in src/usage.ts  nearest: line 5	  const cap = 10ERROR_CODE=not_found

A broken edit puts itself back.

The file is parsed before and after; a new parse error reverts the edit in the same call.

Without lets3 turns

  1. sed -i 's/total(id, now)/total(id, now))/' src/usage.tslands broken Made unnecessary by: ── src/usage.ts · 1 replacement · line 8 · REVERTED
  2. npm testfails, several turns later Made unnecessary by: 8~ return total(id, now)) ← parse error
  3. Edit {"old_string": "total(id, now))", …}undo it by hand Made unnecessary by: ── check: failed → reverted · file unchanged · sha:6fae9e677

With lets1 call

$ 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 	}
10 	── check: failed → reverted · file unchanged · sha:6fae9e67700e · ~40 tokensstructure check failed for src/usage.ts: failed
ERROR_CODE=check_failed

Two files change together, or neither does.

One batch on stdin; every edit is matched and checked before any file is written.

Without lets4 turns

  1. Edit {"file_path": "src/config.ts", …}edit one file Made unnecessary by: ── src/config.ts · 1 replacement · line 1 · exact
  2. Edit {"file_path": "src/usage.ts", …}edit the other Made unnecessary by: ── src/usage.ts · 1 replacement · line 1 · exact
  3. cat src/config.tsre-read to confirm Made unnecessary by: ── 2 files · 2 edits · all applied · checks: structure ok ×2
  4. cat src/usage.tsre-read to confirm Made unnecessary by: ── 2 files · 2 edits · all applied · checks: structure ok ×2

With lets1 call

$ lets edit --from - <<'LETS'the 12-line batch on stdin is folded on this page── 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

Set a value; comments and order stay.

transform edits JSON, YAML, TOML and frontmatter by key, then checks the file still parses.

Without lets3 turns

  1. cat package.jsonread it Made unnecessary by: ── package.json · json · set version · line 3
  2. Edit {"old_string": "\"version\": \"1.3.0\"", …}edit by exact text Made unnecessary by: 3~ "version": "1.4.0",
  3. cat package.jsonconfirm it still parses Made unnecessary by: ── check: json ok · sha:cac96a379708→6c53277fd361 · ~21 toke

With lets1 call

$ lets transform package.json --set version=1.4.0── package.json · json · set version · line 31 	{
2 	  "name": "usage-meter",3~	  "version": "1.4.0",4 	  "scripts": {
5 	    "test": "vitest"── check: json ok · sha:cac96a379708→6c53277fd361 · ~21 tokens

Compare is minimised.

Fewer calls, faster, same or better accuracy.

Five tasks on a private 17.7k-file Go monorepo: a stock Claude Code session against one with the lets hook, nothing else changed.

If the footer doesn’t name a cut, nothing was cut.

Hidden lines, capped hits and skipped checks are named on the last line, so your agent can trust the answer and skip the follow-up read.

$ lets edit usage.ts --old 'usageCap' --new 'usageLimit' --all
── usage.ts · 2 replacements · lines 1, 8 · exact
 1~	import { usageLimit } from './config'
 2 	import { clock } from './clock'
 3 	export function usage(id: string) {
  ·	:4-5 not shown
 6 	  const cap = 10
 7 	  if (now > cap) return
 8~	  const total = usageLimit + id.length
 9 	  return total
10 	}
── check: structure ok · sha:c5525cc20b61→0e1115009972 · :4-5 not shown

trial-results.ods — Spreadsheet

B3=64.6/76.5-1
Large-repo trial, with lets against stock
With lets, against stockSonnet 5Opus 5.5
Sessions9 with lets, 6 stock6 with lets, 6 stock
Tool calls−16%64.6 vs 76.5−16%
Wall time−11%403 s vs 451 s−12%
Checks passed99.1%vs 97.8%100%both
Cost−0.5%cost-neutral+8%mean; +4% median
Small samples: 3–9 sessions per arm. Read the method before you quote it.
Large repoMethod

trial-results.ods is minimised.

Reach for cat, get handed lets.

lets hooks install claude-code checks each shell command before it runs. A bare cat, sed -n or grep on a repo file is blocked and handed the lets command instead.

If lets is missing or crashes, the hook lets the command through, so it can never wedge your agent. The old habits are in the Trash.

What else it installs, and why a hook
  • A short note at session start, printed again after every compaction and to every subagent.
  • Untouched: output piped into another program, a heredoc sent to a program’s stdin, files outside the repo.
  • Prose rules in CLAUDE.md get roughly 55% compliance. Replaying 1,502 sessions, the hook would block 28% of shell calls, carrying 45% of shell output bytes.
  • For Codex: lets hooks install codex.

lets hook

  1. A command was handed back

    Your agent ran cat src/usage.ts

    lets show reads several files and ranges in one call.

    run: lets show src/usage.ts
  2. A command was handed back

    Your agent ran sed -n '3,9p' src/usage.ts

    lets show reads several files and ranges in one call.

    run: lets show src/usage.ts:3-9
  3. A command was handed back

    Your agent ran grep -rn usageCap src

    lets find returns every hit numbered and grouped by file.

    run: lets find 'usageCap' src
1 of 3

lets hook is minimised.

Trash

Habits your agent no longer needs

Each one, with the command the hook hands back instead.

  • cat src/usage.tslets show src/usage.ts
  • sed -n '3,9p' src/usage.tslets show src/usage.ts:3-9
  • grep -rn usageCap srclets find 'usageCap' src

The Trash is empty.

The hook catches these for you.

3 items

Five verbs in one small binary.

Each takes several files or ranges per call. No index, no daemon, no config file.

Every flag and exit code is in the manual.

verbs — Files

NameReplacesDoes
showcat, sed -n, ReadFiles, line ranges, a function by name, a markdown section, or lines around a match.
findgrep -rn, rgHits numbered and grouped by file, capped at 50, and says when it hit the cap.
editEdit, sed -iReplaces text that occurs exactly once, or inserts at an anchor. Batches across files; a failed check reverts.
transformjq, yqSets, appends or deletes keys in JSON, YAML, TOML or frontmatter, keeping comments.
writecat > fileCreates a file from stdin, checked, and never overwrites one without --force.
5 itemsone static binary per OS and arch

verbs is minimised.

Questions, answered once.

The same answers your agent gets when it reads this page as markdown.

Questions — Help

Questions a newcomer or an answer engine asks

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.

Questions is minimised.

Two commands, and your agent has it.

  1. curl -fsSL https://raw.githubusercontent.com/mayberuk/lets/main/install.sh | sh
  2. lets hooks install claude-code

Linux and macOS · MIT or Apache-2.0 · Codex: lets hooks install codex · Docs · Source on GitHub

llms.txt — for agents

# lets

> `lets` is a file-operations CLI for coding agents: Locate, Edit, Transform, Show. One Bash call reads, searches, edits or transforms several files and returns bounded, numbered output whose last line (the footer) names everything it left out. One static Rust binary; no index, no daemon, no config file.

Install on Linux or macOS with `curl -fsSL https://raw.githubusercontent.com/mayberuk/lets/main/install.sh | sh`, then `lets hooks install claude-code` (or `lets hooks install codex`).

Run it through Bash the way you already run `cat` or `grep`: `lets show`, `lets find`, `lets edit`, `lets transform`, `lets write`. Pass several files or ranges to one call instead of one call per file; every verb accepts a list. If the footer doesn't name a cut, nothing was cut, so trust the output and skip the follow-up read. In particular, never `cat` or `sed -n` a file right after a `lets edit` on it: the changed region and a syntax-check result are already in that edit's own output. Run `lets` without `| head`: output is already bounded (200 lines per file, 50 search hits by default), and a call over budget exits non-zero and says so rather than truncating silently.

Branch on the exit code or the last stderr line, `ERROR_CODE=<slug>`, not on the message text: 0 done; 1 `not_found`, `over_cap`, `invalid_pattern`, `exists`, `empty_input`, `empty_file`, `mixed_endings`, `no_grammar`, `update_available`, `path_conflict`, `not_on_path`, `no_repository`, `guessed_span`; 2 `ambiguous`, `expect_refused`; 3 `check_failed`; 4 `over_budget`; 5 `changed`; 6 `outside_tree`; 7 `unsupported_file`, `locked`, `read_only`, `io_error`, `update_failed`; 8 `partial_batch`; 64 `usage`. Full table: https://lets.mayberuk.com/docs/exit-codes.md.

The target grammar every verb speaks: `path`, `path:40`, `path:40-80`, `path@'regex'`, `path#symbolName`. `find` needs only a path or directory, not a target, but every hit it prints is itself a valid target (`path:line`) for a following `show` or `edit`.

## Docs

- [lets show](https://lets.mayberuk.com/docs/show.md): Read files, line ranges, symbols and regex-anchored context — several targets per call, with a bounded window and an honest footer.
- [lets find](https://lets.mayberuk.com/docs/find.md): Search files or directories and print hits as path:line, capped at 50 hits, reporting instead of flooding.
- [lets edit](https://lets.mayberuk.com/docs/edit.md): Content-addressed, exactly-once text replacement, with the post-state and a syntax check returned in the same call.
- [lets transform](https://lets.mayberuk.com/docs/transform.md): Structured edits for JSON, YAML, TOML and markdown frontmatter — a dotted path in, the changed value back, comments and order preserved.
- [lets write](https://lets.mayberuk.com/docs/write.md): Create a file from stdin, checked on creation, refusing a silent overwrite or a silent empty file.
- [Install and update](https://lets.mayberuk.com/docs/install.md): The install.sh one-liner and its flags, the dist installer, and lets update / lets version.
- [lets hooks and lets hook classify](https://lets.mayberuk.com/docs/hooks.md): How lets reaches an agent without asking it to remember a prose rule — installing the integration, and what the PreToolUse classifier blocks.
- [lets guide](https://lets.mayberuk.com/docs/guide.md): The one-screen cheat sheet lets guide prints — verbs, targets and exit codes, meant to be read once per session.
- [lets stats](https://lets.mayberuk.com/docs/stats.md): Aggregate session-transcript counts for the hook and the lets calls it drove — what each field counts, --dir, --since and --json.
- [Target grammar](https://lets.mayberuk.com/docs/targets.md): The one grammar show, edit and transform speak for a path, a line, a range, a regex anchor or a symbol.
- [Exit codes](https://lets.mayberuk.com/docs/exit-codes.md): The full table of exit codes and slugs every verb can return, and the rule for branching on the slug instead of the message text.
- [--json and --jsonl](https://lets.mayberuk.com/docs/json.md): The structured output shape every reporting verb carries — one object for --json, one object per item plus a trailing stats record for --jsonl.

## Optional

- [Product overview](https://lets.mayberuk.com/index.md): what `lets` replaces, the two ideas behind it, five before/after pairs, measured results and install.
- [Full reference in one file](https://lets.mayberuk.com/llms-full.txt): the product overview and every page above concatenated, for a single fetch that teaches the whole API.

llms.txt is minimised.