degenbot_cli/lib.rs
1//! `degenbot-cli` - the degenbot console binary (ADR-051 D2).
2//!
3//! One argv facade, one command model. This crate owns exactly the things
4//! [`degenbot_cli_core`] cannot own by charter:
5//!
6//! - **argv declaration** ([`argv`]): the clap v4 derive tree for the whole
7//! command vocabulary, and the argv -> [`Command`](degenbot_cli_core::Command)
8//! mapping. See `argv`'s module docs for where clap's shape differs from the
9//! retired Python click tree.
10//! - **rendering** ([`render`]): typed [`CommandReport`](degenbot_cli_core::CommandReport)
11//! lines to stdout, typed [`CliError`](degenbot_cli_core::CliError) to stderr
12//! with its `ExitCode`.
13//! - **interaction** ([`prompt`]): the stdin/stdout [`Prompter`](degenbot_cli_core::Prompter)
14//! the arms ask; the prompt *policy* stays declared data in cli-core (D4).
15//! - **sinks** ([`sinks`]): the tracing registry + console fmt layer, booted in
16//! the same order as the Python driver (typed config first, then the
17//! subscriber), and **progress** ([`progress`]): the `indicatif` bar, which
18//! exists only here (D9).
19//! - **SIGINT ownership** ([`signal`], D7): first Ctrl+C feeds cli-core's
20//! [`CancelHandle`](degenbot_cli_core::CancelHandle); a second aborts.
21//!
22//! cli-core stays clap-free and indicatif-free (asserted by
23//! `just check-cli-core-purity`); this crate stays free of every domain-engine
24//! dependency (asserted by `just check-cli-shell-purity`).
25
26pub mod argv;
27pub mod progress;
28pub mod prompt;
29pub mod render;
30pub mod signal;
31pub mod sinks;
32
33pub use argv::{Cli, Commands};
34
35use std::io::Write as _;
36
37use degenbot_cli_core::CancelHandle;
38use degenbot_config::ProcessEnv;
39
40/// The banner `--version` prints: the workspace version (ADR-009 lockstep) plus
41/// the shared build receipt, embedded by `build.rs`.
42///
43/// The fingerprint half is byte-identical to the Python FFI's
44/// `degenbot._ffi.build_fingerprint()`, so the two entry surfaces can be
45/// compared directly rather than trusted.
46pub const VERSION_LINE: &str = concat!(
47 env!("CARGO_PKG_VERSION"),
48 " (build ",
49 env!("DEGENBOT_CLI_BUILD_NUMBER"),
50 " ",
51 env!("DEGENBOT_CLI_BUILD_FINGERPRINT"),
52 ")"
53);
54
55/// Parse `args` (argv WITHOUT the program name), boot the sinks, resolve the
56/// argv into a [`Command`](degenbot_cli_core::Command), run it and render the
57/// result. This is the ONE console composition root: the `degenbot` binary and
58/// the Python `cli_main` passthrough both land here, so their sequences cannot
59/// drift apart.
60///
61/// Returns the process exit code and never exits the process, so an embedded
62/// host can drive the console directly: clap owns `--help`/`--version`/usage
63/// errors (its own exit codes apply), a refused typed config is `2` (the same
64/// refusal exit the Python module init uses), and every command outcome maps
65/// through cli-core's single `CliError -> ExitCode` site.
66///
67/// There is deliberately no host parameter here. The native and Python hosts
68/// are not abstracted behind a flag: their genuine difference lives upstream
69/// of this function — the Python `#[pymodule]` init has already installed the
70/// typed config and the driver's log forwarder by the time this runs, while
71/// the native path gets both from `sinks::boot()` below — and `boot`'s
72/// first-wins installs keep that difference in place. A switch would hide the
73/// difference it cannot actually remove.
74#[must_use]
75pub fn run_args<T>(args: &[T]) -> i32
76where
77 T: Into<std::ffi::OsString> + Clone,
78{
79 let parsed = <Cli as clap::Parser>::try_parse_from(
80 std::iter::once(std::ffi::OsString::from("degenbot"))
81 .chain(args.iter().map(|arg| arg.clone().into())),
82 );
83 let cli = match parsed {
84 Ok(cli) => cli,
85 Err(error) => {
86 // clap's `Error::exit` is print-then-exit-code with the print
87 // result discarded; a broken pipe on `--help` is swallowed the
88 // same way here.
89 let code = error.exit_code();
90 let _ = error.print();
91 return code;
92 }
93 };
94
95 let env = ProcessEnv;
96 if cli.command.is_none() {
97 argv::write_missing_subcommand_error();
98 return 2;
99 }
100
101 // Sinks before anything that emits, exactly like the Python module init.
102 let _telemetry = match sinks::boot() {
103 Ok(telemetry) => telemetry,
104 Err(refusal) => {
105 let _ = writeln!(std::io::stderr().lock(), "{refusal}");
106 return 2;
107 }
108 };
109
110 let ctx = argv::context(&cli, &env);
111 let command = match argv::resolve_with_env(&cli, &env) {
112 Ok(command) => command,
113 Err(error) => return render::error(&error),
114 };
115
116 let prompter = prompt::ConsolePrompter::new();
117 let cancel = CancelHandle::new();
118 let _sigint = signal::install(cancel.clone());
119
120 let outcome = degenbot_cli_core::run_with_cancel(&command, &ctx, &prompter, &cancel);
121 render::outcome(&outcome)
122}
123
124/// The binary entry: collect the process argv (minus the program name) and
125/// delegate to the console composition root.
126#[must_use]
127pub fn run() -> i32 {
128 let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
129 run_args(&args)
130}