The Moment You Wrap a CLI, stdout Becomes Your Protocol

Choosing a mature command-line tool as an application backend feels like deleting work.
No new daemon. No hidden API. No second implementation of the domain. Spawn the executable, collect stdout, put the result in a tree view. Very civilized.
Then stdout contains a tab inside user-authored text, an empty field that actually means something, and a path shaped differently on another platform.
The CLI still saved work. It did not remove the boundary. It moved that boundary into text.
When I chose the Git CLI as the only data path for a VS Code extension, every byte it printed became an input my code had to interpret. The useful direction was to stop treating stdout as a convenient string and start treating it as a protocol with explicit producers, parsers, and failure cases.
Ask the producer for parseable output
A parser cannot recover structure the command never emitted.
Human-facing output is built for eyes: aligned columns, decorative markers, localized labels, and whitespace that looks pleasant in a terminal. Those features become ambiguity in an application.
A CLI-backed product should prefer machine-shaped flags and explicit delimiters where the command supports them. The exact choice depends on the command, but the principle is stable:
command arguments define the wire format
parser defines the application meaning
That means the command builder and parser are a pair. Changing one without reviewing the other is a protocol change, even if no network is involved.
This framing catches a common maintenance mistake: “cleaning up” a format string because the output looks equivalent in a sample. Equivalent to a person is not necessarily equivalent to a parser.
Pure parsers make ugly inputs affordable
The extension host should not be required to test what two adjacent separators mean.
A pure parser accepts stdout and returns typed domain data. It does not spawn Git, show UI, read workspace state, or log a helpful notification. That isolation makes malformed strings cheap to test.
function parseRows(stdout: string): Row[] {
// Text in, domain values out.
}
The boring signature is the advantage.
Tests can cover empty output, optional fields, detached states, Unicode, platform paths, user-authored messages, trailing newlines, and records with missing segments. Each fixture documents part of the protocol more precisely than a comment saying “parse Git output.”
Meanwhile the effectful service owns exit status and stderr. A command that exits non-zero is not merely a strange empty list, and an empty list is not necessarily failure. Mixing those cases in one parser produces very polite bugs: the UI says there are no branches when the command never succeeded.
Preserve the raw boundary during failure
Typed errors are useful, but early normalization can erase the clue needed to fix a parser.
When parsing fails, diagnostics should identify the command shape and preserve a safe representation of the unexpected output. That does not mean dumping secrets or entire repository contents into telemetry. It means retaining enough local evidence to distinguish an unsupported record from an execution failure.
The parser should also fail deliberately. Returning a partially invented object because one field was absent pushes uncertainty into the UI, where it becomes harder to trace.
I would rather see “could not parse record 3” than a confident commit row with its author and subject shifted one column to the left. (Text protocols enjoy moving furniture in the dark.)
Mutation is a separate trust boundary
Once a CLI wrapper can read repository state, adding branch checkout or stash application looks like a small extension. Architecturally, it is not.
Reads can refresh quietly. A mutation that may collide with uncommitted work needs a state check and an explicit user decision. Keeping command invocation behind a service layer helps because mutation has a known doorway; the command layer can inspect state and ask for consent before crossing it.
This reinforces the protocol model. Parsing answers “what did the tool report?” Mutation answers “what are we allowed to ask it to change?” They deserve separate tests and separate UI behavior.
Minimal Git Explorer is where I made this trade: visible local commands in exchange for owning their text boundary. If you wrap a CLI, take one parser out of the framework today. Feed it empty, malformed, and inconvenient output. The first test that fails is the protocol your application already had but had not admitted yet.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.