README
¶
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
hint: line 27 sits in " chunks = _tool_chunks(raw)", lines 40, 41 sit in " return _tool_chunks(c)" (near lines 27, 40, 41)
The hint names each place by what a wider block would have to take in to reach it: the line it sits in, where the block begins inside a line, and the line before it, where the block begins a line. Places that read the same are named together, as lines 40 and 41 are here, which says that widening will not separate them.
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)
Where the old block is one line and what it says is wrong rather than how it is spaced, the closest line in the file is quoted, so that a typo can be seen without opening the file. Quoted lines are escaped the way Go escapes a string, which is what makes the leading tab visible here, and a long line is cut with an ellipsis:
$ sub1 occur.go <<'SUB1'
return describeRun(notes)
====
return describeRun(ws.trimmed())
====
SUB1
sub1: occur.go: old block found 0 times, expected 1
hint: the closest line is file line 5: "\treturn describeRun(ws)" (near line 5)
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'
timeout = 30
====
timeout = 60
====
SUB1
sub1: config.go: old block found 0 times, expected 1; no similar text found, read the file again
A hint comes with a count that fell short as well. Two of the three blocks match and the third is indented with a tab, so the count line offers the count that was found and the hint describes the one that is missing:
$ sub1 -n 3 handlers.py <<'SUB1'
if chunk is None:
continue
====
if chunk is None:
break
====
SUB1
sub1: handlers.py: old block found 2 times (lines 27, 40), expected 3; pass -n 2
hint: file line 52 starts with 1 tab, old block line 1 with 4 spaces (near line 52)
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 identifierand1 match ends inside an identifiersay that the byte next to the match and the byte of the old block beside it are both identifier characters, as whenx = 1is found inmax = 1.1 match starts mid-line with a multi-line new blocksays 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 blocksays 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 reachessub1then 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 quotessub1holds lines that are exactlySUB1— this page, theCLAUDE.mdyou 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 quotedsub1call, for example), pass another separator with-d. The message that rejects such an input names one that no line of the input holds, ready to paste. - 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. Ifsub1is 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 once (line N), expected M or sub1: FILE: old block found N times (lines N, M, ...), expected M, and sub1: FILE: old block found 0 times, expected M with no line list, there being no lines to name |
| stderr, wrong count, second line | hint: <what the file has> (near line N), with lines N, M, ... in place of line N where more than one place is named. It follows a count that fell short when something in the file comes close, and a count that was too high when the places can be told apart |
| 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
sub1once 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".
sub1only 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
Documentation
¶
There is no documentation for this package.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
atomicfile
Package atomicfile replaces a file's contents without leaving a partially written file behind if the write is interrupted.
|
Package atomicfile replaces a file's contents without leaving a partially written file behind if the write is interrupted. |
|
block
Package block splits sub1's stdin into the OLD and NEW text blocks.
|
Package block splits sub1's stdin into the OLD and NEW text blocks. |
|
crlf
Package crlf tells CRLF files apart from LF files and converts between the two line endings.
|
Package crlf tells CRLF files apart from LF files and converts between the two line endings. |
|
occur
Package occur locates a block inside file content and describes where a replacement happened, or why it did not.
|
Package occur locates a block inside file content and describes where a replacement happened, or why it did not. |