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 a pipeline quietly when the consumer stops reading, 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/// Restoring `SIGPIPE` to `SIG_DFL` process-wide would be the shorter fix and
190/// is the wrong one: this CLI writes to pipes it owns — a callback
191/// subprocess's stdin, an agent's — and there the write returning `EPIPE` is
192/// how a child that exited early gets *reported*. Under `SIG_DFL` those writes
193/// killed `rhei` mid-diagnostic instead, so a transition that should have
194/// failed with an explanation failed with empty stderr.
195// §FS-rhei-usage.2: an early-closed stdout is normal shell usage, not a failure.
196fn install_quiet_broken_pipe_exit() {
197    let previous = std::panic::take_hook();
198    std::panic::set_hook(Box::new(move |info| {
199        if is_broken_pipe_panic(info) {
200            std::process::exit(EXIT_BROKEN_PIPE);
201        }
202        previous(info);
203    }));
204}
205
206/// Whether a panic is the standard library's "failed printing to stdout"
207/// broken-pipe panic, rather than a real bug.
208///
209/// Matched on the payload text because that is all the standard library
210/// exposes: the panic carries no typed error. Both halves must match, so a
211/// panic that merely mentions a broken pipe in some other context still
212/// reports normally.
213fn is_broken_pipe_panic(info: &std::panic::PanicHookInfo<'_>) -> bool {
214    let payload = info.payload();
215    let message = payload
216        .downcast_ref::<String>()
217        .map(String::as_str)
218        .or_else(|| payload.downcast_ref::<&str>().copied());
219    message.is_some_and(|message| {
220        message.starts_with("failed printing to std") && message.contains("Broken pipe")
221    })
222}
223
224pub fn run() {
225    install_quiet_broken_pipe_exit();
226    install_diagnostic_handler();
227    CompleteEnv::with_factory(cli_command).bin(invoked_bin_name()).complete();
228
229    let cli = match Cli::try_parse() {
230        Ok(cli) => cli,
231        // A bare `rhei` is a request for orientation, so answer it with the
232        // root help on stdout and a success exit. Every *other* missing
233        // subcommand — `rhei snapshot`, say — is a usage error about that
234        // subcommand: let clap render its own contextual help to stderr with
235        // the conventional exit code, or a script cannot tell the two apart.
236        Err(err)
237            if is_bare_invocation()
238                && matches!(
239                    err.kind(),
240                    ErrorKind::MissingSubcommand
241                        | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
242                ) =>
243        {
244            let mut cmd = cli_command();
245            if let Err(io_err) = cmd.print_help() {
246                eprintln!("failed to write CLI help: {io_err}");
247                std::process::exit(1);
248            }
249            println!();
250            return;
251        }
252        Err(err) => err.exit(),
253    };
254
255    let json_mode = command_wants_json(&cli.command);
256
257    if let Err(err) = dispatch(cli) {
258        if json_mode {
259            emit_json_error(&err);
260        } else {
261            eprintln!("{err:?}");
262        }
263        std::process::exit(1);
264    }
265}
266
267/// Returns true when the invoked command's output format is JSON. In that
268/// case, errors are rendered as a single-line JSON object on stderr instead
269/// of the default miette text, so machine consumers don't have to parse two
270/// shapes.
271fn command_wants_json(command: &Commands) -> bool {
272    match command {
273        Commands::Next { json, .. } => *json,
274        Commands::States { json, .. } => *json,
275        Commands::List { json, .. } => *json,
276        Commands::Snapshot { command: SnapshotCommand::List { format, .. }, .. } => {
277            matches!(format, SnapshotListFormat::Json)
278        }
279        Commands::Templates { json, .. } => *json,
280        Commands::Cost { json, .. } => *json,
281        Commands::Render { format, .. } => matches!(format, RenderFormat::Json),
282        _ => false,
283    }
284}
285
286fn emit_json_error(err: &miette::Report) {
287    // §FS-rhei-errors.5: machine consumers get the same next action as humans.
288    let mut error = serde_json::json!({ "message": err.to_string() });
289    if let Some(help) = err.help() {
290        error["help"] = serde_json::Value::String(help.to_string());
291    }
292    let payload = serde_json::json!({ "error": error });
293    let serialized = serde_json::to_string(&payload)
294        .unwrap_or_else(|_| format!("{{\"error\":{{\"message\":{:?}}}}}", err.to_string()));
295    eprintln!("{serialized}");
296}
297
298/// Dispatch the parsed CLI command.
299fn dispatch(cli: Cli) -> MietteResult<()> {
300    // `--state-machine` is accepted both before the subcommand and on the
301    // subcommands that read one; the subcommand copy wins when both appear.
302    let before_subcommand = cli.state_machine;
303    match cli.command {
304        Commands::Init { dir, here, title, no_agents, force } => {
305            init_command(dir.as_deref(), title.as_deref(), no_agents, force, here)
306        }
307        Commands::Validate { watch, input, state_machine } => {
308            // §FS-rhei-validate.1.1: validation never narrows — a member rhei
309            // validates the project it cannot resolve without.
310            let target = resolve_plan_target(input)?;
311            report_validation_widened(&target);
312            validate_command(target.path(), state_machine.or(before_subcommand).as_deref(), watch)
313        }
314        Commands::Render { input, format, pretty, no_color, no_metadata, no_content, state_machine } => {
315            let target = resolve_plan_target(input)?;
316            render_command(
317                target.path(),
318                &target.scope_with(&[]),
319                state_machine.or(before_subcommand).as_deref(),
320                format,
321                pretty,
322                no_color,
323                no_metadata,
324                no_content,
325            )
326        }
327        Commands::States { input, rhei, json, state_machine } => {
328            states_command(input, state_machine.or(before_subcommand).as_deref(), &rhei, json)
329        }
330        Commands::List {
331            input,
332            rhei,
333            state,
334            assignee,
335            no_assignee,
336            kind,
337            has_prior,
338            parent,
339            root,
340            contains,
341            terminal,
342            non_terminal,
343            ready,
344            blocked,
345            limit,
346            json,
347            state_machine,
348        } => {
349            let target = resolve_plan_target(input)?;
350            let rhei = target.scope_with(&rhei);
351            list_command(
352            target.path(),
353            state_machine.or(before_subcommand).as_deref(),
354            ListFilters {
355                rhei,
356                states: state,
357                assignee,
358                no_assignee,
359                kind,
360                has_prior,
361                parent,
362                root,
363                contains,
364                terminal,
365                non_terminal,
366                ready,
367                blocked,
368                limit,
369            },
370            json,
371            )
372        }
373        Commands::Transition { input, task, from, to, result, no_callbacks, state_machine } => {
374            let (input, task) = split_transition_ticket_target(input, task)?;
375            let target = resolve_plan_target(input)?;
376            transition_command(
377                target.path(),
378                &target.scope_with(&[]),
379                state_machine.or(before_subcommand).as_deref(),
380                &task,
381                &from,
382                &to,
383                result.as_deref(),
384                no_callbacks,
385            )
386        }
387        Commands::Run { input, standalone, agent, program, snapshot, state_machine } => {
388            let target = resolve_plan_target(input)?;
389            let mut opts: RunOptions = (standalone, agent, program, snapshot).into();
390            opts.narrow_to(target.scope_with(opts.rhei_scope()));
391            run_command(target.path(), state_machine.or(before_subcommand).as_deref(), opts)
392        }
393        // `rhei cost` reads accounting artifacts under the target's own runtime
394        // root and resolves no dependency graph, so it stays on the path it was
395        // given rather than widening to the enclosing project.
396        Commands::Cost { input, task, json, by } => {
397            cost_command(resolve_plan_target(input)?.path(), task.as_deref(), json, by)
398        }
399        Commands::Intervene { plan, task, slot, message } => {
400            intervene_command(&plan, &task, slot, &message)
401        }
402        Commands::Viz { input, output, open, state_machine } => {
403            let target = resolve_plan_target(input)?;
404            viz_command(
405                target.path(),
406                &target.scope_with(&[]),
407                state_machine.or(before_subcommand).as_deref(),
408                output.as_deref(),
409                open,
410            )
411        }
412        Commands::Snapshot { command, state_machine } => snapshot_command(command, state_machine.or(before_subcommand).as_deref()),
413        Commands::Templates { template, json, source } => {
414            templates::templates_command(json, &source, template.as_deref())
415        }
416        Commands::Instantiate {
417            template,
418            set_values,
419            set_files,
420            values,
421            output,
422            execute,
423            dry_run,
424            keep_on_error,
425            list_inputs,
426            input_args,
427        } => templates::instantiate_command(
428            template.as_deref(),
429            &input_args,
430            &instantiate_execute_args_from_env(),
431            &set_values,
432            &set_files,
433            &values,
434            output.as_deref(),
435            execute,
436            dry_run,
437            keep_on_error,
438            list_inputs,
439        ),
440        Commands::Next { input, task, json, no_callbacks, peek, rhei, state_machine } => {
441            let target = resolve_plan_target(input)?;
442            next_command(
443                target.path(),
444                state_machine.or(before_subcommand).as_deref(),
445                task.as_deref(),
446                json,
447                no_callbacks,
448                peek,
449                &target.scope_with(&rhei),
450            )
451        }
452        Commands::Complete { input, task, result, no_callbacks, state_machine } => {
453            let (input, task) = split_complete_ticket_target(input, task)?;
454            let target = resolve_plan_target(input)?;
455            complete_command(
456                target.path(),
457                &target.scope_with(&[]),
458                state_machine.or(before_subcommand).as_deref(),
459                &task,
460                &result,
461                no_callbacks,
462            )
463        }
464        Commands::Release { input, task, all, rhei, dry_run, state_machine } => {
465            let (input, task) = split_ticket_target(input, task)?;
466            let target = resolve_plan_target(input)?;
467            release_command(
468                target.path(),
469                state_machine.or(before_subcommand).as_deref(),
470                task.as_deref(),
471                all,
472                &target.scope_with(&rhei),
473                dry_run,
474            )
475        }
476        Commands::Reset { input, rhei, dry_run, yes, state_machine } => {
477            // §FS-rhei-panta.6: reset destroys runtime state, so it is the one
478            // plan-taking command that never infers an omitted target.
479            let Some(input) = input else {
480                return Err(miette!(
481help = "preview it first: rhei reset <plan-or-project> --dry-run",
482
483                    "`rhei reset` rewrites every in-scope ticket to the initial state \
484                     and deletes runtime artifacts, so it never infers its target. \
485                     Name the plan or project explicitly: `rhei reset <plan-or-project>`"
486                ));
487            };
488            // Reset never *infers* a target, but an explicit member rhei still
489            // loads through its project and narrows to itself. §FS-rhei-panta.6
490            let target = resolve_plan_target(Some(input))?;
491            reset_command(
492                target.path(),
493                state_machine.or(before_subcommand).as_deref(),
494                &target.scope_with(&rhei),
495                dry_run,
496                yes,
497            )
498        }
499        Commands::Version => {
500            print_versions();
501            Ok(())
502        }
503        Commands::InstallSkills { agent, local, link, uninstall, dry_run, skills } => {
504            install_skills_command(agent, local, link, uninstall, dry_run, &skills)
505        }
506        Commands::Completions { shell, install, user: _, system, output, dry_run } => {
507            completions_command(shell, install, system, output.as_deref(), dry_run)
508        }
509    }
510}