Semantic versioning says to bump the major version for incompatible API changes. For a library the API is obvious: the importable functions and classes. For a command-line tool it is not — and the ambiguity causes real breakage. Is renaming --dir to --directory breaking? Rewording an error message? Adding a field to --json output? Changing an exit code from 1 to 2? Users who pin mytool>=2,<3 expect their scripts to keep working, and they can only rely on that if the project has decided — and written down — what its public interface is. This guide defines that interface for a CLI, gives a decision procedure for classifying changes, lays out a deprecation process that lets you evolve the tool without surprising anyone, and shows how to test that the policy is followed. It belongs to the managing CLI versioning and changelogs topic.
Prerequisites
- A CLI with users beyond its authors — especially users who run it from scripts, CI or cron.
- A changelog, ideally structured as described in automating changelogs with conventional commits.
What is a CLI's public API?
Everything a script can observe and depend on. People read help text and messages; scripts parse output, check exit codes and pass flags. The versioning policy protects the scripts.
- Commands, subcommands, flags and arguments — names, meanings, defaults and whether they are required. Scripts invoke them.
- Exit codes — scripts branch on them. Changing "no results" from exit 1 to exit 0 silently breaks every
if mytool search ...; thenin the wild. - Machine-readable output —
--json,--format csv,--porcelain. Fields, their types and their meaning. See emitting JSON output for scripting. - Environment variables and config file keys the tool reads.
- Files the tool writes in documented locations, where other tools read them.
- The
requires-pythonrange — dropping a Python version breaks installs on that version.
Explicitly not public: human-readable output formatting, wording of messages and help text, colours, progress bars, log lines, the order of lines in human output, and performance. Say so in your documentation. That statement is what frees you to improve the human experience in minor releases without being accused of breaking changes — and it is why tools like git provide separate --porcelain formats for scripts.
The recipe: classifying a change
Ask one question: could a script that worked with the previous release fail, or silently behave differently, with this one?
- Yes → major. Removing or renaming a command or flag, changing a default in a way scripts observe, changing an exit code's meaning, removing or retyping a JSON field, stopping reading a config key, dropping a Python version.
- No, but something new is available → minor. New commands, new flags, new JSON fields, new config keys, new supported Python versions, deprecation warnings.
- No, and behaviour now matches the documentation → patch. Bug fixes, performance improvements, message rewording, dependency updates that change nothing observable.
Two grey areas deserve a written rule. Bug fixes that change behaviour scripts may rely on — for example an exit code that was wrong — are technically fixes; treat them as breaking if the old behaviour was plausible and documented nowhere as a bug, or at least call them out prominently in the changelog. New validation that rejects previously accepted input is breaking, even if the input was never meaningful; introduce it as a warning first.
The recipe: deprecate, then remove
Most breaking changes can be made gentle with a deprecation window: introduce the replacement, warn when the old form is used, and remove it only in the next major release.
A small helper keeps deprecation warnings consistent, sends them to stderr (so they never break parsers of stdout), and shows each warning once per run:
# src/mytool/deprecation.py
from __future__ import annotations
import os
import sys
_shown: set[str] = set()
def deprecated(old: str, new: str, remove_in: str) -> None:
"""Warn once per run that `old` is deprecated in favour of `new`."""
if old in _shown or os.environ.get("MYTOOL_NO_DEPRECATION_WARNINGS"):
return
_shown.add(old)
print(f"warning: {old} is deprecated and will be removed in {remove_in}; use {new} instead",
file=sys.stderr)
# src/mytool/cli.py
from pathlib import Path
from typing import Annotated
import typer
from mytool.deprecation import deprecated
app = typer.Typer()
@app.callback()
def main() -> None:
"""Build tool."""
@app.command()
def build(
directory: Annotated[Path | None, typer.Option("--directory", "-C")] = None,
old_dir: Annotated[Path | None, typer.Option("--dir", hidden=True)] = None,
) -> None:
"""Build the project in DIRECTORY (default: current directory)."""
if old_dir is not None:
deprecated("--dir", "--directory", remove_in="3.0")
directory = directory or old_dir
target = directory or Path.cwd()
typer.echo(f"building {target}", err=True)
The old flag is hidden=True, so new users only learn the new name from --help, while existing scripts keep working and see a warning they can act on. The mechanics of deprecating flags are covered in depth in versioning and deprecating CLI flags.
How long should the window be? At least one minor release, and long enough that users who update monthly will see the warning — a few months is typical for internal tools, longer for widely used public ones. The removal itself goes in the next major release, listed under a "Breaking changes" heading in the changelog.
Before 1.0
SemVer treats 0.x versions as unstable: anything may change at any time. That is honest for a prototype and a problem for a tool people already depend on. Two workable conventions:
- Treat the minor version as major while in 0.x.
0.5 → 0.6may break things;0.5.1 → 0.5.2may not. Poetry's caret operator and many users' expectations already work this way. - Go to 1.0 as soon as others script against the tool. The version number should describe your compatibility promise, and "people depend on this in CI" is exactly the moment a promise is needed.
UX considerations
- Write the policy down. A short "Compatibility" section in the README — what is public, what is not, how long deprecations last — sets expectations and settles arguments.
- Make breaking changes discoverable. Put them first in the release notes, under their own heading, with the migration step for each.
- Offer an escape hatch for warnings. An environment variable to silence deprecation warnings helps users who cannot update their scripts immediately and are drowning in stderr noise in CI.
- Consider
--versionoutput stable. Scripts parsemytool --version. Keep its first line in a fixed format (mytool 2.4.0) and put build metadata on later lines, as described in exposing version info and build metadata.
Testing the behaviour
A policy is only as good as its enforcement. The most effective enforcement is a set of contract tests pinning the public surfaces: they must keep passing across minor releases, and changing them is a deliberate, reviewed act that signals a major bump.
# tests/test_contract.py
"""Public CLI contract. Changing these tests means a MAJOR release."""
import json
from typer.testing import CliRunner
from mytool.cli import app
runner = CliRunner()
def test_deprecated_flag_still_works_and_warns(tmp_path):
result = runner.invoke(app, ["build", "--dir", str(tmp_path)])
assert result.exit_code == 0
assert "--dir is deprecated" in result.output
def test_deprecated_flag_hidden_from_help():
result = runner.invoke(app, ["build", "--help"])
assert "--directory" in result.output
assert "--dir " not in result.output
Extend the same file with tests for exit codes on documented failure modes and for the exact key set of every --json output. Keeping them in one clearly named file makes the review question obvious: if a pull request edits test_contract.py, it is proposing a breaking change. For whole-output snapshots of machine-readable formats, see snapshot testing CLI output.
Conclusion
Semantic versioning works for command-line tools once you decide what the API is: commands and flags, exit codes, machine-readable output, config keys and environment variables — not the wording of human output. Classify each change by whether an existing script could break, deprecate before removing with warnings on stderr, go to 1.0 when people depend on you, and pin the contract in tests that make breaking changes impossible to make by accident.
Frequently asked questions
Is adding a new field to JSON output a breaking change?
No, provided you documented that consumers must ignore unknown fields — which you should. Removing, renaming or changing the type of a field is breaking.
What about changing defaults?
If a script that omits the flag now behaves differently in a way it can observe, it is breaking. A new default for colour or verbosity usually is not; a new default output directory usually is.
Should I use CalVer instead?
Calendar versioning (2026.9.0) suits tools whose releases are driven by time rather than compatibility — data releases, or tools that promise to always support "the current platform". It tells users when, not whether it will break. For most CLIs that other scripts depend on, SemVer's compatibility signal is more useful.
How do I communicate a major release to users who install with pipx?
pipx and uv tool installs do not upgrade automatically, so users often skip notes. A gentle update notice in the tool itself — rate-limited, disabled in CI — that mentions breaking changes in the new major version is the most reliable channel.