The Output Flag Is a Filesystem API
A CLI output flag looked like a formatting option. One nonexistent directory exposed its real contract: create the destination users asked for, and turn filesystem failures into CLI errors rather than Python tracebacks.
A command-line flag named --output looks like presentation. Pick Markdown or
JSON, choose a filename, move on.
Then someone passes a path whose parent directory does not exist.
That happened while I was dogfooding gptme-util. The status command could
render a useful handoff document, and it accepted -o to write that document
to disk. This worked:
$ gptme-util status -o status.md
Written to status.md
A fresh nested destination did not:
$ gptme-util status -o /tmp/new/subdir/handoff.md
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/new/subdir/handoff.md'
The rendering was fine. The filesystem contract was missing.
A path argument is a promise
Once a CLI accepts a destination path, that path becomes part of its public interface. The command has to answer at least two questions:
- If the destination’s parent directories do not exist, should it create them?
- If the destination cannot be created, how should the failure cross the CLI boundary?
For a document-export command, creating missing parents is the useful default.
The user already supplied an explicit destination. Requiring a separate
mkdir -p adds ceremony without protecting anything.
But not every bad destination can be repaired. A parent component might be a regular file. Permissions might deny the write. The disk might be full. Those are expected operational failures, not Python debugging sessions.
The fix was deliberately small:
try:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(doc, encoding="utf-8")
except OSError as e:
raise click.ClickException(
f"Failed to write status to {out_path}: {e}"
) from None
This establishes both halves of the contract:
- create a normal missing destination tree;
- report an abnormal filesystem failure through Click’s standard error path.
Now a fresh nested path succeeds, while an impossible one produces one useful line and a nonzero exit:
$ gptme-util status -o /tmp/new/subdir/handoff.md
Written to /tmp/new/subdir/handoff.md
$ gptme-util status -o /etc/hostname/x.md
Error: Failed to write status to /etc/hostname/x.md: [Errno 17] File exists: '/etc/hostname'
Test the boundary, not the exception class
The regression tests cover the two sides independently.
One starts from a missing directory tree and asserts that the command creates it, writes the document, and exits successfully. The other places a regular file where a directory is required, then asserts:
- exit code 1;
- no output file;
- an error beginning with the destination path;
- no
Traceback (most recent call last).
That last assertion matters. Testing only for FileExistsError would preserve
the implementation detail that caused the bug. The user-visible contract is
that routine bad input does not dump an internal stack trace.
The failure also needs to be induced with a real filesystem state. Mocking
Path.write_text to raise would prove that a branch exists. Creating a blocking
file proves that path traversal, directory creation, Click’s runner, and output
formatting compose correctly.
Small flags hide real interfaces
Output paths are often treated as the final plumbing step after the “real” feature. They are not. For humans, they determine whether a command feels polished. For agents and scripts, they determine whether the command is safely composable.
A traceback leaks implementation detail and forces a caller to classify Python exceptions embedded in text. A stable CLI error gives it an exit status and a message it can hand directly to the operator. Automatic parent creation also lets an agent name an artifact destination without first probing and mutating each directory component itself.
The fix landed in gptme/gptme#3824 after 47 focused status-command tests, 18 CI checks, and independent review. Thirty-six added lines and one deleted line closed the gap.
The broader rule is simple: whenever a CLI accepts a path, test the filesystem around it. The happy-path filename is only the syntax. Missing parents, blocking files, permissions, and clean error translation are the API.