Metadata-Version: 2.5
Name: duho
Version: 0.6.4
Summary: A declarative CLI framework for building command-line applications with minimal boilerplate
Project-URL: Homepage, https://github.com/jose-pr/duho/
Project-URL: Documentation, https://jose-pr.github.io/duho/
Project-URL: Issues, https://github.com/jose-pr/duho/issues
Project-URL: Repository, https://github.com/jose-pr/duho
Author: Jose A.
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Shells
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: colorama
Requires-Dist: colorama; extra == 'colorama'
Provides-Extra: config
Requires-Dist: tomli; (python_version < '3.11') and extra == 'config'
Provides-Extra: dev
Requires-Dist: black; extra == 'dev'
Requires-Dist: build; extra == 'dev'
Requires-Dist: hatchling; extra == 'dev'
Requires-Dist: hypothesis; extra == 'dev'
Requires-Dist: mcp; (python_version >= '3.10') and extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs; extra == 'docs'
Requires-Dist: mkdocs-material; extra == 'docs'
Requires-Dist: mkdocstrings[python]; extra == 'docs'
Description-Content-Type: text/markdown

# Duho

[![PyPI version](https://img.shields.io/pypi/v/duho.svg)](https://pypi.org/project/duho/)
[![Python versions](https://img.shields.io/pypi/pyversions/duho.svg)](https://pypi.org/project/duho/)
[![Documentation](https://img.shields.io/badge/docs-jose--pr.github.io%2Fduho-blue.svg)](https://jose-pr.github.io/duho/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/jose-pr/duho/blob/main/LICENSE)

**Duho** is a declarative CLI framework for Python that turns the complexity of building command-line applications into simple, type-safe class definitions.

Named after the sacred Taíno ceremonial stool—a symbol of power and authority—duho provides the **foundation** from which you command your application.

## Features

- **Declarative**: Define CLI arguments as class annotations—no boilerplate argparse setup
- **Type-safe**: Built-in type conversion and validation from Python type hints
- **Logging**: Integrated colored logging with configurable verbosity levels
- **Subcommands**: Easily compose multi-command CLI applications
- **Extensible**: Customize argument behavior with protocols and builders

## Quick Start

```python
from duho import Args

class MyApp(Args):
    name: str
    "The name to greet"

    count: int = 1
    "How many times to greet"

if __name__ == "__main__":
    parser = MyApp._parser_()
    args = parser.parse_args()
    for _ in range(args.count):
        print(f"Hello, {args.name}!")
```

Run it:

```bash
python app.py --name Alice --count 3
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
```

**Every annotated field gets a default long flag for free** —
`"--" + kebabcase(field_name)` (underscores become dashes; a camelCase or
ACRONYM name is kebab-cased too: `dry_run` → `--dry-run`, `testMe` →
`--test-me`). `name` above needs no tuple to become `--name`. Reach for an
explicit flags tuple only for:

- a **positional** — `("source",)`
- a **short flag or extra aliases** — `("-n", "--name")`
- a **different spelling** — `("--env",)` for a field named `environment`

A bare `"--"` entry inside a tuple expands to that same default long flag, so
you can pair a short flag with it without spelling it out:
`("-n", "--")` → `("-n", "--name")`.

## Installation

```bash
pip install duho
```

### Optional Dependencies

Colored logging works out of the box (gated on a TTY / `NO_COLOR` / `FORCE_COLOR`,
raw ANSI codes, no dependency required). `colorama` is only needed to resolve a
*named* color (e.g. `color="red"` on `duho.add_logging_level`) and to patch a
legacy Windows console so it renders ANSI codes instead of showing them literally:

```bash
pip install duho[colorama]
```

## Core Concepts

### Args: Declare Your CLI

Define arguments using class annotations. The docstring becomes the help text, and expressions after the annotation become argument flags:

```python
from duho import Args
import typing as ty

class Deploy(Args):
    """Deploy the application to production."""
    
    environment: str
    "Target environment (prod, staging, dev)"
    ("--env",)
    
    version: ty.Optional[str] = None
    "Release version (defaults to latest)"
    
    dry_run: bool = False
    "Preview changes without applying them"
```

Bool fields defaulting to `False` (or with no default) get a simple `--flag`
switch. Bool fields defaulting to `True` get `--flag`/`--no-flag` (via
`argparse.BooleanOptionalAction`) so the default can be explicitly turned back off.

**The docstring is optional.** The flags-tuple alone declares an argument — a
field needs no docstring. When present, the docstring only sets the argument's
`help=` text; when absent, `help` defaults to `""`. Add a docstring where the help
text earns its keep, and skip it where the flag speaks for itself:

```python
class Copy(Args):
    # No docstring needed -- the flags-tuple alone declares the argument.
    source: str
    ("-s", "--source")

    force: bool = False
    "Overwrite the destination if it exists."  # help text where it's useful
    ("-f", "--force")
```

**The flags-tuple/docstring idiom needs readable source.** Duho locates a
class's own body by reading and parsing its source file at parser-build time,
so it can tell a docstring/flags-tuple statement apart from an ordinary class
attribute. A class whose source isn't available this way — defined in a REPL
or via `exec`, or shipped as a frozen executable (PyInstaller, Nuitka), a
`.pyc`-only install, or a zipapp — logs a one-time warning and falls back to a
derived `--field-name` flag with no help text for every field, silently
dropping any class-body flags/docstrings/`NS(env=...)`. Use `Meta`/`NS`
metadata instead in that case — it needs no source lookup:

```python
class Copy(Args):
    source: Arg[str, Meta(flags=("-s", "--source"), help="Source path")]
```

### Supported Field Types

| Annotation | Behavior |
| --- | --- |
| `str`, `int`, `float`, `bool` | Direct conversion; `bool` gets `store_true` or `--flag`/`--no-flag` (see above) |
| `typing.Literal["a", "b"]` | `choices=("a", "b")`; mixed-type literals (`Literal["auto", 1]`) try each declared value's own type and keep whichever round-trips |
| `enum.Enum` subclass | `choices` are the member **names**; the parsed value is the Enum member (`Color["RED"] -> Color.RED`) |
| `list` / `list[T]` | As an OPTION: one value per flag occurrence, repeated (`--x a --x b`) to accumulate — via `action="extend", nargs=None`. As a POSITIONAL: variadic (`nargs="*"`, space-separated: `a b c`). Bare `list` elements are `str`; default is `[]` when no explicit default is given. Pass an explicit `NS(nargs="*")` to opt an OPTION back into space-separated multi-value |
| `set` / `set[T]` | Same option-vs-positional split as `list`, but the final value is a `set` (dedups; **iteration order is not guaranteed**); bare `set` elements are `str`; default is `set()` when no explicit default is given |
| `tuple[T, ...]` / `tuple` | Variadic **homogeneous** tuple, same option-vs-positional split as `list`, final value a `tuple` (order preserved, no dedup); bare `tuple` elements are `str`; default is `()` when no explicit default is given. A fixed-length heterogeneous `tuple[A, B]` is **not** supported and raises a clear error at parser build — use `tuple[T, ...]` |
| `dict` / `dict[str, V]` | Each occurrence is one `KEY=VALUE` token; repeated flags merge into one dict (`--opt k=1 --opt j=2` → `{"k": ..., "j": ...}`) via `UpdateAction`; the value half is converted with `V` (bare `dict` == `dict[str, str]`); only **`str` keys** are supported (a non-`str` key type is a clear build-time error); default is `{}` when no explicit default is given |
| `datetime.date` / `datetime.datetime` / `datetime.time` | Parsed via the type's own `fromisoformat` (e.g. `2026-01-01`, `2026-01-01T12:00:00`). A trailing `Z` UTC marker (RFC 3339) is accepted for `datetime`/`time` on **every** supported Python version, including 3.9/3.10 (rewritten to `+00:00` before delegating); a `date` value never accepts a trailing `Z` on any version (a date has no time/UTC component). A basic, no-dash format like `20260101` works for `date`/`datetime` on 3.11+ (native `fromisoformat` accepts it) but is **not** supported on 3.9/3.10 |
| `typing.Optional[T]` / `T \| None` (3.10+) | Not required; tries `T` |
| `typing.Union[A, B]` / `A \| B` (3.10+) | Tries each type in declaration order |
| `Union`/`Optional` containing an `Enum` | The Enum member is matched by **name**, same as a bare `enum.Enum` field — a name match wins before falling through to a later `str` member, so declaration order matters (`Union[Color, str]` with `--c RED` yields `Color.RED`, while `--c other` yields the string `"other"`) |
| `Arg[int, duho.Count()]` | A repeatable counted flag (`-vvv` → `3`), via argparse `action="count"`. The value is the number of occurrences; pair a short flag like `("-v",)` with it. `LoggingArgs` uses this for `-v`/`-q` |

**Negative numbers** work as values out of the box — `--temp -5` and a positional
`int` accepting `-3` both parse correctly (argparse's `_negative_number_matcher`
handles them as long as no option is itself declared to look like `-1`). If you
truly need a `-1`-style flag, use the `NS(kwargs=...)` escape hatch.

### Positional arguments

A flags-tuple whose single entry does **not** start with `-` declares a
positional instead of an option. Duho picks the `nargs` for you from the type
and default:

```python
class Move(Args):
    source: str
    ("source",)                 # required positional

    dest: str = "."
    ("dest",)                   # optional positional -> nargs="?" (uses the default when omitted)

    extra: list[str]
    ("extra",)                  # variadic positional -> nargs="*" (a list[str] positional)
```

```bash
python move.py a.txt              # source="a.txt", dest=".",   extra=[]
python move.py a.txt out/         # source="a.txt", dest="out/", extra=[]
python move.py a.txt out/ x y z   # source="a.txt", dest="out/", extra=["x", "y", "z"]
```

An optional positional (a real default present, `nargs` unset) automatically
gets `nargs="?"` — without it argparse would make the positional required and
ignore the default. A `list`/`list[T]` positional becomes variadic
(`nargs="*"`), defaulting to `[]`. `required=` is never emitted for positionals.

### An option between two positionals just works

argparse's own greedy positional matching normally breaks when an option is
placed BETWEEN a fixed positional and a variadic one after it (a well-known
argparse limitation, [bpo-15112](https://bugs.python.org/issue15112)) — the
classic `<command> <name> [TARGET ...]` shape a per-target/fan-out app wants:

```python
class Query(Args):
    namespace: str
    ("namespace",)

    targets: list[str] = []
    ("targets",)

    filters: list[str] = []
    ("-f",)
```

```bash
python query.py user -f username=root nas1   # used to fail: "unrecognized arguments: nas1"
```

Duho detects this shape (a variable-arity positional alongside another
positional in the same parser) and transparently reorders recognized flags
ahead of the positional run before the real parse — all four orderings
(flag before/after/between the positionals, or no flag at all) now parse
identically. A genuinely unrecognized/misspelled flag still raises argparse's
own honest "unrecognized arguments" error; the fix never silently absorbs a
typo as a phantom positional value.

### Field metadata: `NS` or `Meta`

Extra per-field configuration goes in the `Arg[T, ...]` metadata slot. `NS(...)`
(an `argparse.Namespace`) is the untyped form; `duho.Meta` is the typed,
typo-safe form — a dataclass whose unknown keyword is a `TypeError`
(`NS(hlep=...)` would silently vanish) raised as soon as the annotation is
evaluated: at class-definition time on Python 3.9-3.13 with eager
annotations, or at first parser build on 3.14+ (PEP 649), under string
annotations, or with `from __future__ import annotations`:

```python
from duho import Args, Arg, Meta

class App(Args):
    level: Arg[int, Meta(help="verbosity", env="LEVEL")] = 0
```

`Meta` accepts every field `NS` does EXCEPT `dest` (`help`, `env`, `conflicts`,
`conflicts_required`, `group`, `action`, `nargs`, `const`, `default`, `choices`,
`metavar`, `required`, `type`, `version`, `flags`, `kwargs`) and only merges the
fields you set. A field's `dest` is always its declared name — there is no
`dest=` override on `Meta`, so `Meta(dest=...)` is a `TypeError` at
class-definition time instead of `NS(dest=...)`'s silently-ignored value.
`NS` keeps working forever. Any metadata object exposing a str
`.documentation` attribute (a PEP-727-style `Doc`) contributes help text.

### Mutually exclusive options

Set `NS(conflicts="group-name")` on the fields that must not be used together.
Duho builds one `argparse` mutually-exclusive group per distinct `conflicts`
value, so only one option from the group may appear on the command line:

```python
from duho import Args, Arg, NS

class Archive(Args):
    """Create an archive."""

    gzip: Arg[bool, NS(conflicts="compression")] = False
    "Compress with gzip."

    zstd: Arg[bool, NS(conflicts="compression")] = False
    "Compress with zstd."

    none: Arg[bool, NS(conflicts="compression")] = False
    "Store uncompressed."
```

```bash
python archive.py --gzip            # ok
python archive.py --gzip --zstd     # error: not allowed with argument --gzip
```

Fields sharing the same `conflicts` string join the same group; use different
strings for independent exclusive sets. (The `examples/fileinstall.py` `--type`
field uses `NS(conflicts="type")` this way.)

Add `conflicts_required=True` on **any** member to make the whole group
required — the user must supply exactly one of its options:

```python
    push: Arg[bool, NS(conflicts="mode", conflicts_required=True)] = False

    pull: Arg[bool, NS(conflicts="mode")] = False
```

```bash
python app.py            # error: one of --push, --pull is required
python app.py --push     # ok
```

### Titled argument groups

Set `NS(group="Section title")` to bucket fields under a named section in
`--help`. Fields sharing a title join the same section; the rest stay under the
default `options:`:

```python
class App(Args):
    outfile: Arg[str, NS(group="Output options")] = "-"
    "Where to write."

    verbose: Arg[bool, NS(group="Output options")] = False
    "Verbose output."
```

A field may combine `group=` and `conflicts=`: the mutually-exclusive group is
nested inside the titled section (still exclusive, and shown under the title).

### Prettier help: defaults & color

Set a class-level `_help_formatter_` to opt into a richer `--help`. duho ships
three `argparse.HelpFormatter` subclasses (all off by default, so plain help is
unchanged unless you ask):

| Formatter | Effect |
| --- | --- |
| `duho.DefaultsFormatter` | Appends `(default: X)` to each option's help — but skips the noise of `None`/`""`/`False` defaults (unlike argparse's own `ArgumentDefaultsHelpFormatter`) |
| `duho.ColorHelpFormatter` | ANSI-colors section headings and option flags, **gated** on a TTY (honors `NO_COLOR`; `FORCE_COLOR` forces it on). When color is off the output is byte-identical to the default, so piping stays clean |
| `duho.ColorDefaultsFormatter` | Both composed |

```python
class App(duho.Cli):
    _help_formatter_ = duho.ColorDefaultsFormatter
    region: str = "us-east"
    "Target region"
```

A root's `_help_formatter_` propagates to its `_subcommands_` tree, so one setting
styles the whole app. You can also point it at any custom `HelpFormatter`
subclass. (Note: argparse only renders defaults for options that *have* help text,
so give a field a docstring to see its `(default: …)`.)

### Run your app

`duho.main(cls, argv=None, *, setup_logging=True)` builds the parser, parses
`argv` (or `sys.argv` when omitted), optionally wires up stderr logging and
verbosity (for classes mixing in `LoggingArgs`), and runs the command. The class
must be a `duho.Cmd` (see [Commands: Args vs Cmd](#commands-args-vs-cmd) below) —
`main` dispatches the parsed instance via `__call__`:

> **`main` vs `app` — which entry point?** Use **`duho.main(cls)`** for a command
> (or a tree declared statically with `_subcommands_`) — it's the simple, direct
> runner. Reach for **`duho.app(root, ...)`** when you need what `main` doesn't do:
> **discovering** commands from a package/directory (`source=`), threading app-wide
> **config/env** down to subcommands, or **overriding dispatch** (`dispatch=`, e.g. to
> fan a command out over targets). `app` is the multi-command driver; `main` is the
> one-shot runner. Both dispatch a `Cmd` root via `__call__`.

<!-- runnable -->
```python
from duho import Cmd, main

class Greet(Cmd):
    """Print a greeting."""
    name: str = "world"
    "Who to greet"

    def __call__(self):
        print(f"Hello, {self.name}!")
        # returning None counts as a successful exit (code 0)

if __name__ == "__main__":
    raise SystemExit(main(Greet))
```

`SystemExit` raised by argparse (bad args, `--help`, `--version`) propagates
normally. Dispatching a bare data `Args` (not a `Cmd`) raises a clear
`NotImplementedError` naming the class.

`__call__` may be `async def` — when it returns a coroutine, `main` (and
`run_command`) run it to completion via `asyncio.run` at the call site (imported
lazily, so a synchronous app never pays for it), and the awaited value becomes
the exit code. Module-command lifecycle hooks stay synchronous.

**Subcommands**: set `_subcommands_` to a sequence of `Cmd` subclasses and
`main`/`_parser_` wires up `add_subparsers(dest="_duho_command_", required=True)`
automatically — no manual subparser plumbing needed. The dest is private; a
parsed instance has no `.command` attribute from this. Nested `_subcommands_`
(a subcommand that itself declares `_subcommands_`) compose naturally into
multi-level command trees, and `main` always dispatches to the deepest
selected command via `__call__`.

```python
class Serve(Cmd):
    """Start the development server."""
    port: int = 8000
    def __call__(self):
        print(f"serving on {self.port}")

class Build(Cmd):
    """Build the project."""
    output: str = "dist"
    def __call__(self):
        print(f"building to {self.output}")

class App(Args):
    """Example multi-command app."""
    _subcommands_ = [Serve, Build]

if __name__ == "__main__":
    raise SystemExit(main(App))
```

```bash
python app.py Serve --port 3000
python app.py Build --output dist
```

**Subcommand aliases**: set `_parseraliases_` on a `Cmd` subclass to register
short or alternate names for it within a `_subcommands_` tree. An alias dispatches
to the same `__call__` as the full name:

```python
class Create(Cmd):
    """Create a new resource."""
    _parseraliases_ = ["c", "new"]
    name: str
    ("name",)
    def __call__(self):
        print(f"creating {self.name}")

class App(Args):
    _subcommands_ = [Create]
```

```bash
python app.py Create web   # full name (the class name, verbatim)
python app.py c web        # alias -> same command
python app.py new web      # alias -> same command
```

Absence of `_parseraliases_` is the default (no aliases). Aliases apply only to
nested subcommands (argparse's `add_parser` accepts `aliases`; a top-level parser
does not).

**Version flag**: set `_version_` on any `Args` subclass to add a `--version`
flag that prints `"%(prog)s <version>"` and exits 0 (skipped if a `version`-dest
action already exists, e.g. from a parent parser):

```python
class MyApp(Args):
    _version_ = "1.2.3"
```

**Autodetected version**: set `_version_ = duho.AUTO` to resolve the version
from installed package metadata via `importlib.metadata.version(...)` instead
of hardcoding a string. By default the distribution name is the class's
top-level import package (`cls.__module__.split(".")[0]`); set `_distribution_`
to override it when the import name differs from the distribution name on
PyPI:

```python
import duho

class MyApp(duho.Args):
    _version_ = duho.AUTO
    _distribution_ = "my-package"  # only needed if it differs from the import name
```

If the distribution can't be found (e.g. running from a source checkout that
isn't installed), duho does **not** add a `--version` flag at all — it logs a
debug message via `logging.getLogger("duho")` instead of printing a bogus
`0.0.0+unknown`-style version or raising.

### Output encoding

`duho.main`/`duho.app` call `duho.utf8_stdio()` first thing, before anything
else runs. It reconfigures `sys.stdout`/`sys.stderr` to UTF-8 whenever nothing
already guarantees correct encoding — skipping a real terminal (`isatty()`),
an already-UTF-8 stream, a stream with no `.reconfigure()` (pytest's capture,
`io.StringIO`), Python's own UTF-8 mode, and an explicit `PYTHONIOENCODING`.
This matters most on Windows: piped or redirected output there defaults to
the console's ANSI code page (`cp1252`) with strict error handling, so
`print()`-ing (or `--version`-ing) a non-ASCII character used to raise
`UnicodeEncodeError` — empty output, exit 1. UTF-8 is the only encoding that
can't raise, so the default makes that whole class of crash impossible.

**Opt-out**, for an app that wants to manage its own stdio: set
`_utf8_stdio_ = False` on the root class, or pass `utf8_stdio=False` to
`main`/`app` (an explicit kwarg always wins over the class attribute):

```python
class MyApp(Cli):
    _utf8_stdio_ = False  # duho leaves stdio completely alone
```

```python
main(MyApp, utf8_stdio=False)      # same, for one call
app(MyApp, utf8_stdio=False)
```

Opted out (or when a parser built by duho is used outside `main`/`app`
entirely), duho's own writes still can't crash: `--version` and duho's
internal error messages go through a write helper that falls back to
`errors="backslashreplace"` instead of raising when the stream's own
encoding can't represent a character. The app may call `duho.utf8_stdio()`
itself — it accepts an optional `streams=` mapping for reconfiguring
something other than the real `sys.stdout`/`sys.stderr` — or do anything else.

**PowerShell tip**: capturing/piping a Python process's UTF-8 output through
PowerShell still needs the *console itself* set to UTF-8 for the text to
*display* correctly (`Select-String`, `> file`, etc. all decode using
`[Console]::OutputEncoding`):

```powershell
[Console]::OutputEncoding = [Text.UTF8Encoding]::new()
```

### Build and Parse

```python
parser = Deploy._parser_()
args = parser.parse_args()

print(f"Deploying to {args.environment} (dry-run: {args.dry_run})")
```

### Quick parse

`duho.parser(cls, ...)` is the module-level entry point for building a parser
(delegates to `cls._parser_(...)`). `duho.parse(spec, argv=None, *,
parser_kwargs=None)` goes one step further and parses in a single call:

```python
import duho

# spec is a type: build + parse in one call
args = duho.parse(Deploy)
```

`spec` can also be an **instance**, letting you layer CLI overrides on top of
config-file/programmatic defaults. The instance's current field values become
the argparse defaults; CLI args still win; the original instance is left
unmutated and a new instance of the same type is returned:

```python
base = Deploy(environment="staging", dry_run=False)

# No --env on the CLI -> falls back to base.environment ("staging")
result = duho.parse(base, ["--dry-run"])

assert result.environment == "staging"   # from base
assert result.dry_run is True            # from CLI
assert base.dry_run is False             # base is untouched
```

Precedence: **CLI args > instance field values > class defaults**. This also
means a required field with no class default becomes effectively optional
for that call if the instance already supplies a value.

### Parsing only the globals (config before commands)

Sometimes you need to read a root/global option *before* you can build the full
subcommand parser — for example, a `--config` path (or an env-derived setting)
that decides which command modules to discover and load. `duho.parse_globals`
parses only the root command's global args and ignores the subcommand tree:

```python
import duho

# Root is a Cli/Cmd with global flags and a subcommand tree.
globals_only = duho.parse_globals(Root, ["--config", "prod.toml", "deploy", "..."])
assert globals_only.config == "prod.toml"   # resolved without validating "deploy"
```

A missing subcommand does not error, and an unknown trailing token (a not-yet-
loaded subcommand name and its args) does not crash the parse — it is simply
ignored in this pass. `parse_globals` returns the parsed root instance (globals
only); it is the public form of the prepass `duho.app` runs internally. Pass any
`cls._parser_` keyword through it (e.g. `add_help=False`). If you also want the
leftover argv, call `parser.parse_known_args` directly instead.

### Configuration layers

Beyond instance overrides, `duho.parse`/`duho.main` support two more default
layers: per-field environment variables and a TOML config file. Combined
precedence ladder, highest wins:

```
CLI args > env var > config file > class default
```

A value supplied by *any* layer also un-requires that field — a field with
no class default that's set in the config file (say) no longer needs to be
passed on the CLI.

**Environment variables**: annotate a field with `NS(env="VAR_NAME")`:

```python
from duho import Args, Arg, NS

class Deploy(Args):
    token: Arg[str, NS(env="DEPLOY_TOKEN")] = ""
    "Auth token"
```

**Config file**: set `_config_` on the class, or pass `config=` to
`duho.parse`/`duho.main` (the kwarg overrides the class attr):

```python
class Deploy(Args):
    _config_ = "~/.config/myapp/config.toml"
    ...

result = duho.parse(Deploy, config="./deploy.toml")
result = duho.main(Deploy, config="./deploy.toml")
```

Top-level TOML keys map to the root command's fields; a table named after a
subcommand's `_parsername_` maps to that subcommand's fields:

```toml
# deploy.toml
verbose = true

[install]
target = "prod"
```

Reading TOML uses the stdlib `tomllib` on Python 3.11+; on 3.9/3.10 it falls
back to the third-party `tomli` package if installed (`pip install
duho[config]`) — duho stays zero-runtime-dependency by default, so this
extra is only needed if you actually use `_config_`/`config=` on an older
interpreter.

**JSON config**: a config path ending in `.json` is parsed as JSON (stdlib, no
extra dependency), producing the same nested-dict shape as TOML — top-level keys
map to the root, a nested object named for a subcommand maps to that subcommand:

```json
{ "verbose": true, "install": { "target": "prod" } }
```

**Any other format via `_config_loader_`**: set a class-level
`_config_loader_ = Callable[[Path], dict]` and duho calls it *instead of* the
built-in JSON/TOML dispatch. This is the zero-dependency escape hatch for a
format duho does not ship — e.g. YAML, plugged with your own `yaml.safe_load`,
without duho ever importing or depending on it:

```python
import yaml

class Deploy(duho.Cli):
    _config_ = "./deploy.yaml"
    _config_loader_ = staticmethod(lambda path: yaml.safe_load(path.read_text()) or {})
```

**Env/config value conversion.** Layered values are converted to match what CLI
parsing of the same field yields. A `bool` field reads `1/true/yes/on/y/t` as
`True` and `0/false/no/off/n/f`/empty as `False` (an unknown string is an error).
A **collection** field (`list`/`set`/`tuple`) treats an env var or a TOML
*string* as a **single element** (`FILES=a.txt` → `["a.txt"]`, matching one CLI
occurrence), while a TOML **array** converts element-wise. Non-string TOML scalars
are coerced to the field type (`timeout = 30` for a `float` field → `30.0`).

**Debugging where a value came from**: `duho.value_sources(parsed)` returns
`{field_name: "cli" | "env" | "config" | "default"}` for the instance
returned by `duho.parse`/`duho.main`.

```python
result = duho.parse(Deploy, [], config="./deploy.toml")
duho.value_sources(result)  # {"token": "env", "verbose": "config", ...}
```

### Logging Integration

Combine with `LoggingArgs` for structured logging:

```python
from duho import LoggingArgs, Cmd

class MyApp(LoggingArgs, Cmd):
    command: str
    "The command to run"

    def __call__(self):
        logger = self._logger_
        logger.info(f"Running: {self.command}")
```

`duho.main()` calls `self._set_loglevels_()` for you before dispatching the
command (pass `setup_logging=False` to opt out). If you drive the parser
yourself instead of using `duho.main()`, call `self._set_loglevels_()` before
you start logging.

Control logging from the CLI:

```bash
python app.py mycommand -v                    # Verbose: INFO -> DEBUG
python app.py mycommand -vv                   # More verbose: -> TRACE (max)
python app.py mycommand -q                    # Quiet: INFO -> WARNING
python app.py mycommand -qq                   # Quieter: -> ERROR
python app.py mycommand --loglevel DEBUG      # Debug level
python app.py mycommand --loglevel foo:TRACE  # Module-specific level
```

`-v`/`-q` are counted flags that move away from/toward the default `INFO` level in
opposite directions and can be combined (e.g. `-vv -q` nets one step more verbose
than the default); each end of the scale (`CRITICAL`/`TRACE`) clamps rather than
wrapping or erroring.

### Shell completion

Generate a self-contained bash/zsh/fish/PowerShell completion script from your
parser — **static** generation (no runtime dependency, no per-keystroke
re-invocation of your program, unlike argcomplete):

```python
import duho

class MyApp(duho.Args):
    _completion_ = True  # opt-in: adds --print-completion to --help
    ...
```

```bash
python app.py --print-completion bash > _myapp.bash && source _myapp.bash
python app.py --print-completion zsh  > _myapp   # place on your $fpath
python app.py --print-completion fish > myapp.fish && source myapp.fish
```

```powershell
# PowerShell: emit and dot-source (add to your $PROFILE to persist)
python app.py --print-completion powershell | Out-String | Invoke-Expression
```

`_completion_` is off by default (matches the `_version_` opt-in precedent) —
set it to add the `--print-completion {bash,zsh,fish,powershell}` flag, which
registers the emitted script under the invoked command name (`sys.argv[0]`'s
stem) by default. You can also generate a script without adding the flag at
all, via the standalone function:

```python
import sys
import duho

duho.print_completion(MyApp, "bash", file=sys.stdout, prog="myapp")
```

`print_completion`'s keyword-only `prog=` overrides the command name the script
binds to. An explicit `_parsername_`/`duho.app(name=...)` always wins;
otherwise it defaults to the stem of *this call's own* `sys.argv[0]` — correct
when called from behind the app's own `--print-completion` flag, but wrong
when called from a separate build/doc-generation script, which would bind the
completion script to that script's own name instead of `myapp`. Pass `prog=`
explicitly whenever you generate a completion script from anywhere other than
the target app's own invocation.

Both paths walk the built parser tree, including nested `_subcommands_`:
`Literal`/`Enum` fields offer their choices as completion candidates, and
`pathlib.Path`-typed fields get the shell's native file/directory
completion. The PowerShell emitter registers a `Register-ArgumentCompleter
-Native` script block resolving the subcommand path to its flags/choices, with
file completion falling through to PowerShell's defaults.

### Agent help (machine-readable `--help`)

Alongside the compact, human-facing `--help`, duho can emit a **complete,
machine-readable JSON description** of your CLI — enough for an AI agent (or any
tool) to understand the whole command surface in one shot: every subcommand,
each option's type/default/required/repeatable/choices, positionals, per-field
env-var bindings, mutually-exclusive conflict groups, examples, and exit codes.
It is built entirely on duho's existing introspection — no second declaration.

Two triggers:

```bash
# 1) Always on, zero-config: set AGENT_HELP and --help emits the agent document.
#    Human --help is byte-identical when AGENT_HELP is unset.
AGENT_HELP=1 python app.py --help
AGENT_HELP=1 python app.py deploy --help    # scoped to the subcommand

# 2) Opt-in discoverable flag (set _agent_help_ = True on the root):
python app.py --help-agents
```

```python
import duho

class MyApp(duho.Cli):
    """My app."""
    _version_ = "1.2.3"
    _agent_help_ = True                       # adds --help-agents (opt-in)
    _agent_help_env_ = "AGENT_HELP"           # env trigger name (default shown)
    _examples_ = [("myapp deploy --env prod", "Deploy to prod")]
    _exit_codes_ = {3: "Partial failure."}    # merged over duho's 0/1/2 defaults
```

The document is tagged with a `schema` (`duho/agent-help@1`) so consumers can
detect and pin the shape. Each option carries `type`, `default`, `required`,
`repeatable`, `choices`, `metavar`, and — when declared — its `env` binding and
`conflicts` group; subcommands nest recursively with their `aliases`. You can
also produce it directly, independent of either trigger:

```python
import duho

duho.print_agent_help(MyApp)                  # JSON to stdout
spec = duho.agenthelp.describe(MyApp)         # the document as a dict
```

The `AGENT_HELP` env trigger is safe to leave always-on: it changes `--help`
behavior only when the variable is deliberately set, so nothing changes for
ordinary human use. Set `_agent_help_env_` to rename the trigger per-app.

**No secrets in agent help.** Neither the agent-help JSON nor human `--help`
ever renders a field's *live* env/config-sourced value as its default — that
would print a secret straight from the flagship `NS(env="DEPLOY_TOKEN")`
example. Both instead show the field's **declared class default**, plus, only
when the value actually came from an env var or a config file, a value-free
provenance note in its place: `"default_source": "env DEPLOY_TOKEN"` in the
JSON document, `(from env DEPLOY_TOKEN)` appended to the option's help in
human `--help`. This covers a module command's own env/config-bound fields
too, not just declarative ones.

### Manual subparsers

`_subcommands_` (above) is the recommended way to build command trees. If you
need to attach duho commands to a parser you build yourself, pass the
subparsers action to `_parser_`:

```python
import argparse
import duho
from duho import Cmd

class Serve(Cmd):
    """Start the development server."""
    port: int = 8000

    def __call__(self):
        print(f"serving on {self.port}")

root = argparse.ArgumentParser()
subparsers = root.add_subparsers()
Serve._parser_(subparsers, name="serve")

args = root.parse_args()
```

A plain `root.parse_args()` returns a raw `Namespace` still carrying duho's
internal subcommand-selection marker, not a real `Serve` instance — call
`duho.finish_parse(args)` to get the genuine instance the recipe was always
meant to produce (methods, `_passthrough_`, and all):

```python
cmd = duho.finish_parse(args)   # -> a real Serve instance
raise SystemExit(cmd())
```

## Commands: Args vs Cmd

`Args` classes are **pure data** — a typed namespace of parsed values. To make one
*runnable*, subclass `duho.Cmd` and implement `__call__(self)`. A `Cmd` instance is
directly callable (`__call__` runs the command), and `duho.main`/`duho.app`
dispatch a `Cmd`:

```python
import duho

class Deploy(duho.Cmd):
    """Deploy the application."""
    environment: str
    ("--env",)

    def __call__(self):
        print(f"deploying to {self.environment}")
        # returning None counts as a successful exit (code 0)

if __name__ == "__main__":
    raise SystemExit(duho.main(Deploy))
```

> **Upgrade note (breaking):** earlier releases made *every* `Args` instance
> callable. `Args` is now data-only; make a command a `Cmd` (or build one with
> `duho.command(...)`) and implement `__call__(self)`. Dispatching a bare data
> `Args` raises a clear `NotImplementedError` instead of silently doing nothing.
> The command entrypoint is `__call__` (not a plain `main` method): a `Cmd`
> subclass's namespace is user-owned — annotated fields become CLI flags — so a
> `main` method would collide with a declared `main` field (`--main`), whereas
> the `__call__` dunder never can.

To attach behavior to an **existing** data `Args` class without rewriting it,
use `duho.command(args_cls, func, *, name=None)` — it returns a `Cmd` subclass
whose `__call__` calls `func(self)` (the parsed instance):

```python
class Greet(duho.Args):
    name: str = "world"

def run(args):
    print(f"Hello, {args.name}!")

GreetCmd = duho.command(Greet, run, name="greet")
raise SystemExit(duho.main(GreetCmd))
```

`LoggingArgs` stays a data mixin; combine it as `class App(LoggingArgs, Cmd)`
(recommended base order — data mixin first, executable base last) to get logging
plus a runnable command.

## Cli: the application root

A leaf `Cmd` is lean — it declares its own flags and a `__call__`. The **root** of
a multi-command app usually wants more: a `--version` flag, shell completion, a
config file, a subcommand tree. `duho.Cli` is an **opt-in** mixin over `Cmd` that
gives those a typed home. Subclass `Cli` for your app root; keep leaf commands as
plain `Cmd`:

```python
import duho
from duho import Cli, LoggingArgs

class MyApp(LoggingArgs, Cli):     # data mixin first, root base last
    """My multi-command app."""
    _version_ = "1.2.3"            # adds --version
    _completion_ = True            # adds --print-completion {bash,zsh,fish}
    _config_ = "myapp.toml"        # layered config-file defaults
```

`Cli` is purely additive: it adds **no** new runtime behavior for *running* (it
inherits `Cmd.__call__` unchanged), and a plain `Cmd` root still works everywhere
`Cli` does — `Cli` just types and documents the app-root attributes (`_version_`,
`_distribution_`, `_completion_`, `_config_`, `_subcommands_`), all sandwich-named
so your CLI-field namespace stays 100% yours. `LoggingArgs` stays orthogonal — mix
it in when you want `-v`/`-q` verbosity, leave it out when you don't.

### Self-registration: `@MyApp.subcommand`

Instead of the root listing every child in `_subcommands_`, a leaf command file can
**attach itself** to the root with the `@MyApp.subcommand` decorator. This keeps
command definitions decentralized — each command lives in its own file and opts into
the app:

```python
# myapp/app.py
from duho import Cli

class MyApp(Cli):
    """My app."""
    _version_ = "1.0.0"

# myapp/commands/deploy.py
import duho
from myapp.app import MyApp

@MyApp.subcommand
class Deploy(duho.Cmd):
    """Deploy to a region."""
    region: str = "local"

    def __call__(self):
        print(f"deploying to {self.region}")

# myapp/commands/build.py
from myapp.app import MyApp

@MyApp.subcommand
class Build(duho.Cmd):
    """Build the project."""
    def __call__(self):
        print("building")
```

Each `@MyApp.subcommand` appends the class to `MyApp`'s **own** subcommand list
(materialized copy-on-write, so two `Cli` subclasses never cross-contaminate and a
parent's list is never mutated by a subclass). It composes with a
statically-declared `_subcommands_` (union + dedup — a child listed both ways
appears once). `MyApp._register_subcmd_(Deploy)` is the non-decorator form. Once the
command files are imported, `duho.main(MyApp)` sees the full tree (use `duho.app` if
you also want discovery/config/env — see [main vs app](#run-your-app)).

### App-wide config & env with `duho.app`

`duho.app(root, ...)` threads a `Cli` root's `_config_` and any `env` down to the
dispatched subcommand. TOML top-level keys apply to the root's fields; a
`[<Subcommand>]` table applies to that subcommand; and the resolved `duho.Env` (if
passed) is reachable from the command as `self._env_`:

```python
# app.toml
# [Deploy]
# region = "eu-west"

raise SystemExit(duho.app(MyApp, source="myapp.commands",
                          env=duho.Env("myapp")))
# `myapp deploy` now defaults region to "eu-west" (CLI still overrides),
# and Deploy.__call__ can read self._env_ for app-wide settings.
```

Pass `config="other.toml"` to `duho.app` to override the root's `_config_` for one
run. Precedence is unchanged: CLI > env > config > class default.

## Environment access

`duho.Env(prefix)` is an app-wide, typed view over the environment variables
sharing a common prefix. The prefix is uppercased with `-`→`_` and a trailing `_`
ensured, so `Env("my-app")` reads `MY_APP_*` keys:

```python
from pathlib import Path
import duho

env = duho.Env("my-app")           # reads MY_APP_* from os.environ
debug = env.bool("DEBUG")          # MY_APP_DEBUG -> True for 1/true/yes/y/t
paths = env.paths("CMDS_PATH", ty=Path)  # MY_APP_CMDS_PATH split on os.pathsep into Paths
```

`Env` is a `MutableMapping`, so `env["KEY"]`, `env.get(...)`, `in`, and iteration
all work. `.bool(key)` treats a missing key as `False`. `.list(key, sep=":",
ty=str)` splits on a caller-supplied separator (default `:`) and applies `ty` to
each part — the right choice for a generic delimited value. `.paths(key, ty=str)`
is specifically for an OS path list (`CMDS_PATH` and friends): it splits on
`os.pathsep` (`;` on Windows, `:` on POSIX, so a Windows drive letter is never
mis-split) and drops empty segments before `ty` ever sees them, rather than
treating one as the current directory. Both return `[]` — never `[ty("")]` — for
a missing or empty value. On construction `Env` also autoloads an optional
companion `<prefix>env` module of defaults an app may ship (e.g. `my_app_env`),
seeding its **upper-case, non-underscore** variables (all `str()`-coerced); a
missing one is ignored. Pass `Env(prefix, autoload=False)` to disable the import
— autoload imports `<prefix>env` from anywhere on `sys.path` (including the CWD),
so disable it if the prefix is not fully under your control. This is distinct from
the per-field `NS(env="VAR")` default layer above — that resolves one argparse
field; `Env` is the app-level accessor a driver reads settings through.

## String/target expansion

`duho.expand` expands `[a-b]` brace ranges into concrete strings — handy for
turning a host pattern into a target list. Output is **not** zero-padded:

```python
import duho

list(duho.expand("web[01-03].example.com"))
# ['web1.example.com', 'web2.example.com', 'web3.example.com']

list(duho.expand("rack[A-C]"))
# ['rackA', 'rackB', 'rackC']

list(duho.expand("plain"))          # no range -> unchanged
# ['plain']

list(duho.expand("x[1-2]y[1-2]"))   # multiple ranges -> cartesian product
# ['x1y1', 'x2y1', 'x1y2', 'x2y2']
```

Companion helpers `duho.pysafe` (coerce text to a Python-safe dotted identifier),
`duho.snakecase`/`duho.camelcase`/`duho.kebabcase` (case conversion — `kebabcase`
is acronym-aware and is the rule behind a class-derived command name and a
field's default flag, both above), and `duho.gettext` (a `gettext` shim) round
out the text utilities.

## Dynamic command discovery

Instead of listing subcommands by hand, point `duho.app` at a package or directory
and it discovers every command living there. Commands come in two shapes — a
**class command** (a `Cmd` subclass) and a **module command** (a `.py` file whose
top-level `main` is the entrypoint):

```
myapp/
├── cli.py            # defines the CLI root (global options)
└── cmds/
    ├── deploy.py     # a class command
    └── backup.py     # a module command
```

```python
# myapp/cmds/deploy.py
import duho

class Deploy(duho.Cmd):
    """Deploy the application."""
    name: str
    def __call__(self):
        print("deployed", self.name)
```

```python
# myapp/cmds/backup.py
"""Back things up."""

def main(args):
    print("backing up")
```

```python
import duho

class CLI(duho.LoggingArgs, duho.Cmd):
    "myapp"

# discover by dotted package name...
raise SystemExit(duho.app(CLI, source="myapp.cmds"))
# ...or by directory path:
raise SystemExit(duho.app(CLI, source=Path("myapp/cmds")))
```

The subcommand name is the class's `_parsername_`, or the **kebab-case of its
class name** when it declares none (`BuildPyz` → `build-pyz`), for class
commands; and the file **stem with `_`→`-`** for module commands
(`deploy_all.py` → `deploy-all`; override with a module-level `_parsername_`).
You can
also call `duho.discover_commands(source)` directly to get the `list[Command]`.

Discovery is **resilient**: a command that can't be imported (a missing optional
dependency → `ImportError`) or isn't actually a command (`NotImplementedError`) is
logged with a warning and skipped, so one broken command never takes down the rest.
A genuine bug in a command file (e.g. a `SyntaxError`) is *not* swallowed — it
surfaces so you can fix it.

### Plugins via entry points

For commands that ship in **separately-installed packages**, point `duho.app` at
an entry-point **group** instead of a local package. Every entry point advertised
in that group by any installed distribution becomes a subcommand — so a third-party
plugin can extend your app without your app importing it directly:

```python
# your app
raise SystemExit(duho.app(CLI, entry_points="myapp.commands"))
```

```toml
# a plugin package's pyproject.toml
[project.entry-points."myapp.commands"]
hello = "myapp_hello.plugin:HelloCmd"   # a Cmd subclass -> class command
bye   = "myapp_hello.bye"               # a module with main() -> module command
```

An entry point may resolve to a `Cmd` subclass (class command) or a command module
(module command); it is coerced through the same path as every other source. Loading
is **resilient** in the same spirit — a plugin that fails to import or does not resolve
to a command is logged and skipped, so one bad plugin never takes the app down.
`importlib.metadata` is imported **lazily**, so an app that does not use
`entry_points=` never pays its import cost. Call `duho.discover_entry_points(group)`
directly to get the `list[Command]`.

## RunPath: ordered step commands (opt-in)

`duho.runpath` is an **opt-in** module that turns a directory of numbered `.py`
files into a single command that runs them **in order**. It plugs into the
discovery provider hook above and needs no core changes — core `duho` never
imports it; you activate it explicitly:

```python
import duho.runpath   # importing it registers the RunPath provider
```

A **RunPath directory** is a directory (with *no* `__init__.py`) of `NN-name.py`
*step* files:

```
release/
├── 10-build.py
├── 20-test.py
└── 30-publish.py
```

```python
# 10-build.py — a step's body is its top-level main/run/call (same precedence
# as module commands). It receives the parsed command instance.
def main(args):
    args._logger_.info("building")
```

### Inheriting your app's shared root (`register(base=...)`)

`args._logger_` above works out of the box: by default every RunPath command
this module builds ALSO inherits `duho.LoggingArgs` (alongside `RunPathCmd`
itself), so `-v`/`_logger_`/`_set_loglevels_` are real inherited methods —
not just data fields copied onto the parsed instance by `app()`'s `parents=`
mechanism (which only ever copies *data*, never *methods*). If your app's
shared root is a custom `LoggingArgs` subclass carrying its own methods, call
`register(base=MyAppRoot)` once, early, so every RunPath command your app
builds inherits those methods too:

```python
import duho.runpath

class MyAppRoot(duho.LoggingArgs):
    def greet(self):
        return f"hi, {self.label}"

duho.runpath.register(base=MyAppRoot)   # before building/running any RunPath command
```

### Giving steps your app's own signature (`register(step_adapter=...)`)

Steps are called `main(cmd)` or `main(cmd, ctx)`. If your app's *module*
commands take a different shape — say `run(client, args, logger)` — steps and
commands disagree, and the same body cannot move between them.

`step_adapter` is a callable applied to each step's entrypoint just before it
runs; it receives the entrypoint and returns the callable to call instead. That
makes the step signature an app-wide convention rather than something every
step file has to opt into with a decorator:

```python
def adapter(entrypoint):
    def call(cmd, ctx=None):
        return entrypoint(ctx, cmd, cmd._logger_)   # (client, args, logger)
    return call

duho.runpath.register(base=MyAppRoot, step_adapter=adapter)
```

The *adapted* callable is what arity detection inspects, so a wrapper is free
to change the signature. An adapter that returns its argument unchanged leaves
duho-native steps alone, which is how an app supports both shapes at once.

Pass `None` to clear it; omit the argument to leave it unchanged. Unlike
`base`, it takes effect immediately for every RunPath command in the process —
it is consulted per step run, not at class-build time. The default is `None`:
steps are called exactly as written.

Each step is named after the part of the filename after `NN-`; the numeric prefix
is its ordering key. A step module may override ordering and declare dependencies:

- `PRIORITY: int` — overrides the `NN` prefix for ordering.
- `REQUIRED: list[str]` — a **hard** dependency: names of steps that must run and
  succeed **before** this one. A missing or disabled `REQUIRED` name is a warning
  (or, under strict, an error) — see "Strict vs. resilient" below.
- `BEFORE: list[str]` / `AFTER: list[str]` — **soft** ordering only (no
  existence/success requirement), styled after systemd's `Before=`/`After=`:
  `BEFORE = ["x"]` on a step means "I run before `x`, if `x` is present and
  enabled"; `AFTER = ["x"]` means "I run after `x`, if `x` runs" — the mirror
  direction. A `BEFORE`/`AFTER` name that's missing, or present but disabled, is
  silently a no-op for ordering (never a warning — contrast with `REQUIRED`
  above). `REQUIRED`'s hardness is independent of `BEFORE`/`AFTER` and of a
  step's own filename modifiers (below): all three are separate axes.

### The optional `__main__.py` lifecycle

A RunPath directory may define a `__main__.py` file — the same dunder Python
already uses for "this directory's entrypoint" (as in `python -m package`), no
new naming convention invented. Its own leading `_` already excludes it from
step discovery. It defines up to three optional callables:

```python
# __main__.py — runs once per invocation, before any step
def init(cmd, logger):
    return connect_once()          # ctx handed to every 2-arg step

def success(ctx, cmd, logger):
    logger.info("all steps completed cleanly")

def finally_(ctx, cmd, logger):
    ctx.close()                    # always runs, success or failure
```

```python
# 20-provision.py — a step opting into ctx just adds a 2nd parameter
def main(cmd, ctx):
    ctx.provision()
```

A step written `(cmd)` (the original shape) is unaffected — arity is detected
automatically, so old and new steps coexist in the same directory. `init`
raising is **always fatal**, regardless of `--rcopts strict` — every step
depends on `ctx`, so there is no meaningful partial/resilient init. A directory
with no `__main__.py` behaves exactly as before this lifecycle existed.

### Filename-encoded per-step options

Before a step file's `NN-name` prefix is parsed, its stem is checked for a
leading `!` and `:`/`;`-separated option tokens (both stripped first, so
`!02-provision:key.py` still yields prefix `02`, name `provision`):

- a leading `!` **disables** the step by default — `!02-provision.py`;
- everything after that is a list of `key` / `!key` / `key=value` tokens,
  separated by `:` **or** `;` interchangeably — both work identically
  everywhere (not an OS-conditional split), so a Windows-authored filename can
  use `;` (`:` is invalid in a Windows filename; `;` is valid on both Windows
  and POSIX). Two tokens are recognized specially:
  - `strict` / `!strict` — a step's own default (no token) is **strict**;
    `!strict` opts that ONE step OUT of strict — `03-cleanup;!strict.py` logs
    and continues past a failure in `cleanup` even while every other
    plain-named step is fatal;
  - `enable` / `!enable` — an explicit alternative to the leading `!`:
    `!step1.py` and `step1;!enable.py` disable the same step. If BOTH the
    leading `!` and an explicit token are present, the **token wins** (more
    specific than the whole-name shorthand).
  - any other token is collected for forward compatibility (not yet consumed
    by anything).

This is the exact same token grammar `--rcopts` uses per comma-entry (below) —
one parser, not two.

Precedence for a step's effective strict setting: the step's own filename
default (strict, absent `!strict`), then a per-pattern `--rcopts` `!strict`
token matching that step, then an EXPLICIT bare `--rcopts strict`/`!strict`
(no pattern attached — the run-wide toggle), which wins last of all. This is
symlink-transparent by construction: two symlinks (or copies) pointing at the
same physical step file, named differently in two RunPath directories, get
different effective enabled/strict defaults, because the parse reads the
directory entry's own name, never the target file's content.

```
01-step1.py                       # enabled, strict (defaults)
!02-step2.py                      # disabled by default
02-step2;!enable.py              # same as above, explicit-token spelling
03-cleanup;!strict.py             # enabled, non-strict for this ONE step
04-report;key=daily;!strict.py    # extra key=value token, also non-strict
```

Once `duho.runpath` is imported, pointing at the directory yields a run-path
command:

```python
import duho, duho.runpath
from pathlib import Path

cmd = duho.CmdBuilder("release", Path("release")).command
raise SystemExit(cmd()())   # build → test → publish, in order
```

### Selecting steps with `--rcopts` (`-O`)

`--rcopts` takes a comma-separated list of entries, each an [fnmatch] pattern
matched against step names, optionally followed by `:`/`;`-separated option
tokens — the exact same grammar (and `strict`/`enable` special tokens) a
step's own filename uses, above:

- a leading `!` **disables** matching steps — `!*` disables everything, so
  `--rcopts '!*,test'` means "run only `test`"; `test:!enable` is an
  equivalent, more explicit spelling of `!test`;
- a bare entry that is exactly `strict`/`!strict` (no pattern) opts into
  **run-wide strict mode** (see below);
- an entry with a pattern AND a `strict`/`!strict` token (e.g.
  `build:!strict`) scopes that override to steps matching `build` only,
  without touching the run-wide flag or any other step.

Later entries win when several match the same step, so `!*,build-*` disables
all then re-enables everything matching `build-*`.

```
--rcopts '!*,test'                 # run only test
--rcopts 'test:!enable'           # same effect as --rcopts '!test'
--rcopts 'build:!strict'           # build's own failure is resilient;
                                    #   every other step's strict handling
                                    #   is untouched
--rcopts 'strict'                  # run-wide: overrides every step's own
                                    #   filename-derived strict setting
```

### Strict vs. resilient

The default is **resilient**, matching duho's discovery philosophy:

- an `--rcopts` pattern that matches no step is a **warning**, not an error;
- a step whose body raises is **logged and skipped** — the run continues.

Passing a bare `strict`/`!strict` in `--rcopts` (e.g. `--rcopts 'strict'`) flips
this **run-wide**: an unmatched pattern raises, a `REQUIRED` dependency naming a
missing step raises, and — because it's the EXPLICIT run-wide token — it
overrides every step's own filename-derived (or per-pattern `--rcopts`)
strict setting too, uniformly. So you run resilient by default and ask for
strict when you want a hard failure; independently, a plain step **filename**
is fatal-on-failure by its own default even without any `--rcopts strict` at
all, and a `!strict`-tokened one (by filename or by a matching `--rcopts`
entry) stays resilient even in an otherwise-strict run, for exactly that one
step (see "Filename-encoded per-step options" above) — unless the bare
run-wide `strict`/`!strict` is passed, which wins last of all.

The module's public API is `duho.runpath.RunPathCmd`, `register()`, and
`unregister()` (`register`/`unregister` give explicit control over the provider —
`unregister()` is what tests use to keep provider state from leaking). These are
deliberately **not** on the top-level `duho.*` surface — RunPath is opt-in.

[fnmatch]: https://docs.python.org/3/library/fnmatch.html

## Module commands & lifecycle

A **module command** is a plain `.py` file. Its entrypoint is `main` (preferred),
falling back to `run` or `call`, and receives the parsed args instance:

```python
"""Restore from a backup."""   # docstring -> subcommand help

def init(args):                # optional: build a shared context
    return {"db": connect()}

def main(args):                # required entrypoint (or run/call)
    print("restoring", args)

def success(ctx, args):        # optional: runs only on a successful exit
    ctx["db"].commit()

def finally_(ctx, args):       # optional: always runs (cleanup)
    ctx["db"].close()

def register(parser, args):    # optional: add args directly on argparse
    parser.add_argument("--force", action="store_true")
```

The driver runs the lifecycle `init → main → success / finally_`: `ctx =
init(args)` builds a shared context (default: `None`), `main(args)` runs the
command, `success(ctx, args)` runs only on a **successful** exit (`main` returned
`None` or `0`, and did not raise — a non-zero exit code skips `success`), and
`finally_(ctx, args)` always runs. A `finally_` that itself raises is logged and
swallowed so it never masks `main`'s original exception or exit code. Note the
entrypoint receives **only** the args instance
(`main(args)`) — the context is threaded to `success`/`finally_`, not to `main`.
There is **no separate `logger` parameter**: hooks read the logger from the args
instance's `_logger_` (present on `LoggingArgs`-based commands), falling back to
`logging.getLogger("duho")`.

## Customizing a subcommand parser

A module command's optional `register` hook hands you the raw argparse subparser
so you can add arguments the declarative layer doesn't cover. It may be written
either **2-arg** `register(parser, args)` or **3-arg**
`register(parser, args, logger)` — duho inspects your hook's signature and calls
the form you declared:

```python
def register(parser, args):                 # 2-arg form
    parser.add_argument("--force", action="store_true")

def main(args):
    if args.force:
        ...
```

```python
def register(parser, args, logger):         # 3-arg form: logger is supplied
    logger.debug("registering deploy flags")
    parser.add_argument("--force", action="store_true")
```

For the 3-arg form the `logger` passed is the parsed args' own `_logger_` (on a
`LoggingArgs`-based root) or `logging.getLogger("duho")` — the same logger the
lifecycle hooks read off `args._logger_`. A `*args` hook is treated as
3-arg-capable; anything whose signature can't be introspected falls back to the
2-arg call.

Every subcommand parser is built with **parent-arg inheritance** — the root
command's global options (verbosity, etc.) appear on each subcommand automatically
via argparse `parents=`, so `myapp -v deploy` and `myapp deploy -v` both work.

> **Avoid the root's reserved flags in `register`.** Because the subparser already
> carries every root/global option, a `register` hook that adds one of them
> collides. Steer clear of the globals the root contributes — `-h`/`--help`,
> `--version` (if `_version_` is set), and, with a `LoggingArgs` root, `-v`
> (verbose), `-q` (quiet), and `--loglevel`. If you do collide, `duho.app` raises a
> clear error naming your command and the offending flag (rather than argparse's
> bare "conflicting option string"), so pick a different flag.

## Passthrough args

Argv after the first literal `--` separator is captured at parse time and exposed
on the parsed instance as `_passthrough_: list[str]` — useful for forwarding
trailing args to a wrapped tool. Only the first `--` splits; a second `--` is part
of the payload:

```python
class Run(duho.Cmd):
    def __call__(self):
        subprocess.run(["pytest", *self._passthrough_])

# myapp Run -- -k test_foo -x   ->   self._passthrough_ == ["-k", "test_foo", "-x"]
```

`--` never acts as argparse's own end-of-options marker in duho — it always
starts the passthrough capture, even for a command that never reads
`_passthrough_` and even when a variadic positional would otherwise happily
accept a value starting with `-`. So a value that genuinely needs to start
with `-` (the POSIX `rm -- -oddfile.txt` idiom) cannot be given after a `--`
to a duho command; it is captured into `_passthrough_` instead, read or not.

## Target fan-out (`duho.fanout`, opt-in)

duho dispatches **one** command per run by design. When you need to run that one
command against a list of targets (hosts, environments, datasets) and roll their
exit codes into one, `import duho.fanout` — an opt-in, stdlib-only helper (core
never imports it, and it stays off the top-level `duho.*` surface).

`run_targets(func, targets, *, max_workers=None, aggregate=max)` runs `func(target)`
for each target concurrently on a thread pool and returns an aggregated exit code
(`None` → `0`, an int as-is, an unhandled exception → logged and treated as `1` so
one failing target never aborts the rest; codes reduced by `max` — `0` only if all
succeed). Log lines a target emits while it runs are tagged with a `[<target>]`
prefix so interleaved concurrent output stays attributable; the prefixing filter is
installed on your existing stderr handler for the duration and removed afterwards.

<!-- runnable -->
```python
import logging
import duho, duho.fanout

duho.init_stderr_logging(level=logging.INFO)   # so INFO-level log lines are visible

targets = list(duho.expand("web[01-03].example.com"))

def deploy_to(host):
    log = duho.logging.getLogger("duho.deploy")
    log.info("deploying")          # tagged "[web1.example.com] deploying" (see below)
    return 0                       # your per-target work; int/None exit code

raise SystemExit(duho.fanout.run_targets(deploy_to, targets, max_workers=4))
```

Each line above lands on stderr prefixed with its target (the timestamp/level
columns are `duho.init_stderr_logging`'s own formatting, elided here):

```text
[web1.example.com] deploying
[web2.example.com] deploying
[web3.example.com] deploying
```

`fan_out_command(command, make_instance, targets, ...)` is thin sugar for "run one
resolved duho command once per target": you supply `make_instance(target)` (a parsed
instance is app-specific) and each is dispatched via `duho.run_command`. Pass
`aggregate=any` or a custom reducer to change the exit-code policy. You can still
hand-roll a `ThreadPoolExecutor` wrapper if you prefer.

### Composing `app()`: the `dispatch=` seam

`duho.app(...)` owns discovery, parser build, registration, config/env thread-down,
parsing, and logging setup, then runs **one** selected command. To keep all of that
but override only the final run step — e.g. build a per-invocation context or fan the
command out over targets — pass `dispatch`:

```python
import duho, duho.fanout

def dispatch(command, instance):           # (resolved Command, parsed instance)
    targets = list(duho.expand(instance.targets))
    return duho.fanout.fan_out_command(
        command, lambda t: instance_for(t), targets
    )

raise SystemExit(duho.app(Root, source="commands/", dispatch=dispatch))
```

The callable receives the resolved command and the parsed instance and returns an
`int` exit code (which becomes `app()`'s return). With `dispatch=None` (the default)
`app()` behaves exactly as before, calling `duho.run_command` — existing callers are
unaffected.

## Generating launchers (`duho.scaffold`, opt-in)

An app laid out as `bin/` + a `lib/` (or `src/`) package can be run straight from a
checkout — no install — with a tiny launcher that puts the package on `PYTHONPATH`
and runs `python -m <app>`. `duho.scaffold` generates that launcher for you, as a
cross-platform pair. It's an **opt-in** dev tool: core `duho` never imports it, and it
is deliberately **not** on the top-level `duho.*` surface — you `import duho.scaffold`
or run `python -m duho.scaffold`.

```console
$ python -m duho.scaffold myapp --root . --libdir lib
bin/myapp
bin/myapp.cmd
```

This writes a matched pair into `<root>/bin/`:

- `bin/myapp` — a POSIX `sh` launcher that resolves its own directory, derives the app
  root (the parent of `bin/`), prepends `<root>/<libdir>` to `PYTHONPATH`, and execs
  `python -m myapp "$@"`;
- `bin/myapp.cmd` — the Windows cousin doing the same via `%~dp0` and `%PYTHON%`.

Both launchers are dependency-free and honor a **`PYTHON` environment override** so you
can pin the interpreter (e.g. `PYTHON=python3.11 bin/myapp`). The generator writes
**plain files — never symlinks** (symlinks need privilege on Windows and add failure
modes), and sets the POSIX launcher's executable bit best-effort. An existing launcher
is **not** overwritten unless you pass `--force` (`overwrite=True`), so a customized
launcher is never silently clobbered.

The same thing from Python:

```python
from duho.scaffold import generate_launchers

paths = generate_launchers("myapp", ".", libdir="src")   # -> [Path("bin/myapp"), Path("bin/myapp.cmd")]
```

The CLI dogfoods duho itself — `duho.scaffold.ScaffoldCmd` is an ordinary `duho.Cli`
command.

## MCP tool surface (`duho.mcp`, opt-in)

Expose the *same* `Cmd`/`Cli` classes that back a duho CLI as **MCP tools** (the
[Model Context Protocol](https://modelcontextprotocol.io)) — zero redeclaration.
Like `duho.runpath`/`duho.fanout`/`duho.scaffold`, this is an **opt-in, standalone**
module: core `duho` never imports it, it's not on the top-level `duho.*` surface, and
it stays **zero-dependency** — a stdlib JSON-RPC-over-stdio server, no MCP SDK.

```console
$ python -m duho.mcp mypackage.cli:MyApp
```

`<app>` is a dotted qualname to your `Cmd`/`Cli` root class — either
`module.sub:ClassName` (colon syntax, the same convention this project's own
entry-point plugins use) or the legacy dotted `module.sub.ClassName` form; both are
resolved via the stdlib `pkgutil.resolve_name`. The process then speaks
newline-delimited JSON-RPC 2.0 over stdin/stdout — wire it into any MCP client as a
stdio server.

Every `Cmd` reachable from your root — the root itself, and every `_subcommands_`
node, recursively — becomes one tool, named after its own `_parsername_`, or the
kebab-case of its class name when it declares none (`parent.child` when nested,
e.g. `MyApp` (no `_parsername_`) with a `Deploy` child → `my-app.deploy`). A tool's
`inputSchema` is a real JSON Schema built from the same field declarations that
already drive your `--help`:

```python
from duho.mcp import describe_tools, call_tool

tools = describe_tools(MyApp)   # -> [{"name", "description", "inputSchema"}, ...]
result = call_tool(MyApp, "my-app.deploy", {"environment": "prod", "replicas": 3})
```

`str`/`int`/`float`/`bool` map to `string`/`integer`/`number`/`boolean`;
`Literal[...]`/`Enum` become a schema `enum` (an `Enum` by member **name**, duho's
standing convention); `list[T]`/`set[T]`/`tuple[T, ...]` become an `array` (a `set`
additionally gets `uniqueItems: true`); `dict[str, V]` becomes an `object` with
`additionalProperties`; `Optional[T]`/`Union[...]` drop out of `required` (a single
non-`None` member unwraps directly, several become `anyOf`); `pathlib.Path` is a
plain `string`.

**Calling a tool** synthesizes an argv from the JSON `arguments` — a repeatable field
becomes a repeated flag, a `dict` field becomes repeated `KEY=VALUE` tokens, a
positional a bare token — and reuses your class's own parser + `duho.run_command` to
dispatch, so every bit of argparse coercion/validation you already rely on runs
unchanged. **Return convention** (the one new contract this module adds): a command
returning `None`/`0` is a success result with your captured stdout as one `text`
content block; a non-zero return is `isError: true` (stdout + a trailing
`exit code: N` line); a JSON-serialisable object/list return is passed through as one
`text` block holding its JSON dump — this is additive, existing int/`None` commands
keep working exactly as before.

**v1 limitations** (documented, not silently wrong): a custom `action=`/`type=`
field with no registered override is passed through as a plain string; an
`NS(conflicts=...)` exclusive group is noted in the tool's description text only (no
`oneOf`/`not` JSON Schema encoding yet); it's strictly one request → one result, no
streaming/long-running commands. See [`examples/mcp_app.py`](https://github.com/jose-pr/duho/blob/main/examples/mcp_app.py) for
a runnable app plus a note on wiring it into an MCP client.

### Serving a full `duho.app()` tree

`describe_tools`/`call_tool`/`serve` also accept a `duho.app()`-built tree — class
AND module commands, from discovered files, `CMDS_PATH`, entry points, or an
explicit `commands=` list — not just a class's static `_subcommands_`. A module
command with its own declared `Args` (or none at all) is listed **and callable**,
exactly like a class command, through the same one dispatch path and the same
security checks.

### Launching a server from the CLI itself

No MCP-specific code is required to make an existing CLI servable: every
`duho.main(cls)`/`duho.app(...)` call checks a launch-trigger environment variable
*first*, before parsing `argv`:

```console
$ MYAPP_MCP=stdio myapp
```

The variable name is `<PREFIX>MCP` when the app supplies an `Env` (`app(env=Env
("myapp"))` → `MYAPP_MCP`), else `<NAME>_MCP` derived from a declared
`_parsername_`, `app(name=...)`, the program name, or the kebab-case of the class
name when none of those apply (upper-cased, every character outside `[A-Z0-9]`
replaced by `_`) — e.g. `SomeApp` → `SOME_APP_MCP`. Set to `stdio`, it serves the
app's full tool tree over stdio instead of running any command; set to anything
else, the process exits `2` naming the unsupported transport. The variable is
always removed from `os.environ` the moment it's seen — present or not — so a
served command's own child processes never inherit it. This is **on by default**;
disable it with a root class attribute `_mcp_ = False` (on a `Cli`) or
`app(..., mcp=False)`.

Prefer a real subcommand instead of an env var? `duho.mcp.McpCmd` is a ready `Cmd`
(`--transport {stdio}`) that serves the CLI it was dispatched from — register it
under any name, or let the class attribute do it for you under **either**
`duho.main` or `duho.app`:

```python
class MyApp(Cli):
    _mcp_command_ = True  # adds "myapp mcp" (a str instead names it explicitly)
    _subcommands_ = [Deploy, Rollback]
```

```console
$ myapp mcp
```

`app(..., mcp_command=...)` overrides the class attribute per call (`duho.main` has
no such kwarg — it always reads `_mcp_command_` directly). Either entry point
requires the app to already have at least one OTHER subcommand, and a chosen name
that collides with an existing command/alias is a build-time error — both checked
before anything is registered, never silently swallowed.

### Naming and identity

The dotted tool-name namespace, and the `serverInfo` an MCP client sees in its
`initialize` response, both follow the SAME resolved name — a declared
`_parsername_`, an explicit `duho.app(name=...)`, or the kebab-case of the class
name, in that order of precedence:

```python
app(Dotagents, name="dotagents")   # tools come out "dotagents.*"
app(Dotagents)                     # same result: kebab-case of the class name
```

`serverInfo.version` reports the served app's own `_version_` when it resolves to a
string (a literal, `duho.AUTO`, or a class-level `__version__` fallback — see
"`--version`" above); otherwise it reports the empty string. duho's own version is
never reported as the served app's.

### Excluding a command from the tool surface

A command that should never be reachable over MCP — one that prints secrets, or
returns something a client has no business asking for — opts out with
`_mcp_ = False` on that command's own class:

```python
class Env(Cmd):
    """Print resolved environment values (may include secrets)."""

    _mcp_ = False

    def __call__(self):
        ...
```

The excluded command, and its whole subtree if it has one (a `Cli` with its own
`_subcommands_`), is left out of `tools/list` entirely; calling it by name (or
anything nested under it) fails exactly like an unknown tool name — no existence is
disclosed either way. A subclass of an excluded command inherits the exclusion
without redeclaring it (plain attribute lookup). A **module command** gets the same
opt-out via a module-level `_mcp_ = False`:

```python
# secrets.py
"""Print resolved secrets."""

_mcp_ = False


def main(args=None):
    ...
```

`_mcp_` on the ROOT of the tree being served keeps its separate, pre-existing
meaning (the launch-trigger opt-out above) — it is never treated as this
per-command exclusion, and `duho.mcp.McpCmd`'s own registered subcommand is always
excluded regardless of this attribute.

## Examples

Two self-contained example CLIs under [`examples/`](https://github.com/jose-pr/duho/tree/main/examples) each build a small
umbrella app with an `install` subcommand, ported from real-world scripts to show
duho's full surface (they stub the actual filesystem work — the point is the CLI):

- [`examples/dotagents.py`](https://github.com/jose-pr/duho/blob/main/examples/dotagents.py) — an agent-config installer
  (`LoggingArgs`, `_subcommands_`, `--dest`/`--dry-run`/`--with-examples`):

  ```python
  class Install(LoggingArgs):
      """Copy the agent-config payload into the destination directory."""

      dest: Path = Path.home() / ".agents"
      dry_run: bool = False

  # ...
  if __name__ == "__main__":
      sys.exit(duho.main(Dotagents))
  ```

  ```bash
  python examples/dotagents.py install --dry-run
  ```

- [`examples/fileinstall.py`](https://github.com/jose-pr/duho/blob/main/examples/fileinstall.py) — an `install(1)`-like file
  installer; exercises positionals, `Union` types, `NS(nargs="?")`, a custom
  `action=UpdateAction`, and `NS(conflicts=...)` mutually-exclusive grouping:

  ```python
  class Install(LoggingArgs, Cmd):
      """Install SOURCE at DESTINATION."""

      options: Arg[
          dict,
          NS(action=UpdateAction, type=lambda x: [x.split("=", maxsplit=1)]),
      ] = {}
      ("-O",)
      source: Path
      ("source",)
      destination: Path
      ("destination",)

  # ...
  if __name__ == "__main__":
      sys.exit(duho.main(FileInstall))
  ```

  ```bash
  python examples/fileinstall.py install --type dir -O k=v src dst
  ```

## Documentation

Full documentation: https://jose-pr.github.io/duho/

## Contributing

Contributions welcome! See [CONTRIBUTING.md](https://github.com/jose-pr/duho/blob/main/CONTRIBUTING.md) for guidelines.

## License

MIT License. See [LICENSE](https://github.com/jose-pr/duho/blob/main/LICENSE) for details.
