DEV Community

Howth Technology Factory
Howth Technology Factory

Posted on

I Wrapped a PDF API in a CLI Tool So I'd Never Touch the Docs Again

Every PDF API I've ever used has the same rough edges the second or third time I need it for something quick: open the docs again, remember the auth header format, remember whether it's files or file_urls, write a throwaway script, run it once, delete it. That workflow is fine the first time. It gets old by the fifth.

So I wrapped the whole thing in a fifteen-minute CLI tool, and it turned out to be one of the higher-leverage things I've built this year, mostly because of how often "quick PDF task" shows up in a normal week of backend work.

What it actually needed to do

Nothing fancy. A thin command-line wrapper around a handful of PDF operations, merge, split, compress, watermark, rotate, convert, that takes file paths as arguments and writes the result to disk. No server, no UI, just pdftk -o , callable from a terminal, a shell script, or a Makefile target.

The implementation

The whole thing is built on Python's argparse, with one subcommand per operation:

import argparse, sys
import pdf_api

def main():
parser = argparse.ArgumentParser(prog="pdftk")
sub = parser.add_subparsers(dest="action", required=True)

merge_p = sub.add_parser("merge")
merge_p.add_argument("files", nargs="+")
merge_p.add_argument("-o", "--output", required=True)

compress_p = sub.add_parser("compress")
compress_p.add_argument("file")
compress_p.add_argument("-o", "--output", required=True)

watermark_p = sub.add_parser("watermark")
watermark_p.add_argument("file")
watermark_p.add_argument("--text", required=True)
watermark_p.add_argument("-o", "--output", required=True)

args = parser.parse_args()

if args.action == "merge":
    result = pdf_api.run({"action": "merge", "files": args.files})
elif args.action == "compress":
    result = pdf_api.run({"action": "compress", "file": args.file})
elif args.action == "watermark":
    result = pdf_api.run({"action": "watermark", "file": args.file, "text": args.text})

if result.status != "success":
    print(f"✗ {args.action} failed: {result.status}", file=sys.stderr)
    sys.exit(1)

result.save(args.output)
print(f"✓ {args.action} → {args.output}")
Enter fullscreen mode Exit fullscreen mode

if name == "main":
main()

That's most of it. Each subcommand maps directly onto one API call, and the whole file is short enough to read top to bottom in under a minute, which matters more than it sounds like it should.

Why this was worth building

The honest case for a CLI wrapper isn't that it's technically impressive, it clearly isn't. It's that "quick PDF task" shows up constantly in ordinary backend work, cleaning up a batch of scanned files before a demo, merging a few reports before sending them somewhere, watermarking a draft before a review, and every one of those used to mean either opening a script I'd half-forgotten or writing a new one from scratch. Now it means one command, tab-completed, with output I can immediately check with ls and open.

There's a second, quieter benefit: once the CLI existed, it became trivial to drop pdftk calls into shell scripts, Makefiles, and pre-commit-style hooks, places where writing a full script every time would never have felt worth it, but running one existing command absolutely does.

What I'd do differently building it again

I'd add a --json flag sooner than I did. The human-readable success and failure lines are great for interactive use and terrible for anything that wants to parse the output programmatically, and I ended up needing both within the first week of using it inside other scripts. A --json flag that switches the output to structured status objects would have saved me from grepping stdout, which is exactly the kind of thing that's obvious in hindsight and easy to skip when you're building something "just for yourself" on a Friday afternoon.

Handling config and auth without making it annoying

The other thing worth getting right early is where the API key lives. My first version took it as a command-line flag, which is exactly as annoying as it sounds the second time you type pdftk merge a.pdf b.pdf -o c.pdf --key sk_live_xxxxx into a terminal history that other processes on the machine can read. It moved to an environment variable almost immediately, PDFTK_API_KEY, read once at startup, with a clear error message if it's missing rather than a stack trace three calls deep into the API client:

import os

def get_api_key():
key = os.environ.get("PDFTK_API_KEY")
if not key:
print("✗ PDFTK_API_KEY is not set. Export it and try again.", file=sys.stderr)
sys.exit(1)
return key

That's a small thing, but it's the difference between a tool that feels safe to hand to a teammate and one that quietly encourages people to paste secrets into shell history. A CLI tool that's going to get used more than once by more than one person is worth treating like a real piece of internal tooling, even when it started as a fifteen-minute Friday project.

Packaging it so it's actually easy to install

The last step that made this genuinely sticky on the team was packaging it as an installable command rather than a script people had to remember the path to. A minimal pyproject.toml with an entry point turns python pdftk.py merge ... into just pdftk merge ..., installable with pip install -e . from the repo, or published internally if it needs to reach more than one machine. It's a small amount of extra setup, and it's the difference between a tool a couple of people know about and a tool that shows up in everyone's $PATH without anyone having to explain it twice. Once that happened, I started seeing it show up in other people's shell scripts and Makefiles without me having anything to do with it, which is usually a decent signal that a small internal tool earned its place.

Where error handling actually needs the most care

The failure mode that mattered most in practice wasn't a missing file or a bad flag, both of which argparse handles cleanly on its own. It was a batch of files where one file in the middle was corrupted or password-protected, and the CLI needed to say exactly which one, rather than failing the whole batch with a generic error and leaving me to guess. That meant surfacing the API's structured failure reason directly in the CLI's output instead of collapsing every non-success response into the same flat "failed" message, a small design choice that made the tool meaningfully more useful the first time a real, messy batch of files hit it.

The API underneath it

None of this would be a fifteen-minute project if the CLI also had to implement merge, split, compress, watermark, rotate, and convert itself. It's a thin wrapper around a PDF API for CLI tools, covering all six operations as simple calls, priced per successful result, which meant the entire build was argument parsing and output formatting, nothing else. If you're reaching for the same API from a terminal more than twice a week, it's worth the fifteen minutes.

Top comments (0)