Created
February 18, 2026 00:17
-
-
Save kkirsche/4e04acf828a3e45f965aed07df1c18d5 to your computer and use it in GitHub Desktop.
CLI Guidelines
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| clig.py — Reusable toolkit implementing https://clig.dev guidelines. | |
| Covers: | |
| - TTY detection (stdout/stderr color/formatting awareness) | |
| - Styled output (info, success, warning, error) to correct streams | |
| - Structured exit codes | |
| - Spinner / progress feedback for long ops | |
| - Suggestions on typo / unknown subcommand | |
| - Stdin-is-TTY guard | |
| - --json output toggle | |
| - Confirmation prompts | |
| - Version callback (argparse + typer flavors) | |
| - Rich help formatter for argparse | |
| - Typer integration helpers | |
| Usage with argparse: | |
| from clig import CLIGFormatter, out, err, ExitCode, confirm, spinner | |
| Usage with typer: | |
| from clig import typer_app, out, err, ExitCode, confirm, spinner | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import difflib | |
| import json | |
| import os | |
| import shutil | |
| import signal | |
| import sys | |
| import threading | |
| import time | |
| from contextlib import contextmanager | |
| from enum import IntEnum | |
| from typing import Any, Callable, Generator, Iterable, NoReturn, Sequence | |
| # --------------------------------------------------------------------------- | |
| # Exit codes | |
| # | |
| # Prefer os.EX_* (POSIX sysexits.h) where available (Unix/macOS). | |
| # Windows doesn't define these constants, so fall back to the raw int | |
| # values from sysexits.h so cross-platform code still compiles. | |
| # os.EX_OK and the plain 1/2 convention are universally safe. | |
| # --------------------------------------------------------------------------- | |
| def _ex(posix_const: str, fallback: int) -> int: | |
| """Return os.EX_* value if the platform defines it, else the fallback int.""" | |
| return getattr(os, posix_const, fallback) | |
| class ExitCode(IntEnum): | |
| # Universal codes — safe on all platforms | |
| OK = _ex("EX_OK", 0) | |
| GENERAL_ERROR = 1 # no POSIX constant; convention | |
| MISUSE = _ex("EX_USAGE", 64) # bad CLI usage / invalid args | |
| # sysexits.h codes — fall back to canonical values on Windows | |
| DATA_ERR = _ex("EX_DATAERR", 65) # bad input data | |
| NO_INPUT = _ex("EX_NOINPUT", 66) # input file not found/readable | |
| NO_USER = _ex("EX_NOUSER", 67) # addressed user unknown | |
| NO_HOST = _ex("EX_NOHOST", 68) # host name unknown | |
| UNAVAILABLE = _ex("EX_UNAVAILABLE", 69) # service/resource unavailable | |
| SOFTWARE = _ex("EX_SOFTWARE", 70) # internal software error | |
| OS_ERR = _ex("EX_OSERR", 71) # OS error (fork/pipe/etc.) | |
| OS_FILE = _ex("EX_OSFILE", 72) # critical OS file missing | |
| CANT_CREATE = _ex("EX_CANTCREAT", 73) # output file cannot be created | |
| IO_ERROR = _ex("EX_IOERR", 74) # I/O error | |
| TEMP_FAIL = _ex("EX_TEMPFAIL", 75) # temporary failure; retry later | |
| PROTOCOL = _ex("EX_PROTOCOL", 76) # remote protocol error | |
| NO_PERM = _ex("EX_NOPERM", 77) # insufficient permissions | |
| CONFIG_ERR = _ex("EX_CONFIG", 78) # configuration error | |
| # --------------------------------------------------------------------------- | |
| # TTY detection | |
| # --------------------------------------------------------------------------- | |
| def is_tty(stream=None) -> bool: | |
| """Return True if the given stream (default stdout) is a TTY.""" | |
| s = stream if stream is not None else sys.stdout | |
| return hasattr(s, "isatty") and s.isatty() | |
| def supports_color(stream=None) -> bool: | |
| """Return True if the stream supports ANSI color codes.""" | |
| if not is_tty(stream): | |
| return False | |
| if os.environ.get("NO_COLOR"): # https://no-color.org | |
| return False | |
| if os.environ.get("TERM") == "dumb": | |
| return False | |
| return True | |
| # --------------------------------------------------------------------------- | |
| # ANSI helpers | |
| # --------------------------------------------------------------------------- | |
| _RESET = "\033[0m" | |
| _BOLD = "\033[1m" | |
| _DIM = "\033[2m" | |
| _RED = "\033[31m" | |
| _GREEN = "\033[32m" | |
| _YELLOW = "\033[33m" | |
| _CYAN = "\033[36m" | |
| _WHITE = "\033[97m" | |
| def _colorize(text: str, *codes: str, stream=None) -> str: | |
| if supports_color(stream): | |
| return "".join(codes) + text + _RESET | |
| return text | |
| # --------------------------------------------------------------------------- | |
| # Output helpers (stdout / stderr routing per CLIG) | |
| # --------------------------------------------------------------------------- | |
| class _Out: | |
| """ | |
| Primary output writer. Machine-readable data → stdout. | |
| Color/formatting only when stdout is a TTY. | |
| """ | |
| def __call__(self, msg: str = "", end: str = "\n") -> None: | |
| print(msg, end=end, file=sys.stdout) | |
| def json(self, data: Any, indent: int = 2) -> None: | |
| """Emit JSON to stdout (always, regardless of TTY).""" | |
| print(json.dumps(data, indent=indent), file=sys.stdout) | |
| def success(self, msg: str) -> None: | |
| prefix = _colorize("✓ ", _GREEN, _BOLD, stream=sys.stdout) | |
| print(f"{prefix}{msg}", file=sys.stdout) | |
| def info(self, msg: str) -> None: | |
| prefix = _colorize("→ ", _CYAN, stream=sys.stdout) | |
| print(f"{prefix}{msg}", file=sys.stdout) | |
| def dim(self, msg: str) -> None: | |
| print(_colorize(msg, _DIM, stream=sys.stdout), file=sys.stdout) | |
| class _Err: | |
| """ | |
| Messaging writer (logs, warnings, errors) → stderr per CLIG. | |
| """ | |
| def __call__(self, msg: str = "", end: str = "\n") -> None: | |
| print(msg, end=end, file=sys.stderr) | |
| def warning(self, msg: str) -> None: | |
| prefix = _colorize("⚠ Warning: ", _YELLOW, _BOLD, stream=sys.stderr) | |
| print(f"{prefix}{msg}", file=sys.stderr) | |
| def error(self, msg: str, hint: str | None = None) -> None: | |
| prefix = _colorize("✗ Error: ", _RED, _BOLD, stream=sys.stderr) | |
| print(f"{prefix}{msg}", file=sys.stderr) | |
| if hint: | |
| print(_colorize(f" Hint: {hint}", _DIM, stream=sys.stderr), file=sys.stderr) | |
| def fatal( | |
| self, | |
| msg: str, | |
| hint: str | None = None, | |
| code: int = ExitCode.GENERAL_ERROR, | |
| ) -> NoReturn: | |
| self.error(msg, hint) | |
| sys.exit(code) | |
| out = _Out() | |
| err = _Err() | |
| # --------------------------------------------------------------------------- | |
| # Stdin guard (CLIG: if stdin is a TTY when piped input expected → show help) | |
| # --------------------------------------------------------------------------- | |
| def require_piped_stdin(parser: "argparse.ArgumentParser | None" = None) -> None: | |
| """ | |
| Call this in commands that expect piped stdin. | |
| If stdin is interactive, print help (or a notice) and exit 2. | |
| """ | |
| if is_tty(sys.stdin): | |
| if parser is not None: | |
| parser.print_help(sys.stderr) | |
| else: | |
| err("This command expects piped input (e.g. `cat file | mytool`).") | |
| sys.exit(ExitCode.MISUSE) | |
| # --------------------------------------------------------------------------- | |
| # Spinner / progress feedback | |
| # --------------------------------------------------------------------------- | |
| _SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") | |
| class Spinner: | |
| """ | |
| Simple non-blocking terminal spinner. Auto-disabled when stderr is | |
| not a TTY (e.g. piped/CI) so output stays clean. | |
| """ | |
| def __init__(self, message: str = "Working…", stream=sys.stderr): | |
| self.message = message | |
| self.stream = stream | |
| self._active = False | |
| self._thread: threading.Thread | None = None | |
| self._enabled = is_tty(stream) | |
| def _spin(self) -> None: | |
| idx = 0 | |
| while self._active: | |
| if self._enabled: | |
| frame = _colorize(_SPINNER_FRAMES[idx % len(_SPINNER_FRAMES)], _CYAN, stream=self.stream) | |
| self.stream.write(f"\r{frame} {self.message}") | |
| self.stream.flush() | |
| idx += 1 | |
| time.sleep(0.08) | |
| def start(self) -> "Spinner": | |
| self._active = True | |
| self._thread = threading.Thread(target=self._spin, daemon=True) | |
| self._thread.start() | |
| return self | |
| def stop(self, final_msg: str | None = None) -> None: | |
| self._active = False | |
| if self._thread: | |
| self._thread.join() | |
| if self._enabled: | |
| self.stream.write("\r\033[K") # clear line | |
| self.stream.flush() | |
| if final_msg: | |
| print(final_msg, file=self.stream) | |
| def __enter__(self) -> "Spinner": | |
| return self.start() | |
| def __exit__(self, exc_type, exc_val, exc_tb) -> None: | |
| if exc_type: | |
| self.stop(_colorize("✗ Failed", _RED, _BOLD, stream=self.stream)) | |
| else: | |
| self.stop(_colorize("✓ Done", _GREEN, _BOLD, stream=self.stream)) | |
| @contextmanager | |
| def spinner(message: str = "Working…") -> Generator[Spinner, None, None]: | |
| """Context manager wrapper around Spinner.""" | |
| s = Spinner(message) | |
| with s: | |
| yield s | |
| # --------------------------------------------------------------------------- | |
| # Confirmation prompt | |
| # --------------------------------------------------------------------------- | |
| def confirm( | |
| prompt: str, | |
| default: bool = False, | |
| destructive: bool = False, | |
| ) -> bool: | |
| """ | |
| Prompt user for yes/no confirmation. Returns bool. | |
| If stdin is not a TTY (scripted), returns `default` silently. | |
| Mark destructive=True to highlight the risk. | |
| """ | |
| if not is_tty(sys.stdin): | |
| return default | |
| yn = "Y/n" if default else "y/N" | |
| if destructive: | |
| prompt = _colorize(prompt, _RED, _BOLD, stream=sys.stderr) | |
| try: | |
| answer = input(f"{prompt} [{yn}]: ").strip().lower() | |
| except (EOFError, KeyboardInterrupt): | |
| print(file=sys.stderr) | |
| return False | |
| if not answer: | |
| return default | |
| return answer in ("y", "yes") | |
| # --------------------------------------------------------------------------- | |
| # Typo / did-you-mean suggestions | |
| # --------------------------------------------------------------------------- | |
| def suggest( | |
| word: str, | |
| candidates: Iterable[str], | |
| cutoff: float = 0.6, | |
| n: int = 3, | |
| ) -> list[str]: | |
| """Return close matches from candidates for the given word.""" | |
| return difflib.get_close_matches(word, list(candidates), n=n, cutoff=cutoff) | |
| def die_with_suggestion( | |
| unknown: str, | |
| candidates: Iterable[str], | |
| noun: str = "command", | |
| code: int = ExitCode.MISUSE, | |
| ) -> NoReturn: | |
| """ | |
| Print 'unknown <noun>' error, offer did-you-mean suggestions, exit. | |
| """ | |
| matches = suggest(unknown, candidates) | |
| err.error(f"Unknown {noun}: {unknown!r}") | |
| if matches: | |
| if len(matches) == 1: | |
| print(_colorize(f" Did you mean: {matches[0]}", _CYAN, stream=sys.stderr), file=sys.stderr) | |
| else: | |
| alts = ", ".join(matches) | |
| print(_colorize(f" Did you mean one of: {alts}", _CYAN, stream=sys.stderr), file=sys.stderr) | |
| sys.exit(code) | |
| # --------------------------------------------------------------------------- | |
| # --json flag helper | |
| # --------------------------------------------------------------------------- | |
| def add_json_flag(parser: "argparse.ArgumentParser") -> None: | |
| """Add --json flag to an argparse parser.""" | |
| parser.add_argument( | |
| "--json", | |
| action="store_true", | |
| default=False, | |
| help="Output result as JSON (machine-readable).", | |
| ) | |
| def output_or_json(data: dict | list, as_json: bool) -> None: | |
| """Emit data as JSON to stdout if --json, else return data for caller to format.""" | |
| if as_json: | |
| out.json(data) | |
| return data | |
| # --------------------------------------------------------------------------- | |
| # Version callback (argparse) | |
| # --------------------------------------------------------------------------- | |
| def add_version_flag( | |
| parser: "argparse.ArgumentParser", | |
| version: str, | |
| prog: str | None = None, | |
| ) -> None: | |
| name = prog or parser.prog or "app" | |
| parser.add_argument( | |
| "-V", "--version", | |
| action="version", | |
| version=f"{name} {version}", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Rich help formatter for argparse (CLIG: formatted, scannable help text) | |
| # --------------------------------------------------------------------------- | |
| class CLIGFormatter(argparse.HelpFormatter): | |
| """ | |
| Argparse formatter that: | |
| - Bolds section headings when stdout is a TTY | |
| - Shows default values | |
| - Widens the option column | |
| - Preserves line breaks in descriptions (use RawDescription variant) | |
| """ | |
| def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 32, width: int | None = None): | |
| w = width or min(shutil.get_terminal_size().columns, 100) | |
| super().__init__(prog, indent_increment, max_help_position, w) | |
| def start_section(self, heading: str | None) -> None: | |
| if heading and supports_color(sys.stdout): | |
| heading = _colorize(heading.upper(), _BOLD, stream=sys.stdout) | |
| super().start_section(heading) | |
| def _get_help_string(self, action: argparse.Action) -> str | None: | |
| h = action.help or "" | |
| if action.default not in (argparse.SUPPRESS, None, False): | |
| if "%(default)" not in h: | |
| h += f" (default: %(default)s)" | |
| return h | |
| class CLIGRawFormatter(CLIGFormatter, argparse.RawDescriptionHelpFormatter): | |
| """Combines CLIG styling with preserved description whitespace.""" | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # Argparse base parser factory | |
| # --------------------------------------------------------------------------- | |
| def make_parser( | |
| prog: str, | |
| description: str, | |
| version: str | None = None, | |
| epilog: str | None = None, | |
| formatter: type = CLIGRawFormatter, | |
| add_json: bool = False, | |
| support_url: str | None = None, | |
| ) -> argparse.ArgumentParser: | |
| """ | |
| Create an argparse.ArgumentParser pre-configured for CLIG compliance. | |
| Args: | |
| prog: Program name. | |
| description: Short description (shown at top of help). | |
| version: If given, adds -V/--version flag. | |
| epilog: Shown at bottom of help. Good place for examples. | |
| formatter: Help formatter class (default: CLIGRawFormatter). | |
| add_json: If True, adds --json flag. | |
| support_url: If given, appended to epilog. | |
| """ | |
| full_epilog = epilog or "" | |
| if support_url: | |
| full_epilog += f"\n\nDocumentation / support: {support_url}" | |
| parser = argparse.ArgumentParser( | |
| prog=prog, | |
| description=description, | |
| epilog=full_epilog.strip() or None, | |
| formatter_class=formatter, | |
| add_help=True, | |
| ) | |
| if version: | |
| add_version_flag(parser, version, prog) | |
| if add_json: | |
| add_json_flag(parser) | |
| return parser | |
| # --------------------------------------------------------------------------- | |
| # Graceful signal handling (CLIG: handle SIGPIPE, SIGINT cleanly) | |
| # --------------------------------------------------------------------------- | |
| def setup_signals() -> None: | |
| """ | |
| Install clean signal handlers. | |
| - SIGPIPE: exit 0 silently (piped output closed by consumer) | |
| - SIGINT: exit 130 with newline (Ctrl-C, standard convention) | |
| """ | |
| if hasattr(signal, "SIGPIPE"): | |
| signal.signal(signal.SIGPIPE, signal.SIG_DFL) | |
| def _handle_interrupt(sig, frame): | |
| print(file=sys.stderr) # move past ^C on the line | |
| sys.exit(130) | |
| signal.signal(signal.SIGINT, _handle_interrupt) | |
| # --------------------------------------------------------------------------- | |
| # Typer integration (optional — only imported when typer is available) | |
| # --------------------------------------------------------------------------- | |
| def get_typer_app(**kwargs) -> "Any": | |
| """ | |
| Return a typer.Typer app pre-configured with CLIG-friendly settings. | |
| Requires `typer` to be installed. Raises ImportError otherwise. | |
| Usage: | |
| app = get_typer_app(name="mytool", help="Does cool things.") | |
| @app.command() | |
| def main(name: str): ... | |
| """ | |
| try: | |
| import typer | |
| except ImportError as exc: | |
| raise ImportError("Install typer: pip install typer") from exc | |
| defaults = dict( | |
| no_args_is_help=True, | |
| add_completion=True, | |
| pretty_exceptions_show_locals=False, | |
| context_settings={"help_option_names": ["-h", "--help"]}, | |
| ) | |
| defaults.update(kwargs) | |
| return typer.Typer(**defaults) | |
| # --------------------------------------------------------------------------- | |
| # Convenience: run a callable, handle exceptions, emit proper exit codes | |
| # --------------------------------------------------------------------------- | |
| def safe_run(fn: Callable, *args, **kwargs) -> NoReturn: | |
| """ | |
| Call fn(*args, **kwargs). Catches KeyboardInterrupt and common | |
| exceptions, routes them to stderr, exits with appropriate codes. | |
| """ | |
| setup_signals() | |
| try: | |
| result = fn(*args, **kwargs) | |
| sys.exit(result if isinstance(result, int) else ExitCode.OK) | |
| except KeyboardInterrupt: | |
| print(file=sys.stderr) | |
| sys.exit(130) | |
| except BrokenPipeError: | |
| sys.exit(ExitCode.OK) | |
| except SystemExit: | |
| raise | |
| except Exception as exc: # noqa: BLE001 | |
| err.fatal(str(exc), code=ExitCode.GENERAL_ERROR) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment