Skip to main content

rhei_cli/cli/
cli_dispatch.rs

1/// Snapshot cache maintenance commands.
2#[derive(Subcommand, Debug)]
3enum SnapshotCommand {
4    /// List cached snapshot generations
5    List {
6        /// Path to a plan file or workspace root; defaults to the current directory
7        #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
8        plan: PathBuf,
9        /// Filter by task id
10        #[arg(long, value_name = "ID", add = ArgValueCompleter::new(complete_task_id))]
11        task: Option<String>,
12        /// Filter by snapshot name; use _state for auto-emitted snapshots
13        #[arg(long, value_name = "SNAPSHOT")]
14        name: Option<String>,
15        /// Filter by emitting state
16        #[arg(long, value_name = "STATE", add = ArgValueCompleter::new(complete_state_name))]
17        state: Option<String>,
18        /// Filter by emission origin
19        #[arg(long, value_enum, default_value = "orchestrator")]
20        produced_by: SnapshotProducedByFilter,
21        /// Show only snapshots that no longer resolve in the current plan/state machine
22        #[arg(long)]
23        orphaned: bool,
24        /// Output format
25        #[arg(long, value_enum, default_value = "text")]
26        format: SnapshotListFormat,
27    },
28    /// Show one snapshot manifest and transcript preview
29    Show {
30        /// Snapshot reference
31        #[arg(value_name = "REF")]
32        reference: String,
33        /// Path to a plan file or workspace root; defaults to the current directory
34        #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
35        plan: PathBuf,
36    },
37    /// Delete cached snapshot generations by policy
38    Gc {
39        /// Path to a plan file or workspace root; defaults to the current directory
40        #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
41        plan: PathBuf,
42        /// Filter by task id
43        #[arg(long, value_name = "ID", add = ArgValueCompleter::new(complete_task_id))]
44        task: Option<String>,
45        /// Filter by snapshot name
46        #[arg(long, value_name = "SNAPSHOT")]
47        name: Option<String>,
48        /// Delete only generations older than this duration (for example 7d or 4h)
49        #[arg(long, value_name = "DURATION")]
50        older_than: Option<String>,
51        /// Keep the newest N generations per snapshot identity
52        #[arg(long, value_name = "N")]
53        keep_generations: Option<usize>,
54        /// Include operator-produced generations in retention and deletion decisions
55        #[arg(long)]
56        include_operator: bool,
57        /// Delete only snapshots that no longer resolve in the current plan/state machine
58        #[arg(long)]
59        orphaned: bool,
60        /// Print what would be deleted without removing files
61        #[arg(long)]
62        dry_run: bool,
63        /// Bypass the live-run interlock
64        #[arg(long)]
65        force: bool,
66    },
67    /// Continue interactively from a cached snapshot
68    Continue {
69        /// Snapshot reference
70        #[arg(value_name = "REF")]
71        reference: String,
72        /// Path to a plan file or workspace root; defaults to the current directory
73        #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
74        plan: PathBuf,
75        /// Select a target slug when the reference is ambiguous
76        #[arg(long, value_name = "SLUG")]
77        target: Option<String>,
78        /// Continue from a specific generation
79        #[arg(long, value_name = "N")]
80        generation: Option<u64>,
81        /// Do not capture the resulting operator transcript
82        #[arg(long)]
83        no_capture: bool,
84    },
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
88enum SnapshotProducedByFilter {
89    Orchestrator,
90    Operator,
91    All,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
95enum SnapshotListFormat {
96    Text,
97    Json,
98}
99
100/// Output formats supported by the [`Render`](Commands::Render) subcommand.
101#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
102enum RenderFormat {
103    Json,
104    Github,
105    Progress,
106}
107
108/// Supported AI coding agents for skill installation.
109#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
110enum Agent {
111    ClaudeCode,
112    Cursor,
113    Windsurf,
114    Copilot,
115    Kilocode,
116    Pi,
117    Codex,
118    Antigravity,
119    All,
120}
121
122/// Shells supported by the completion generator.
123#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
124enum CompletionShell {
125    Bash,
126    Zsh,
127    Fish,
128    #[value(name = "powershell")]
129    PowerShell,
130    Elvish,
131}
132
133impl CompletionShell {
134    fn as_str(self) -> &'static str {
135        match self {
136            CompletionShell::Bash => "bash",
137            CompletionShell::Zsh => "zsh",
138            CompletionShell::Fish => "fish",
139            CompletionShell::PowerShell => "powershell",
140            CompletionShell::Elvish => "elvish",
141        }
142    }
143}
144
145/// Program entry point.
146///
147/// Delegates to fallible command logic so tests can exercise it directly.
148/// Wrap diagnostics at word boundaries but never *inside* a word.
149///
150/// miette's defaults offer a break opportunity at every hyphen and every `/`,
151/// and split an overlong token outright. All three land mid-path on the
152/// filesystem diagnostics this CLI prints constantly, and a path broken across
153/// lines cannot be copied, clicked, or grepped. Treating only spaces as break
154/// points keeps prose wrapping while a long path overflows the wrap column
155/// intact, where the terminal soft-wraps it.
156fn install_diagnostic_handler() {
157    let _ = miette::set_hook(Box::new(|_| {
158        Box::new(
159            miette::MietteHandlerOpts::new()
160                .break_words(false)
161                .word_separator(textwrap::WordSeparator::AsciiSpace)
162                .word_splitter(textwrap::WordSplitter::NoHyphenation)
163                .build(),
164        )
165    }));
166}
167
168/// True when `rhei` was invoked with no arguments at all.
169///
170/// Distinguishes the orientation case from a subcommand-level usage error;
171/// see the call site in [`main`].
172fn is_bare_invocation() -> bool {
173    std::env::args_os().count() <= 1
174}
175
176/// Conventional status for a process that stopped because a pipe consumer
177/// closed early: `128 + SIGPIPE`, the same value the shell reports for
178/// `yes | head`.
179const EXIT_BROKEN_PIPE: i32 = 141;
180
181/// Leave quietly when there is no longer anywhere to print, instead of
182/// surfacing an internal error.
183///
184/// Rust ignores `SIGPIPE` before `main`, so a closed stdout comes back as an
185/// `EPIPE` write error and `println!` panics on it — `rhei list | head` exited
186/// 101 with a stack trace. This intercepts exactly that panic and exits the way
187/// a Unix filter killed by the signal does.
188///
189/// A terminal that goes away is the same situation with a different errno: a
190/// `rhei run` whose window is closed writes `EIO` to the dead pty from then on,
191/// and the end-of-run console summary panicked on it — then panicked *again*
192/// from the report guard's own `println!` while unwinding, which is a double
193/// panic and aborts. A run that ended is not a run that crashed.
194///
195/// Restoring `SIGPIPE` to `SIG_DFL` process-wide would be the shorter fix and
196/// is the wrong one: this CLI writes to pipes it owns — a callback
197/// subprocess's stdin, an agent's — and there the write returning `EPIPE` is
198/// how a child that exited early gets *reported*. Under `SIG_DFL` those writes
199/// killed `rhei` mid-diagnostic instead, so a transition that should have
200/// failed with an explanation failed with empty stderr.
201// §FS-rhei-usage.2 §FS-rhei-run.3.2 §FS-rhei-run-tui.1.8
202fn install_quiet_broken_pipe_exit() {
203    // Before any output can be lost, because the question cannot be asked
204    // afterwards. §FS-rhei-run.3.2
205    record_startup_terminals();
206    let previous = std::panic::take_hook();
207    std::panic::set_hook(Box::new(move |info| {
208        if is_lost_output_panic(info) {
209            // `exit` runs no destructor: the shutdown guard never gets its
210            // turn, so the hook is the last code that can end the groups.
211            // §FS-rhei-run.3.2
212            terminate_all_live_groups();
213            // An interrupted run still names its signal: losing the terminal is
214            // how the interruption arrived, not a second outcome.
215            // §FS-rhei-run.3.2
216            let code = interrupt_exit_code().unwrap_or(EXIT_BROKEN_PIPE);
217            // The run really is ending, so its registry entry must go with it —
218            // otherwise a reader that lost its pipe leaves a run listed as live
219            // forever. §FS-rhei-run-headless.2
220            finalize_run_descriptor(code);
221            std::process::exit(code);
222        }
223        previous(info);
224    }));
225}
226
227/// `EPIPE`, by `strerror`'s message and by errno. Both forms because the
228/// message follows the locale, while the `(os error N)` suffix the standard
229/// library appends does not — either one identifies the errno on its own.
230const BROKEN_PIPE_MARKERS: [&str; 2] = ["Broken pipe", "(os error 32)"];
231
232/// `EIO`, in the same two forms.
233const IO_ERROR_MARKERS: [&str; 2] = ["Input/output error", "(os error 5)"];
234
235/// Which of the process's own output streams a print failed on.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237enum LostStream {
238    Stdout,
239    Stderr,
240}
241
242/// The stream a standard-library "failed printing to …" panic names, or `None`
243/// for a panic that is about something else entirely.
244fn printing_failure_stream(message: &str) -> Option<LostStream> {
245    let rest = message.strip_prefix("failed printing to ")?;
246    if rest.starts_with("stdout") {
247        Some(LostStream::Stdout)
248    } else if rest.starts_with("stderr") {
249        Some(LostStream::Stderr)
250    } else {
251        None
252    }
253}
254
255/// Whether stdout and stderr were terminals when the process started, asked
256/// once and remembered.
257///
258/// It has to be once, and it has to be then. `isatty` on a pty whose master
259/// has closed does not answer "yes, a terminal that has gone away" — the
260/// hangup swaps the slave's file operations out and the `TCGETS` behind
261/// `isatty` fails with `EIO` like every other ioctl on it, so the stream reads
262/// as *not a terminal* from exactly the moment the guard below needs it to
263/// read as one. Asked at startup the answer is the true one, and it cannot
264/// change afterwards: a redirected stdout does not become a terminal, and a
265/// terminal that goes away was still a terminal.
266// §FS-rhei-run.3.2: a lost console ends the run quietly.
267static STARTUP_TERMINALS: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
268
269/// Ask the question while both streams are still whatever they are.
270fn record_startup_terminals() -> (bool, bool) {
271    *STARTUP_TERMINALS.get_or_init(|| {
272        use std::io::IsTerminal as _;
273        (std::io::stdout().is_terminal(), std::io::stderr().is_terminal())
274    })
275}
276
277fn stream_is_terminal(stream: LostStream) -> bool {
278    // `get_or_init` and not `get`: a panic on a path that never installed the
279    // hook — a unit test, a library caller — still gets a real answer rather
280    // than a default that silently changes the verdict.
281    let (stdout, stderr) = record_startup_terminals();
282    match stream {
283        LostStream::Stdout => stdout,
284        LostStream::Stderr => stderr,
285    }
286}
287
288/// Whether a panic is the standard library's "failed printing to stdout" panic
289/// for an output that no longer exists, rather than a real bug.
290///
291/// Matched on the payload text because that is all the standard library
292/// exposes: the panic carries no typed error. The message must be that panic,
293/// so one that merely mentions a broken pipe in some other context still
294/// reports normally.
295fn is_lost_output_panic(info: &std::panic::PanicHookInfo<'_>) -> bool {
296    let payload = info.payload();
297    let message = payload
298        .downcast_ref::<String>()
299        .map(String::as_str)
300        .or_else(|| payload.downcast_ref::<&str>().copied());
301    message.is_some_and(message_is_lost_output)
302}
303
304fn message_is_lost_output(message: &str) -> bool {
305    lost_output_verdict(message, stream_is_terminal)
306}
307
308/// The decision itself, over the panic message and a way to ask whether the
309/// stream it names is a terminal, so it can be tested — a `PanicHookInfo` is
310/// not constructible outside a real panic, and a test cannot close the
311/// harness's own stdout.
312///
313/// `EPIPE` always means the reader is gone. `EIO` means it only on a terminal,
314/// where it is how a closed pty reports the session hanging up; on a redirected
315/// stdout it is a real write failure — a full device, a dropped network mount —
316/// and treating that as "the output is gone" would kill every in-flight agent
317/// and exit `141` without a word about what actually went wrong.
318///
319/// `is_terminal` therefore answers for the stream as it was at startup, never
320/// as it is now: see [`STARTUP_TERMINALS`] for why asking now inverts the
321/// answer in the one case this exists for.
322fn lost_output_verdict(message: &str, is_terminal: impl Fn(LostStream) -> bool) -> bool {
323    let Some(stream) = printing_failure_stream(message) else {
324        return false;
325    };
326    if BROKEN_PIPE_MARKERS.iter().any(|marker| message.contains(marker)) {
327        return true;
328    }
329    IO_ERROR_MARKERS.iter().any(|marker| message.contains(marker)) && is_terminal(stream)
330}
331
332pub fn run() {
333    install_quiet_broken_pipe_exit();
334    install_diagnostic_handler();
335    CompleteEnv::with_factory(cli_command).bin(invoked_bin_name()).complete();
336
337    let cli = match Cli::try_parse() {
338        Ok(cli) => cli,
339        // A bare `rhei` is a request for orientation, so answer it with the
340        // root help on stdout and a success exit. Every *other* missing
341        // subcommand — `rhei snapshot`, say — is a usage error about that
342        // subcommand: let clap render its own contextual help to stderr with
343        // the conventional exit code, or a script cannot tell the two apart.
344        Err(err)
345            if is_bare_invocation()
346                && matches!(
347                    err.kind(),
348                    ErrorKind::MissingSubcommand
349                        | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
350                ) =>
351        {
352            let mut cmd = cli_command();
353            if let Err(io_err) = cmd.print_help() {
354                eprintln!("failed to write CLI help: {io_err}");
355                std::process::exit(1);
356            }
357            println!();
358            return;
359        }
360        Err(err) => err.exit(),
361    };
362
363    let json_mode = command_wants_json(&cli.command);
364
365    if let Err(err) = dispatch(cli) {
366        if json_mode {
367            emit_json_error(&err);
368        } else {
369            eprintln!("{err:?}");
370        }
371        // A run that a signal ended reports the signal, not a generic failure:
372        // whatever error it surfaced on the way out is a consequence of the
373        // interruption. §FS-rhei-run.3.2
374        let code = interrupt_exit_code().unwrap_or(1);
375        finalize_run_descriptor(code);
376        std::process::exit(code);
377    }
378    // Checked after `dispatch` so every guard has run and the report is
379    // written: `128 + signal` is what a shell reports for a process the signal
380    // killed, and `rhei run` was asked to stop by one. §FS-rhei-run.3.2
381    if let Some(code) = interrupt_exit_code() {
382        finalize_run_descriptor(code);
383        std::process::exit(code);
384    }
385    // The exit code is only knowable here, which is why the descriptor's
386    // terminal status is stamped from the exit path rather than from a guard
387    // that cannot see it. §FS-rhei-run-headless.2
388    finalize_run_descriptor(0);
389}
390
391/// Returns true when the invoked command's output format is JSON. In that
392/// case, errors are rendered as a single-line JSON object on stderr instead
393/// of the default miette text, so machine consumers don't have to parse two
394/// shapes.
395fn command_wants_json(command: &Commands) -> bool {
396    match command {
397        Commands::Next { json, .. } => *json,
398        Commands::States { json, .. } => *json,
399        Commands::List { json, .. } => *json,
400        Commands::Snapshot { command: SnapshotCommand::List { format, .. }, .. } => {
401            matches!(format, SnapshotListFormat::Json)
402        }
403        Commands::Templates { json, .. } => *json,
404        Commands::Cost { json, .. } => *json,
405        Commands::Runs { json } => *json,
406        // `attach --json` streams records on stdout, so a failure must not
407        // print miette prose beside them. §FS-rhei-run-json.1
408        Commands::Attach { json, .. } => *json,
409        Commands::Run { standalone, .. } => standalone.json,
410        Commands::Render { format, .. } => matches!(format, RenderFormat::Json),
411        _ => false,
412    }
413}
414
415fn emit_json_error(err: &miette::Report) {
416    // §FS-rhei-errors.5: machine consumers get the same next action as humans.
417    let mut error = serde_json::json!({ "message": err.to_string() });
418    if let Some(help) = err.help() {
419        error["help"] = serde_json::Value::String(help.to_string());
420    }
421    let payload = serde_json::json!({ "error": error });
422    let serialized = serde_json::to_string(&payload)
423        .unwrap_or_else(|_| format!("{{\"error\":{{\"message\":{:?}}}}}", err.to_string()));
424    eprintln!("{serialized}");
425}
426
427/// Dispatch the parsed CLI command.
428fn dispatch(cli: Cli) -> MietteResult<()> {
429    // `--state-machine` is accepted both before the subcommand and on the
430    // subcommands that read one; the subcommand copy wins when both appear.
431    let before_subcommand = cli.state_machine;
432    match cli.command {
433        Commands::Init { dir, here, title, no_agents, force } => {
434            init_command(dir.as_deref(), title.as_deref(), no_agents, force, here)
435        }
436        Commands::Validate { watch, input, state_machine } => {
437            // §FS-rhei-validate.1.1: validation never narrows — a member rhei
438            // validates the project it cannot resolve without.
439            let target = resolve_plan_target(input)?;
440            report_validation_widened(&target);
441            validate_command(target.path(), state_machine.or(before_subcommand).as_deref(), watch)
442        }
443        Commands::Render { input, format, pretty, no_color, no_metadata, no_content, state_machine } => {
444            let target = resolve_plan_target(input)?;
445            render_command(
446                target.path(),
447                &target.scope_with(&[]),
448                state_machine.or(before_subcommand).as_deref(),
449                format,
450                pretty,
451                no_color,
452                no_metadata,
453                no_content,
454            )
455        }
456        Commands::States { input, rhei, json, state_machine } => {
457            states_command(input, state_machine.or(before_subcommand).as_deref(), &rhei, json)
458        }
459        Commands::List {
460            input,
461            rhei,
462            state,
463            assignee,
464            no_assignee,
465            kind,
466            has_prior,
467            parent,
468            root,
469            contains,
470            terminal,
471            non_terminal,
472            ready,
473            blocked,
474            limit,
475            json,
476            state_machine,
477        } => {
478            let target = resolve_plan_target(input)?;
479            let rhei = target.scope_with(&rhei);
480            list_command(
481            target.path(),
482            state_machine.or(before_subcommand).as_deref(),
483            ListFilters {
484                rhei,
485                states: state,
486                assignee,
487                no_assignee,
488                kind,
489                has_prior,
490                parent,
491                root,
492                contains,
493                terminal,
494                non_terminal,
495                ready,
496                blocked,
497                limit,
498            },
499            json,
500            )
501        }
502        Commands::Transition { input, task, from, to, result, no_callbacks, state_machine } => {
503            let (input, task) = split_transition_ticket_target(input, task)?;
504            let target = resolve_plan_target(input)?;
505            transition_command(
506                target.path(),
507                &target.scope_with(&[]),
508                state_machine.or(before_subcommand).as_deref(),
509                &task,
510                &from,
511                &to,
512                result.as_deref(),
513                no_callbacks,
514            )
515        }
516        Commands::Run { input, standalone, agent, program, snapshot, state_machine } => {
517            let target = resolve_plan_target(input)?;
518            let mut opts: RunOptions = (standalone, agent, program, snapshot).into();
519            opts.narrow_to(target.scope_with(opts.rhei_scope()));
520            run_command(target.path(), state_machine.or(before_subcommand).as_deref(), opts)
521        }
522        // `rhei cost` reads accounting artifacts under the target's own runtime
523        // root and resolves no dependency graph, so it stays on the path it was
524        // given rather than widening to the enclosing project.
525        Commands::Cost { input, task, json, by } => {
526            cost_command(resolve_plan_target(input)?.path(), task.as_deref(), json, by)
527        }
528        Commands::Attach { run, json, since, wait } => {
529            attach_command(run.as_deref(), json, since, wait)
530        }
531        Commands::Runs { json } => runs_command(json),
532        Commands::Stop { run, kill, wait } => stop_command(run.as_deref(), kill, wait),
533        Commands::Intervene { plan, task, slot, message } => {
534            intervene_command(&plan, &task, slot, &message)
535        }
536        Commands::Viz { input, output, open, state_machine } => {
537            let target = resolve_plan_target(input)?;
538            viz_command(
539                target.path(),
540                &target.scope_with(&[]),
541                state_machine.or(before_subcommand).as_deref(),
542                output.as_deref(),
543                open,
544            )
545        }
546        Commands::Snapshot { command, state_machine } => snapshot_command(command, state_machine.or(before_subcommand).as_deref()),
547        Commands::Templates { template, json, source } => {
548            templates::templates_command(json, &source, template.as_deref())
549        }
550        Commands::Instantiate {
551            template,
552            set_values,
553            set_files,
554            values,
555            output,
556            execute,
557            dry_run,
558            keep_on_error,
559            list_inputs,
560            input_args,
561        } => templates::instantiate_command(
562            template.as_deref(),
563            &input_args,
564            &instantiate_execute_args_from_env(),
565            &set_values,
566            &set_files,
567            &values,
568            output.as_deref(),
569            execute,
570            dry_run,
571            keep_on_error,
572            list_inputs,
573        ),
574        Commands::Next { input, task, json, no_callbacks, peek, rhei, state_machine } => {
575            let target = resolve_plan_target(input)?;
576            next_command(
577                target.path(),
578                state_machine.or(before_subcommand).as_deref(),
579                task.as_deref(),
580                json,
581                no_callbacks,
582                peek,
583                &target.scope_with(&rhei),
584            )
585        }
586        Commands::Complete { input, task, result, no_callbacks, state_machine } => {
587            let (input, task) = split_complete_ticket_target(input, task)?;
588            let target = resolve_plan_target(input)?;
589            complete_command(
590                target.path(),
591                &target.scope_with(&[]),
592                state_machine.or(before_subcommand).as_deref(),
593                &task,
594                &result,
595                no_callbacks,
596            )
597        }
598        Commands::Release { input, task, all, rhei, dry_run, state_machine } => {
599            let (input, task) = split_ticket_target(input, task)?;
600            let target = resolve_plan_target(input)?;
601            release_command(
602                target.path(),
603                state_machine.or(before_subcommand).as_deref(),
604                task.as_deref(),
605                all,
606                &target.scope_with(&rhei),
607                dry_run,
608            )
609        }
610        Commands::Reset { input, rhei, dry_run, yes, state_machine } => {
611            // §FS-rhei-panta.6: reset destroys runtime state, so it is the one
612            // plan-taking command that never infers an omitted target.
613            let Some(input) = input else {
614                return Err(miette!(
615help = "preview it first: rhei reset <plan-or-project> --dry-run",
616
617                    "`rhei reset` rewrites every in-scope ticket to the initial state \
618                     and deletes runtime artifacts, so it never infers its target. \
619                     Name the plan or project explicitly: `rhei reset <plan-or-project>`"
620                ));
621            };
622            // Reset never *infers* a target, but an explicit member rhei still
623            // loads through its project and narrows to itself. §FS-rhei-panta.6
624            let target = resolve_plan_target(Some(input))?;
625            reset_command(
626                target.path(),
627                state_machine.or(before_subcommand).as_deref(),
628                &target.scope_with(&rhei),
629                dry_run,
630                yes,
631            )
632        }
633        Commands::Version => {
634            print_versions();
635            Ok(())
636        }
637        Commands::InstallSkills { agent, local, link, uninstall, dry_run, skills } => {
638            install_skills_command(agent, local, link, uninstall, dry_run, &skills)
639        }
640        Commands::Completions { shell, install, user: _, system, output, dry_run } => {
641            completions_command(shell, install, system, output.as_deref(), dry_run)
642        }
643    }
644}