blue specification · 2026-07-10
A uv/Python library for building idempotent devops CLIs: desired state in YAML, workflows as step graphs threaded by one plain dict, Selmer-style template-scaffolded configuration, OpenTofu as the infrastructure runner, and Ansible for SSH provisioning.
- Philosophy
- Naming, desired state & the opts contract
- Steps and errors
- Workflows: wire_fn, next_fn, and composition
- Parallelism, fan-out & joins
- Advice
- blue.yml and validation gates
- The scaffolding DSL and renderer
- OpenTofu integration
- Ansible integration
- Dry-run, progress, and runtime
- The CLI and ./blue launcher
- Examples
- Testing strategy
- Project layout & API index
1. Philosophy
- blue is a library. Users define steps, wiring, and workflows in their own project; blue supplies the engine, infrastructure helpers, template rendering, advice, and CLI plumbing.
- Everything is one dict. Desired state is YAML loaded into
opts. Every step reads and returns that plain dict, possibly enriched with observed reality such as OpenTofu outputs or Ansible recaps. - Idempotent apply. There is no plan phase in blue itself. Re-running the same event should converge.
createanddeleteare conventions; the event set is open. - Errors are values. Steps report outcome with
"blue/exit". Raised exceptions are caught at step boundaries and converted into opts values, so failures can route throughnext_fnand joins like any other result. - Python/uv only. blue is the uv/Python implementation of the green workflow model and the behavioral port of red's TypeScript/Bun contract. A red project ports mechanically by replacing red names with blue names and TypeScript modules with Python modules.
2. Naming, desired state & the opts contract
Everything called green in the original model and red in the TypeScript implementation is called blue here. Clojure keywords become namespaced string keys, and keyword values become plain strings.
| green | red | blue |
|---|---|---|
:green/exit | "red/exit" | "blue/exit" |
:green.scaffold/written | "red.scaffold/written" | "blue.scaffold/written" |
green.edn | red.yml | blue.yml |
./green | ./red, a Bun script | ./blue, a uv script |
green.workflow | red/workflow | blue.workflow |
:zk/servers | "zk/servers" | "zk/servers" |
:create | "create" | "create" |
The base type is open: Opts = dict. blue reserves blue/* and blue.*/* keys; project and library state should live under namespace-like keys such as "zk/servers", "once/workdir", or "tofu/outputs".
| Key | Meaning |
|---|---|
"blue/event" | First positional CLI argument: "create", "delete", "rotate", etc. |
"blue/dry-run" | Stamped by --dry-run; dry-run advice uses it to skip named steps. |
"blue/exit" | Integer outcome. Missing means success and is normalized to 0 after a step returns. |
"blue/err" | Error message when "blue/exit" is positive. |
"blue/trace" | Stack trace string when a raised exception is converted to a blue failure. |
"blue/step" | Current step name, stamped before each step runs. Advice such as progress reads it. |
"blue/branches" | List of per-branch result dicts at a join or collapsed failed fork. Sort by your own key if order matters. |
"blue.workflow/inherited" | Private advice-inheritance payload. It is stripped from public step and workflow results. |
FrozenDict and FrozenList. Steps should return new dicts with {**opts, ...} instead of mutating input. If a step raises, the failure opts are built from the frozen input so partial mutation cannot leak.3. Steps and errors
A step is a plain sync or async function (opts) -> opts, named by a namespaced string such as "zk/node". The name anchors wiring, logging, CLI slices, joins, and advice.
- A returned dict without
"blue/exit"is treated as success and stamped with0. - Returning a non-dict, list, or missing return is a step failure.
- Raising is allowed: the runner catches the exception and returns
"blue/exit": 1, plus"blue/err"and"blue/trace". - Raise
StepError(message, exit=n)when a step or advice wants to choose a specific failure exit code. - Steps usually branch on
"blue/event". Library steps use the convention “any non-"delete"event creates/applies;"delete"destroys/deprovisions.” - Async steps must await everything they start. A stray un-awaited exception cannot be attributed to a branch;
exec_clireports unhandled loop exceptions and exits nonzero.
from blue.workflow import Opts, StepError
def check(opts: Opts) -> Opts:
if not opts.get("app/name"):
raise StepError("app/name is required", exit=2)
return {**opts, "blue/exit": 0}
The helper failed(opts) is true when (opts.get("blue/exit") or 0) > 0. Without a next_fn, a failed step produces no successors and that branch halts. With a next_fn, cleanup, retries, and alternate paths are ordinary user routing.
4. Workflows: wire_fn, next_fn, and composition
from blue.workflow import Opts, run, step, workflow
wf = workflow(
start="zk/start", # required
end=None, # optional inclusive slice boundary
wire_fn=wire_fn, # required static graph
next_fn=next_fn, # optional dynamic router
)
result = await run(wf, {"blue/event": "create"})
wire_fn — the static graph
wire_fn(step, run_opts) returns a tuple/list containing the step function followed by its default successors for this run. run_opts is the initial opts passed to run, so the graph may depend on stable run-level inputs such as "blue/event".
def wire_fn(step: str, run_opts: Opts):
match step:
case "zk/start":
return (start_step, "zk/node")
case "zk/node":
return (node_step, "zk/zoo-cfg")
case "zk/zoo-cfg":
return (zoo_cfg_step,)
case "ci/fork":
return (fork_step, "ci/a", "ci/b")
Multiple successors mean a static fork. The same graph is used for join scheduling, so keep the happy path visible even when next_fn adds dynamic behavior. Do not base wire_fn on values produced after the run starts; route those cases in next_fn.
next_fn — the dynamic router
When present, next_fn decides successors after each non-end step:
next_fn(step, default_next, opts) # -> iterable of (next_step, opts) pairs
- Return
[],None, or any falsy iterable to terminate a branch; return one pair to continue; return many pairs to fan out in parallel. - Because it sees the step result, including
"blue/exit",next_fncan route failures to cleanup, retry, or halt. - Without
next_fn, positive"blue/exit"stops that path; otherwise the default successors run.
def next_fn(step: str, default_next: list | None, opts: Opts):
if (opts.get("blue/exit") or 0) > 0:
return []
if step == "zk/start":
return [("zk/node", {**opts, "zk/node": server})
for server in opts["zk/servers"]]
return [(s, opts) for s in (default_next or [])]
Composition — a workflow is a step
step(sub_workflow)
step(sub_workflow,
in_=lambda opts: sub_opts,
out=lambda opts, sub_result: parent_result)
- The sub-workflow's result is just a step result, so failures propagate through parent routing naturally.
- With default
in_, ambient keys such as"blue/event"and"blue/dry-run"flow into the child. If customin_builds opts from scratch, carry the ambient keys the child needs. - Advice inheritance is preserved even when
in_rebuilds opts from scratch. This lets a parent impose dry-run, backend, audit, or provider advice on embedded workflows by flat step name.
Slices
The optional end step is inclusive: it runs, then the workflow stops. The CLI's --start and --end override workflow boundaries for safe idempotent slice runs.
5. Parallelism, fan-out & joins
- Static fork:
wire_fnlists several successors. - Dynamic fan-out:
next_fnreturns several(step, opts)pairs. - Join: branches from different origins that converge on the same step run that step once, using the fork-point opts plus
"blue/branches". - Same-origin fan-out: same-step entries from one fan-out parent run independently instead of joining, which is how dynamic N-way item processing works.
- Failure: a failed branch inside a fork lets siblings finish their current step, collapses the fork, skips the join, and propagates the worst exit with all branch results under
"blue/branches".
wire_fn graph; dynamic detours not visible in that graph may require explicit routing care.Scheduler algorithm in plain English
- Start with one live task: run
startwith the initial opts. - Loop while work remains, tracking live branches and finished terminals.
- Group live entries by step.
- Run only ready steps: if a different live branch can still reach a step, wait so it can join.
- Run ready units concurrently with asyncio tasks.
- Expand each result into zero, one, or many next entries.
- When different origins arrive at one step, run a join unit once.
- Collapse failed forks before scheduling more work.
- Finalize to one opts dict: first failure wins, otherwise the last success.
Independent pipelines inside a fan-out
When next_fn fans out N branches and each branch traverses multiple steps in sequence, converging on the same second step can be interpreted as a join. Wrap the per-item pipeline in a sub-workflow with step(sub_workflow) when the count is dynamic — N items, N nodes, N tenants.
pipeline_wf = workflow(
start="item/validate",
wire_fn=lambda step, _run_opts: {
"item/validate": (validate, "item/transform"),
"item/transform": (transform, "item/load"),
"item/load": (load,),
}.get(step),
)
def parent_wire_fn(step_name: str, _run_opts: Opts):
return {
"batch/start": (start, "batch/process"),
"batch/process": (step(pipeline_wf),),
}.get(step_name)
step. If the number of branches is fixed and known at design time, distinct step names work: "deploy/staging" and "deploy/production" never converge, so they run independently without a sub-workflow.6. Advice
Advice wraps step functions without changing graph wiring. Registries are immutable workflow data; adding/removing advice returns a new workflow.
from blue.workflow import advice_add, advice_add_all, advice_remove, advice_remove_all
advised = advice_add(wf, "zk/node", "before", "app/backend", write_backend)
with_progress = advice_add_all(advised, "around", "app/progress", progress)
without_backend = advice_remove(with_progress, "zk/node", "app/backend")
quiet = advice_remove_all(without_backend, "app/progress")
| How | Effect | Use cases |
|---|---|---|
"around" | advice(base, opts) | Dry-run, retry, timing, locks; may call the base zero, one, or many times. |
"override" | advice(opts), base never runs | Replace/stub a step. |
"before" | Advice side effect, then base; advice return ignored | Setup: backend files, inventories, directories, locks. |
"after" | Base, then advice side effect; base return flows out | Audit, metrics, notifications, cleanup. |
"before-while" | Continue inward only while advice returns truthy | Validation and policy gates. |
"before-until" | Use advice result when it is truthy; otherwise base | Fast path / already converged no-op. |
"after-while" | Run advice only when the inward result is blue-true | Success-only verification or registration. |
"after-until" | Use inward result if blue-true; otherwise advice fallback | Recovery or alternate result. |
"filter-args" | Transform opts before base | Normalize/scope inputs. |
"filter-return" | Transform base result | Normalize/enrich outputs. |
Advice functions may be sync or async; composition awaits each layer. For while/until combinators, blue uses Clojure-style truthiness: only None and False are falsy, so 0 and "" are truthy. For after-while and after-until, a step-result dict is true when "blue/exit" is missing or zero and false when it is positive.
Stacking is Emacs-like. At equal depth, newest advice is outermost. depth (-100..100, default 0) overrides add order: lower depths run farther outside, higher depths farther inside. Re-adding the same id replaces the old entry and moves it according to the new sequence/depth.
Advice inheritance through step. The enclosing run stamps its effective advice into opts under a private key, and the nested run merges that advice over the child's own registries. Ancestor entries are outermost at equal depth; same-id ancestor entries replace child entries. Use advice_plan(wf, step) or advice_plan([parent, child], step) to inspect the composed stack.
7. blue.yml and validation gates
The CLI reads blue.yml by default (override with -f/--file), parses it with PyYAML plus YAML 1.2 core-schema boolean behavior, stamps the lifecycle event, and passes the resulting dict to run.
zk/workdir: work
zk/servers:
- {id: 1, name: zk1, ip: 10.0.0.1}
- {id: 2, name: zk2, ip: 10.0.0.2}
- {id: 3, name: zk3, ip: 10.0.0.3}
# Quote version-like values; YAML would parse 3.10 as a number.
zk/version: "3.10"
- Namespaced keys such as
zk/serversparse unquoted. - blue's loader treats values like
no,yes,on, andoffas strings; only true/false variants are booleans. - Validation should happen at boundaries and/or as
before-whilegates.
schema_gate(schema, exit=2) builds a Pydantic-backed before-while advice. Gates validate and return the original opts; they must not replace opts with the parsed output because Pydantic can drop or transform unrelated namespace keys.
from typing_extensions import TypedDict
from blue.gates import schema_gate
from blue.workflow import advice_add
# Functional TypedDict syntax carries namespaced string keys.
Server = TypedDict("Server", {"id": int, "name": str})
State = TypedDict("State", {"zk/servers": list[Server]})
gated = advice_add(wf, "zk/start", "before-while",
"zk/state-schema", schema_gate(State))
8. The scaffolding DSL and renderer
scaffold(opts, specs) materializes or removes a flat sequence of file specs:
from pathlib import Path
from blue.scaffold import scaffold
main_tf = (Path(__file__).parent / "templates/main.tf").read_text()
specs = [{
"template": {"name": "zk/main.tf", "content": main_tf},
"target": "{{workdir}}/nodes/{{node.id}}/main.tf",
"data": {"workdir": opts["zk/workdir"], "node": node},
}]
return scaffold(opts, specs)
| Key | Meaning |
|---|---|
template | {"name", "content"}. The content should be read by the owning package, next to the module or via importlib.resources. There is no runtime template lookup. |
target | Output path, itself rendered with the same renderer against data. |
data | Template data for both body and target path. |
opts | Optional render options, including delimiter overrides. |
On "blue/event": "delete", the same specs name targets to remove. The implementation removes files and prunes the immediate parent directory if it becomes empty. Return keys are "blue.scaffold/written" or "blue.scaffold/deleted".
The renderer implements the Selmer-compatible subset blue exercises: variables, dotted paths, missing values as empty, HTML escaping by default, safe, sort(attribute='...'), for loops, and delimiter overrides (tag_open, tag_close, filter_open, filter_close). Delimiter overrides are useful when scaffolded Ansible files must preserve Jinja2 {{ }} and {% %}.
9. OpenTofu integration
from blue.tofu import local_backend_advice, s3_backend_advice, tofu_step
async def apply_node(opts: Opts) -> Opts:
return await tofu_step(opts, dir=f"{opts['zk/workdir']}/node-{opts['zk/id']}")
- Any non-
"delete"event:tofu init -input=false -no-color, thentofu apply -auto-approve -input=false -no-color, thentofu output -json. "delete":tofu init, thentofu destroy -auto-approve -input=false -no-color.- Apply outputs are parsed to
{name: value}and assoc'd under"tofu/outputs"by default. If you overrideoutput_key, keep it namespaced. - Non-zero process exits become
"blue/exit"and"blue/err". - All subprocesses go through
runtime.exec, which tests can stub.
Backends are not hardwired. backend_advice writes backend.tf for any backend type and flat config dict (or function of opts). Helpers ship for local, S3, and GCS.
wf = advice_add(
wf,
"zk/tofu",
"before",
"zk/backend",
local_backend_advice(lambda opts: f"{opts['zk/workdir']}/node-{opts['zk/id']}"),
)
10. Ansible integration
from blue.ansible import ansible_step, inventory_advice
async def provision(opts: Opts) -> Opts:
return await ansible_step(
opts,
dir="ansible",
inventory="inventory.ini",
private_key="work/ssh/id_ed25519",
user="root",
host_key_checking=False,
)
- Any non-
"delete"event runs the create playbook (create.ymlby default);"delete"runs the delete playbook (delete.ymlby default). Override withplaybooks. - Options map to
ansible-playbook:inventory,private_key,user,extra_vars(JSON-e), andhost_key_checking=False(exportsANSIBLE_HOST_KEY_CHECKING=False). - On success, PLAY RECAP is parsed into per-host counters under
"ansible/recap"by default; override with namespacedrecap_key. inventory_inirenders deterministic INI from{group: {"hosts": [{"name", "vars"}], "vars": {...}}}.inventory_adviceis abeforeadvice that writes an inventory file from data or a function of opts, mirroring OpenTofu backend advice.ansible_with_specscaffolds then runs on create, and runs then removes scaffolded files on delete.
11. Dry-run, progress, and runtime
Dry-run and progress are advice layers, not special cases in the engine.
dry_run.advise(wf, steps)attachesaroundadvice id"blue.dry-run/skip"to named steps. With"blue/dry-run", it logsdry-run: would run <step> (<event>)and returns success without calling the step.progress.advise(wf)attaches all-steparoundadvice id"blue.progress/progress". It logs>>> <step> (<event>)before and<<< <step> (<n>ms)after, reading"blue/step".runtime.exec(cmd, cwd=None, env=None)is the single subprocess seam used by OpenTofu and Ansible helpers.runtime.log(*args)is the single log seam used by dry-run and progress.
from blue import dry_run, progress
wf2 = progress.advise(
dry_run.advise(wf, ["zk/scaffold", "zk/tofu", "zk/ansible"])
)
12. The CLI and ./blue launcher
./blue <event> [-f|--file blue.yml] [--start step] [--end step] [--dry-run]
./blue create
./blue delete -f prod.yml
./blue create --start zk/node --end zk/node
./blue create --dry-run
- The first positional argument is stamped into
"blue/event". --startand--endoverride workflow boundaries.--dry-runstamps"blue/dry-run": True; it has an effect only on steps advised withblue.dry-run.- Missing event, missing state file, YAML parse errors, or other CLI config errors return exit
2fromrun_cli. exec_cliprints"blue/err"and"blue/trace"to stderr and exits with"blue/exit".
A project's ./blue is a self-contained uv script: executable, with a #!/usr/bin/env -S uv run --script shebang and PEP 723 inline metadata. It owns the workflow definition, advice wiring, exec_cli call, and reads of local templates.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["blue"]
# ///
from blue import Opts, exec_cli, workflow
def start(opts: Opts) -> Opts:
return {**opts, "app/seen": True}
def wire_fn(step: str, _run_opts: Opts):
if step == "app/start":
return (start,)
wf = workflow(start="app/start", wire_fn=wire_fn)
if __name__ == "__main__":
exec_cli(wf)
blue==x.y.z or a pinned git reference. In-repo examples use PEP 723 [tool.uv.sources] with a relative path; do not recommend unpinned dependencies.13. Examples
Each example is a self-contained uv CLI that imports this checkout by relative path and keeps desired state in blue.yml.
| Path | What it teaches |
|---|---|
examples/zookeeper | Fake 3-node cluster: dynamic fan-out from desired state, join at zk/zoo-cfg, scaffolding plus OpenTofu, backend-as-advice, and dry-run. |
examples/multi-zookeeper | Workflow composition: one cluster workflow embedded with step, fanned out once per cluster, with inherited parent advice. |
examples/once | ONCE-style single VPS with provider-swap advice, compute ∥ smtp → dns → smtp-post → (ansible-local ∥ ansible-remote), per-step tofu state, and scaffold-only Ansible files. |
examples/multi-once | Many ONCE boxes from one workflow. The parent replaces inherited provider/backend advice; S3 state keys are isolated by deployment and step. Dry-run is offline. |
examples/floci-zookeeper | Real local ZooKeeper on floci: OpenTofu targets floci, Ansible provisions over SSH, delete routes through Ansible before destroys, validation gates and retry advice are used. Non-dry-run needs Linux, Docker, floci, tofu, ansible-playbook, docker, and aws. |
cd examples/zookeeper
./blue create --dry-run
./blue create
./blue delete
cd ../multi-zookeeper
./blue create --dry-run
./blue create
./blue delete
cd ../once
./blue create --dry-run
./blue create
./blue delete
cd ../multi-once
./blue create --dry-run # offline path; real create needs an S3 bucket
./blue create
./blue delete
cd ../floci-zookeeper
./blue create --dry-run # offline path; real create needs Linux + Docker + floci
./blue create
./blue delete
14. Testing strategy
The suite runs with uv run pytest. Test files are discovered under tests/.
tests/test_workflow.pycovers linear runs, errors, slices, event-specific static graphs, dynamic routing, fork/join, failure collapse, concurrency, and composition.tests/test_advice.pycovers every advice combinator, ordering/depth, all-step advice, removal/replacement, and inheritance throughstep.tests/test_scaffold.pycovers template rendering, create/delete symmetry, and idempotent rendering.tests/test_tofu.pyandtests/test_ansible.pystubruntime.execinstead of shelling out.tests/test_cli.py,tests/test_dry_run.py,tests/test_progress.py, andtests/test_gates.pycover CLI flags, advice helpers, logging, and Pydantic validation gates.tests/test_zookeeper.pyis the e2e suite: iftofuis onPATH, it runs real render/apply/output/destroy cycles over HCL containing onlylocalsandoutputblocks.tests/test_floci_zookeeper.pyis opt-in withBLUE_FLOCI_E2E=1because it requires Docker/floci, Ansible, provider downloads, and minutes of runtime.
15. Project layout & API index
blue/
├── pyproject.toml uv/Python package metadata and dependencies
├── uv.lock Locked dependency graph
├── README.md User-facing overview
├── index.html This specification
├── src/blue/
│ ├── advice.py nadvice combinators and pure registry ops
│ ├── workflow.py workflow, run, step, advice add/remove/all, advice_plan
│ ├── renderer.py small internal Selmer-compatible renderer
│ ├── scaffold.py flat file-spec DSL over text-read templates
│ ├── tofu.py tofu_step, outputs, backend advices (local/s3/gcs)
│ ├── ansible.py ansible_step, ansible_with_spec, recap parsing, inventory advice
│ ├── dry_run.py dry-run advice + advise
│ ├── progress.py progress advice + advise
│ ├── cli.py run_cli, exec_cli, arg parsing, YAML loader
│ ├── gates.py Pydantic schema gates
│ ├── runtime.py mutable subprocess/log seam
│ └── __init__.py root re-exports
├── tests/ advice, workflow, scaffold, cli, dry-run, progress, tofu, ansible, gates, zookeeper
└── examples/ zookeeper, multi-zookeeper, once, multi-once, floci-zookeeper
Distribution
Library package name: blue. Version in this checkout: 0.1.0.
- Current development: in-repo examples depend on this checkout by relative path via uv
[tool.uv.sources]. - External consumers: pin a released version such as
blue==x.y.zor use a pinned git reference. - Launcher style: project CLIs should be self-contained uv scripts, not generated wrappers.
| Function / export | Signature / purpose |
|---|---|
workflow | workflow(start, wire_fn, end=None, next_fn=None) -> Workflow; wire_fn is (step, run_opts) -> (fn, *next). |
run | await run(workflow, opts) -> Opts; runs the scheduler and returns final opts. |
step | step(workflow, in_=None, out=None) -> StepFn; embeds a workflow as an ordinary step. |
StepError | Error subclass with exit; raise to choose a step failure exit code. |
failed | failed(opts) -> bool; true when "blue/exit" is positive. |
advice_add | (workflow, step, how, id, fn, props=None) -> Workflow. |
advice_remove | (workflow, step, id) -> Workflow. |
advice_add_all | (workflow, how, id, fn, props=None) -> Workflow. |
advice_remove_all | (workflow, id) -> Workflow. |
advice_plan | (workflow_or_chain, step) -> list[dict]; printable advice stack. |
advice.HOWS, advice.ordered, advice.compose | Low-level advice combinator set, ordering, and wrapper composition. |
advice.add, advice.remove_id, advice.add_global, advice.remove_global_id | Low-level pure registry operations used by blue.workflow. |
advice.merge_entries, advice.merge_registry | Low-level inheritance helpers for composing parent/child advice registries. |
render | (template, data, opts=None) -> str; render a template string. |
render_template | (template, data, opts=None) -> str; render a {"name", "content"} template dict. |
scaffold | (opts, specs) -> opts; create or delete rendered targets. |
tofu.outputs | await outputs(dir) -> dict; parse tofu output -json. |
tofu.tofu_step | await tofu_step(opts, dir=..., output_key="tofu/outputs") -> Opts. |
tofu.backend_advice | (dir_fn, type, config) -> before_advice; write backend.tf. |
tofu.local_backend_advice | (dir_fn, config=None) -> before_advice. |
tofu.s3_backend_advice | (dir_fn, config) -> before_advice. |
tofu.gcs_backend_advice | (dir_fn, config) -> before_advice. |
ansible.default_playbooks | Default event-to-playbook map: {"create": "create.yml", "delete": "delete.yml"}. |
ansible.playbook | (opts, playbooks=None) -> str; select create/delete playbook. |
ansible.parse_recap | (ansible_output) -> per_host_counters. |
ansible.ansible_step | await ansible_step(opts, **config) -> Opts. |
ansible.ansible_with_spec | await ansible_with_spec(opts, specs, **config) -> Opts; scaffold/run ordering for Ansible. |
ansible.inventory_ini | (groups) -> str; deterministic INI renderer. |
ansible.inventory_advice | (file_fn, groups) -> before_advice; write inventory before a step. |
dry_run.advice | (step) -> around_advice. |
dry_run.advise | (workflow, steps) -> Workflow. |
progress.progress | All-step around advice that logs entry/exit timing. |
progress.advise | (workflow) -> Workflow. |
schema_gate | (pydantic_schema, exit=2) -> before_while_advice; validate without transforming opts. |
USAGE | CLI usage string. |
load_yaml | (text) -> Opts; YAML 1.2 core-schema boolean loader. |
run_cli | await run_cli(workflow, args) -> Opts; non-exiting CLI runner. |
exec_cli | exec_cli(workflow, args=None) -> None; print errors and exit process. |
ExecResult | Dataclass result from subprocess execution: {exit, out, err}. |
runtime | Mutable {exec, log} seam used by subprocess and logging helpers. |