sub1

command module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 11 Imported by: 0

README

sub1

release Go

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.

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'`, then the old lines, a line `====`, the new lines, and `SUB1`. Before writing the heredoc, check both blocks: if a line is exactly `SUB1`, use another terminator; if a line is exactly `====`, pass `-d SEP` and write SEP on the separator line instead. The file is rewritten only when the old block occurs exactly once. On exit 1, read the reported count and lines. When the count is 0, a second line, if present, says how the old block differs from the file: fix the old block, or read the file again if there is no second line. Otherwise widen the old block until it is unique, or pass `-n N` for the number of occurrences you expect.

That paragraph is all the agent needs. sub1 --instructions prints the same paragraph, for setup scripts and machines where this page is not at hand.

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

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
  near line 3: file line 4 starts with 1 tab, old block line 2 with 4 spaces

The second line is a guess at what went wrong. The count on the first line is what decided that nothing was replaced.

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, split at the single 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.

Options:
  -n N            expect OLD N times (default 1)
  -d SEP          separator line between OLD and 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  usage error, or FILE could not be read or 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 ends with (CRLF). A file that mixes CRLF and LF is matched as is.
  • An empty new block deletes the old block. To remove a whole line including its line break, start the old block one line earlier and repeat that line in the new block.
  • If either block contains a line that is exactly ==== (a Markdown setext underline, for example), pass another separator with -d.
  • The file's permissions are kept. Only its contents change.
  • The file is never left half-written. 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.

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.

License

MIT

Documentation

The Go Gopher

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL