A command-line tool for reading, filling, and commenting on PDF files — built for agents to drive.
- Reads a PDF as annotated markdown. Tables become tables, form fields become HTML controls, figures become image links that resolve to real files.
- Fills forms and leaves comments, saving incrementally so the original bytes stay a byte-identical prefix and signatures survive.
- Every change reads back. A fill you cannot see in
form export, or a comment you cannot see incomments list, is treated as a failure by the tool itself — not left for you to discover.
Status: pre-release (0.1.0). The command surface and output contract are stable enough to build on. The read view is still improving on dense tables;
docs/metrics.mdtracks exactly where it stands, and known gaps are markedtest.failingin the suite rather than quietly omitted.
macOS only for now (arm64 and x64). The platform gate is deliberate rather than incidental — the tool refuses to run elsewhere instead of half-working.
bun add -g bun-pdf # or: bunx bun-pdf --helpOr grab a standalone binary — no runtime needed:
curl -fsSLO https://github.com/kklimuk/pdf-cli/releases/latest/download/install.sh
sh install.shThe installer is a release asset, not a file served from a branch — so it is
immutable per release, listed in that release's SHA256SUMS, and you can verify
it before you run it. It downloads only release assets and executes no remote
code. It verifies the binary against SHA256SUMS and refuses to install on a
mismatch.
Once the standalone binary is installed, it updates itself:
pdf upgrade # latest release
pdf upgrade --to v0.1.2 # a specific tag
pdf upgrade --dry-run # report onlyupgrade runs the same installer, embedded in the binary rather than fetched,
so the replacement is pinned to a release tag and SHA-256 verified before it
lands. An npm install is owned by the package manager; upgrade says so and
exits nonzero instead of fighting it.
skills/pdf-cli/ is a self-contained Agent Skill. Claude Code and Codex discover
it from the plugin manifests in .claude-plugin/ and .codex-plugin/. An agent
with no Bun can run sh skills/pdf-cli/scripts/bootstrap.sh, which resolves the
latest release tag, downloads the binary pinned to it, and verifies its SHA-256
before installing.
SKILL.md is generated by the binary (pdf info skill) — see
CONTRIBUTING.md.
$ pdf info fw4.pdf
5 pages · ~4210 tokens · form: xfa-static (19 fields)
next: pdf read fw4.pdf (fields render inline) · pdf form fields fw4.pdf
$ pdf form fields fw4.pdf
xfa-static · 5 page(s) · 19 fields
aliases are stable; fill: pdf form fill fw4.pdf --data values.json
p1 f1_01 text (a) First name and middle initial
p1 f1_02 text Last name
p1 f1_05 text (b) Social security number
p1 c1_1 choose-one set to ONE of: 1 (Single or Married filing separately) …
$ echo '{"f1_01":"Dana","f1_02":"Okafor","f1_05":"123-45-6789","c1_1":"1"}' \
| pdf form fill fw4.pdf --data -
4 fields filled, verified on re-read
$ pdf form export fw4.pdf
{"f1_01":"Dana","f1_02":"Okafor","f1_05":"123-45-6789","c1_1":"1"}The fill is all-or-nothing: if any value is invalid, nothing is written and every problem is listed at once. After saving, the file is re-opened and every touched field re-read — "filled but blank when printed" becomes a hard error instead of a silent success.
$ pdf comments add lease.pdf --anchor "shall not be liable" --text "Check this" --highlight
p2.ann0
$ pdf comments reply lease.pdf --at p2.ann0 --text "Counsel signed off"
p2.ann1
$ pdf comments list lease.pdf
[{"locator":"p2.ann0","page":2,"kind":"highlight","author":"Reviewer","text":"Check this", …},
{"locator":"p2.ann1","page":2,"kind":"note","text":"Counsel signed off","inReplyTo":"p2.ann0", …}]You say where in the document's own words. The phrase is found the way
pdf find finds it, and the highlight lands on the glyphs that drew it — a
phrase that wraps is marked as two runs, the way a person would draw it. A
phrase matching several places is refused with a count and the flag that
resolves it, because a comment on the wrong clause is worse than no comment.
Help text renders the locator and anchor grammars from the same registry the
parser uses, so it cannot drift from what the tool accepts. Where this README
and --help disagree, --help is right.
info Inspect a FILE, or print reference material (pdf info locators)
read Print pages as annotated markdown (fields as HTML controls)
outline Document structure as a pN locator tree
find Locate text, mint span locators for --at
wc Pages, words, and estimated tokens before you read
render Rasterize pages or a region to PNG (--read: the markdown view)
images List and extract embedded images (list, extract)
form Inspect and fill AcroForm fields (fields, fill, export)
comments Read and write annotations (list, add, reply)
- Exit codes are the signal:
0ok,1error,2usage or bad locator,3not found. - Errors are one JSON line:
{"code": "...", "error": "...", "hint": "..."}. - A mutation that changes nothing is an error, never a cheerful zero-count —
NO_CHANGE,MATCH_NOT_FOUND,ANCHOR_NOT_FOUND. Weak agents react to exit codes, not to prose. - Mutators print what they minted (a locator per line) or a one-line ack;
--verboseprints the full JSON. --dry-runvalidates everything — anchors resolved, save ladder checked — and writes nothing. A passing dry run means the real run will succeed.
Everything addressable has a stable handle. Run pdf info locators (no file
needed) for the full grammar.
p3 page 3 (1-based, agrees with --pages)
p3.b7 block 7 of page 3
p3.b7:10-24 characters 10–24 of that block (end-exclusive)
p3.t0 table 0 of page 3
p3.img1 image 1 of page 3
p3.ann0 comment 0 of page 3
Page numbers are 1-based because they agree with what a person sees; everything minted below the page is 0-based. Ids are deterministic for a given file and pipeline version, so an id from one run still means the same thing in the next.
Two engines, one document. PDFium reads (text, page objects, rasterizing,
images, the structure tree); pdf-lib writes (fills, annotations, incremental
save). Nothing outside src/core/engine/ imports either — everything else
compiles against neutral types, so the engines stay swappable.
Coordinates are top-left, y-down, in points everywhere above the engine adapters. PDF's bottom-up space is flipped in exactly two files and nowhere else, so no downstream code has to remember which way is up.
Saves are incremental by default. The original bytes must remain a
byte-identical prefix of the output — asserted on every save, not assumed. A
signed document stays verifiable as "signed, then modified" rather than being
quietly invalidated. A full rewrite happens only with --rewrite, and is
refused on a signed document without --force.
Reading is a layout problem, not a text-extraction problem. A PDF stores glyphs at coordinates; paragraphs, columns, tables and reading order are all inferred. Figures are settled before tables so chart labels never become candidate cells; columns are settled before either. The table detector is a face lattice — edges propose boundaries, merging disposes of them.
Content renders as what it is. Images are  with the file
actually written; form fields are <input name="alias"> fillable by that name;
a scanned page says so and names the command that rasterizes it, because an
agent that can see is better served by a picture than by an empty string.
| @embedpdf/pdfium | MIT (PDFium itself BSD-3) — read engine |
| @cantoo/pdf-lib | MIT — write engine |
| @pdf-lib/fontkit | MIT — font embedding |
| ESDB / SCOWL word list | permissive, notice required — dehyphenation |
No copyleft: no GPL, LGPL or AGPL code, and no MPL-licensed source. NOTICES
carries the full attribution and ships with every release.
bun install, then bun run check (biome + knip + tsc) and bun test before
anything is considered done. CLAUDE.md carries the conventions and the
invariants the code holds itself to.