1#[derive(Subcommand, Debug)]
3enum SnapshotCommand {
4 List {
6 #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
8 plan: PathBuf,
9 #[arg(long, value_name = "ID", add = ArgValueCompleter::new(complete_task_id))]
11 task: Option<String>,
12 #[arg(long, value_name = "SNAPSHOT")]
14 name: Option<String>,
15 #[arg(long, value_name = "STATE", add = ArgValueCompleter::new(complete_state_name))]
17 state: Option<String>,
18 #[arg(long, value_enum, default_value = "orchestrator")]
20 produced_by: SnapshotProducedByFilter,
21 #[arg(long)]
23 orphaned: bool,
24 #[arg(long, value_enum, default_value = "text")]
26 format: SnapshotListFormat,
27 },
28 Show {
30 #[arg(value_name = "REF")]
32 reference: String,
33 #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
35 plan: PathBuf,
36 },
37 Gc {
39 #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
41 plan: PathBuf,
42 #[arg(long, value_name = "ID", add = ArgValueCompleter::new(complete_task_id))]
44 task: Option<String>,
45 #[arg(long, value_name = "SNAPSHOT")]
47 name: Option<String>,
48 #[arg(long, value_name = "DURATION")]
50 older_than: Option<String>,
51 #[arg(long, value_name = "N")]
53 keep_generations: Option<usize>,
54 #[arg(long)]
56 include_operator: bool,
57 #[arg(long)]
59 orphaned: bool,
60 #[arg(long)]
62 dry_run: bool,
63 #[arg(long)]
65 force: bool,
66 },
67 Continue {
69 #[arg(value_name = "REF")]
71 reference: String,
72 #[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
74 plan: PathBuf,
75 #[arg(long, value_name = "SLUG")]
77 target: Option<String>,
78 #[arg(long, value_name = "N")]
80 generation: Option<u64>,
81 #[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#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
102enum RenderFormat {
103 Json,
104 Github,
105 Progress,
106}
107
108#[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#[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
145fn 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
168fn is_bare_invocation() -> bool {
173 std::env::args_os().count() <= 1
174}
175
176const EXIT_BROKEN_PIPE: i32 = 141;
180
181fn install_quiet_broken_pipe_exit() {
203 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 terminate_all_live_groups();
213 let code = interrupt_exit_code().unwrap_or(EXIT_BROKEN_PIPE);
217 finalize_run_descriptor(code);
221 std::process::exit(code);
222 }
223 previous(info);
224 }));
225}
226
227const BROKEN_PIPE_MARKERS: [&str; 2] = ["Broken pipe", "(os error 32)"];
231
232const IO_ERROR_MARKERS: [&str; 2] = ["Input/output error", "(os error 5)"];
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237enum LostStream {
238 Stdout,
239 Stderr,
240}
241
242fn 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
255static STARTUP_TERMINALS: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
268
269fn 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 let (stdout, stderr) = record_startup_terminals();
282 match stream {
283 LostStream::Stdout => stdout,
284 LostStream::Stderr => stderr,
285 }
286}
287
288fn 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
308fn 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 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 let code = interrupt_exit_code().unwrap_or(1);
375 finalize_run_descriptor(code);
376 std::process::exit(code);
377 }
378 if let Some(code) = interrupt_exit_code() {
382 finalize_run_descriptor(code);
383 std::process::exit(code);
384 }
385 finalize_run_descriptor(0);
389}
390
391fn 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 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 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
427fn dispatch(cli: Cli) -> MietteResult<()> {
429 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 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 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 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 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}