DEV Community

Sanskar
Sanskar

Posted on

Your CLI Is an API — Even If You Never Meant It to Be

Your CLI Is an API — Even If You Never Meant It to Be

Most developers think of a command-line interface as a simple way to run a program.

You type a command, receive some output, and move on.

But consider what happens when someone uses your tool inside a shell script, connects it to another program, or adds it to a CI/CD pipeline.

Suddenly, your CLI is no longer just an interface for humans. It is an interface that other software depends on.

That makes your CLI an API.

And like any API, it needs predictable behavior, clear contracts, and thoughtful design.

1. Standard output is a contract

Consider a command that analyzes a project and returns a JSON report.

A developer might run:

project-analyzer --format json > report.json
Enter fullscreen mode Exit fullscreen mode

They expect report.json to contain valid JSON.

Now imagine your application writes this to standard output:

Analyzing 42 files...
Found 3 warnings.
{"files": 42, "warnings": 3}
Enter fullscreen mode Exit fullscreen mode

The human can understand the output, but a JSON parser cannot.

The problem isn't the JSON. The problem is that the program mixed informational messages with machine-readable data.

A better design separates the two streams:

  • stdout: The requested result, suitable for piping or redirection.
  • stderr: Progress indicators, warnings, diagnostics, and errors.

For example, in Rust:

fn main() {
    println!(r#"{{"files":42,"warnings":3}}"#);
    eprintln!("Analysis completed successfully.");
}
Enter fullscreen mode Exit fullscreen mode

This is a simplified illustration, but it demonstrates the principle: output intended for another program should not be polluted by human-oriented messages.

A useful rule is simple:

Anything you expect another program to parse should have a predictable format.

2. Exit codes communicate success or failure

Suppose your tool detects a problem but still exits with status code 0.

A shell script might interpret the operation as successful and continue running.

That can turn a small reporting mistake into a much larger automation problem.

Use exit codes intentionally:

Exit code Meaning
0 The operation succeeded
Nonzero The operation failed or encountered a defined problem

For example, a project validator might return 0 when everything passes and a nonzero status when validation fails.

Choose a documented convention appropriate to your application. If callers need to distinguish different failure categories, define those categories deliberately rather than assigning arbitrary numbers.

In Rust, a basic error-handling structure could look like this:

use std::process;

fn run() -> Result<(), String> {
    // Perform the operation here.
    // Return Err("Validation failed".into())
    // when validation cannot succeed.
    Ok(())
}

fn main() {
    if let Err(error) = run() {
        eprintln!("Error: {error}");
        process::exit(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

The key idea is to make success and failure observable to the programs that depend on yours.

3. Human-readable output and machine-readable output serve different users

Pretty tables are excellent for people. JSON, CSV, and other structured formats are often better for automation.

Trying to make one output format serve both audiences can create unnecessary complexity.

Instead, give users an explicit choice:

project-analyzer report ./src
project-analyzer report ./src --format json
project-analyzer report ./src --format csv
Enter fullscreen mode Exit fullscreen mode

The default can prioritize readability, while other formats can support scripts and integrations.

When you add a format, document its schema and behavior. If automation depends on particular fields, changing or renaming those fields can break existing users.

This doesn't mean your CLI can never evolve. It means breaking changes should be intentional and communicated.

4. Design commands to behave predictably

A command-line tool becomes difficult to automate when the same command behaves differently for unclear reasons.

Consider these design questions:

  • Does the command modify files, or does it only inspect them?
  • Will it overwrite existing output?
  • What happens when a requested file does not exist?
  • Does it prompt for confirmation when running without an interactive terminal?
  • Can users preview changes before applying them?

Good defaults reduce surprises.

For destructive operations, consider requiring explicit confirmation, providing a dry-run mode, or supporting an explicit overwrite option where appropriate.

For example:

project-analyzer format ./src --check
project-analyzer format ./src --write
Enter fullscreen mode Exit fullscreen mode

Here, --check could report whether formatting changes are needed, while --write applies them.

The exact flags matter less than having clear, consistent semantics.

5. Treat command-line options as public interfaces

Once people put your flags into scripts, those flags become dependencies.

Renaming --output to --destination may look like a small internal cleanup, but it can break other people's workflows.

Before changing a flag, think about compatibility.

A well-designed CLI should also have helpful behavior for common situations:

project-analyzer --help
project-analyzer --version
Enter fullscreen mode Exit fullscreen mode

Error messages should explain what went wrong and, where practical, how to fix it.

Compare:

Error: invalid argument
Enter fullscreen mode Exit fullscreen mode

with:

Error: unsupported output format "yml".
Supported formats: json, csv, text.
Enter fullscreen mode Exit fullscreen mode

The second message gives a developer enough information to take the next step.

6. Test the interface, not just the internal functions

Unit tests can prove that your parsing and analysis logic works correctly. They cannot, by themselves, prove that your complete CLI behaves as users expect.

Add integration tests for observable behavior:

  • The command returns the expected exit code.
  • Standard output contains the expected result.
  • Errors are written to standard error.
  • JSON output parses successfully.
  • Missing files produce useful errors.
  • Help and version commands work.
  • Existing commands remain compatible when you change the implementation.

These tests protect the contract between your tool and its users.

They also make refactoring safer because you can improve the implementation without accidentally changing behavior that external scripts rely on.

7. The bigger engineering lesson

A CLI is a particularly clear example of an important software engineering principle:

An interface is defined by how other people and programs depend on it, not by how simple it looks internally.

You might start with a twenty-line script. Over time, it could become part of a build pipeline, a deployment workflow, a developer's editor, or another application's automation.

You cannot predict every future integration, and you do not need to support every possible use case from day one.

But you can establish good foundations:

  1. Keep machine-readable output clean.
  2. Use meaningful exit statuses.
  3. Make errors understandable.
  4. Document commands and their effects.
  5. Test observable behavior.
  6. Preserve compatibility deliberately.

These practices make a small tool easier to trust, automate, maintain, and extend.

Final thought

The best command-line tools don't merely execute commands. They communicate clearly with both people and software.

The next time you build a CLI, don't ask only, "Does this command work?"

Ask another question:

"Can someone safely depend on this command without knowing how it was implemented?"

That is where a script starts becoming a reliable piece of software.


What is one command-line tool you use regularly that gets these details right—or gets them frustratingly wrong?

I'd be interested in hearing your examples.

Top comments (0)