11. CLI commands
Thirteen commands: six that work on PDFs, four on scripts, two for distribution and one for the shell.
| Command | What it does |
|---|---|
run |
Validates a PDF with a script |
compare |
Compares two versions of a PDF |
pixelcompare |
Compares two PDFs pixel by pixel, with a viewer to see the change |
watch |
Watches a folder and validates what arrives |
fix |
Applies corrections and saves a new PDF |
inspect |
Quick summary of a PDF |
lint |
Analyzes a script without running it |
fmt |
Formats a script |
test |
Runs a script against a folder of PDFs and compares each report |
doc |
Generates documentation from a script |
pack |
Packages profiles and data files |
add |
Installs a package |
completions |
Prints a completion script for your shell |
Exit codes
Section titled “Exit codes”Every validating command uses the same convention:
| Code | Meaning |
|---|---|
0 |
Everything passed |
1 |
Warnings only |
2 |
Validation errors |
3 |
Syntax error in the script |
10 |
The document could not be read, or a file could not be written — no verdict was reached |
In shell scripts:
pdfl run profile.pdfl file.pdf > report.jsoncase $? in 0) echo "approved" ;; 1) echo "approved with warnings" ;; 2) echo "rejected — see report.json" ;; 3) echo "error in the validation script" ;;esacGlobal options
Section titled “Global options”| Option | What it does |
|---|---|
--quiet |
Silences progress and confirmations on stderr |
--quiet works before or after the subcommand, and on every one of them. It
removes the lines a person wants and a pipeline does not — report saved to …,
watching …, the per-file result of watch. It does not remove errors: a
quiet run that fails still says why.
It does not silence print() either. That is the script’s own output, and
dropping it would change what the script does. Redirect stderr if you want it
gone.
--quiet wins over --verbose.
pdfl run
Section titled “pdfl run”Validates a PDF with a script.
pdfl run <script.pdfl> <input.pdf> [options]| Option | Default | What it does |
|---|---|---|
--output json|csv|html|pdf|sarif|junit |
json |
Report format |
--output-file <file> |
— | Writes to a file instead of stdout |
--fail-on error|warning |
error |
With warning, warnings also exit 2 |
--verbose |
— | Extra information on stderr |
--var NAME=VALUE |
— | Value the script reads as vars.NAME; repeatable |
--tags TAG |
— | Run only checks carrying this tag; repeatable. A tag no check carries is an error, not an empty pass |
# JSON report in the terminalpdfl run prepress.pdfl magazine.pdf
# HTML to send back to the clientpdfl run prepress.pdfl magazine.pdf --output html --output-file report.html
# Audit PDF (the pdf format always writes to a file)pdfl run prepress.pdfl magazine.pdf --output pdf --output-file report.pdf
# CSV for a spreadsheetpdfl run prepress.pdfl magazine.pdf --output csv --output-file findings.csv
# Strict: warnings fail toopdfl run prepress.pdfl magazine.pdf --fail-on warningThe JSON report
Section titled “The JSON report”{ "schema_version": 1, "script_name": "prepress.pdfl", "input_file": "magazine.pdf", "profile": "offset-magazine", "status": "FAIL", "total_pages_analyzed": 120, "error_count": 2, "warning_count": 0, "info_count": 0, "diagnostics": [ { "id": "PDFL-093751a2", "severity": "error", "check_name": "Ink coverage", "message": "page 7: 324% ink (limit 300%)", "line": 12 } ], "checks_run": ["Ink coverage", "Fonts", "Bleed"]}The same PDF with the same script always produces the same report, byte for byte — so it can be versioned and diffed in CI.
schema_version is the first key so a consumer can branch on it before parsing
anything else. It is bumped only when a reader of the previous output would
break; a new field does not bump it.
SARIF and JUnit
Section titled “SARIF and JUnit”Two more formats, so the result shows up where the team already looks instead of in a log nobody opens.
# GitHub code scanning: the findings become annotations on the pull requestpdfl run prepress.pdfl magazine.pdf --output sarif --output-file pdfl.sarif
# Any CI's test panel: one test per check, the ones that passed includedpdfl run prepress.pdfl magazine.pdf --output junit --output-file pdfl.xmlIn SARIF a result is anchored on the script, not on the PDF: the line we
know is the line of the check, and the PDF is usually an artifact passing
through CI rather than a file in the repository, so pointing there would
annotate a path that does not exist. The file under validation travels in
properties.inputFile, and the diagnostic id in partialFingerprints — which
is what lets GitHub recognise a finding it has already seen instead of reopening
it on every run.
In JUnit every check that ran is a test case, including the ones that found
nothing. A format that listed only the failures would report a clean run as zero
tests, and a CI reads that as a run that never happened. An info finding does
not fail its case; it is written to <system-out>.
- name: Preflight run: pdfl run prepress.pdfl magazine.pdf --output sarif --output-file pdfl.sarif # exit 2 is a rejected file, and the upload still has to happen continue-on-error: true- uses: github/codeql-action/upload-sarif@v3 with: sarif_file: pdfl.sarifpdfl compare
Section titled “pdfl compare”Compares two versions of a PDF: text, structure and metadata.
pdfl compare <v1.pdf> <v2.pdf> [options]| Option | Default | What it does |
|---|---|---|
--output json|csv|html|pdf|sarif|junit |
json |
Format |
--output-file <file> |
— | Writes to a file |
--normalize |
— | Ignores case and spacing |
--ignore-dates |
— | Masks dates before comparing |
--similarity-threshold <0-100> |
100 |
Minimum acceptable similarity |
# Straight comparisonpdfl compare approved_v1.pdf new_v2.pdf
# Tolerating small formatting and date differencespdfl compare approved_v1.pdf new_v2.pdf --normalize --ignore-dates
# Accepts up to 1% difference; below that it becomes an errorpdfl compare v1.pdf v2.pdf --similarity-threshold 99 \ --output html --output-file diff.htmlHow it works
Section titled “How it works”- Pages are aligned by content, not by number: if a page was inserted in the middle, the comparison notices instead of flagging everything after it as different. It scales to documents of over a thousand pages.
- Each aligned page gets a similarity score and a sample of the lines that
changed (
-removed,+added). - Changed metadata becomes a warning; changed text becomes an error when it falls below the threshold and a warning when above it.
- The report carries a
similarityfield with the overall score.
page 4 → 4: similarity 97.8% | -original title | +revised titlepdfl pixelcompare
Section titled “pdfl pixelcompare”Compares two PDFs by what they look like, page by page.
pdfl pixelcompare <original.pdf> <new.pdf> [options]| Option | Default | What it does |
|---|---|---|
--output json|csv|html|pdf|sarif|junit |
json |
Report format |
--output-file <file> |
— | Writes the report to a file |
--viewer <folder> |
— | Writes a self-contained viewer: the pages, the differences, and an index.html to look at them |
--dpi <n> |
150 |
Render resolution. Higher sees more and costs more |
--threshold <0.0-1.0> |
0.05 |
Colour distance at which two pixels count as different |
--max-diff <percent> |
0.0 |
How much of a page may change before it is reported |
--pages <range> |
all | 1-10 or 1,3,7-12 |
--no-align |
— | Do not compensate a global shift between the pages |
--blur <radius> |
0 |
Box blur before comparing, to absorb anti-aliasing |
--jobs <n> |
one per CPU | Pages compared at the same time |
pdfl compare answers “did the text or the structure change”. This answers a
different question — “does it still look the same” — and they disagree more
often than you would think. A logo nudged 2mm, a hairline that vanished, a
spot colour swapped for a CMYK build of it: the text is identical in all three.
# The whole document, reported as JSONpdfl pixelcompare approved.pdf reprint.pdf
# With somewhere to actually look at the differencespdfl pixelcompare approved.pdf reprint.pdf --viewer diff/
# Tolerate a little noise, look harder at what is leftpdfl pixelcompare approved.pdf reprint.pdf --max-diff 0.1 --dpi 300One finding per page that changed, with the share of pixels and how many separate areas they fall into:
page 7: 0.51% of the pixels differ, in 29 area(s)A page present in one file and not the other is its own finding — there is
nothing to compare it against. The report’s similarity is the mean across
the pages compared, so one rebuilt page in two hundred does not read as a
different document; the per-page figures are in the diagnostics.
Alignment, and why it is on
Section titled “Alignment, and why it is on”A file exported again from the same source often lands a pixel or two off.
Without compensating for that, every glyph edge on the page is “different” and
the one change that matters is buried. pixelcompare looks for a single global
shift — coarsely on a downsampled copy first, then refined — and reports it
when it finds one:
page 3: 2.10% of the pixels differ, in 44 area(s) (aligned by 2, -1 px)Turn it off with --no-align when the position is the thing being checked.
The viewer
Section titled “The viewer”--viewer diff/ writes a folder holding three PNGs per page and one
index.html. It has no dependencies of any kind — no CDN, no bundler, no
server. Open the file, or zip the folder and send it to whoever has to approve
the reprint.
Three panes, side by side, always on the same page:
| Pane | What it shows |
|---|---|
| Original | the first file’s page, untouched |
| New | the second file’s page, untouched |
| Difference | both, with what changed painted over them — drag to wipe |
All three panes carry the same pair of bars — one upright, one flat — in the same place, and they move in all three at once. Both follow the pointer — there is nothing to grab and nothing to aim at, the crossing is simply wherever the mouse is. Where they cross is the corner of what is revealed.
In the Difference pane the bars cut: the new file shows to the right of the upright bar and below the flat one, the original everywhere else. Untouched, the flat bar sits at the top, which makes the upright one a plain full-height wipe — drag the flat bar down when the change you are chasing is in a band rather than a column. In the other two panes the bars are rulers on the same column and the same line of the page, so what the wipe is cutting can be found in either original without measuring by eye.
Both positions are percentages of the page, not of a pane, so they survive changing page and resizing the window.
The wheel zooms, up to 8×, and all three panes zoom together around the point under the pointer — so what you were looking at stays where it was. Zooming out stops at the fitted page: there is nothing useful below it, the pane is already sized to hold the whole page. The bars keep their weight at any zoom. Holding either mouse button and moving drags the page itself, in all three panes together and at the speed of the pointer whatever the zoom — which is how you reach a corner once magnified. The bars go on following the pointer while you do it. Reset view puts the zoom, the pan and the bars back where they started; it is disabled while there is nothing to undo.
The differences are painted in place, and the colour says which kind:
| Colour | Meaning |
|---|---|
| Red | Ink that is gone from the new file |
| Green | Ink that is new in it |
| Blue | Same weight, different colour |
The three panes are sized against the window, so the whole comparison is on screen without scrolling, and they keep the page’s proportions at any window shape. Where the two files disagree about a page’s size — one turned landscape, say — each is shown whole inside the shared box rather than stretched to fill it.
Moving to another page starts it from the whole page again: a zoom and a pan belong to the page they were set on, and carried over they land wherever the next page happens to have something. This applies however you get there — the strip, the arrows, the buttons.
It opens on the pages that differ. On a two-hundred-page document where
three pages moved, those three are the reason you opened it; All puts the
rest back. The arrows and ← → follow the filter, stepping over what the
strip is hiding rather than landing on it. When nothing differs, the filter
button says so and stays disabled instead of filtering the strip down to
nothing.
Progress
Section titled “Progress”Rasterising a long document at 300 dpi takes minutes, so each stage draws a bar on stderr: one for each file being rasterised, one for the comparison, and one for writing the viewer.
rasterising approved.pdf [############------------] 98/207It is drawn only when stderr is a terminal. The bar works by returning to the
start of the line and overwriting it; a log file has no cursor to move, so a
redirected run would collect thousands of fragments instead. Redirected, it
stays silent and the ordinary messages still come through. --quiet silences it
everywhere.
The comparison runs on every CPU by default. On 41 pages at 150 dpi:
--jobs |
Time |
|---|---|
1 |
3.6s |
4 |
1.7s |
8 |
1.2s |
20 |
1.3s |
It stops improving around eight because the stage is limited by memory bandwidth, not by arithmetic — it streams whole pages through the CPU — so past that point the threads are queueing for the same memory. Asking for more is not harmful, only pointless.
Note what is not parallel: rasterising. pdfium serialises every call behind
one global lock, so a second thread in front of it does nothing but wait. That
puts a floor under the run — about 0.8s of the numbers above — and it is why
--jobs 8 is three times faster rather than eight.
This defaults to every CPU while pdfl test and pdfl watch default to
--jobs 1. The difference is real: there a job is a child process holding its
own document, so each one costs another document in memory. Here the pages are
already in memory and the threads share them, so a job costs one page’s working
set. Lower it if you are sharing the machine.
Exit codes: 0 no page changed by more than --max-diff, 2 at least one
did, 10 a file could not be read or the viewer could not be written.
The report does not depend on --jobs. Pages are folded back in page order, so
the diagnostics, their order and their fingerprints come out identical whatever
you set — a test asserts it, and the viewer’s files come out byte for byte the
same.
pdfl watch
Section titled “pdfl watch”Watches a folder and validates every PDF that arrives or changes.
pdfl watch <folder> --script <script.pdfl> [options]| Option | Default | What it does |
|---|---|---|
--pattern <glob> |
*.pdf |
Which files to process |
--exclude <glob> |
— | Which to skip |
--output-dir <folder> |
next to the PDF | Where to write reports |
--depth <n> |
1 |
Subfolder levels |
--debounce <ms> |
1000 |
Waits for the file to stop being copied |
--report json|csv|html|pdf|sarif|junit |
json |
Report format |
--fail-fast |
— | Stops at the first error |
--events |
— | Wake on filesystem notifications instead of a timer — not on network shares |
--journal <file> |
— | Append-only record of what was validated; re-running skips what it covers |
--timeout <s> |
— | Kills a file’s analysis past this many seconds and reports it as rejected |
--var NAME=VALUE |
— | Value every file reads as vars.NAME; repeatable |
--jobs <n> |
1 |
Files to validate at the same time; 0 means one per CPU |
--once |
— | Processes what is already there and exits |
# The print shop's inbox, running continuouslypdfl watch inbox/ --script preflight.pdfl --output-dir reports/ --report html
# Batch mode for CI: process everything and exit with the worst codepdfl watch inbox/ --script preflight.pdfl --onceecho "result: $?"
# Skipping draftspdfl watch inbox/ --script preflight.pdfl \ --pattern "*.pdf" --exclude "*_draft*"--jobs applies to whatever the pass has to get through, in batch mode and in a
burst of arrivals alike. Each file is validated by its own pdfl process — the
same reason pdfl test works that way — and this one renders the reports, so
the file written is identical whatever --jobs says. On eight 41-page files:
9.5s at --jobs 1, 1.2s at --jobs 0.
With --fail-fast, no new file is started once one has failed; the ones already
running finish, because killing them would leave half-written reports. The
reports are written in the order the files were found, so a batch prints the
same lines however many ran at once.
Debounce exists because large files arrive in pieces: watch only processes a file once it stops changing, so it never reads half a PDF. The wait ends exactly when the freshest file has settled, so a file that arrives during a wait is not held for a whole extra interval.
The folder is listed on a timer by default; --events waits on the operating
system’s notifications instead. The default is the timer, and that was measured:
listing 10,000 files every 200ms costs no measurable CPU, and the settle time
dominates the latency either way, so on a local folder the two modes finish
within a hundredth of a second of each other.
Do not use --events on a network share. inotify on an NFS or SMB mount reports
what the local machine writes and nothing else, so files arriving from elsewhere
would never be noticed — and the watcher would say nothing about it. Where it
does pay is a machine watching many folders, or one where a directory listing is
expensive. If the watcher cannot be created, watch says so and falls back to the
timer rather than going quiet.
The journal: finishing a batch that was interrupted
Section titled “The journal: finishing a batch that was interrupted”Five thousand files, and the machine reboots at four thousand. Without a record, the next run starts from the first file.
pdfl watch inbox/ --script offset.pdfl --once --journal batch.jsonlOne JSON object per file, appended as each one is validated:
{"input":"inbox/cover.pdf","sha256":"9f2b…","status":"FAIL","errors":2,"warnings":0,"exit":2}Run it again with the same journal and the files it covers are skipped. Their
verdicts are not: a resumed batch that skips a rejected file still exits 2,
because the journal is the record of the batch and the exit code is its verdict.
A batch that reported clean because it had already seen the failure would be the
worst bug this tool could have.
A file is matched by its bytes, not by its name or its timestamp. Replace
cover.pdf with a different cover.pdf and it is validated again — its hash is
not the one recorded.
Nothing is written without --journal. The tool keeps no state of its own; this
is a file you asked for by name, exactly like a report. And there is no
timestamp in a line: the journal says whether a file was validated and what
came of it, the report beside it says what, and the filesystem says when —
which keeps a re-run byte-identical to the first, like everything else here.
The lines are flushed one at a time, so what a crash leaves behind is true as far as it goes. A journal that cannot be parsed stops the run naming the line; skipping files on a misread record would be worse than starting over.
--timeout: one bad file must not stall the batch
Section titled “--timeout: one bad file must not stall the batch”pdfl watch inbox/ --script offset.pdfl --once --timeout 60A file whose analysis runs past 60 seconds is killed and reported the same way
an unreadable PDF is — a report with one finding, check_name: "timeout" — so it
prints, writes to disk and enters the journal exactly like any other verdict.
Nothing is silently skipped, and the batch moves on to the next file instead of
hanging on this one.
{"input":"inbox/adversarial.pdf","sha256":"7a1c…","status":"FAIL","errors":1,"warnings":0,"exit":2}There is nothing in the .pdfl language a script can use to hang the
interpreter on purpose — recursion is depth-limited — so --timeout exists for
what a script cannot cause: pdfium looping or blocking on a malformed or
adversarial PDF. Unset, a file’s analysis waits as long as it takes, which was
the only behaviour before this flag existed.
--var reaches every file unchanged — the same value for the whole run, useful
for something constant across a folder (a client name) rather than something
that varies per file (an order number). Without it, a script reading vars.*
could never be watched at all: every file would fail with “was not provided”.
Reports are written as <name>.report.json (or .csv, .html, .pdf).
pdfl fix
Section titled “pdfl fix”Applies fix:: operations and saves a new PDF. Details in
chapter 8.
pdfl fix <input.pdf> <script.pdfl> --output <output.pdf> [options]| Option | What it does |
|---|---|
--output <file> |
Output PDF (required) |
--dry-run |
Lists the operations without saving |
--report json|csv|html|pdf|sarif|junit |
Report format |
--report-file <file> |
Writes the report to a file |
# See what would happen, touching nothingpdfl fix original.pdf normalize.pdfl --output out.pdf --dry-run
# Apply for realpdfl fix original.pdf normalize.pdfl --output fixed.pdfpdfl inspect
Section titled “pdfl inspect”Quick summary of a PDF, no script needed.
pdfl inspect <file.pdf>--json gives the same summary as data.
File: magazine.pdfSize: 26 KB (27284713 bytes)SHA-256: af1029842e5bfeae338ead82fb449ef851be742b1d63117c12596e3ea123a616
Pages: 120Page size: 496 x 709 ptBoxes: MediaBox, TrimBox, BleedBox
Metadata: Title: Example Magazine Creator: Adobe InDesign 19.3
Fonts: 26 ABCDEF+Helvetica — embedded Arial — NOT embeddedImages: 81 (minimum DPI 136, spaces: DeviceCMYK, Indexed)Max. estimated TAC: 300% (RGB render approximation)
Warnings: ! there are non-embedded fonts ! 3 image(s) below 300 DPIThis is the first command to run when a new file lands: within seconds you know whether it is worth opening.
pdfl lint
Section titled “pdfl lint”Analyzes a script without running it, reporting quality issues.
pdfl lint <script.pdfl>--json gives the same warnings as data.
It detects:
- variables, block parameters and functions that are declared and never used
(prefix with
_to silence:_page) - duplicate or empty checks
- unknown namespaces (
text::,struct::,visual::,prepress::,codes::,fix::,data::) assert/requireoutside any check- use of
fix::(which only runs underpdfl fix)
$ pdfl lint profile.pdflprofile.pdfl: warning: variable 'LIMIT' declared and never usedprofile.pdfl: warning: check "Fonts" declared 2 timesExits with 1 when there are warnings — usable in CI.
pdfl fmt
Section titled “pdfl fmt”Formats the script: two-space indentation, consistent spacing, collapsed blank
lines. Comments and units are preserved (3mm stays 3mm).
pdfl fmt <script.pdfl> # formats in placepdfl fmt <script.pdfl> --check # changes nothing; exits 1 if unformatted# In CI, enforcing a team standardfor f in profiles/*.pdfl; do pdfl fmt "$f" --check || exit 1; donepdfl doc
Section titled “pdfl doc”Generates documentation for a script from the code itself.
pdfl doc <script.pdfl> [--output markdown|html|json]It produces: the profile, a table of constants, functions, imports and — for each
check — its tags and what it validates (the assert messages become the
description).
# Markdown for the repositorypdfl doc prepress.pdfl > docs/prepress-profile.md
# HTML to hand to people who do not read codepdfl doc prepress.pdfl --output html > profile.htmlThis is the artifact that lets a production manager understand what a profile validates without opening the script.
pdfl pack
Section titled “pdfl pack”Packages scripts and data files into a distributable .pdflpkg.
pdfl pack <folder> [--name <name>] [--version <version>] [--output <file>]It includes .pdfl, .csv, .txt and .json files from the folder
(recursively), plus a manifest.json recording the SHA-256 of each file. The
package is deterministic: the same folder produces identical bytes.
A spreadsheet (.xlsx, .xls, .ods) is not packaged, and pack says
which file it left behind. No data:: function can open one, so packaging it
would ship a package that installs cleanly and fails at the first lookup.
pdfl pack profiles/print-shop --name print-profile --version 1.0.0# creates print-profile.pdflpkgpdfl add
Section titled “pdfl add”Installs a local package, verifying the manifest hashes.
pdfl add <package.pdflpkg> [--dir <folder>]pdfl add print-profile.pdflpkg# installs into ./pdfl_profiles/[email protected]/
If any file’s hash differs from the recorded one, installation is refused — a corrupted or tampered package never lands.
A remote repository and digital signatures are not part of this version:
addinstalls from local files.
pdfl test
Section titled “pdfl test”Runs a script against every PDF in a folder and compares each report to the one recorded beside it. A profile that starts finding something different fails a test instead of surprising someone downstream.
pdfl test <script.pdfl> [--dir <folder>] [--update]| Option | Default | What it does |
|---|---|---|
--dir <folder> |
tests/ next to the script |
Where the case PDFs live |
--update |
— | Records the expected reports instead of comparing them |
--jobs <n> |
1 |
Cases to run at the same time; 0 means one per CPU |
--var NAME=VALUE |
— | Value every case reads as vars.NAME; repeatable |
A case is a PDF and the report expected of it, side by side:
profiles/print-shop/ prepress.pdfl tests/ approved.pdf approved.expected.json heavy_ink.pdf heavy_ink.expected.json# First time: record what the script finds todaypdfl test prepress.pdfl --update
# From then onpdfl test prepress.pdflok approved.pdfFAIL heavy_ink.pdf error_count: expected 1, got 0 missing: PDFL-093751a2 [error] Ink coverage (line 12): page 7: 324% ink (limit 300%)1 passed, 1 failedThe failure names what changed — the counts, the verdict, and which findings appeared or vanished — rather than printing two JSON files side by side.
Recording is always deliberate: a run that refreshed its own baseline would
never fail. Read the diff first, then re-record with --update when the change
is the one you meant.
The expected report is the one pdfl run produces, with input_file reduced to
the file’s name — a baseline that changed with the directory you invoked it from
would not be a baseline. A PDF that cannot be opened fails its own case and
leaves the others to run.
Exit codes: 0 all passed, 2 at least one failed, 10 the folder could not
be read or holds no PDF.
Running cases at the same time
Section titled “Running cases at the same time”Each case runs as its own pdfl process, so --jobs turns a suite into real
parallel work: on eight 41-page files, --jobs 1 took 8.9s and --jobs 8 took
1.1s. Threads inside one process would not have done it — pdfium serialises
every call behind a single mutex, and the threaded version measured slower
than sequential.
The default is 1 because each job is a process holding one document in
memory, and this tool exists for files that can be very large. Raise it when
your cases are ordinary: --jobs 0 gives one per CPU.
The order of the output never changes with --jobs; the cases are judged in
the order they were found, whichever child finished first.
A case whose PDF cannot be read is judged like any other — its report carries
the reason as a finding, so “this file must be rejected as unreadable” can
itself be a test. That report names the file as it was passed, so record
baselines with a relative --dir if they are going to be committed.
--var reaches every case unchanged — one value for the whole run, not one per
file. Without it, a script reading vars.* could never be tested at all: every
case would fail with “was not provided”, regardless of the PDF.
pdfl completions
Section titled “pdfl completions”Prints a completion script for your shell to stdout.
pdfl completions <bash|zsh|fish|elvish|powershell># bash, for the current userpdfl completions bash > ~/.local/share/bash-completion/completions/pdfl
# zsh — anywhere on your $fpathpdfl completions zsh > ~/.zfunc/_pdfl
# fishpdfl completions fish > ~/.config/fish/completions/pdfl.fishNothing else is written to stdout, so the output can be redirected straight into the completion directory. Regenerate it after upgrading: the script is built from the commands and flags of the binary that printed it.