cmdchain 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. cmdchain-0.2.0/.github/dependabot.yml +17 -0
  2. cmdchain-0.2.0/.github/workflows/ci.yml +95 -0
  3. cmdchain-0.2.0/.github/workflows/publish.yml +38 -0
  4. {cmdchain-0.1.2 → cmdchain-0.2.0}/.gitignore +2 -0
  5. cmdchain-0.2.0/CHANGELOG.md +125 -0
  6. cmdchain-0.2.0/CLAUDE.md +47 -0
  7. {cmdchain-0.1.2 → cmdchain-0.2.0}/PKG-INFO +227 -84
  8. {cmdchain-0.1.2 → cmdchain-0.2.0}/README.md +220 -78
  9. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/07_video_convert.py +1 -1
  10. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/10_report_sbom.py +6 -2
  11. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/12_webhook_metrics.py +3 -3
  12. cmdchain-0.2.0/examples/13_live_output.py +20 -0
  13. {cmdchain-0.1.2 → cmdchain-0.2.0}/pyproject.toml +21 -7
  14. cmdchain-0.2.0/src/clichain/__init__.py +26 -0
  15. cmdchain-0.2.0/src/clichain/checks.py +208 -0
  16. cmdchain-0.2.0/src/clichain/cli.py +102 -0
  17. cmdchain-0.2.0/src/clichain/errors.py +156 -0
  18. cmdchain-0.2.0/src/clichain/execute.py +867 -0
  19. cmdchain-0.2.0/src/clichain/live.py +547 -0
  20. cmdchain-0.2.0/src/clichain/manifest.py +191 -0
  21. cmdchain-0.2.0/src/clichain/pipeline.py +380 -0
  22. cmdchain-0.2.0/src/clichain/process.py +204 -0
  23. cmdchain-0.2.0/src/clichain/render.py +248 -0
  24. cmdchain-0.2.0/src/clichain/result.py +88 -0
  25. cmdchain-0.2.0/src/clichain/steps.py +244 -0
  26. cmdchain-0.2.0/tests/conftest.py +117 -0
  27. cmdchain-0.2.0/tests/test_cli.py +96 -0
  28. {cmdchain-0.1.2 → cmdchain-0.2.0}/tests/test_compile.py +7 -5
  29. cmdchain-0.2.0/tests/test_concurrency.py +153 -0
  30. {cmdchain-0.1.2 → cmdchain-0.2.0}/tests/test_core.py +45 -44
  31. cmdchain-0.2.0/tests/test_each_merge.py +62 -0
  32. cmdchain-0.2.0/tests/test_environment.py +29 -0
  33. cmdchain-0.2.0/tests/test_features.py +107 -0
  34. cmdchain-0.2.0/tests/test_live.py +435 -0
  35. cmdchain-0.2.0/tests/test_reliability.py +393 -0
  36. cmdchain-0.2.0/tests/test_render.py +80 -0
  37. cmdchain-0.2.0/tests/test_sbom.py +51 -0
  38. cmdchain-0.2.0/tests/test_security.py +276 -0
  39. cmdchain-0.2.0/tests/test_signals.py +118 -0
  40. cmdchain-0.2.0/tests/test_vs_bash.py +147 -0
  41. cmdchain-0.2.0/uv.lock +375 -0
  42. cmdchain-0.1.2/.github/workflows/ci.yml +0 -61
  43. cmdchain-0.1.2/.github/workflows/publish.yml +0 -34
  44. cmdchain-0.1.2/src/clichain/__init__.py +0 -29
  45. cmdchain-0.1.2/src/clichain/checks.py +0 -177
  46. cmdchain-0.1.2/src/clichain/cli.py +0 -151
  47. cmdchain-0.1.2/src/clichain/core.py +0 -1639
  48. {cmdchain-0.1.2 → cmdchain-0.2.0}/LICENSE-APACHE +0 -0
  49. {cmdchain-0.1.2 → cmdchain-0.2.0}/LICENSE-MIT +0 -0
  50. {cmdchain-0.1.2 → cmdchain-0.2.0}/benchmarks/bench.py +0 -0
  51. {cmdchain-0.1.2 → cmdchain-0.2.0}/benchmarks/bench_from_file.py +0 -0
  52. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/01_basic_pipe.py +0 -0
  53. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/02_capture_interpolate.py +0 -0
  54. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/03_filter.py +0 -0
  55. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/04_file_io.py +0 -0
  56. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/05_each.py +0 -0
  57. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/06_disk_image.py +0 -0
  58. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/08_checks.py +0 -0
  59. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/09_meter.py +0 -0
  60. {cmdchain-0.1.2 → cmdchain-0.2.0}/examples/11_compilable.py +0 -0
  61. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-favicon.svg +0 -0
  62. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-horizontal-dark.svg +0 -0
  63. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-horizontal-light.svg +0 -0
  64. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-logo-dark.svg +0 -0
  65. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-logo-light.svg +0 -0
  66. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-mark-dark.svg +0 -0
  67. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain-mark-light.svg +0 -0
  68. {cmdchain-0.1.2 → cmdchain-0.2.0}/logos/clichain_angular_chain_final.svg +0 -0
  69. {cmdchain-0.1.2 → cmdchain-0.2.0}/src/clichain/py.typed +0 -0
  70. {cmdchain-0.1.2 → cmdchain-0.2.0}/src/cmdchain/__init__.py +0 -0
  71. {cmdchain-0.1.2 → cmdchain-0.2.0}/tests/__init__.py +0 -0
@@ -0,0 +1,17 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ groups:
8
+ actions:
9
+ patterns: ["*"]
10
+
11
+ - package-ecosystem: uv
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
15
+ groups:
16
+ dev-tools:
17
+ patterns: ["*"]
@@ -0,0 +1,95 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_call: # publish.yml runs this before uploading
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ # Actions are pinned by commit SHA (tag in the comment); Dependabot keeps them current.
14
+ # Tools come from the `dev` dependency group via uv.lock, so a new ruff or basedpyright
15
+ # release cannot break main without a code change.
16
+
17
+ jobs:
18
+ lint:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
23
+ with:
24
+ python-version: "3.15"
25
+ - run: uv sync --locked
26
+ - run: uv run ruff check src/ tests/ examples/
27
+ - run: uv run ruff format --check src/ tests/ examples/
28
+
29
+ typecheck:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
33
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
34
+ with:
35
+ python-version: "3.15"
36
+ - run: uv sync --locked
37
+ - run: uv run basedpyright src/ tests/
38
+
39
+ test:
40
+ runs-on: ${{ matrix.os }}
41
+ strategy:
42
+ fail-fast: false
43
+ matrix:
44
+ os: [ubuntu-latest, macos-latest]
45
+ # 3.15t is the free-threaded build: the concurrency tests run without a GIL.
46
+ python-version: ["3.11", "3.12", "3.13", "3.14", "3.15", "3.15t"]
47
+ steps:
48
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
49
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
50
+ with:
51
+ python-version: ${{ matrix.python-version }}
52
+ - run: uv sync --locked
53
+ # pytest settings (pyproject.toml) turn every warning into an error, ResourceWarning
54
+ # included, and give each test a 60 s timeout so a regression cannot hang the job.
55
+ - run: uv run coverage run -m pytest -v
56
+ - run: uv run coverage report
57
+
58
+ prerelease:
59
+ # The next Python, so breakage shows up before its release. Allowed to fail.
60
+ runs-on: ubuntu-latest
61
+ continue-on-error: true
62
+ steps:
63
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
64
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
65
+ with:
66
+ python-version: "3.16"
67
+ - run: uv sync --locked
68
+ - run: uv run pytest -v
69
+
70
+ compile:
71
+ runs-on: ubuntu-latest
72
+ steps:
73
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
74
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
75
+ with:
76
+ python-version: "3.15"
77
+ - run: uv sync --locked --extra compile
78
+ - run: uv run pytest -v tests/test_compile.py
79
+
80
+ examples:
81
+ runs-on: ubuntu-latest
82
+ steps:
83
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
84
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
85
+ with:
86
+ python-version: "3.15"
87
+ - run: uv sync --locked
88
+ # 12_webhook_metrics.py is skipped: it posts to a remote HTTP endpoint.
89
+ # 06 and 07 only describe their pipelines, so they run without pv or ffmpeg.
90
+ - run: |
91
+ for f in examples/*.py; do
92
+ case "$f" in *12_webhook_metrics.py) continue ;; esac
93
+ echo "=== $f ==="
94
+ (cd "$RUNNER_TEMP" && uv run --project "$GITHUB_WORKSPACE" python -W error "$GITHUB_WORKSPACE/$f") || exit 1
95
+ done
@@ -0,0 +1,38 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ ci:
12
+ # Nothing is uploaded unless the full CI suite passes on the release commit.
13
+ uses: ./.github/workflows/ci.yml
14
+
15
+ build:
16
+ needs: ci
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
21
+ - run: uv build
22
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
23
+ with:
24
+ name: dist
25
+ path: dist/
26
+
27
+ publish:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment: pypi
31
+ permissions:
32
+ id-token: write # PyPI trusted publishing; also produces attestations
33
+ steps:
34
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -21,3 +21,5 @@ venv/
21
21
  .vscode/
22
22
  *.swp
23
23
  *.swo
24
+ .coverage
25
+ .coverage.*
@@ -0,0 +1,125 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version is 0.x,
6
+ a breaking change bumps the minor version and everything else bumps the patch version.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-11-03
11
+
12
+ A rewrite of the executor for safety and correctness. Every item below has a test.
13
+
14
+ ### Breaking changes
15
+
16
+ - **Python 3.11+** is required (3.10 reached end of life in October 2026). POSIX only.
17
+ - `result.sbom()` / `sbom_json()` now return a **CycloneDX 1.6** document. The previous
18
+ run record is `result.manifest()` / `manifest_json()`.
19
+ - `redirect()` streams straight to its file and **consumes** what it writes, like `>`:
20
+ redirected stdout no longer appears in `result.stdout`, and redirected stderr not in
21
+ `result.stderr`. `redirect(stderr=...)` now writes the commands' stderr (it used to
22
+ write stdout's text).
23
+ - A writer killed by **SIGPIPE** because its reader exited early (`yes | head -1`) is no
24
+ longer a failure; use `run(ignore_sigpipe=False)` for the old behaviour.
25
+ - **Version checks fail closed**: if a tool's version cannot be determined, a `version=`
26
+ constraint is not met (`on_fail="warn"` to continue). Invalid constraints raise
27
+ `ValueError` when the tool is defined. Only `--version` is probed (`version_args=` for
28
+ other flags).
29
+ - **Pipelines are immutable**: builder methods return a new pipeline instead of
30
+ appending to the one they were called on. A second input source raises `ValueError`.
31
+ - After `.capture()`, only `{name}` for captured names is substituted; other braces are
32
+ literal (they used to raise `KeyError`).
33
+ - `CLICHAIN_DESCRIBE` only enables describe mode for `1`, `true`, `yes`, `describe`,
34
+ `text` or `json` (it used to be any non-empty value, including `0`), and raises
35
+ `DescribeExit` (a `SystemExit` subclass) instead of calling `sys.exit()`.
36
+ - A signal during `run()` reaches the previously installed handler after cleanup:
37
+ `KeyboardInterrupt` for SIGINT by default, instead of `SystemExit(130)`.
38
+ - Missing input files and unwritable redirects return a failed `Result` (returncode 1)
39
+ instead of raising `OSError`. Exceptions from your own callbacks propagate instead of
40
+ being printed by the thread and silently truncating output.
41
+ - `clichain check` exits 1 when a check fails, and no longer executes pipelines run by
42
+ the script.
43
+ - `clichain.core` is gone; import from `clichain`.
44
+
45
+ ### Added
46
+
47
+ - `tool(..., env=, inherit_env=, cwd=, version_args=, ok_codes=)`; `ok_codes={0, 1}` makes
48
+ an expected status (grep with no match, diff with differences) a success.
49
+ - `feed()` accepts `bytes`.
50
+ - Live output: `run(live="auto" | "blocks" | "grouped" | "lines")` or `Live(...)`. Every
51
+ job (a run, or an `each()` line; or every process with `unit="process"`) gets its own
52
+ buffer, so concurrent tools never mangle each other's output. Blocks redraw in place on
53
+ a terminal; grouped output uses `::group::` on GitHub Actions. Default: nothing live.
54
+ - `tool(..., tty=True)`: the command's stderr is a pseudo-terminal, so tools that only
55
+ show progress on a terminal (git, curl, rsync) do; stdout stays a pipe.
56
+ - `run(..., timeout=, check=, encoding=, errors=, ignore_sigpipe=, output=)`;
57
+ `PipelineError` for `check=True`; exit code 124 (`X124`) for timeouts.
58
+ - `result.stdout_bytes`, and lossless handling of binary / non-UTF-8 data
59
+ (`errors="surrogateescape"` by default).
60
+ - `result.manifest(redact=True)`, which removes hostname, cwd and directory paths.
61
+ - `capture(name, reject_options=True)` against option injection.
62
+ - `pipe()` accepts a `Pipeline`, not only a `Cmd`.
63
+ - Free-threaded Python support, tested in CI.
64
+
65
+ ### Changed
66
+
67
+ - `StepProfile.bytes_in` / `bytes_out` are measured at every group boundary (they were
68
+ only set when a meter was present, and always equal); the report always has a bytes
69
+ column. Both are now `int`, never `None`.
70
+
71
+ ### Fixed
72
+
73
+ - Hangs: a command writing more than 64 KB to stderr; a filter/peek/meter callback that
74
+ raises; a bridge in front of an early-exit reader (`... | filter | head -1`); a stopped
75
+ run whose processes left descendants holding a pipe open.
76
+ - File descriptor leaks (stderr pipes, bridge inputs).
77
+ - Meter statistics accumulating across runs of the same pipeline; meter callbacks
78
+ receiving a live, still-changing object.
79
+ - `explain` codes: invalid input to `clichain explain` no longer crashes; exit codes above
80
+ 128 that are not signals (e.g. 255) are explained as exit codes.
81
+ - Parallel `each()`: a callback error stops the remaining lines; a failing line's stderr
82
+ is kept even when an earlier line was skipped.
83
+
84
+ ### Security
85
+
86
+ - The version probe never passes positional words (`version`) or `-v`, which tools
87
+ without a version flag treated as file operands (BSD `rm` deleted `./version`), and it
88
+ runs with stdin closed (it consumed the caller's stdin).
89
+ - `import clichain` no longer runs `git`; the commit is looked up lazily, labelled
90
+ `source_commit`, from the repository of the calling script.
91
+ - CI actions are pinned by commit SHA, tools come from `uv.lock`, Dependabot keeps them
92
+ current, and publishing runs only after the full CI suite passes.
93
+
94
+ ### Performance
95
+
96
+ - Bridges process data in blocks: filter about 6x, peek 11x and meter 49x faster on
97
+ 2M lines. Redirected output never passes through Python memory.
98
+
99
+ ## [0.1.2] - 2026-04-02
100
+
101
+ ### Added
102
+
103
+ - `cmdchain` import alias for the `clichain` module.
104
+
105
+ ### Fixed
106
+
107
+ - Logo rendering on PyPI; the version is single-sourced from `pyproject.toml`.
108
+
109
+ ## [0.1.1] - 2026-04-02
110
+
111
+ ### Added
112
+
113
+ - Git commit hash in the SBOM and reports; README included on PyPI.
114
+
115
+ ## [0.1.0] - 2026-04-02
116
+
117
+ First release: `tool()`, pipelines with `pipe`, `filter`, `each`, `peek`, `meter`,
118
+ `capture`, `feed`, `from_file`, `redirect`; preflight checks; reports and SBOM; the
119
+ `clichain` CLI (`explain`, `check`, `compile`).
120
+
121
+ [Unreleased]: https://github.com/dardevelin/clichain/compare/v0.2.0...HEAD
122
+ [0.2.0]: https://github.com/dardevelin/clichain/compare/v0.1.2...v0.2.0
123
+ [0.1.2]: https://github.com/dardevelin/clichain/compare/v0.1.1...v0.1.2
124
+ [0.1.1]: https://github.com/dardevelin/clichain/compare/v0.1.0...v0.1.1
125
+ [0.1.0]: https://github.com/dardevelin/clichain/releases/tag/v0.1.0
@@ -0,0 +1,47 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ clichain: fluent, lazily-evaluated pipelines of CLI commands connected by real kernel pipes. Zero runtime dependencies, Python 3.11+ (free-threaded builds included), POSIX only.
8
+
9
+ **Versioning:** Semantic Versioning, recorded in `CHANGELOG.md` (Keep a Changelog). While 0.x, a breaking change bumps the minor version, anything else the patch; bump `version` in `pyproject.toml` and run `uv lock`. Releases are git tags `vX.Y.Z`; publishing a GitHub release uploads to PyPI after CI passes.
10
+
11
+ **Naming split:** the PyPI distribution is `cmdchain`, the import module is `clichain`; `src/cmdchain/` only re-exports `clichain`. The version is single-sourced in `pyproject.toml` and read via `importlib.metadata.version("cmdchain")` (in `manifest.py`), so it reads `0.0.0` unless the package is installed.
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ uv sync # .venv + dev tools from uv.lock (CI uses --locked)
17
+ uv run ruff check src/ tests/ examples/
18
+ uv run ruff format --check src/ tests/ examples/
19
+ uv run basedpyright src/ tests/
20
+ uv run pytest # all tests
21
+ GITHUB_ACTIONS=true CI=true TERM=dumb CLICHAIN_DESCRIBE=json uv run pytest # must pass too: CI's env
22
+ uv run pytest tests/test_core.py::test_feed # single test
23
+ uv run coverage run -m pytest && uv run coverage report # also measures subprocesses
24
+ uv run -p 3.15t pytest # free-threaded interpreter
25
+ uv sync --extra compile && uv run pytest tests/test_compile.py # pyinstaller; skipped without it
26
+ ```
27
+
28
+ Tests are hermetic: `tests/conftest.py` pins every environment variable the library reads (`HERMETIC_ENV`), and `tests/test_environment.py` fails if the library starts reading one that is not pinned. A local pass that differs from CI usually means a new environment dependency. pytest config makes **every warning an error** (so a leaked fd's `ResourceWarning` fails the test) and gives each test a 60 s timeout. `tests/conftest.py` fails any test whose worker threads raised unhandled exceptions, and provides `run_snippet()` for anything that could hang or involves signals: it runs code in a fresh interpreter in its own session and kills the whole session on timeout. CI runs 3.11–3.15 and 3.15t on Ubuntu and macOS, plus an allowed-to-fail 3.16 job; tests shell out to coreutils, so code must work with both GNU and BSD tools.
29
+
30
+ ## Architecture
31
+
32
+ Flow: `tool()` → immutable `Pipeline` of frozen step dataclasses → `run()` (`pipeline.py`) → `execute()` (`execute.py`) → `Result`.
33
+
34
+ - **Immutability.** Every builder method returns a new `Pipeline`; `Cmd` is a one-step `Pipeline` whose `__call__` appends args. Steps are frozen, and runtime state (meter stats, captured values, stderr) lives in per-run objects. Pipelines can therefore be shared and run concurrently. `.feed()` / `.from_file()` set the pipeline's stdin (stored at index 0) and a second source raises.
35
+ - **Steps own their description** (`steps.py`): `title()`, `node()` (manifest fields) and the `streamable` class flag. Only `execute.py` knows how to run them. A new step type needs a dataclass there plus handling in `_Run.run_group` (buffered) or `_StreamGroup` (streaming).
36
+ - **Planning** (`execute.plan`): consecutive streamable steps form one group; a `RedirectStep` right after them joins as the group's sink (streams to the file). Every other step is its own buffering boundary; text passes between groups as `str`.
37
+ - **Streaming group** (`_StreamGroup`): processes run in byte mode; Python bridges (filter/peek/meter) are `_Worker` threads processing line batches (`_batches`) and decoding only for callbacks. stderr is drained concurrently into a bounded `_Tail`.
38
+ - **Abandonable pipe I/O:** pipes are unbuffered, and every blocking read or write goes through `_read`/`_send`, which `poll()` and ask the group's `_give_up()`. That returns True once the run is stopped or aborting and none of the group's direct children is alive, so a descendant holding a pipe open can't block teardown. Children deliberately share the terminal's process group (no `process_group=0`), so tty prompts keep working; descendants are not signalled.
39
+ - **Resource invariants (keep them):** every fd has a single owner. The main thread holds it in `owned` until it hands it to a child (and closes its copy) or to a worker (which closes it in `finally`). Never close an fd another thread may use. Stopping signals processes and never closes fds. A worker that stops closes its output first, then its input.
40
+ - **Process supervision** (`process.py`): one `ProcessGroup` per top-level run is shared by nested `each()` runs, including thread-pool workers. `stop(reason, returncode, message)` is thread- and signal-safe (RLock, because a handler can interrupt the main thread mid-critical-section). The first stop decides the Result's returncode and message (timeout → 124); `finish()` makes later stops (a late timer) no-ops. `SignalGuard` installs handlers only on the main thread, skips ignored signals, and after cleanup `deliver()` re-sends the signal to the previous handler (KeyboardInterrupt, default SIGTERM death, or a custom handler → Result 128+n).
41
+ - **Error model:** command and environment failures (exit codes, 127 not found, missing files/dirs → 1, timeouts → 124) become a failed `Result`; exceptions from user callbacks propagate out of `run()` after cleanup; bad definitions (`tool(version="x")`, second source) raise at build time. `run(check=True)` raises `PipelineError`.
42
+ - **Interpolation:** after `.capture(name)`, only `{name}` for names captured so far is replaced (`CmdStep.argv`); other braces are literal.
43
+ - **Version probing** (`checks.detect_version`): only `--version` (or `version_args`), stdin closed, cached per (path, args) under a lock. Unknown version + constraint fails closed. Constraints are parsed at `tool()` time.
44
+ - **Live output** (`live.py`): `run(live=...)` builds a `Console`; the executor only calls `console.job(label)` (per run and per `each()` line), `job.source(label)` (per process, and for teed stdout), `source.write(bytes)` from the stderr drain threads, `source.done(rc)` and `job.finish(rc)`. Mode (capture/lines/grouped/blocks) and unit (job/process: with "process", `job.source()` makes a block of its own) live only in the console. One writer per stream: lines/grouped write whole lines under the console lock; in blocks mode the renderer thread is the only terminal writer and everything else updates the model under the lock. `tty=True` gives a child's stderr a raw pty (never stdout).
45
+ - **Output and records:** `render.py` holds the `set_output` destination (resolved once per run; `run(output=)` overrides) and a global emit lock, plus all explain/report/describe text. `manifest.py` owns the tool/file inventory used by `describe`, `manifest()`, `sbom()` (CycloneDX 1.6) and `report()`. `manifest(redact=True)` strips host details.
46
+ - **Side channels:** `CLICHAIN_DESCRIBE` (`1/true/yes/describe/text` or `json`) makes `run()` print and raise `DescribeExit` (a SystemExit). It is stripped from child envs. `clichain check` uses `dry_run()` (a process-wide flag, so threads the script starts are covered too) so `run()` records instead of executing, and exits 1 on blocking check failures.
47
+ - **Error codes** (`S<signal>` / `X<exit>`): tables live in `errors.py` and are shared by `Result.explain()` and `clichain explain`.