Project Setup

A Semantic Versioning Policy for CLI Tools

Decide what a major, minor or patch release means for a Python CLI: the public surfaces scripts depend on, deprecation windows, pre-1.0 rules and a tested policy.

Updated

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

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.

What a CLI's public API includes The parts of a command line tool that users and scripts depend on, and whether changing each one requires a major version bump. What a CLI's public API includes Surface Breaking change example Bump Commands and flags rename --dir to --directory major Exit codes "no match" 1 → 0 major --json output remove or rename a field major Config file keys stop reading an old key major Human-readable output reword a message minor/patch New flag or command add --dry-run minor Scripts parse JSON and exit codes; people read prose. Version accordingly.
  • 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 ...; then in 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-python range — 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

Is this change breaking? A decision for classifying a CLI change: if any existing documented invocation now behaves differently for scripts, it is a major change; otherwise minor or patch. Is this change breaking? Could a script that worked yesterday fail today? Yes — output, codes or flags changed Major or deprecate first No — only new things were added Minor new flags, commands No — behaviour fixed to match docs Patch bug fixes When unsure, deprecate in a minor release and remove in the next major.

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.

Retiring a flag without surprising anyone A deprecation timeline for a command line flag across releases: introduce the replacement, warn on the old flag, then remove it in the next major version. Retiring a flag without surprising anyone Add --directory --dir still works 2.3.0 Warn on --dir stderr, once per run 2.4.0 Keep warning at least one cycle 2.x Remove --dir changelog says so 3.0.0 warnings go to stderr so they never break parsers of stdout The major bump is where the removal lands; the warning is what makes it fair.

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.6 may break things; 0.5.1 → 0.5.2 may 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 --version output stable. Scripts parse mytool --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.