clyops

clyops — declarative CLI parsing for Python. Behavior follows spec/SPEC.md.

from clyops import Cli

cli = Cli() cli.arg("input", "Input file", rule="path") cli.opt("PORT", "port", "p", "8080", "Server port", group="Network", rule="port") cli.opt("VERBOSE", "verbose", "v", "flag", "Verbose output") args = cli.run() print(args.input, args.PORT, args.VERBOSE)

   1"""clyops — declarative CLI parsing for Python. Behavior follows spec/SPEC.md.
   2
   3    from clyops import Cli
   4
   5    cli = Cli()
   6    cli.arg("input", "Input file", rule="path")
   7    cli.opt("PORT", "port", "p", "8080", "Server port", group="Network", rule="port")
   8    cli.opt("VERBOSE", "verbose", "v", "flag", "Verbose output")
   9    args = cli.run()
  10    print(args.input, args.PORT, args.VERBOSE)
  11"""
  12from __future__ import annotations
  13
  14import json
  15import os
  16import re
  17import shutil
  18import sys
  19import time
  20from dataclasses import dataclass, field
  21from typing import Dict, List, Mapping, NoReturn, Optional, Sequence, Union
  22
  23from ._completions import SCRIPTS as _COMPLETION_SCRIPTS
  24
  25__all__ = [
  26    "Cli", "Values", "ParseResult", "ValidationError", "validate", "resolve_path", "describe_rule", "wrap_text",
  27    "info", "warn", "error", "success", "die", "set_silent",
  28]
  29__version__ = "0.3.0"
  30
  31Scalar = Union[str, int, float, bool]
  32Value = Union[Scalar, List[Scalar], None]
  33
  34# ---------------------------------------------------------------------------
  35# Logging
  36# ---------------------------------------------------------------------------
  37
  38_COLORS = {"info": "\033[1;37m", "warning": "\033[0;33m", "error": "\033[0;31m", "success": "\033[0;32m"}
  39_silent = os.environ.get("CLYOPS_SILENT") == "true"
  40
  41
  42def set_silent(value: bool) -> None:
  43    """Suppress info/warn/error/success output (die and parse errors still print)."""
  44    global _silent
  45    _silent = value
  46
  47
  48def _emit(level: str, msg: str, force: bool = False) -> None:
  49    if _silent and not force:
  50        return
  51    tag = level
  52    if sys.stderr.isatty() and not os.environ.get("NO_COLOR"):
  53        tag = f"{_COLORS[level]}{level}\033[0m"
  54    sys.stderr.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [{tag}] {msg}\n")
  55
  56
  57def info(msg: str) -> None:
  58    """Write a timestamped informational message to stderr."""
  59    _emit("info", msg)
  60
  61
  62def warn(msg: str) -> None:
  63    """Write a timestamped warning to stderr."""
  64    _emit("warning", msg)
  65
  66
  67def error(msg: str) -> None:
  68    """Write a timestamped error to stderr."""
  69    _emit("error", msg)
  70
  71
  72def success(msg: str) -> None:
  73    """Write a timestamped success message to stderr."""
  74    _emit("success", msg)
  75
  76
  77def die(code: int, msg: str) -> NoReturn:
  78    """Print an error and exit with `code`. Never suppressed."""
  79    _emit("error", msg, force=True)
  80    sys.exit(code)
  81
  82
  83# ---------------------------------------------------------------------------
  84# Rules
  85# ---------------------------------------------------------------------------
  86
  87_TRUE = ("true", "yes", "1", "on")
  88_FALSE = ("false", "no", "0", "off")
  89_FIXED_RULES = {
  90    "int", "float", "string", "path", "ip", "hostname", "url", "port", "email", "uuid", "bool",
  91    "date:YYYY-MM-DD", "file:exists", "file:readable", "file:writable", "dir:exists", "dir:writable",
  92}
  93_HOSTNAME = r"[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
  94
  95
  96_EFFECTS = ("read-only", "idempotent", "destructive", "network")
  97
  98
  99def _bool_word(value: str) -> Optional[bool]:
 100    lower = value.lower()
 101    if lower in _TRUE:
 102        return True
 103    if lower in _FALSE:
 104        return False
 105    return None
 106
 107
 108def _known_rule(rule: str) -> bool:
 109    if not rule or rule in _FIXED_RULES:
 110        return True
 111    if re.fullmatch(r"int:(\d+-\d*|-\d+)", rule) or re.fullmatch(r"float:(\d*\.?\d+-(\d*\.?\d+)?|-\d*\.?\d+)", rule):
 112        return True
 113    if re.fullmatch(r"string:(\d+|\d+-\d*|-\d+)", rule) or re.fullmatch(r"choice:.+", rule):
 114        return True
 115    if rule.startswith("regex:") and len(rule) > 6:
 116        try:
 117            re.compile(rule[6:])
 118            return True
 119        except re.error:
 120            return False
 121    return False
 122
 123
 124def _bounds(rule: str):
 125    rng = rule.split(":", 1)[1]
 126    lo, _, hi = rng.partition("-")
 127    return lo, hi
 128
 129
 130def _is_path_rule(rule: str) -> bool:
 131    return rule == "path" or rule.startswith(("file:", "dir:"))
 132
 133
 134def describe_rule(rule: str) -> str:
 135    """Help text for a validation rule (spec section 5)."""
 136    fixed = {
 137        "int": "integer", "float": "number", "string": "text", "path": "path", "ip": "IP address",
 138        "hostname": "hostname", "url": "URL", "port": "port: 1-65535", "email": "email address", "uuid": "UUID",
 139        "bool": "true/false, yes/no, 1/0, on/off", "date:YYYY-MM-DD": "date: YYYY-MM-DD",
 140        "file:exists": "existing file", "file:readable": "readable file", "file:writable": "writable file",
 141        "dir:exists": "existing directory", "dir:writable": "writable directory",
 142    }
 143    if rule in fixed:
 144        return fixed[rule]
 145    for prefix, noun, suffix in (("int:", "integer", ""), ("float:", "number", ""), ("string:", "text", " chars")):
 146        if rule.startswith(prefix):
 147            if "-" not in rule:
 148                return f"{noun}: {rule[len(prefix):]}{suffix}"
 149            lo, hi = _bounds(rule)
 150            if lo and hi:
 151                return f"{noun}: {lo}-{hi}{suffix}"
 152            return f"{noun}: >={lo}{suffix}" if lo else f"{noun}: <={hi}{suffix}"
 153    if rule.startswith("choice:"):
 154        return "choices: " + ", ".join(rule[7:].split(","))
 155    if rule.startswith("regex:"):
 156        return "pattern: " + rule[6:]
 157    return rule
 158
 159
 160class ValidationError(ValueError):
 161    """An invalid value reported with the validation rule and option or argument name."""
 162    pass
 163
 164
 165def validate(value: str, rule: str, name: str) -> Scalar:
 166    """Validate `value` against `rule` and return its typed form.
 167
 168    Raises ValidationError with the spec's error text.
 169    """
 170    def fail(msg: str):
 171        raise ValidationError(f"{name} {msg}")
 172
 173    def check_bounds(num: float, lo: str, hi: str):
 174        if lo and num < float(lo):
 175            fail(f"must be >= {lo}, got {value}")
 176        if hi and num > float(hi):
 177            fail(f"must be <= {hi}, got {value}")
 178
 179    if rule == "int" or rule.startswith("int:"):
 180        if not re.fullmatch(r"-?[0-9]+", value):
 181            fail(f"must be an integer, got '{value}'")
 182        num: Union[int, float] = int(value)
 183        if rule != "int":
 184            check_bounds(num, *_bounds(rule))
 185        return num
 186    if rule == "float" or rule.startswith("float:"):
 187        if not re.fullmatch(r"-?[0-9]*\.?[0-9]+", value):
 188            fail(f"must be a number, got '{value}'")
 189        num = float(value)
 190        if rule != "float":
 191            check_bounds(num, *_bounds(rule))
 192        return num
 193    if rule.startswith("string:"):
 194        length = len(value)
 195        if "-" not in rule:
 196            exact = rule[7:]
 197            if length != int(exact):
 198                fail(f"must be exactly {exact} characters, got {length}")
 199        else:
 200            lo, hi = _bounds(rule)
 201            if lo and length < int(lo):
 202                fail(f"must be at least {lo} characters, got {length}")
 203            if hi and length > int(hi):
 204                fail(f"must be at most {hi} characters, got {length}")
 205        return value
 206    if rule.startswith("choice:"):
 207        choices = rule[7:].split(",")
 208        if value not in choices:
 209            fail(f"must be one of: {', '.join(choices)}, got '{value}'")
 210        return value
 211    if rule.startswith("regex:"):
 212        if not re.search(rule[6:], value):
 213            fail(f"does not match required pattern, got '{value}'")
 214        return value
 215
 216    if rule == "bool":
 217        b = _bool_word(value)
 218        if b is None:
 219            fail(f"must be a boolean (true/false, yes/no, 1/0, on/off), got '{value}'")
 220        return bool(b)
 221    if rule == "port":
 222        if not re.fullmatch(r"[0-9]+", value) or not 1 <= int(value) <= 65535:
 223            fail(f"must be a valid port (1-65535), got '{value}'")
 224        return int(value)
 225    if rule == "ip":
 226        if not (re.fullmatch(r"([0-9]{1,3}\.){3}[0-9]{1,3}", value)
 227                or re.fullmatch(r"([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}", value)):
 228            fail(f"must be a valid IP address, got '{value}'")
 229    elif rule == "hostname":
 230        if not re.fullmatch(_HOSTNAME, value):
 231            fail(f"must be a valid hostname, got '{value}'")
 232    elif rule == "url":
 233        if not re.fullmatch(r"https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?", value, re.S):
 234            fail(f"must be a valid URL, got '{value}'")
 235    elif rule == "email":
 236        if not re.fullmatch(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", value):
 237            fail(f"must be a valid email address, got '{value}'")
 238    elif rule == "uuid":
 239        if not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value):
 240            fail(f"must be a valid UUID, got '{value}'")
 241    elif rule == "date:YYYY-MM-DD":
 242        if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
 243            fail(f"must be in YYYY-MM-DD format, got '{value}'")
 244    elif rule == "file:exists":
 245        if not os.path.isfile(value):
 246            fail(f"file does not exist: {value}")
 247    elif rule == "file:readable":
 248        if not os.access(value, os.R_OK):
 249            fail(f"file is not readable: {value}")
 250    elif rule == "file:writable":
 251        if os.path.lexists(value):
 252            if not os.access(value, os.W_OK):
 253                fail(f"file is not writable: {value}")
 254        else:
 255            directory = os.path.dirname(value)
 256            if not (os.path.isdir(directory) and os.access(directory, os.W_OK)):
 257                fail(f"directory is not writable: {directory}")
 258    elif rule == "dir:exists":
 259        if not os.path.isdir(value):
 260            fail(f"directory does not exist: {value}")
 261    elif rule == "dir:writable":
 262        if not (os.path.isdir(value) and os.access(value, os.W_OK)):
 263            fail(f"directory does not exist or is not writable: {value}")
 264    return value
 265
 266
 267def resolve_path(value: str, base: str, search_dirs: Sequence[str] = ()) -> str:
 268    """Resolve a path value against `base` (spec section 6)."""
 269    if not value or value in ("-", "disabled", "optional"):
 270        return value
 271    if os.path.isabs(value) or re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", value):
 272        return value
 273    from_base = os.path.normpath(os.path.join(base, value))
 274    bare = not re.match(r"\.\.?(/|$)", value)
 275    if bare and search_dirs and not os.path.exists(from_base):
 276        for directory in search_dirs:
 277            candidate = os.path.normpath(os.path.join(directory, value))
 278            if os.path.exists(candidate):
 279                return candidate
 280    return from_base
 281
 282
 283def wrap_text(text: str, width: int) -> List[str]:
 284    """Greedy word wrap that keeps existing line breaks (spec section 7)."""
 285    out: List[str] = []
 286    for original in re.split(r"\r?\n", text):
 287        words = original.split()
 288        if not words:
 289            out.append("")
 290            continue
 291        line = ""
 292        for word in words:
 293            if not line:
 294                line = word
 295            elif len(line) + 1 + len(word) <= width:
 296                line += " " + word
 297            else:
 298                out.append(line)
 299                line = word
 300        out.append(line)
 301    return out
 302
 303
 304def _completion_kind(rule: str, search_dirs: Sequence[str]):
 305    if rule == "path" or rule.startswith("file:"):
 306        return "file", ":".join(search_dirs)
 307    if rule.startswith("dir:"):
 308        return "dir", ":".join(search_dirs)
 309    if rule.startswith("choice:"):
 310        return "choice", rule[7:]
 311    if rule == "bool":
 312        return "choice", "true,false"
 313    if rule in ("hostname", "ip"):
 314        return "host", ""
 315    return ("none", "") if rule else ("default", "")
 316
 317
 318# ---------------------------------------------------------------------------
 319# Cli
 320# ---------------------------------------------------------------------------
 321
 322@dataclass
 323class _Option:
 324    var: str
 325    long: str
 326    short: str
 327    kind: str  # flag | value | array
 328    default: str
 329    required: bool
 330    description: str
 331    group: str
 332    rule: str
 333    search_dirs: List[str] = field(default_factory=list)
 334    secret: bool = False
 335
 336    @property
 337    def label(self) -> str:
 338        head = f"-{self.short}, --{self.long}" if self.short else f"    --{self.long}"
 339        return head if self.kind == "flag" else head + "=<value>"
 340
 341    @property
 342    def bool_like(self) -> bool:
 343        return self.kind == "flag" or self.rule in ("bool", "choice:true,false", "choice:false,true")
 344
 345
 346@dataclass
 347class _Arg:
 348    name: str
 349    description: str
 350    default: str
 351    rule: str
 352    variadic: bool
 353
 354
 355@dataclass
 356class ParseResult:
 357    """Non-exiting parse outcome. status is ok, help, or error; error/detail explain failures and show_usage controls help."""
 358    status: str  # ok | help | error
 359    error: str = ""
 360    show_usage: bool = True
 361    detail: List[str] = field(default_factory=list)
 362
 363
 364class Values(Dict[str, Value]):
 365    """Resolved values; keys are option vars and argument names, also readable as attributes."""
 366
 367    def __getattr__(self, name: str) -> Value:
 368        """Read a resolved key as an attribute; missing keys raise AttributeError."""
 369        try:
 370            return self[name]
 371        except KeyError:
 372            raise AttributeError(name) from None
 373
 374
 375class _ParseError(Exception):
 376    pass
 377
 378
 379class Cli:
 380    """A program CLI. Register options and arguments, then call run or parse. Resolved names are runtime keys."""
 381    def __init__(self, name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None,
 382                 env: Optional[Mapping[str, str]] = None):
 383        """Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ."""
 384        self.name = name or os.path.basename(sys.argv[0] or "cli")
 385        self.cwd = cwd or os.getcwd()
 386        self.root = os.path.normpath(os.path.join(self.cwd, root or "."))
 387        self.env = os.environ if env is None else env
 388        self.description = ""
 389        self.epilog = ""
 390        self.values = Values()
 391        self._options: List[_Option] = []
 392        self._by_long: Dict[str, _Option] = {}
 393        self._by_short: Dict[str, _Option] = {}
 394        self._args: List[_Arg] = []
 395        self._commands: List[tuple] = []
 396        self._config_option = ""
 397        self._config_prefixes: List[str] = []
 398        self._raw: Dict[str, Union[str, List[str]]] = {}
 399        self._arg_raw: Dict[str, Union[str, List[str]]] = {}
 400        self._sources: Dict[str, str] = {}
 401        self._config: Dict[str, tuple] = {}  # key -> (value, dir)
 402        self._effects: List[str] = []
 403        self._stdin: Optional[Dict[str, str]] = None
 404        self._stdout: Optional[Dict[str, str]] = None
 405        self._constraints: List[Dict[str, object]] = []
 406        self._children: List["Cli"] = []
 407        self._parent: Optional["Cli"] = None
 408        self._word = ""
 409        # The command selected by the last parse (this one when it has no commands).
 410        self._selected: "Cli" = self
 411
 412    # -- registration ---------------------------------------------------------
 413
 414    def set_description(self, text: str) -> "Cli":
 415        """Set the help description and return this CLI."""
 416        self.description = text
 417        return self
 418
 419    def set_epilog(self, text: str) -> "Cli":
 420        """Set the final help text and return this CLI."""
 421        self.epilog = text
 422        return self
 423
 424    def set_effects(self, *effects: str) -> "Cli":
 425        """What running the program does: read-only, idempotent, destructive, network."""
 426        for e in effects:
 427            if e not in _EFFECTS:
 428                raise ValueError(f"Unknown effect '{e}'")
 429        self._effects = list(effects)
 430        return self
 431
 432    def set_stdin(self, description: str, content_type: str = "") -> "Cli":
 433        """What the program reads on stdin; `content_type` is a MIME type or a comma-separated list."""
 434        self._stdin = {"description": description, "contentType": content_type}
 435        return self
 436
 437    def set_stdout(self, description: str, content_type: str = "") -> "Cli":
 438        """What the program writes on stdout; undeclared means text."""
 439        self._stdout = {"description": description, "contentType": content_type}
 440        return self
 441
 442    def exclusive(self, *longs: str) -> "Cli":
 443        """At most one of these options may be given."""
 444        return self._constraint("exclusive", longs)
 445
 446    def requires(self, long: str, *longs: str) -> "Cli":
 447        """When the first option is given, the others must be too."""
 448        return self._constraint("requires", (long,) + longs)
 449
 450    def one_of(self, *longs: str) -> "Cli":
 451        """At least one of these options must be given."""
 452        return self._constraint("oneOf", longs)
 453
 454    def _constraint(self, kind: str, longs: Sequence[str]) -> "Cli":
 455        for long in longs:
 456            if not self._find(long, False):
 457                raise ValueError(f"Unknown option --{long} in constraint")
 458        self._constraints.append({"type": kind, "options": list(longs)})
 459        return self
 460
 461    def command(self, name: str, description: str = "") -> "Cli":
 462        """Register a command (spec section 1.7) and return it, to register its options and arguments on."""
 463        if self._args:
 464            raise ValueError("Cannot mix commands and positional arguments")
 465        if any(c._word == name for c in self._children):
 466            raise ValueError(f"Duplicate command {name}")
 467        child = Cli(f"{self.name} {name}", self.root, self.cwd, self.env)
 468        child.description = description
 469        child._parent = self
 470        child._word = name
 471        self._children.append(child)
 472        return child
 473
 474    @property
 475    def command_path(self) -> List[str]:
 476        """The command words selected by the last parse, e.g. ["db", "migrate"]."""
 477        words: List[str] = []
 478        node: Optional[Cli] = self._selected
 479        while node is not None and node is not self:
 480            words.insert(0, node._word)
 481            node = node._parent
 482        return words
 483
 484    def _chain(self) -> List["Cli"]:
 485        """The selected command, its parent, ... up to this one."""
 486        out: List[Cli] = []
 487        node: Optional[Cli] = self._selected
 488        while node is not None:
 489            out.append(node)
 490            node = None if node is self else node._parent
 491        return out
 492
 493    def _chain_options(self) -> List[_Option]:
 494        return [o for n in self._chain() for o in n._options]
 495
 496    def _find(self, name: str, short: bool) -> Optional[_Option]:
 497        """An option by long (or short) name, in this level and its ancestors."""
 498        node: Optional[Cli] = self
 499        while node is not None:
 500            opt = (node._by_short if short else node._by_long).get(name)
 501            if opt:
 502                return opt
 503            node = node._parent
 504        return None
 505
 506    def set_config(self, option: str, prefixes: str) -> "Cli":
 507        """`option` holds the config file path; `prefixes` is comma-separated."""
 508        self._config_option = option
 509        self._config_prefixes = [p.strip() for p in prefixes.split(",") if p.strip()]
 510        return self
 511
 512    def require_command(self, command: str, description: str, install_hint: str = "") -> "Cli":
 513        """Require an executable on PATH when parsing; description and install_hint explain how to install it."""
 514        self._commands.append((command, description, install_hint))
 515        return self
 516
 517    def set_path_search(self, long: str, dirs: Union[str, Sequence[str]]) -> "Cli":
 518        """Fallback dirs (relative to root) for bare relative values of a path option."""
 519        opt = self._by_long[long]
 520        items = dirs.split(":") if isinstance(dirs, str) else list(dirs)
 521        opt.search_dirs = [os.path.normpath(os.path.join(self.root, d)) for d in items if d]
 522        if not opt.rule:
 523            opt.rule = "path"
 524        return self
 525
 526    def opt(self, var: str, long: str, short: str = "", default: str = "", description: str = "",
 527            group: str = "Options", rule: str = "") -> "Cli":
 528        """Register an option. `default` is a value, "flag", "optional", or "" (required)."""
 529        kind = "flag" if default == "flag" else "value"
 530        value = "" if default in ("flag", "optional") else default
 531        return self._add(_Option(var, long, short, kind, value, default == "", description, group, rule))
 532
 533    def opt_array(self, var: str, long: str, short: str = "", description: str = "",
 534                  group: str = "Options", rule: str = "") -> "Cli":
 535        """Register a repeatable option whose values accumulate into a list."""
 536        return self._add(_Option(var, long, short, "array", "", False, description, group, rule))
 537
 538    def arg(self, name: str, description: str = "", default: str = "", rule: str = "") -> "Cli":
 539        """Register a positional argument. An empty default makes it required."""
 540        return self._add_arg(_Arg(name, description, default, rule, False))
 541
 542    def arg_variadic(self, name: str, description: str = "", rule: str = "") -> "Cli":
 543        """Register a final positional argument that collects all remaining tokens."""
 544        return self._add_arg(_Arg(name, description, "", rule, True))
 545
 546    def _add(self, opt: _Option) -> "Cli":
 547        if opt.long in self._by_long:
 548            raise ValueError(f"Duplicate option --{opt.long}")
 549        if opt.short and (len(opt.short) != 1 or opt.short in self._by_short):
 550            raise ValueError(f"Invalid or duplicate short option -{opt.short}")
 551        if opt.rule == "secret" or opt.rule.startswith("secret:"):
 552            opt.secret, opt.rule = True, opt.rule[7:]
 553        if not _known_rule(opt.rule):
 554            raise ValueError(f"Unknown validation rule '{opt.rule}' for --{opt.long}")
 555        self._options.append(opt)
 556        self._by_long[opt.long] = opt
 557        if opt.short:
 558            self._by_short[opt.short] = opt
 559        return self
 560
 561    def _add_arg(self, arg: _Arg) -> "Cli":
 562        if self._children:
 563            raise ValueError("Cannot mix commands and positional arguments")
 564        if any(a.variadic for a in self._args):
 565            raise ValueError(f"Argument {arg.name} registered after a variadic argument")
 566        if not _known_rule(arg.rule):
 567            raise ValueError(f"Unknown validation rule '{arg.rule}' for {arg.name}")
 568        self._args.append(arg)
 569        return self
 570
 571    def _ensure_help(self) -> None:
 572        if "help" not in self._by_long:
 573            short = "" if "h" in self._by_short else "h"
 574            self._add(_Option("HELP", "help", short, "flag", "", False, "Show this help message and exit", "Global", ""))
 575
 576    # -- parsing --------------------------------------------------------------
 577
 578    def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult:
 579        """Parse without exiting. Values are in `self.values` when status is "ok"."""
 580        argv = list(sys.argv[1:] if argv is None else argv)
 581        self._raw, self._arg_raw, self._sources, self._config = {}, {}, {}, {}
 582        self.values = Values()
 583        self._selected = self
 584        self._ensure_help()
 585
 586        try:
 587            self._scan(argv)
 588            scan_error = None
 589        except _ParseError as exc:
 590            scan_error = str(exc)
 591        if scan_error is None and self._config_option:
 592            try:
 593                self._load_config()
 594            except _ParseError as exc:
 595                scan_error = str(exc)
 596        if self._raw.get("help") == "true":
 597            return ParseResult("help")
 598        if scan_error is not None:
 599            return ParseResult("error", scan_error)
 600
 601        try:
 602            self._resolve()
 603        except (_ParseError, ValidationError) as exc:
 604            return ParseResult("error", str(exc))
 605
 606        missing_cmds = [c for n in reversed(self._chain()) for c in n._commands
 607                        if not shutil.which(c[0], path=self.env.get("PATH", ""))]
 608        if missing_cmds:
 609            detail = []
 610            for cmd, desc, hint in missing_cmds:
 611                detail.append(f"  {cmd} - {desc}")
 612                if hint:
 613                    detail.append(f"    Install: {hint}")
 614            return ParseResult("error", "Missing required command(s): " + ", ".join(c[0] for c in missing_cmds),
 615                               False, detail)
 616
 617        missing = [f"--{o.long}" for o in self._chain_options() if o.required and not self._raw.get(o.long)]
 618        if missing:
 619            return ParseResult("error", "Missing required argument(s): " + " ".join(missing))
 620        conflict = self._check_constraints()
 621        if conflict:
 622            return ParseResult("error", conflict)
 623        return ParseResult("ok")
 624
 625    def _check_constraints(self) -> Optional[str]:
 626        """Spec section 1.6: the first relationship that fails, from the program down."""
 627        def given(long: str) -> bool:
 628            opt = self._selected._find(long, False)
 629            v = self.values.get(opt.var) if opt else None
 630            return self.source(long) in ("cli", "config", "env") and v is not False and v != []
 631
 632        for node in reversed(self._chain()):
 633            for c in node._constraints:
 634                longs: List[str] = c["options"]  # type: ignore[assignment]
 635                on = [o for o in longs if given(o)]
 636                if c["type"] == "exclusive" and len(on) > 1:
 637                    return f"Options --{on[0]} and --{on[1]} cannot be used together"
 638                if c["type"] == "requires" and given(longs[0]):
 639                    absent = next((o for o in longs[1:] if not given(o)), None)
 640                    if absent:
 641                        return f"Option --{longs[0]} requires --{absent}"
 642                if c["type"] == "oneOf" and not on:
 643                    return "One of " + ", ".join(f"--{o}" for o in longs) + " is required"
 644        return None
 645
 646    def _set_cli(self, opt: _Option, value: str) -> None:
 647        if opt.kind == "array":
 648            current = self._raw[opt.long] if self._sources.get(opt.long) == "cli" else []
 649            self._raw[opt.long] = list(current) + [value]
 650        else:
 651            self._raw[opt.long] = value
 652        self._sources[opt.long] = "cli"
 653
 654    def _scan(self, argv: List[str]) -> None:
 655        pos = 0
 656        rest: Optional[List[str]] = None
 657        end_of_options = False
 658        i = 0
 659        while i < len(argv):
 660            token = argv[i]
 661            i += 1
 662            if end_of_options or token == "-" or not token.startswith("-"):
 663                node = self._selected
 664                if rest is not None:
 665                    rest.append(token)
 666                elif node._children:
 667                    child = next((c for c in node._children if c._word == token), None)
 668                    if child is None:
 669                        raise _ParseError(f"Unknown command: {token}")
 670                    self._selected = child
 671                elif pos >= len(node._args):
 672                    raise _ParseError(f"Unexpected argument: {token}")
 673                else:
 674                    arg = node._args[pos]
 675                    pos += 1
 676                    if arg.variadic:
 677                        rest = [token]
 678                        self._arg_raw[arg.name] = rest
 679                    else:
 680                        self._arg_raw[arg.name] = token
 681            elif token == "--":
 682                end_of_options = True
 683            elif token.startswith("--"):
 684                name, eq, value = token[2:].partition("=")
 685                opt = self._selected._find(name, False)
 686                if opt and eq:
 687                    if opt.kind == "flag":
 688                        b = _bool_word(value)
 689                        if b is None:
 690                            raise _ParseError(f"Option --{name} expects a boolean value, got '{value}'")
 691                        value = "true" if b else "false"
 692                    self._set_cli(opt, value)
 693                elif opt:
 694                    if opt.kind == "flag":
 695                        self._set_cli(opt, "true")
 696                    else:
 697                        if i >= len(argv) or argv[i].startswith("--"):
 698                            raise _ParseError(f"Option --{name} requires an argument")
 699                        self._set_cli(opt, argv[i])
 700                        i += 1
 701                elif name.startswith("no-") and not eq and self._selected._find(name[3:], False):
 702                    target = self._selected._find(name[3:], False)
 703                    assert target is not None
 704                    if not target.bool_like:
 705                        raise _ParseError(f"Option --{name} can only be used with flag/boolean options")
 706                    self._set_cli(target, "false")
 707                else:
 708                    raise _ParseError(f"Unknown option: --{name}")
 709            else:
 710                cluster = token[1:]
 711                for j, ch in enumerate(cluster):
 712                    opt = self._selected._find(ch, True)
 713                    if not opt:
 714                        raise _ParseError(f"Unknown option: -{ch}")
 715                    if opt.kind == "flag":
 716                        self._set_cli(opt, "true")
 717                        continue
 718                    if j + 1 < len(cluster):
 719                        self._set_cli(opt, cluster[j + 1:])
 720                        break
 721                    if i >= len(argv) or argv[i].startswith("-"):
 722                        raise _ParseError(f"Option -{ch} requires an argument")
 723                    self._set_cli(opt, argv[i])
 724                    i += 1
 725                    break
 726
 727    def _load_config(self) -> None:
 728        opt = self._selected._find(self._config_option, False)
 729        if not opt:
 730            return
 731        path, source = self._raw.get(opt.long), "cli"
 732        assert not isinstance(path, list)
 733        if path is None and self.env.get(opt.var):
 734            path, source = self.env[opt.var], "env"
 735        if path is None and opt.default:
 736            path, source = opt.default, "default"
 737        if not path or path == "disabled":
 738            return
 739        resolved = resolve_path(path, self.cwd, opt.search_dirs)
 740        self._raw[opt.long] = resolved
 741        self._sources[opt.long] = source
 742        self._read_config(resolved, 0, set())
 743
 744        for key, (value, _) in self._config.items():
 745            target = self._selected._find(key, False)
 746            if not target or target is opt or self._sources.get(key) == "cli":
 747                continue
 748            if target.kind == "flag":
 749                b = _bool_word(value)
 750                if b is None:
 751                    raise _ParseError(f"Config value for --{key} must be a boolean, got '{value}'")
 752                self._raw[key] = "true" if b else "false"
 753            else:
 754                self._raw[key] = [value] if target.kind == "array" else value
 755            self._sources[key] = "config"
 756
 757    def _read_config(self, path: str, depth: int, stack: set) -> None:
 758        if depth > 10:
 759            raise _ParseError(f"Config include depth exceeded (10) while processing: {path}")
 760        if not os.path.isfile(path):
 761            raise _ParseError(f"Config file not found: {path}")
 762        if path in stack:
 763            raise _ParseError(f"Circular config include detected: {path}")
 764        stack.add(path)
 765        directory = os.path.dirname(path)
 766        with open(path, encoding="utf-8", newline="") as fh:
 767            lines = fh.read().split("\n")
 768        for line in lines:
 769            line = line[:-1] if line.endswith("\r") else line
 770            trimmed = line.strip()
 771            if not trimmed or trimmed.startswith("#"):
 772                continue
 773            include = re.match(r"\s*@include\s+(.+)$", line)
 774            if include:
 775                target = include.group(1).strip()
 776                quoted = re.fullmatch(r"(['\"])(.*)\1", target)
 777                if quoted:
 778                    target = quoted.group(2)
 779                self._read_config(os.path.normpath(os.path.join(directory, target)), depth + 1, stack)
 780                continue
 781            if self._config_prefixes:
 782                prefix = next((p for p in self._config_prefixes if line.startswith(p)), None)
 783                if prefix is None:
 784                    continue
 785                body = line[len(prefix):]
 786            else:
 787                body = line
 788            key, eq, value = body.partition("=")
 789            key = key.strip()
 790            if key.startswith("--"):
 791                key = key[2:]
 792            if not eq or not key:
 793                continue
 794            self._config[key] = (value.strip(), directory)
 795        stack.discard(path)
 796
 797    def _resolve(self) -> None:
 798        if self._selected._children:
 799            raise _ParseError("Missing command")
 800        options = self._chain_options()
 801        args = self._selected._args
 802        for arg in args:
 803            if arg.name in self._arg_raw:
 804                continue
 805            if arg.variadic:
 806                self._arg_raw[arg.name] = []
 807            elif not arg.default:
 808                raise _ParseError(f"Missing required positional argument: {arg.name}")
 809            else:
 810                self._arg_raw[arg.name] = arg.default
 811
 812        for opt in options:
 813            if opt.long in self._sources:
 814                continue
 815            env_value = None if opt.kind == "array" else self.env.get(opt.var)
 816            if env_value:
 817                if opt.kind == "flag":
 818                    b = _bool_word(env_value)
 819                    if b is None:
 820                        raise _ParseError(f"Environment variable {opt.var} must be a boolean, got '{env_value}'")
 821                    env_value = "true" if b else "false"
 822                self._raw[opt.long] = env_value
 823                self._sources[opt.long] = "env"
 824            elif opt.kind == "flag":
 825                self._raw[opt.long] = "false"
 826                self._sources[opt.long] = "default"
 827            elif opt.default:
 828                self._raw[opt.long] = opt.default
 829                self._sources[opt.long] = "default"
 830
 831        # Path resolution: the base depends on where the value came from.
 832        for opt in options:
 833            if not _is_path_rule(opt.rule) or opt.long not in self._raw or opt.long == self._config_option:
 834                continue
 835            source = self._sources[opt.long]
 836            base = self.cwd if source == "cli" else self._config[opt.long][1] if source == "config" else self.root
 837            value = self._raw[opt.long]
 838            if isinstance(value, list):
 839                self._raw[opt.long] = [resolve_path(v, base, opt.search_dirs) for v in value]
 840            else:
 841                self._raw[opt.long] = resolve_path(value, base, opt.search_dirs)
 842        for arg in args:
 843            if _is_path_rule(arg.rule):
 844                value = self._arg_raw[arg.name]
 845                if isinstance(value, list):
 846                    self._arg_raw[arg.name] = [resolve_path(v, self.cwd) for v in value]
 847                else:
 848                    self._arg_raw[arg.name] = resolve_path(value, self.cwd)
 849
 850        def convert(v: str, rule: str, name: str) -> Scalar:
 851            return validate(v, rule, name) if v and rule else v
 852
 853        for opt in options:
 854            raw = self._raw.get(opt.long)
 855            if raw is None:
 856                self.values[opt.var] = [] if opt.kind == "array" else None
 857            elif opt.kind == "flag":
 858                self.values[opt.var] = raw == "true"
 859            elif isinstance(raw, list):
 860                self.values[opt.var] = [convert(v, opt.rule, f"--{opt.long}") for v in raw]
 861            else:
 862                self.values[opt.var] = convert(raw, opt.rule, f"--{opt.long}")
 863        for arg in args:
 864            value = self._arg_raw[arg.name]
 865            if isinstance(value, list):
 866                self.values[arg.name] = [convert(v, arg.rule, arg.name) for v in value]
 867            else:
 868                self.values[arg.name] = convert(value, arg.rule, arg.name)
 869        if self._children:
 870            self.values["command"] = self.command_path  # type: ignore[assignment]
 871
 872    def run(self, argv: Optional[Sequence[str]] = None) -> Values:
 873        """Parse like a CLI: handles --help, --help-json-schema and --bash-completion,
 874        prints errors and exits on failure. Returns the values."""
 875        argv = list(sys.argv[1:] if argv is None else argv)
 876        head = argv[:argv.index("--")] if "--" in argv else argv
 877        if "--help-json-schema" in head:
 878            sys.stdout.write(self.json_schema() + "\n")
 879            sys.exit(0)
 880        if "--bash-completion" in head:
 881            sys.stdout.write(self.completion_data(argv[argv.index("--") + 1:] if "--" in argv else []))
 882            sys.exit(0)
 883        if "--completion" in head:
 884            shell = head[head.index("--completion") + 1] if head.index("--completion") + 1 < len(head) else ""
 885            script = self.completion_script(shell)
 886            if script is None:
 887                die(1, f"Unknown shell '{shell}' (expected bash, zsh or fish)")
 888            sys.stdout.write(script)
 889            sys.exit(0)
 890
 891        result = self.parse(argv)
 892        if result.status == "help":
 893            sys.stdout.write(self.usage())
 894            sys.exit(0)
 895        if result.status == "error":
 896            _emit("error", result.error, force=True)
 897            for line in result.detail:
 898                sys.stderr.write(line + "\n")
 899            if result.show_usage:
 900                sys.stderr.write(self.usage())
 901            sys.exit(1)
 902        return self.values
 903
 904    # -- accessors ------------------------------------------------------------
 905
 906    def get(self, name: str) -> Value:
 907        """Return the resolved value for a variable or argument name, or None when absent."""
 908        return self.values.get(name)
 909
 910    def source(self, long: str) -> str:
 911        """Where an option's value came from: cli, config, env, default or unset."""
 912        return self._sources.get(long[2:] if long.startswith("--") else long, "unset")
 913
 914    def is_set(self, long: str) -> bool:
 915        """Return whether the option was supplied on the command line (long name, optionally prefixed with --)."""
 916        return self.source(long) == "cli"
 917
 918    def is_explicitly_set(self, long: str) -> bool:
 919        """Return whether the option came from CLI, config, or environment rather than a default."""
 920        return self.source(long) in ("cli", "config", "env")
 921
 922    def values_json(self) -> str:
 923        """Resolved values as JSON (spec section 10)."""
 924        out: Dict[str, object] = {}
 925        for o in self._chain_options():
 926            v = self.values.get(o.var)
 927            out[o.var] = (["***"] * len(v) if isinstance(v, list) else "***") if o.secret and v is not None else v
 928        out.update({a.name: self.values.get(a.name) for a in self._selected._args})
 929        if self._children:
 930            out["command"] = self.command_path
 931        return json.dumps(out, indent=2, ensure_ascii=False)
 932
 933    # -- output ---------------------------------------------------------------
 934
 935    def usage(self) -> str:
 936        """Help text (spec section 7), for the selected command."""
 937        self._ensure_help()
 938        node = self._selected
 939        chain = self._chain()
 940        options = self._chain_options()
 941        width = self.env.get("CLYOPS_MAX_WIDTH", "")
 942        max_width = int(width) if width.isdigit() and int(width) > 0 else 100
 943        longest = max([len(o.label) for o in options] + [len(c._word) for c in node._children], default=0)
 944        indent = min(50, max(32, longest + 4))
 945        text_width = max(20, max_width - indent)
 946
 947        def row(label: str, text: str) -> List[str]:
 948            left = "  " + label
 949            left = left.ljust(indent) if len(left) < indent else left + " "
 950            lines = wrap_text(text, text_width)
 951            return [left + lines[0]] + [" " * indent + line for line in lines[1:]]
 952
 953        def annotate(text: str, notes: List[str]) -> str:
 954            return f"{text} ({', '.join(notes)})" if notes else text
 955
 956        sections: List[List[str]] = []
 957        usage = f"Usage: {node.name}"
 958        if node._children:
 959            usage += " <command>"
 960        for arg in node._args:
 961            usage += f" [<{arg.name}...>]" if arg.variadic else f" [<{arg.name}>]" if arg.default else f" <{arg.name}>"
 962        sections.append([usage + " [OPTIONS]"])
 963
 964        if node.description:
 965            sections.append(wrap_text(node.description, max_width))
 966
 967        io = []
 968        for label, decl in (("Input:", node._stdin), ("Output:", node._stdout)):
 969            if decl is not None:
 970                ctype = decl["contentType"]
 971                io.append(" ".join(x for x in (label, decl["description"], ctype and f"({ctype})") if x))
 972        if io:
 973            sections.append(io)
 974
 975        if node._children:
 976            sections.append(["Commands:"] + [line for c in node._children for line in row(c._word, c.description)])
 977
 978        if node._args:
 979            lines = ["Positional Arguments:"]
 980            for arg in node._args:
 981                notes = (["variadic"] if arg.variadic else []) + ([f"default: {arg.default}"] if arg.default else [])
 982                if arg.rule:
 983                    notes.append(f"accepts: {describe_rule(arg.rule)}")
 984                lines += row(arg.name, annotate(arg.description, notes))
 985            sections.append(lines)
 986
 987        commands = [c for n in reversed(chain) for c in n._commands]
 988        if commands:
 989            lines = ["Required Commands:"]
 990            for cmd, desc, hint in commands:
 991                status = "installed" if shutil.which(cmd, path=self.env.get("PATH", "")) else "not found"
 992                lines += row(f"{cmd} [{status}]", f"{desc} ({hint})" if hint else desc)
 993            sections.append(lines)
 994
 995        constraints = [c for n in reversed(chain) for c in n._constraints]
 996        groups = list(dict.fromkeys(o.group for o in options))
 997        for group in groups:
 998            lines = [f"{group}:"]
 999            for opt in (o for o in options if o.group == group):
1000                notes = []
1001                if opt.required:
1002                    notes.append("required")
1003                if opt.kind == "array":
1004                    notes.append("multiple")
1005                if opt.secret:
1006                    notes.append("secret")
1007                if opt.long in self._config:
1008                    notes.append(f"config: {'***' if opt.secret else self._config[opt.long][0]}")
1009                if opt.default:
1010                    notes.append(f"default: {opt.default}")
1011                if opt.rule:
1012                    notes.append(f"accepts: {describe_rule(opt.rule)}")
1013                for c in constraints:
1014                    longs: List[str] = c["options"]  # type: ignore[assignment]
1015                    if opt.long not in longs:
1016                        continue
1017                    if c["type"] == "exclusive":
1018                        notes.append("conflicts with: " + ", ".join(f"--{o}" for o in longs if o != opt.long))
1019                    elif c["type"] == "requires" and longs[0] == opt.long:
1020                        notes.append("requires: " + ", ".join(f"--{o}" for o in longs[1:]))
1021                    elif c["type"] == "oneOf":
1022                        notes.append("one of: " + ", ".join(f"--{o}" for o in longs))
1023                lines += row(opt.label, annotate(opt.description, notes))
1024            sections.append(lines)
1025
1026        if node.epilog:
1027            sections.append(node.epilog.rstrip("\n").split("\n"))
1028
1029        text = "\n\n".join("\n".join(s) for s in sections)
1030        return "\n".join(line.rstrip() for line in text.split("\n")) + "\n"
1031
1032    def json_schema(self) -> str:
1033        """JSON description of the CLI (spec section 8)."""
1034        self._ensure_help()
1035        return json.dumps({"clyops": 1, "script": self.name, **self._schema_node()}, indent=2, ensure_ascii=False)
1036
1037    def _schema_node(self) -> Dict[str, object]:
1038        def type_of(o: _Option) -> str:
1039            r = o.rule
1040            if o.kind == "flag" or r == "bool":
1041                return "boolean"
1042            if r.startswith("int") or r == "port":
1043                return "integer"
1044            if r.startswith("float"):
1045                return "number"
1046            if r.startswith("choice:"):
1047                return "choice"
1048            return "path" if _is_path_rule(r) else "string"
1049
1050        return {
1051            **({"name": self._word} if self._parent else {}),
1052            "description": self.description,
1053            "epilog": self.epilog,
1054            "arguments": [{
1055                "name": a.name, "description": a.description, "required": not a.variadic and not a.default,
1056                "isVariadic": a.variadic, "default": a.default, "validation": a.rule,
1057            } for a in self._args],
1058            "options": [{
1059                "name": o.long, "shortName": o.short, "variableName": o.var, "description": o.description,
1060                "default": "false" if o.kind == "flag" else o.default, "group": o.group, "type": type_of(o),
1061                "isFlag": o.kind == "flag", "isArray": o.kind == "array", "required": o.required,
1062                "validation": o.rule, "choices": o.rule[7:].split(",") if o.rule.startswith("choice:") else [],
1063                "secret": o.secret,
1064            } for o in self._options],
1065            "requiredCommands": [{"command": c, "description": d, "installHint": h} for c, d, h in self._commands],
1066            "effects": self._effects,
1067            "constraints": self._constraints,
1068            "stdin": self._stdin,
1069            "stdout": self._stdout,
1070            "commands": [c._schema_node() for c in self._children],
1071        }
1072
1073    def completion_script(self, shell: str) -> Optional[str]:
1074        """Shell script that enables completion for this program (spec section 9):
1075        eval "$(prog --completion bash)". None for an unknown shell."""
1076        template = _COMPLETION_SCRIPTS.get(shell)
1077        if template is None:
1078            return None
1079        func = re.sub(r"[^A-Za-z0-9_]", "_", self.name)
1080        return template.replace("__CLYOPS_FUNC__", func).replace("__CLYOPS_PROG__", self.name)
1081
1082    def completion_data(self, words: Sequence[str] = ()) -> str:
1083        """Tab-separated completion records (spec section 9). `words` are the words typed
1084        after the program name; a program with commands follows them."""
1085        self._ensure_help()
1086
1087        def clean(s: str) -> str:
1088            return s.replace("\t", " ").replace("\n", " ")
1089
1090        lines = ["#clyops-completion 1"]
1091        node: Cli = self
1092        if self._children:
1093            skip = 0
1094            for w in words:
1095                child = next((c for c in node._children if c._word == w), None)
1096                if child is None:
1097                    break
1098                node, skip = child, skip + 1
1099            if node._children and skip < len(words) and not words[skip].startswith("-"):
1100                return lines[0] + "\n"
1101            lines.append(f"skip\t{skip}")
1102            lines += [f"cmd\t{c._word}\t{clean(c.description)}" for c in node._children]
1103        options: List[_Option] = []
1104        current: Optional[Cli] = node
1105        while current is not None:
1106            options += current._options
1107            current = current._parent
1108        for o in options:
1109            short = f"-{o.short}" if o.short else "-"
1110            if o.kind == "flag":
1111                lines.append(f"opt\t--{o.long}\t{short}\tflag\tnone\t-\t{clean(o.description)}")
1112            else:
1113                kind, values = _completion_kind(o.rule, o.search_dirs)
1114                lines.append(f"opt\t--{o.long}\t{short}\tvalue\t{kind}\t{values or '-'}\t{clean(o.description)}")
1115            if o.bool_like:
1116                lines.append(f"opt\t--no-{o.long}\t-\tflag\tnone\t-\t{clean(o.description)}")
1117        for a in node._args:
1118            kind, values = _completion_kind(a.rule, [])
1119            arity = "variadic" if a.variadic else "single"
1120            lines.append(f"arg\t{a.name}\t{arity}\t{kind}\t{values or '-'}\t{clean(a.description)}")
1121        return "\n".join(lines) + "\n"
class Cli:
 380class Cli:
 381    """A program CLI. Register options and arguments, then call run or parse. Resolved names are runtime keys."""
 382    def __init__(self, name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None,
 383                 env: Optional[Mapping[str, str]] = None):
 384        """Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ."""
 385        self.name = name or os.path.basename(sys.argv[0] or "cli")
 386        self.cwd = cwd or os.getcwd()
 387        self.root = os.path.normpath(os.path.join(self.cwd, root or "."))
 388        self.env = os.environ if env is None else env
 389        self.description = ""
 390        self.epilog = ""
 391        self.values = Values()
 392        self._options: List[_Option] = []
 393        self._by_long: Dict[str, _Option] = {}
 394        self._by_short: Dict[str, _Option] = {}
 395        self._args: List[_Arg] = []
 396        self._commands: List[tuple] = []
 397        self._config_option = ""
 398        self._config_prefixes: List[str] = []
 399        self._raw: Dict[str, Union[str, List[str]]] = {}
 400        self._arg_raw: Dict[str, Union[str, List[str]]] = {}
 401        self._sources: Dict[str, str] = {}
 402        self._config: Dict[str, tuple] = {}  # key -> (value, dir)
 403        self._effects: List[str] = []
 404        self._stdin: Optional[Dict[str, str]] = None
 405        self._stdout: Optional[Dict[str, str]] = None
 406        self._constraints: List[Dict[str, object]] = []
 407        self._children: List["Cli"] = []
 408        self._parent: Optional["Cli"] = None
 409        self._word = ""
 410        # The command selected by the last parse (this one when it has no commands).
 411        self._selected: "Cli" = self
 412
 413    # -- registration ---------------------------------------------------------
 414
 415    def set_description(self, text: str) -> "Cli":
 416        """Set the help description and return this CLI."""
 417        self.description = text
 418        return self
 419
 420    def set_epilog(self, text: str) -> "Cli":
 421        """Set the final help text and return this CLI."""
 422        self.epilog = text
 423        return self
 424
 425    def set_effects(self, *effects: str) -> "Cli":
 426        """What running the program does: read-only, idempotent, destructive, network."""
 427        for e in effects:
 428            if e not in _EFFECTS:
 429                raise ValueError(f"Unknown effect '{e}'")
 430        self._effects = list(effects)
 431        return self
 432
 433    def set_stdin(self, description: str, content_type: str = "") -> "Cli":
 434        """What the program reads on stdin; `content_type` is a MIME type or a comma-separated list."""
 435        self._stdin = {"description": description, "contentType": content_type}
 436        return self
 437
 438    def set_stdout(self, description: str, content_type: str = "") -> "Cli":
 439        """What the program writes on stdout; undeclared means text."""
 440        self._stdout = {"description": description, "contentType": content_type}
 441        return self
 442
 443    def exclusive(self, *longs: str) -> "Cli":
 444        """At most one of these options may be given."""
 445        return self._constraint("exclusive", longs)
 446
 447    def requires(self, long: str, *longs: str) -> "Cli":
 448        """When the first option is given, the others must be too."""
 449        return self._constraint("requires", (long,) + longs)
 450
 451    def one_of(self, *longs: str) -> "Cli":
 452        """At least one of these options must be given."""
 453        return self._constraint("oneOf", longs)
 454
 455    def _constraint(self, kind: str, longs: Sequence[str]) -> "Cli":
 456        for long in longs:
 457            if not self._find(long, False):
 458                raise ValueError(f"Unknown option --{long} in constraint")
 459        self._constraints.append({"type": kind, "options": list(longs)})
 460        return self
 461
 462    def command(self, name: str, description: str = "") -> "Cli":
 463        """Register a command (spec section 1.7) and return it, to register its options and arguments on."""
 464        if self._args:
 465            raise ValueError("Cannot mix commands and positional arguments")
 466        if any(c._word == name for c in self._children):
 467            raise ValueError(f"Duplicate command {name}")
 468        child = Cli(f"{self.name} {name}", self.root, self.cwd, self.env)
 469        child.description = description
 470        child._parent = self
 471        child._word = name
 472        self._children.append(child)
 473        return child
 474
 475    @property
 476    def command_path(self) -> List[str]:
 477        """The command words selected by the last parse, e.g. ["db", "migrate"]."""
 478        words: List[str] = []
 479        node: Optional[Cli] = self._selected
 480        while node is not None and node is not self:
 481            words.insert(0, node._word)
 482            node = node._parent
 483        return words
 484
 485    def _chain(self) -> List["Cli"]:
 486        """The selected command, its parent, ... up to this one."""
 487        out: List[Cli] = []
 488        node: Optional[Cli] = self._selected
 489        while node is not None:
 490            out.append(node)
 491            node = None if node is self else node._parent
 492        return out
 493
 494    def _chain_options(self) -> List[_Option]:
 495        return [o for n in self._chain() for o in n._options]
 496
 497    def _find(self, name: str, short: bool) -> Optional[_Option]:
 498        """An option by long (or short) name, in this level and its ancestors."""
 499        node: Optional[Cli] = self
 500        while node is not None:
 501            opt = (node._by_short if short else node._by_long).get(name)
 502            if opt:
 503                return opt
 504            node = node._parent
 505        return None
 506
 507    def set_config(self, option: str, prefixes: str) -> "Cli":
 508        """`option` holds the config file path; `prefixes` is comma-separated."""
 509        self._config_option = option
 510        self._config_prefixes = [p.strip() for p in prefixes.split(",") if p.strip()]
 511        return self
 512
 513    def require_command(self, command: str, description: str, install_hint: str = "") -> "Cli":
 514        """Require an executable on PATH when parsing; description and install_hint explain how to install it."""
 515        self._commands.append((command, description, install_hint))
 516        return self
 517
 518    def set_path_search(self, long: str, dirs: Union[str, Sequence[str]]) -> "Cli":
 519        """Fallback dirs (relative to root) for bare relative values of a path option."""
 520        opt = self._by_long[long]
 521        items = dirs.split(":") if isinstance(dirs, str) else list(dirs)
 522        opt.search_dirs = [os.path.normpath(os.path.join(self.root, d)) for d in items if d]
 523        if not opt.rule:
 524            opt.rule = "path"
 525        return self
 526
 527    def opt(self, var: str, long: str, short: str = "", default: str = "", description: str = "",
 528            group: str = "Options", rule: str = "") -> "Cli":
 529        """Register an option. `default` is a value, "flag", "optional", or "" (required)."""
 530        kind = "flag" if default == "flag" else "value"
 531        value = "" if default in ("flag", "optional") else default
 532        return self._add(_Option(var, long, short, kind, value, default == "", description, group, rule))
 533
 534    def opt_array(self, var: str, long: str, short: str = "", description: str = "",
 535                  group: str = "Options", rule: str = "") -> "Cli":
 536        """Register a repeatable option whose values accumulate into a list."""
 537        return self._add(_Option(var, long, short, "array", "", False, description, group, rule))
 538
 539    def arg(self, name: str, description: str = "", default: str = "", rule: str = "") -> "Cli":
 540        """Register a positional argument. An empty default makes it required."""
 541        return self._add_arg(_Arg(name, description, default, rule, False))
 542
 543    def arg_variadic(self, name: str, description: str = "", rule: str = "") -> "Cli":
 544        """Register a final positional argument that collects all remaining tokens."""
 545        return self._add_arg(_Arg(name, description, "", rule, True))
 546
 547    def _add(self, opt: _Option) -> "Cli":
 548        if opt.long in self._by_long:
 549            raise ValueError(f"Duplicate option --{opt.long}")
 550        if opt.short and (len(opt.short) != 1 or opt.short in self._by_short):
 551            raise ValueError(f"Invalid or duplicate short option -{opt.short}")
 552        if opt.rule == "secret" or opt.rule.startswith("secret:"):
 553            opt.secret, opt.rule = True, opt.rule[7:]
 554        if not _known_rule(opt.rule):
 555            raise ValueError(f"Unknown validation rule '{opt.rule}' for --{opt.long}")
 556        self._options.append(opt)
 557        self._by_long[opt.long] = opt
 558        if opt.short:
 559            self._by_short[opt.short] = opt
 560        return self
 561
 562    def _add_arg(self, arg: _Arg) -> "Cli":
 563        if self._children:
 564            raise ValueError("Cannot mix commands and positional arguments")
 565        if any(a.variadic for a in self._args):
 566            raise ValueError(f"Argument {arg.name} registered after a variadic argument")
 567        if not _known_rule(arg.rule):
 568            raise ValueError(f"Unknown validation rule '{arg.rule}' for {arg.name}")
 569        self._args.append(arg)
 570        return self
 571
 572    def _ensure_help(self) -> None:
 573        if "help" not in self._by_long:
 574            short = "" if "h" in self._by_short else "h"
 575            self._add(_Option("HELP", "help", short, "flag", "", False, "Show this help message and exit", "Global", ""))
 576
 577    # -- parsing --------------------------------------------------------------
 578
 579    def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult:
 580        """Parse without exiting. Values are in `self.values` when status is "ok"."""
 581        argv = list(sys.argv[1:] if argv is None else argv)
 582        self._raw, self._arg_raw, self._sources, self._config = {}, {}, {}, {}
 583        self.values = Values()
 584        self._selected = self
 585        self._ensure_help()
 586
 587        try:
 588            self._scan(argv)
 589            scan_error = None
 590        except _ParseError as exc:
 591            scan_error = str(exc)
 592        if scan_error is None and self._config_option:
 593            try:
 594                self._load_config()
 595            except _ParseError as exc:
 596                scan_error = str(exc)
 597        if self._raw.get("help") == "true":
 598            return ParseResult("help")
 599        if scan_error is not None:
 600            return ParseResult("error", scan_error)
 601
 602        try:
 603            self._resolve()
 604        except (_ParseError, ValidationError) as exc:
 605            return ParseResult("error", str(exc))
 606
 607        missing_cmds = [c for n in reversed(self._chain()) for c in n._commands
 608                        if not shutil.which(c[0], path=self.env.get("PATH", ""))]
 609        if missing_cmds:
 610            detail = []
 611            for cmd, desc, hint in missing_cmds:
 612                detail.append(f"  {cmd} - {desc}")
 613                if hint:
 614                    detail.append(f"    Install: {hint}")
 615            return ParseResult("error", "Missing required command(s): " + ", ".join(c[0] for c in missing_cmds),
 616                               False, detail)
 617
 618        missing = [f"--{o.long}" for o in self._chain_options() if o.required and not self._raw.get(o.long)]
 619        if missing:
 620            return ParseResult("error", "Missing required argument(s): " + " ".join(missing))
 621        conflict = self._check_constraints()
 622        if conflict:
 623            return ParseResult("error", conflict)
 624        return ParseResult("ok")
 625
 626    def _check_constraints(self) -> Optional[str]:
 627        """Spec section 1.6: the first relationship that fails, from the program down."""
 628        def given(long: str) -> bool:
 629            opt = self._selected._find(long, False)
 630            v = self.values.get(opt.var) if opt else None
 631            return self.source(long) in ("cli", "config", "env") and v is not False and v != []
 632
 633        for node in reversed(self._chain()):
 634            for c in node._constraints:
 635                longs: List[str] = c["options"]  # type: ignore[assignment]
 636                on = [o for o in longs if given(o)]
 637                if c["type"] == "exclusive" and len(on) > 1:
 638                    return f"Options --{on[0]} and --{on[1]} cannot be used together"
 639                if c["type"] == "requires" and given(longs[0]):
 640                    absent = next((o for o in longs[1:] if not given(o)), None)
 641                    if absent:
 642                        return f"Option --{longs[0]} requires --{absent}"
 643                if c["type"] == "oneOf" and not on:
 644                    return "One of " + ", ".join(f"--{o}" for o in longs) + " is required"
 645        return None
 646
 647    def _set_cli(self, opt: _Option, value: str) -> None:
 648        if opt.kind == "array":
 649            current = self._raw[opt.long] if self._sources.get(opt.long) == "cli" else []
 650            self._raw[opt.long] = list(current) + [value]
 651        else:
 652            self._raw[opt.long] = value
 653        self._sources[opt.long] = "cli"
 654
 655    def _scan(self, argv: List[str]) -> None:
 656        pos = 0
 657        rest: Optional[List[str]] = None
 658        end_of_options = False
 659        i = 0
 660        while i < len(argv):
 661            token = argv[i]
 662            i += 1
 663            if end_of_options or token == "-" or not token.startswith("-"):
 664                node = self._selected
 665                if rest is not None:
 666                    rest.append(token)
 667                elif node._children:
 668                    child = next((c for c in node._children if c._word == token), None)
 669                    if child is None:
 670                        raise _ParseError(f"Unknown command: {token}")
 671                    self._selected = child
 672                elif pos >= len(node._args):
 673                    raise _ParseError(f"Unexpected argument: {token}")
 674                else:
 675                    arg = node._args[pos]
 676                    pos += 1
 677                    if arg.variadic:
 678                        rest = [token]
 679                        self._arg_raw[arg.name] = rest
 680                    else:
 681                        self._arg_raw[arg.name] = token
 682            elif token == "--":
 683                end_of_options = True
 684            elif token.startswith("--"):
 685                name, eq, value = token[2:].partition("=")
 686                opt = self._selected._find(name, False)
 687                if opt and eq:
 688                    if opt.kind == "flag":
 689                        b = _bool_word(value)
 690                        if b is None:
 691                            raise _ParseError(f"Option --{name} expects a boolean value, got '{value}'")
 692                        value = "true" if b else "false"
 693                    self._set_cli(opt, value)
 694                elif opt:
 695                    if opt.kind == "flag":
 696                        self._set_cli(opt, "true")
 697                    else:
 698                        if i >= len(argv) or argv[i].startswith("--"):
 699                            raise _ParseError(f"Option --{name} requires an argument")
 700                        self._set_cli(opt, argv[i])
 701                        i += 1
 702                elif name.startswith("no-") and not eq and self._selected._find(name[3:], False):
 703                    target = self._selected._find(name[3:], False)
 704                    assert target is not None
 705                    if not target.bool_like:
 706                        raise _ParseError(f"Option --{name} can only be used with flag/boolean options")
 707                    self._set_cli(target, "false")
 708                else:
 709                    raise _ParseError(f"Unknown option: --{name}")
 710            else:
 711                cluster = token[1:]
 712                for j, ch in enumerate(cluster):
 713                    opt = self._selected._find(ch, True)
 714                    if not opt:
 715                        raise _ParseError(f"Unknown option: -{ch}")
 716                    if opt.kind == "flag":
 717                        self._set_cli(opt, "true")
 718                        continue
 719                    if j + 1 < len(cluster):
 720                        self._set_cli(opt, cluster[j + 1:])
 721                        break
 722                    if i >= len(argv) or argv[i].startswith("-"):
 723                        raise _ParseError(f"Option -{ch} requires an argument")
 724                    self._set_cli(opt, argv[i])
 725                    i += 1
 726                    break
 727
 728    def _load_config(self) -> None:
 729        opt = self._selected._find(self._config_option, False)
 730        if not opt:
 731            return
 732        path, source = self._raw.get(opt.long), "cli"
 733        assert not isinstance(path, list)
 734        if path is None and self.env.get(opt.var):
 735            path, source = self.env[opt.var], "env"
 736        if path is None and opt.default:
 737            path, source = opt.default, "default"
 738        if not path or path == "disabled":
 739            return
 740        resolved = resolve_path(path, self.cwd, opt.search_dirs)
 741        self._raw[opt.long] = resolved
 742        self._sources[opt.long] = source
 743        self._read_config(resolved, 0, set())
 744
 745        for key, (value, _) in self._config.items():
 746            target = self._selected._find(key, False)
 747            if not target or target is opt or self._sources.get(key) == "cli":
 748                continue
 749            if target.kind == "flag":
 750                b = _bool_word(value)
 751                if b is None:
 752                    raise _ParseError(f"Config value for --{key} must be a boolean, got '{value}'")
 753                self._raw[key] = "true" if b else "false"
 754            else:
 755                self._raw[key] = [value] if target.kind == "array" else value
 756            self._sources[key] = "config"
 757
 758    def _read_config(self, path: str, depth: int, stack: set) -> None:
 759        if depth > 10:
 760            raise _ParseError(f"Config include depth exceeded (10) while processing: {path}")
 761        if not os.path.isfile(path):
 762            raise _ParseError(f"Config file not found: {path}")
 763        if path in stack:
 764            raise _ParseError(f"Circular config include detected: {path}")
 765        stack.add(path)
 766        directory = os.path.dirname(path)
 767        with open(path, encoding="utf-8", newline="") as fh:
 768            lines = fh.read().split("\n")
 769        for line in lines:
 770            line = line[:-1] if line.endswith("\r") else line
 771            trimmed = line.strip()
 772            if not trimmed or trimmed.startswith("#"):
 773                continue
 774            include = re.match(r"\s*@include\s+(.+)$", line)
 775            if include:
 776                target = include.group(1).strip()
 777                quoted = re.fullmatch(r"(['\"])(.*)\1", target)
 778                if quoted:
 779                    target = quoted.group(2)
 780                self._read_config(os.path.normpath(os.path.join(directory, target)), depth + 1, stack)
 781                continue
 782            if self._config_prefixes:
 783                prefix = next((p for p in self._config_prefixes if line.startswith(p)), None)
 784                if prefix is None:
 785                    continue
 786                body = line[len(prefix):]
 787            else:
 788                body = line
 789            key, eq, value = body.partition("=")
 790            key = key.strip()
 791            if key.startswith("--"):
 792                key = key[2:]
 793            if not eq or not key:
 794                continue
 795            self._config[key] = (value.strip(), directory)
 796        stack.discard(path)
 797
 798    def _resolve(self) -> None:
 799        if self._selected._children:
 800            raise _ParseError("Missing command")
 801        options = self._chain_options()
 802        args = self._selected._args
 803        for arg in args:
 804            if arg.name in self._arg_raw:
 805                continue
 806            if arg.variadic:
 807                self._arg_raw[arg.name] = []
 808            elif not arg.default:
 809                raise _ParseError(f"Missing required positional argument: {arg.name}")
 810            else:
 811                self._arg_raw[arg.name] = arg.default
 812
 813        for opt in options:
 814            if opt.long in self._sources:
 815                continue
 816            env_value = None if opt.kind == "array" else self.env.get(opt.var)
 817            if env_value:
 818                if opt.kind == "flag":
 819                    b = _bool_word(env_value)
 820                    if b is None:
 821                        raise _ParseError(f"Environment variable {opt.var} must be a boolean, got '{env_value}'")
 822                    env_value = "true" if b else "false"
 823                self._raw[opt.long] = env_value
 824                self._sources[opt.long] = "env"
 825            elif opt.kind == "flag":
 826                self._raw[opt.long] = "false"
 827                self._sources[opt.long] = "default"
 828            elif opt.default:
 829                self._raw[opt.long] = opt.default
 830                self._sources[opt.long] = "default"
 831
 832        # Path resolution: the base depends on where the value came from.
 833        for opt in options:
 834            if not _is_path_rule(opt.rule) or opt.long not in self._raw or opt.long == self._config_option:
 835                continue
 836            source = self._sources[opt.long]
 837            base = self.cwd if source == "cli" else self._config[opt.long][1] if source == "config" else self.root
 838            value = self._raw[opt.long]
 839            if isinstance(value, list):
 840                self._raw[opt.long] = [resolve_path(v, base, opt.search_dirs) for v in value]
 841            else:
 842                self._raw[opt.long] = resolve_path(value, base, opt.search_dirs)
 843        for arg in args:
 844            if _is_path_rule(arg.rule):
 845                value = self._arg_raw[arg.name]
 846                if isinstance(value, list):
 847                    self._arg_raw[arg.name] = [resolve_path(v, self.cwd) for v in value]
 848                else:
 849                    self._arg_raw[arg.name] = resolve_path(value, self.cwd)
 850
 851        def convert(v: str, rule: str, name: str) -> Scalar:
 852            return validate(v, rule, name) if v and rule else v
 853
 854        for opt in options:
 855            raw = self._raw.get(opt.long)
 856            if raw is None:
 857                self.values[opt.var] = [] if opt.kind == "array" else None
 858            elif opt.kind == "flag":
 859                self.values[opt.var] = raw == "true"
 860            elif isinstance(raw, list):
 861                self.values[opt.var] = [convert(v, opt.rule, f"--{opt.long}") for v in raw]
 862            else:
 863                self.values[opt.var] = convert(raw, opt.rule, f"--{opt.long}")
 864        for arg in args:
 865            value = self._arg_raw[arg.name]
 866            if isinstance(value, list):
 867                self.values[arg.name] = [convert(v, arg.rule, arg.name) for v in value]
 868            else:
 869                self.values[arg.name] = convert(value, arg.rule, arg.name)
 870        if self._children:
 871            self.values["command"] = self.command_path  # type: ignore[assignment]
 872
 873    def run(self, argv: Optional[Sequence[str]] = None) -> Values:
 874        """Parse like a CLI: handles --help, --help-json-schema and --bash-completion,
 875        prints errors and exits on failure. Returns the values."""
 876        argv = list(sys.argv[1:] if argv is None else argv)
 877        head = argv[:argv.index("--")] if "--" in argv else argv
 878        if "--help-json-schema" in head:
 879            sys.stdout.write(self.json_schema() + "\n")
 880            sys.exit(0)
 881        if "--bash-completion" in head:
 882            sys.stdout.write(self.completion_data(argv[argv.index("--") + 1:] if "--" in argv else []))
 883            sys.exit(0)
 884        if "--completion" in head:
 885            shell = head[head.index("--completion") + 1] if head.index("--completion") + 1 < len(head) else ""
 886            script = self.completion_script(shell)
 887            if script is None:
 888                die(1, f"Unknown shell '{shell}' (expected bash, zsh or fish)")
 889            sys.stdout.write(script)
 890            sys.exit(0)
 891
 892        result = self.parse(argv)
 893        if result.status == "help":
 894            sys.stdout.write(self.usage())
 895            sys.exit(0)
 896        if result.status == "error":
 897            _emit("error", result.error, force=True)
 898            for line in result.detail:
 899                sys.stderr.write(line + "\n")
 900            if result.show_usage:
 901                sys.stderr.write(self.usage())
 902            sys.exit(1)
 903        return self.values
 904
 905    # -- accessors ------------------------------------------------------------
 906
 907    def get(self, name: str) -> Value:
 908        """Return the resolved value for a variable or argument name, or None when absent."""
 909        return self.values.get(name)
 910
 911    def source(self, long: str) -> str:
 912        """Where an option's value came from: cli, config, env, default or unset."""
 913        return self._sources.get(long[2:] if long.startswith("--") else long, "unset")
 914
 915    def is_set(self, long: str) -> bool:
 916        """Return whether the option was supplied on the command line (long name, optionally prefixed with --)."""
 917        return self.source(long) == "cli"
 918
 919    def is_explicitly_set(self, long: str) -> bool:
 920        """Return whether the option came from CLI, config, or environment rather than a default."""
 921        return self.source(long) in ("cli", "config", "env")
 922
 923    def values_json(self) -> str:
 924        """Resolved values as JSON (spec section 10)."""
 925        out: Dict[str, object] = {}
 926        for o in self._chain_options():
 927            v = self.values.get(o.var)
 928            out[o.var] = (["***"] * len(v) if isinstance(v, list) else "***") if o.secret and v is not None else v
 929        out.update({a.name: self.values.get(a.name) for a in self._selected._args})
 930        if self._children:
 931            out["command"] = self.command_path
 932        return json.dumps(out, indent=2, ensure_ascii=False)
 933
 934    # -- output ---------------------------------------------------------------
 935
 936    def usage(self) -> str:
 937        """Help text (spec section 7), for the selected command."""
 938        self._ensure_help()
 939        node = self._selected
 940        chain = self._chain()
 941        options = self._chain_options()
 942        width = self.env.get("CLYOPS_MAX_WIDTH", "")
 943        max_width = int(width) if width.isdigit() and int(width) > 0 else 100
 944        longest = max([len(o.label) for o in options] + [len(c._word) for c in node._children], default=0)
 945        indent = min(50, max(32, longest + 4))
 946        text_width = max(20, max_width - indent)
 947
 948        def row(label: str, text: str) -> List[str]:
 949            left = "  " + label
 950            left = left.ljust(indent) if len(left) < indent else left + " "
 951            lines = wrap_text(text, text_width)
 952            return [left + lines[0]] + [" " * indent + line for line in lines[1:]]
 953
 954        def annotate(text: str, notes: List[str]) -> str:
 955            return f"{text} ({', '.join(notes)})" if notes else text
 956
 957        sections: List[List[str]] = []
 958        usage = f"Usage: {node.name}"
 959        if node._children:
 960            usage += " <command>"
 961        for arg in node._args:
 962            usage += f" [<{arg.name}...>]" if arg.variadic else f" [<{arg.name}>]" if arg.default else f" <{arg.name}>"
 963        sections.append([usage + " [OPTIONS]"])
 964
 965        if node.description:
 966            sections.append(wrap_text(node.description, max_width))
 967
 968        io = []
 969        for label, decl in (("Input:", node._stdin), ("Output:", node._stdout)):
 970            if decl is not None:
 971                ctype = decl["contentType"]
 972                io.append(" ".join(x for x in (label, decl["description"], ctype and f"({ctype})") if x))
 973        if io:
 974            sections.append(io)
 975
 976        if node._children:
 977            sections.append(["Commands:"] + [line for c in node._children for line in row(c._word, c.description)])
 978
 979        if node._args:
 980            lines = ["Positional Arguments:"]
 981            for arg in node._args:
 982                notes = (["variadic"] if arg.variadic else []) + ([f"default: {arg.default}"] if arg.default else [])
 983                if arg.rule:
 984                    notes.append(f"accepts: {describe_rule(arg.rule)}")
 985                lines += row(arg.name, annotate(arg.description, notes))
 986            sections.append(lines)
 987
 988        commands = [c for n in reversed(chain) for c in n._commands]
 989        if commands:
 990            lines = ["Required Commands:"]
 991            for cmd, desc, hint in commands:
 992                status = "installed" if shutil.which(cmd, path=self.env.get("PATH", "")) else "not found"
 993                lines += row(f"{cmd} [{status}]", f"{desc} ({hint})" if hint else desc)
 994            sections.append(lines)
 995
 996        constraints = [c for n in reversed(chain) for c in n._constraints]
 997        groups = list(dict.fromkeys(o.group for o in options))
 998        for group in groups:
 999            lines = [f"{group}:"]
1000            for opt in (o for o in options if o.group == group):
1001                notes = []
1002                if opt.required:
1003                    notes.append("required")
1004                if opt.kind == "array":
1005                    notes.append("multiple")
1006                if opt.secret:
1007                    notes.append("secret")
1008                if opt.long in self._config:
1009                    notes.append(f"config: {'***' if opt.secret else self._config[opt.long][0]}")
1010                if opt.default:
1011                    notes.append(f"default: {opt.default}")
1012                if opt.rule:
1013                    notes.append(f"accepts: {describe_rule(opt.rule)}")
1014                for c in constraints:
1015                    longs: List[str] = c["options"]  # type: ignore[assignment]
1016                    if opt.long not in longs:
1017                        continue
1018                    if c["type"] == "exclusive":
1019                        notes.append("conflicts with: " + ", ".join(f"--{o}" for o in longs if o != opt.long))
1020                    elif c["type"] == "requires" and longs[0] == opt.long:
1021                        notes.append("requires: " + ", ".join(f"--{o}" for o in longs[1:]))
1022                    elif c["type"] == "oneOf":
1023                        notes.append("one of: " + ", ".join(f"--{o}" for o in longs))
1024                lines += row(opt.label, annotate(opt.description, notes))
1025            sections.append(lines)
1026
1027        if node.epilog:
1028            sections.append(node.epilog.rstrip("\n").split("\n"))
1029
1030        text = "\n\n".join("\n".join(s) for s in sections)
1031        return "\n".join(line.rstrip() for line in text.split("\n")) + "\n"
1032
1033    def json_schema(self) -> str:
1034        """JSON description of the CLI (spec section 8)."""
1035        self._ensure_help()
1036        return json.dumps({"clyops": 1, "script": self.name, **self._schema_node()}, indent=2, ensure_ascii=False)
1037
1038    def _schema_node(self) -> Dict[str, object]:
1039        def type_of(o: _Option) -> str:
1040            r = o.rule
1041            if o.kind == "flag" or r == "bool":
1042                return "boolean"
1043            if r.startswith("int") or r == "port":
1044                return "integer"
1045            if r.startswith("float"):
1046                return "number"
1047            if r.startswith("choice:"):
1048                return "choice"
1049            return "path" if _is_path_rule(r) else "string"
1050
1051        return {
1052            **({"name": self._word} if self._parent else {}),
1053            "description": self.description,
1054            "epilog": self.epilog,
1055            "arguments": [{
1056                "name": a.name, "description": a.description, "required": not a.variadic and not a.default,
1057                "isVariadic": a.variadic, "default": a.default, "validation": a.rule,
1058            } for a in self._args],
1059            "options": [{
1060                "name": o.long, "shortName": o.short, "variableName": o.var, "description": o.description,
1061                "default": "false" if o.kind == "flag" else o.default, "group": o.group, "type": type_of(o),
1062                "isFlag": o.kind == "flag", "isArray": o.kind == "array", "required": o.required,
1063                "validation": o.rule, "choices": o.rule[7:].split(",") if o.rule.startswith("choice:") else [],
1064                "secret": o.secret,
1065            } for o in self._options],
1066            "requiredCommands": [{"command": c, "description": d, "installHint": h} for c, d, h in self._commands],
1067            "effects": self._effects,
1068            "constraints": self._constraints,
1069            "stdin": self._stdin,
1070            "stdout": self._stdout,
1071            "commands": [c._schema_node() for c in self._children],
1072        }
1073
1074    def completion_script(self, shell: str) -> Optional[str]:
1075        """Shell script that enables completion for this program (spec section 9):
1076        eval "$(prog --completion bash)". None for an unknown shell."""
1077        template = _COMPLETION_SCRIPTS.get(shell)
1078        if template is None:
1079            return None
1080        func = re.sub(r"[^A-Za-z0-9_]", "_", self.name)
1081        return template.replace("__CLYOPS_FUNC__", func).replace("__CLYOPS_PROG__", self.name)
1082
1083    def completion_data(self, words: Sequence[str] = ()) -> str:
1084        """Tab-separated completion records (spec section 9). `words` are the words typed
1085        after the program name; a program with commands follows them."""
1086        self._ensure_help()
1087
1088        def clean(s: str) -> str:
1089            return s.replace("\t", " ").replace("\n", " ")
1090
1091        lines = ["#clyops-completion 1"]
1092        node: Cli = self
1093        if self._children:
1094            skip = 0
1095            for w in words:
1096                child = next((c for c in node._children if c._word == w), None)
1097                if child is None:
1098                    break
1099                node, skip = child, skip + 1
1100            if node._children and skip < len(words) and not words[skip].startswith("-"):
1101                return lines[0] + "\n"
1102            lines.append(f"skip\t{skip}")
1103            lines += [f"cmd\t{c._word}\t{clean(c.description)}" for c in node._children]
1104        options: List[_Option] = []
1105        current: Optional[Cli] = node
1106        while current is not None:
1107            options += current._options
1108            current = current._parent
1109        for o in options:
1110            short = f"-{o.short}" if o.short else "-"
1111            if o.kind == "flag":
1112                lines.append(f"opt\t--{o.long}\t{short}\tflag\tnone\t-\t{clean(o.description)}")
1113            else:
1114                kind, values = _completion_kind(o.rule, o.search_dirs)
1115                lines.append(f"opt\t--{o.long}\t{short}\tvalue\t{kind}\t{values or '-'}\t{clean(o.description)}")
1116            if o.bool_like:
1117                lines.append(f"opt\t--no-{o.long}\t-\tflag\tnone\t-\t{clean(o.description)}")
1118        for a in node._args:
1119            kind, values = _completion_kind(a.rule, [])
1120            arity = "variadic" if a.variadic else "single"
1121            lines.append(f"arg\t{a.name}\t{arity}\t{kind}\t{values or '-'}\t{clean(a.description)}")
1122        return "\n".join(lines) + "\n"

A program CLI. Register options and arguments, then call run or parse. Resolved names are runtime keys.

Cli( name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None, env: Optional[Mapping[str, str]] = None)
382    def __init__(self, name: Optional[str] = None, root: Optional[str] = None, cwd: Optional[str] = None,
383                 env: Optional[Mapping[str, str]] = None):
384        """Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ."""
385        self.name = name or os.path.basename(sys.argv[0] or "cli")
386        self.cwd = cwd or os.getcwd()
387        self.root = os.path.normpath(os.path.join(self.cwd, root or "."))
388        self.env = os.environ if env is None else env
389        self.description = ""
390        self.epilog = ""
391        self.values = Values()
392        self._options: List[_Option] = []
393        self._by_long: Dict[str, _Option] = {}
394        self._by_short: Dict[str, _Option] = {}
395        self._args: List[_Arg] = []
396        self._commands: List[tuple] = []
397        self._config_option = ""
398        self._config_prefixes: List[str] = []
399        self._raw: Dict[str, Union[str, List[str]]] = {}
400        self._arg_raw: Dict[str, Union[str, List[str]]] = {}
401        self._sources: Dict[str, str] = {}
402        self._config: Dict[str, tuple] = {}  # key -> (value, dir)
403        self._effects: List[str] = []
404        self._stdin: Optional[Dict[str, str]] = None
405        self._stdout: Optional[Dict[str, str]] = None
406        self._constraints: List[Dict[str, object]] = []
407        self._children: List["Cli"] = []
408        self._parent: Optional["Cli"] = None
409        self._word = ""
410        # The command selected by the last parse (this one when it has no commands).
411        self._selected: "Cli" = self

Create a CLI. name defaults to argv[0], cwd to the working directory, root to cwd, and env to os.environ.

name
cwd
root
env
description
epilog
values
def set_description(self, text: str) -> Cli:
415    def set_description(self, text: str) -> "Cli":
416        """Set the help description and return this CLI."""
417        self.description = text
418        return self

Set the help description and return this CLI.

def set_epilog(self, text: str) -> Cli:
420    def set_epilog(self, text: str) -> "Cli":
421        """Set the final help text and return this CLI."""
422        self.epilog = text
423        return self

Set the final help text and return this CLI.

def set_effects(self, *effects: str) -> Cli:
425    def set_effects(self, *effects: str) -> "Cli":
426        """What running the program does: read-only, idempotent, destructive, network."""
427        for e in effects:
428            if e not in _EFFECTS:
429                raise ValueError(f"Unknown effect '{e}'")
430        self._effects = list(effects)
431        return self

What running the program does: read-only, idempotent, destructive, network.

def set_stdin(self, description: str, content_type: str = '') -> Cli:
433    def set_stdin(self, description: str, content_type: str = "") -> "Cli":
434        """What the program reads on stdin; `content_type` is a MIME type or a comma-separated list."""
435        self._stdin = {"description": description, "contentType": content_type}
436        return self

What the program reads on stdin; content_type is a MIME type or a comma-separated list.

def set_stdout(self, description: str, content_type: str = '') -> Cli:
438    def set_stdout(self, description: str, content_type: str = "") -> "Cli":
439        """What the program writes on stdout; undeclared means text."""
440        self._stdout = {"description": description, "contentType": content_type}
441        return self

What the program writes on stdout; undeclared means text.

def exclusive(self, *longs: str) -> Cli:
443    def exclusive(self, *longs: str) -> "Cli":
444        """At most one of these options may be given."""
445        return self._constraint("exclusive", longs)

At most one of these options may be given.

def requires(self, long: str, *longs: str) -> Cli:
447    def requires(self, long: str, *longs: str) -> "Cli":
448        """When the first option is given, the others must be too."""
449        return self._constraint("requires", (long,) + longs)

When the first option is given, the others must be too.

def one_of(self, *longs: str) -> Cli:
451    def one_of(self, *longs: str) -> "Cli":
452        """At least one of these options must be given."""
453        return self._constraint("oneOf", longs)

At least one of these options must be given.

def command(self, name: str, description: str = '') -> Cli:
462    def command(self, name: str, description: str = "") -> "Cli":
463        """Register a command (spec section 1.7) and return it, to register its options and arguments on."""
464        if self._args:
465            raise ValueError("Cannot mix commands and positional arguments")
466        if any(c._word == name for c in self._children):
467            raise ValueError(f"Duplicate command {name}")
468        child = Cli(f"{self.name} {name}", self.root, self.cwd, self.env)
469        child.description = description
470        child._parent = self
471        child._word = name
472        self._children.append(child)
473        return child

Register a command (spec section 1.7) and return it, to register its options and arguments on.

command_path: List[str]
475    @property
476    def command_path(self) -> List[str]:
477        """The command words selected by the last parse, e.g. ["db", "migrate"]."""
478        words: List[str] = []
479        node: Optional[Cli] = self._selected
480        while node is not None and node is not self:
481            words.insert(0, node._word)
482            node = node._parent
483        return words

The command words selected by the last parse, e.g. ["db", "migrate"].

def set_config(self, option: str, prefixes: str) -> Cli:
507    def set_config(self, option: str, prefixes: str) -> "Cli":
508        """`option` holds the config file path; `prefixes` is comma-separated."""
509        self._config_option = option
510        self._config_prefixes = [p.strip() for p in prefixes.split(",") if p.strip()]
511        return self

option holds the config file path; prefixes is comma-separated.

def require_command( self, command: str, description: str, install_hint: str = '') -> Cli:
513    def require_command(self, command: str, description: str, install_hint: str = "") -> "Cli":
514        """Require an executable on PATH when parsing; description and install_hint explain how to install it."""
515        self._commands.append((command, description, install_hint))
516        return self

Require an executable on PATH when parsing; description and install_hint explain how to install it.

def opt( self, var: str, long: str, short: str = '', default: str = '', description: str = '', group: str = 'Options', rule: str = '') -> Cli:
527    def opt(self, var: str, long: str, short: str = "", default: str = "", description: str = "",
528            group: str = "Options", rule: str = "") -> "Cli":
529        """Register an option. `default` is a value, "flag", "optional", or "" (required)."""
530        kind = "flag" if default == "flag" else "value"
531        value = "" if default in ("flag", "optional") else default
532        return self._add(_Option(var, long, short, kind, value, default == "", description, group, rule))

Register an option. default is a value, "flag", "optional", or "" (required).

def opt_array( self, var: str, long: str, short: str = '', description: str = '', group: str = 'Options', rule: str = '') -> Cli:
534    def opt_array(self, var: str, long: str, short: str = "", description: str = "",
535                  group: str = "Options", rule: str = "") -> "Cli":
536        """Register a repeatable option whose values accumulate into a list."""
537        return self._add(_Option(var, long, short, "array", "", False, description, group, rule))

Register a repeatable option whose values accumulate into a list.

def arg( self, name: str, description: str = '', default: str = '', rule: str = '') -> Cli:
539    def arg(self, name: str, description: str = "", default: str = "", rule: str = "") -> "Cli":
540        """Register a positional argument. An empty default makes it required."""
541        return self._add_arg(_Arg(name, description, default, rule, False))

Register a positional argument. An empty default makes it required.

def arg_variadic(self, name: str, description: str = '', rule: str = '') -> Cli:
543    def arg_variadic(self, name: str, description: str = "", rule: str = "") -> "Cli":
544        """Register a final positional argument that collects all remaining tokens."""
545        return self._add_arg(_Arg(name, description, "", rule, True))

Register a final positional argument that collects all remaining tokens.

def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult:
579    def parse(self, argv: Optional[Sequence[str]] = None) -> ParseResult:
580        """Parse without exiting. Values are in `self.values` when status is "ok"."""
581        argv = list(sys.argv[1:] if argv is None else argv)
582        self._raw, self._arg_raw, self._sources, self._config = {}, {}, {}, {}
583        self.values = Values()
584        self._selected = self
585        self._ensure_help()
586
587        try:
588            self._scan(argv)
589            scan_error = None
590        except _ParseError as exc:
591            scan_error = str(exc)
592        if scan_error is None and self._config_option:
593            try:
594                self._load_config()
595            except _ParseError as exc:
596                scan_error = str(exc)
597        if self._raw.get("help") == "true":
598            return ParseResult("help")
599        if scan_error is not None:
600            return ParseResult("error", scan_error)
601
602        try:
603            self._resolve()
604        except (_ParseError, ValidationError) as exc:
605            return ParseResult("error", str(exc))
606
607        missing_cmds = [c for n in reversed(self._chain()) for c in n._commands
608                        if not shutil.which(c[0], path=self.env.get("PATH", ""))]
609        if missing_cmds:
610            detail = []
611            for cmd, desc, hint in missing_cmds:
612                detail.append(f"  {cmd} - {desc}")
613                if hint:
614                    detail.append(f"    Install: {hint}")
615            return ParseResult("error", "Missing required command(s): " + ", ".join(c[0] for c in missing_cmds),
616                               False, detail)
617
618        missing = [f"--{o.long}" for o in self._chain_options() if o.required and not self._raw.get(o.long)]
619        if missing:
620            return ParseResult("error", "Missing required argument(s): " + " ".join(missing))
621        conflict = self._check_constraints()
622        if conflict:
623            return ParseResult("error", conflict)
624        return ParseResult("ok")

Parse without exiting. Values are in self.values when status is "ok".

def run(self, argv: Optional[Sequence[str]] = None) -> Values:
873    def run(self, argv: Optional[Sequence[str]] = None) -> Values:
874        """Parse like a CLI: handles --help, --help-json-schema and --bash-completion,
875        prints errors and exits on failure. Returns the values."""
876        argv = list(sys.argv[1:] if argv is None else argv)
877        head = argv[:argv.index("--")] if "--" in argv else argv
878        if "--help-json-schema" in head:
879            sys.stdout.write(self.json_schema() + "\n")
880            sys.exit(0)
881        if "--bash-completion" in head:
882            sys.stdout.write(self.completion_data(argv[argv.index("--") + 1:] if "--" in argv else []))
883            sys.exit(0)
884        if "--completion" in head:
885            shell = head[head.index("--completion") + 1] if head.index("--completion") + 1 < len(head) else ""
886            script = self.completion_script(shell)
887            if script is None:
888                die(1, f"Unknown shell '{shell}' (expected bash, zsh or fish)")
889            sys.stdout.write(script)
890            sys.exit(0)
891
892        result = self.parse(argv)
893        if result.status == "help":
894            sys.stdout.write(self.usage())
895            sys.exit(0)
896        if result.status == "error":
897            _emit("error", result.error, force=True)
898            for line in result.detail:
899                sys.stderr.write(line + "\n")
900            if result.show_usage:
901                sys.stderr.write(self.usage())
902            sys.exit(1)
903        return self.values

Parse like a CLI: handles --help, --help-json-schema and --bash-completion, prints errors and exits on failure. Returns the values.

def get( self, name: str) -> Union[str, int, float, bool, List[Union[str, int, float, bool]], NoneType]:
907    def get(self, name: str) -> Value:
908        """Return the resolved value for a variable or argument name, or None when absent."""
909        return self.values.get(name)

Return the resolved value for a variable or argument name, or None when absent.

def source(self, long: str) -> str:
911    def source(self, long: str) -> str:
912        """Where an option's value came from: cli, config, env, default or unset."""
913        return self._sources.get(long[2:] if long.startswith("--") else long, "unset")

Where an option's value came from: cli, config, env, default or unset.

def is_set(self, long: str) -> bool:
915    def is_set(self, long: str) -> bool:
916        """Return whether the option was supplied on the command line (long name, optionally prefixed with --)."""
917        return self.source(long) == "cli"

Return whether the option was supplied on the command line (long name, optionally prefixed with --).

def is_explicitly_set(self, long: str) -> bool:
919    def is_explicitly_set(self, long: str) -> bool:
920        """Return whether the option came from CLI, config, or environment rather than a default."""
921        return self.source(long) in ("cli", "config", "env")

Return whether the option came from CLI, config, or environment rather than a default.

def values_json(self) -> str:
923    def values_json(self) -> str:
924        """Resolved values as JSON (spec section 10)."""
925        out: Dict[str, object] = {}
926        for o in self._chain_options():
927            v = self.values.get(o.var)
928            out[o.var] = (["***"] * len(v) if isinstance(v, list) else "***") if o.secret and v is not None else v
929        out.update({a.name: self.values.get(a.name) for a in self._selected._args})
930        if self._children:
931            out["command"] = self.command_path
932        return json.dumps(out, indent=2, ensure_ascii=False)

Resolved values as JSON (spec section 10).

def usage(self) -> str:
 936    def usage(self) -> str:
 937        """Help text (spec section 7), for the selected command."""
 938        self._ensure_help()
 939        node = self._selected
 940        chain = self._chain()
 941        options = self._chain_options()
 942        width = self.env.get("CLYOPS_MAX_WIDTH", "")
 943        max_width = int(width) if width.isdigit() and int(width) > 0 else 100
 944        longest = max([len(o.label) for o in options] + [len(c._word) for c in node._children], default=0)
 945        indent = min(50, max(32, longest + 4))
 946        text_width = max(20, max_width - indent)
 947
 948        def row(label: str, text: str) -> List[str]:
 949            left = "  " + label
 950            left = left.ljust(indent) if len(left) < indent else left + " "
 951            lines = wrap_text(text, text_width)
 952            return [left + lines[0]] + [" " * indent + line for line in lines[1:]]
 953
 954        def annotate(text: str, notes: List[str]) -> str:
 955            return f"{text} ({', '.join(notes)})" if notes else text
 956
 957        sections: List[List[str]] = []
 958        usage = f"Usage: {node.name}"
 959        if node._children:
 960            usage += " <command>"
 961        for arg in node._args:
 962            usage += f" [<{arg.name}...>]" if arg.variadic else f" [<{arg.name}>]" if arg.default else f" <{arg.name}>"
 963        sections.append([usage + " [OPTIONS]"])
 964
 965        if node.description:
 966            sections.append(wrap_text(node.description, max_width))
 967
 968        io = []
 969        for label, decl in (("Input:", node._stdin), ("Output:", node._stdout)):
 970            if decl is not None:
 971                ctype = decl["contentType"]
 972                io.append(" ".join(x for x in (label, decl["description"], ctype and f"({ctype})") if x))
 973        if io:
 974            sections.append(io)
 975
 976        if node._children:
 977            sections.append(["Commands:"] + [line for c in node._children for line in row(c._word, c.description)])
 978
 979        if node._args:
 980            lines = ["Positional Arguments:"]
 981            for arg in node._args:
 982                notes = (["variadic"] if arg.variadic else []) + ([f"default: {arg.default}"] if arg.default else [])
 983                if arg.rule:
 984                    notes.append(f"accepts: {describe_rule(arg.rule)}")
 985                lines += row(arg.name, annotate(arg.description, notes))
 986            sections.append(lines)
 987
 988        commands = [c for n in reversed(chain) for c in n._commands]
 989        if commands:
 990            lines = ["Required Commands:"]
 991            for cmd, desc, hint in commands:
 992                status = "installed" if shutil.which(cmd, path=self.env.get("PATH", "")) else "not found"
 993                lines += row(f"{cmd} [{status}]", f"{desc} ({hint})" if hint else desc)
 994            sections.append(lines)
 995
 996        constraints = [c for n in reversed(chain) for c in n._constraints]
 997        groups = list(dict.fromkeys(o.group for o in options))
 998        for group in groups:
 999            lines = [f"{group}:"]
1000            for opt in (o for o in options if o.group == group):
1001                notes = []
1002                if opt.required:
1003                    notes.append("required")
1004                if opt.kind == "array":
1005                    notes.append("multiple")
1006                if opt.secret:
1007                    notes.append("secret")
1008                if opt.long in self._config:
1009                    notes.append(f"config: {'***' if opt.secret else self._config[opt.long][0]}")
1010                if opt.default:
1011                    notes.append(f"default: {opt.default}")
1012                if opt.rule:
1013                    notes.append(f"accepts: {describe_rule(opt.rule)}")
1014                for c in constraints:
1015                    longs: List[str] = c["options"]  # type: ignore[assignment]
1016                    if opt.long not in longs:
1017                        continue
1018                    if c["type"] == "exclusive":
1019                        notes.append("conflicts with: " + ", ".join(f"--{o}" for o in longs if o != opt.long))
1020                    elif c["type"] == "requires" and longs[0] == opt.long:
1021                        notes.append("requires: " + ", ".join(f"--{o}" for o in longs[1:]))
1022                    elif c["type"] == "oneOf":
1023                        notes.append("one of: " + ", ".join(f"--{o}" for o in longs))
1024                lines += row(opt.label, annotate(opt.description, notes))
1025            sections.append(lines)
1026
1027        if node.epilog:
1028            sections.append(node.epilog.rstrip("\n").split("\n"))
1029
1030        text = "\n\n".join("\n".join(s) for s in sections)
1031        return "\n".join(line.rstrip() for line in text.split("\n")) + "\n"

Help text (spec section 7), for the selected command.

def json_schema(self) -> str:
1033    def json_schema(self) -> str:
1034        """JSON description of the CLI (spec section 8)."""
1035        self._ensure_help()
1036        return json.dumps({"clyops": 1, "script": self.name, **self._schema_node()}, indent=2, ensure_ascii=False)

JSON description of the CLI (spec section 8).

def completion_script(self, shell: str) -> Optional[str]:
1074    def completion_script(self, shell: str) -> Optional[str]:
1075        """Shell script that enables completion for this program (spec section 9):
1076        eval "$(prog --completion bash)". None for an unknown shell."""
1077        template = _COMPLETION_SCRIPTS.get(shell)
1078        if template is None:
1079            return None
1080        func = re.sub(r"[^A-Za-z0-9_]", "_", self.name)
1081        return template.replace("__CLYOPS_FUNC__", func).replace("__CLYOPS_PROG__", self.name)

Shell script that enables completion for this program (spec section 9): eval "$(prog --completion bash)". None for an unknown shell.

def completion_data(self, words: Sequence[str] = ()) -> str:
1083    def completion_data(self, words: Sequence[str] = ()) -> str:
1084        """Tab-separated completion records (spec section 9). `words` are the words typed
1085        after the program name; a program with commands follows them."""
1086        self._ensure_help()
1087
1088        def clean(s: str) -> str:
1089            return s.replace("\t", " ").replace("\n", " ")
1090
1091        lines = ["#clyops-completion 1"]
1092        node: Cli = self
1093        if self._children:
1094            skip = 0
1095            for w in words:
1096                child = next((c for c in node._children if c._word == w), None)
1097                if child is None:
1098                    break
1099                node, skip = child, skip + 1
1100            if node._children and skip < len(words) and not words[skip].startswith("-"):
1101                return lines[0] + "\n"
1102            lines.append(f"skip\t{skip}")
1103            lines += [f"cmd\t{c._word}\t{clean(c.description)}" for c in node._children]
1104        options: List[_Option] = []
1105        current: Optional[Cli] = node
1106        while current is not None:
1107            options += current._options
1108            current = current._parent
1109        for o in options:
1110            short = f"-{o.short}" if o.short else "-"
1111            if o.kind == "flag":
1112                lines.append(f"opt\t--{o.long}\t{short}\tflag\tnone\t-\t{clean(o.description)}")
1113            else:
1114                kind, values = _completion_kind(o.rule, o.search_dirs)
1115                lines.append(f"opt\t--{o.long}\t{short}\tvalue\t{kind}\t{values or '-'}\t{clean(o.description)}")
1116            if o.bool_like:
1117                lines.append(f"opt\t--no-{o.long}\t-\tflag\tnone\t-\t{clean(o.description)}")
1118        for a in node._args:
1119            kind, values = _completion_kind(a.rule, [])
1120            arity = "variadic" if a.variadic else "single"
1121            lines.append(f"arg\t{a.name}\t{arity}\t{kind}\t{values or '-'}\t{clean(a.description)}")
1122        return "\n".join(lines) + "\n"

Tab-separated completion records (spec section 9). words are the words typed after the program name; a program with commands follows them.

class Values(typing.Dict[str, typing.Union[str, int, float, bool, typing.List[typing.Union[str, int, float, bool]], NoneType]]):
365class Values(Dict[str, Value]):
366    """Resolved values; keys are option vars and argument names, also readable as attributes."""
367
368    def __getattr__(self, name: str) -> Value:
369        """Read a resolved key as an attribute; missing keys raise AttributeError."""
370        try:
371            return self[name]
372        except KeyError:
373            raise AttributeError(name) from None

Resolved values; keys are option vars and argument names, also readable as attributes.

@dataclass
class ParseResult:
356@dataclass
357class ParseResult:
358    """Non-exiting parse outcome. status is ok, help, or error; error/detail explain failures and show_usage controls help."""
359    status: str  # ok | help | error
360    error: str = ""
361    show_usage: bool = True
362    detail: List[str] = field(default_factory=list)

Non-exiting parse outcome. status is ok, help, or error; error/detail explain failures and show_usage controls help.

ParseResult( status: str, error: str = '', show_usage: bool = True, detail: List[str] = <factory>)
status: str
error: str = ''
show_usage: bool = True
detail: List[str]
class ValidationError(builtins.ValueError):
161class ValidationError(ValueError):
162    """An invalid value reported with the validation rule and option or argument name."""
163    pass

An invalid value reported with the validation rule and option or argument name.

def validate(value: str, rule: str, name: str) -> Union[str, int, float, bool]:
166def validate(value: str, rule: str, name: str) -> Scalar:
167    """Validate `value` against `rule` and return its typed form.
168
169    Raises ValidationError with the spec's error text.
170    """
171    def fail(msg: str):
172        raise ValidationError(f"{name} {msg}")
173
174    def check_bounds(num: float, lo: str, hi: str):
175        if lo and num < float(lo):
176            fail(f"must be >= {lo}, got {value}")
177        if hi and num > float(hi):
178            fail(f"must be <= {hi}, got {value}")
179
180    if rule == "int" or rule.startswith("int:"):
181        if not re.fullmatch(r"-?[0-9]+", value):
182            fail(f"must be an integer, got '{value}'")
183        num: Union[int, float] = int(value)
184        if rule != "int":
185            check_bounds(num, *_bounds(rule))
186        return num
187    if rule == "float" or rule.startswith("float:"):
188        if not re.fullmatch(r"-?[0-9]*\.?[0-9]+", value):
189            fail(f"must be a number, got '{value}'")
190        num = float(value)
191        if rule != "float":
192            check_bounds(num, *_bounds(rule))
193        return num
194    if rule.startswith("string:"):
195        length = len(value)
196        if "-" not in rule:
197            exact = rule[7:]
198            if length != int(exact):
199                fail(f"must be exactly {exact} characters, got {length}")
200        else:
201            lo, hi = _bounds(rule)
202            if lo and length < int(lo):
203                fail(f"must be at least {lo} characters, got {length}")
204            if hi and length > int(hi):
205                fail(f"must be at most {hi} characters, got {length}")
206        return value
207    if rule.startswith("choice:"):
208        choices = rule[7:].split(",")
209        if value not in choices:
210            fail(f"must be one of: {', '.join(choices)}, got '{value}'")
211        return value
212    if rule.startswith("regex:"):
213        if not re.search(rule[6:], value):
214            fail(f"does not match required pattern, got '{value}'")
215        return value
216
217    if rule == "bool":
218        b = _bool_word(value)
219        if b is None:
220            fail(f"must be a boolean (true/false, yes/no, 1/0, on/off), got '{value}'")
221        return bool(b)
222    if rule == "port":
223        if not re.fullmatch(r"[0-9]+", value) or not 1 <= int(value) <= 65535:
224            fail(f"must be a valid port (1-65535), got '{value}'")
225        return int(value)
226    if rule == "ip":
227        if not (re.fullmatch(r"([0-9]{1,3}\.){3}[0-9]{1,3}", value)
228                or re.fullmatch(r"([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}", value)):
229            fail(f"must be a valid IP address, got '{value}'")
230    elif rule == "hostname":
231        if not re.fullmatch(_HOSTNAME, value):
232            fail(f"must be a valid hostname, got '{value}'")
233    elif rule == "url":
234        if not re.fullmatch(r"https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/.*)?", value, re.S):
235            fail(f"must be a valid URL, got '{value}'")
236    elif rule == "email":
237        if not re.fullmatch(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", value):
238            fail(f"must be a valid email address, got '{value}'")
239    elif rule == "uuid":
240        if not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value):
241            fail(f"must be a valid UUID, got '{value}'")
242    elif rule == "date:YYYY-MM-DD":
243        if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
244            fail(f"must be in YYYY-MM-DD format, got '{value}'")
245    elif rule == "file:exists":
246        if not os.path.isfile(value):
247            fail(f"file does not exist: {value}")
248    elif rule == "file:readable":
249        if not os.access(value, os.R_OK):
250            fail(f"file is not readable: {value}")
251    elif rule == "file:writable":
252        if os.path.lexists(value):
253            if not os.access(value, os.W_OK):
254                fail(f"file is not writable: {value}")
255        else:
256            directory = os.path.dirname(value)
257            if not (os.path.isdir(directory) and os.access(directory, os.W_OK)):
258                fail(f"directory is not writable: {directory}")
259    elif rule == "dir:exists":
260        if not os.path.isdir(value):
261            fail(f"directory does not exist: {value}")
262    elif rule == "dir:writable":
263        if not (os.path.isdir(value) and os.access(value, os.W_OK)):
264            fail(f"directory does not exist or is not writable: {value}")
265    return value

Validate value against rule and return its typed form.

Raises ValidationError with the spec's error text.

def resolve_path(value: str, base: str, search_dirs: Sequence[str] = ()) -> str:
268def resolve_path(value: str, base: str, search_dirs: Sequence[str] = ()) -> str:
269    """Resolve a path value against `base` (spec section 6)."""
270    if not value or value in ("-", "disabled", "optional"):
271        return value
272    if os.path.isabs(value) or re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", value):
273        return value
274    from_base = os.path.normpath(os.path.join(base, value))
275    bare = not re.match(r"\.\.?(/|$)", value)
276    if bare and search_dirs and not os.path.exists(from_base):
277        for directory in search_dirs:
278            candidate = os.path.normpath(os.path.join(directory, value))
279            if os.path.exists(candidate):
280                return candidate
281    return from_base

Resolve a path value against base (spec section 6).

def describe_rule(rule: str) -> str:
135def describe_rule(rule: str) -> str:
136    """Help text for a validation rule (spec section 5)."""
137    fixed = {
138        "int": "integer", "float": "number", "string": "text", "path": "path", "ip": "IP address",
139        "hostname": "hostname", "url": "URL", "port": "port: 1-65535", "email": "email address", "uuid": "UUID",
140        "bool": "true/false, yes/no, 1/0, on/off", "date:YYYY-MM-DD": "date: YYYY-MM-DD",
141        "file:exists": "existing file", "file:readable": "readable file", "file:writable": "writable file",
142        "dir:exists": "existing directory", "dir:writable": "writable directory",
143    }
144    if rule in fixed:
145        return fixed[rule]
146    for prefix, noun, suffix in (("int:", "integer", ""), ("float:", "number", ""), ("string:", "text", " chars")):
147        if rule.startswith(prefix):
148            if "-" not in rule:
149                return f"{noun}: {rule[len(prefix):]}{suffix}"
150            lo, hi = _bounds(rule)
151            if lo and hi:
152                return f"{noun}: {lo}-{hi}{suffix}"
153            return f"{noun}: >={lo}{suffix}" if lo else f"{noun}: <={hi}{suffix}"
154    if rule.startswith("choice:"):
155        return "choices: " + ", ".join(rule[7:].split(","))
156    if rule.startswith("regex:"):
157        return "pattern: " + rule[6:]
158    return rule

Help text for a validation rule (spec section 5).

def wrap_text(text: str, width: int) -> List[str]:
284def wrap_text(text: str, width: int) -> List[str]:
285    """Greedy word wrap that keeps existing line breaks (spec section 7)."""
286    out: List[str] = []
287    for original in re.split(r"\r?\n", text):
288        words = original.split()
289        if not words:
290            out.append("")
291            continue
292        line = ""
293        for word in words:
294            if not line:
295                line = word
296            elif len(line) + 1 + len(word) <= width:
297                line += " " + word
298            else:
299                out.append(line)
300                line = word
301        out.append(line)
302    return out

Greedy word wrap that keeps existing line breaks (spec section 7).

def info(msg: str) -> None:
58def info(msg: str) -> None:
59    """Write a timestamped informational message to stderr."""
60    _emit("info", msg)

Write a timestamped informational message to stderr.

def warn(msg: str) -> None:
63def warn(msg: str) -> None:
64    """Write a timestamped warning to stderr."""
65    _emit("warning", msg)

Write a timestamped warning to stderr.

def error(msg: str) -> None:
68def error(msg: str) -> None:
69    """Write a timestamped error to stderr."""
70    _emit("error", msg)

Write a timestamped error to stderr.

def success(msg: str) -> None:
73def success(msg: str) -> None:
74    """Write a timestamped success message to stderr."""
75    _emit("success", msg)

Write a timestamped success message to stderr.

def die(code: int, msg: str) -> NoReturn:
78def die(code: int, msg: str) -> NoReturn:
79    """Print an error and exit with `code`. Never suppressed."""
80    _emit("error", msg, force=True)
81    sys.exit(code)

Print an error and exit with code. Never suppressed.

def set_silent(value: bool) -> None:
43def set_silent(value: bool) -> None:
44    """Suppress info/warn/error/success output (die and parse errors still print)."""
45    global _silent
46    _silent = value

Suppress info/warn/error/success output (die and parse errors still print).