sub1

Replace a literal text block in a file, exactly once. Built for coding agents that edit through the shell.
$ sub1 app.py <<'SUB1'
if reply is None:
return
====
if reply is None:
return None
====
SUB1
app.py: replaced at line 42
The old and new blocks come from a heredoc, so nothing needs escaping. The file is rewritten only when the old block occurs exactly once. Otherwise it is left alone, the count is reported, and the exit code is 1.
Why
An agent that edits through a shell has two common tools, and both are bad at it.
sed -i succeeds even when the old text is not in the file, so a missed edit goes unnoticed. Multi-line text and characters like /, &, and $ need escaping, which is where edits go wrong.
A Python one-off that reads the file, asserts the old text occurs once, replaces it, and writes it back does the right thing. But the agent re-types it for every edit. In one month of the author's Claude Code sessions, that script was written by hand more than 700 times.
sub1 is that script, made into a command.
An agent with a dedicated editing tool already has its own way to do this, and has little use for sub1. It is for the ones that reach for the shell instead, because that is all they have or because that is what they were told to do.
Setup
Install it where the agent runs:
brew install iwamot/tap/sub1
Or with Go:
go install github.com/iwamot/sub1@latest
Or download a prebuilt binary from the Releases page.
Then tell the agent to use it, in CLAUDE.md, AGENTS.md, or whichever file your agent reads:
To replace part of a file from the shell, use `sub1` instead of sed or an ad-hoc script:
sub1 FILE <<'SUB1'
old lines
====
new lines
====
SUB1
The file is rewritten only when the old block occurs exactly once. Otherwise nothing is written and the message says what to do next. See `sub1 --help` for options.
That paragraph is all the agent needs; everything else it has to know arrives in the output of the call that needed it. sub1 --instructions prints the same paragraph, for setup scripts and machines where this page is not at hand.
One thing to check before you rely on your agent's permission rules: sub1 opens the file itself, the way a one-off script does. A rule or a hook that works by matching the agent's own editing tool will not see an edit made through sub1. One that matches the shell command will, and enforcement at the operating-system level, such as a sandbox, covers it either way. Find out which kind guards the paths you care about before you count on it.
What the agent sees
Renaming a function. The definition is unique, so the edit lands and the output is one line:
$ sub1 handlers.py <<'SUB1'
def _tool_chunks(chunks):
====
def tool_chunks(chunks):
====
SUB1
handlers.py: replaced at line 12
The call sites are not unique. Nothing is written, and the count comes back with the lines:
$ sub1 handlers.py <<'SUB1'
_tool_chunks(
====
tool_chunks(
====
SUB1
sub1: handlers.py: old block found 3 times (lines 27, 40, 41), expected 1; widen the old block, or pass -n 3
The agent then either widens the old block until it is unique, or says how many occurrences it expects. The count is still checked, so a stray extra match would still stop the edit:
$ sub1 -n 3 handlers.py <<'SUB1'
_tool_chunks(
====
tool_chunks(
====
SUB1
handlers.py: replaced at lines 27, 40, 41
The old block was typed with spaces, but the file is indented with tabs. Nothing matches, and the closest place is described so that the old block can be fixed without reading the file again:
$ sub1 Makefile <<'SUB1'
build:
go build ./...
====
build:
go build -trimpath ./...
====
SUB1
sub1: Makefile: old block found 0 times, expected 1
hint: file line 4 starts with 1 tab, old block line 2 with 4 spaces (near line 3)
The hint: line is a guess at what went wrong. The count on the first line is what decided that nothing was replaced. When nothing in the file comes close, there is no hint and the first line says so instead:
$ sub1 config.go <<'SUB1'
func handle(w http.ResponseWriter) {
====
func handle(w http.ResponseWriter, r *http.Request) {
====
SUB1
sub1: config.go: old block found 0 times, expected 1; no similar text found, read the file again
Deleting a line. The new block is empty, so the two ==== lines follow each other:
$ sub1 handlers.py <<'SUB1'
import os
====
====
SUB1
handlers.py: replaced at line 1
Reference
$ sub1 --help
sub1 — replace a literal text block in a file, exactly once.
Usage:
sub1 [-n N] [-d SEP] [--] FILE <<'SUB1'
old text (one or more lines)
====
new text (zero or more lines)
====
SUB1
OLD and NEW come from stdin, each followed by a line equal to SEP. The final
newline of each block is dropped. FILE is rewritten only when OLD occurs
exactly N times; otherwise it is left untouched.
Examples:
sub1 app.py <<'SUB1' # replace the one occurrence
old line
====
new line
====
SUB1
sub1 -n 3 handlers.py <<'SUB1' # replace 3, and fail if there are not 3
_tool_chunks(
====
tool_chunks(
====
SUB1
sub1 -d @@ README.md <<'SUB1' # a block holds a "====" line
Title
====
@@
Title
-----
@@
SUB1
sub1 handlers.py <<'SUB1' # an empty new block deletes the old one
import os
====
====
SUB1
Options:
-n N expect OLD N times (default 1)
-d SEP line that ends OLD and ends NEW (default "====")
-h, --help show this help
-v, --version show the version
--instructions print the paragraph for the agent's instruction file
Exit codes:
0 replaced
1 OLD found a different number of times than expected
2 input error: the flags or the blocks on stdin
3 file error: stdin or FILE could not be read, or FILE could not be written
- Matching is byte-exact: tabs and spaces differ. Line endings are the one exception: when every line of the file ends with CRLF, both blocks are read as CRLF too, so a heredoc can edit such a file, and the summary notes
(CRLF). A file that mixes CRLF and LF is matched as is.
- Because matching is byte-exact, the old block can match part of a line, and the summary then carries a note in parentheses, several joined with
;. 1 match starts inside an identifier and 1 match ends inside an identifier say that the byte next to the match and the byte of the old block beside it are both identifier characters, as when x = 1 is found in max = 1. 1 match starts mid-line with a multi-line new block says that the lines of the new block after the first were inserted with the indentation they were written with, which is not the indentation of the line the match started in. the file already contained the new block says that the new block contains the old one and was already in the file, so the edit repeated the rest of it, which is what running the same edit twice looks like. A note is a fact about where the match sits, not an error: renaming an identifier at its call sites matches inside lines on purpose. Check the edit when a note was not expected.
- An empty new block deletes the old block. When the old block is a whole line or a run of whole lines, the line break after it goes too, so the lines disappear instead of leaving a blank line behind. An old block that starts or ends in the middle of a line leaves that line's break where it is, and an old block that ends with a blank line takes nothing beyond it. Deleting a last line that has no line break leaves the file ending with the line break of the line before it.
- The closing
==== line is required. A heredoc ends at the first line equal to its terminator, so a terminator written on the separator line by mistake, or one that also appears inside a block, ends the heredoc early. The input that reaches sub1 then stops before the closing line, and it is rejected instead of being applied as if it were whole. What the shell cut off, though, it runs as commands, so pick a terminator the blocks cannot contain: a file that quotes sub1 holds lines that are exactly SUB1 — this page, the CLAUDE.md you pasted the paragraph into, test fixtures — and editing one of those with <<'SUB1' ends the heredoc on the wrong line.
- If either block contains a line that is exactly
==== (a Markdown setext underline, or a quoted sub1 call, for example), pass another separator with -d.
- The file's permissions are kept. Only its contents change.
- The file is never left half-written, and nothing is written at all until the replacement is settled. Every error above leaves the file byte for byte as it was. The new contents go to a temporary
.sub1-* file next to it, which is then renamed into place. If sub1 is interrupted, the file still has either the old or the new contents, and any leftover .sub1-* file can be deleted.
- Because of that rename, the directory must be writable, and hard links to the file are not preserved.
Output
The shapes below are stable. Later versions may add to them; they will not reword what is here, so an agent can be told to read them.
| Where |
Shape |
| stdout, replaced |
FILE: replaced at line N or FILE: replaced at lines N, M, ... |
| stderr, wrong count (exit 1) |
sub1: FILE: old block found N times (lines ...), expected M |
| stderr, wrong count, second line |
hint: <what differs> (near line N), when the old block was not found and something in the file comes close |
| stderr, input error (exit 2) |
sub1: <what is wrong>. No FILE: the command is what has to change, not the file |
| stderr, file error (exit 3) |
sub1: FILE: <what is wrong>, with stdin in the place of FILE when stdin is what could not be read |
FILE is the path as it was given on the command line, in every line above.
Two things ride on top of those shapes and are not part of them. A line that ends with ; and a sentence carries what to do next; that is added where there is something to say, so do not depend on its presence or on its wording. A successful line can end with notes in parentheses, described above; the notes are free-form and change as sub1 learns which matches are worth pointing at, so read the part before the ( and treat the rest as prose.
Out of scope
- Regular expressions. The blocks are literal bytes.
- Editing more than one file per call. Run
sub1 once per file.
- Reading the text to edit from stdin. stdin carries the blocks; the file to edit is named on the command line and rewritten in place.
- Ranges such as "from this line to that line".
sub1 only replaces text it was shown, so it never removes more than you wrote in the old block, plus the one line break that follows each occurrence when an empty new block deletes whole lines.
License
MIT