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::New { options } => new_command(&options),
437 Commands::Validate { watch, input, state_machine } => {
438 let target = resolve_plan_target(input)?;
441 report_validation_widened(&target);
442 validate_command(target.path(), state_machine.or(before_subcommand).as_deref(), watch)
443 }
444 Commands::Render { input, format, pretty, no_color, no_metadata, no_content, state_machine } => {
445 let target = resolve_plan_target(input)?;
446 render_command(
447 target.path(),
448 &target.scope_with(&[]),
449 state_machine.or(before_subcommand).as_deref(),
450 format,
451 pretty,
452 no_color,
453 no_metadata,
454 no_content,
455 )
456 }
457 Commands::States { input, rhei, json, state_machine } => {
458 states_command(input, state_machine.or(before_subcommand).as_deref(), &rhei, json)
459 }
460 Commands::List {
461 input,
462 rhei,
463 state,
464 assignee,
465 no_assignee,
466 kind,
467 has_prior,
468 parent,
469 root,
470 contains,
471 terminal,
472 non_terminal,
473 ready,
474 blocked,
475 limit,
476 json,
477 state_machine,
478 } => {
479 let target = resolve_plan_target(input)?;
480 let rhei = target.scope_with(&rhei);
481 list_command(
482 target.path(),
483 state_machine.or(before_subcommand).as_deref(),
484 ListFilters {
485 rhei,
486 states: state,
487 assignee,
488 no_assignee,
489 kind,
490 has_prior,
491 parent,
492 root,
493 contains,
494 terminal,
495 non_terminal,
496 ready,
497 blocked,
498 limit,
499 },
500 json,
501 )
502 }
503 Commands::Transition { input, task, from, to, result, no_callbacks, state_machine } => {
504 let (input, task) = split_transition_ticket_target(input, task)?;
505 let target = resolve_plan_target(input)?;
506 transition_command(
507 target.path(),
508 &target.scope_with(&[]),
509 state_machine.or(before_subcommand).as_deref(),
510 &task,
511 &from,
512 &to,
513 result.as_deref(),
514 no_callbacks,
515 )
516 }
517 Commands::Run { input, standalone, agent, program, snapshot, state_machine } => {
518 let target = resolve_plan_target(input)?;
519 let mut opts: RunOptions = (standalone, agent, program, snapshot).into();
520 opts.narrow_to(target.scope_with(opts.rhei_scope()));
521 run_command(target.path(), state_machine.or(before_subcommand).as_deref(), opts)
522 }
523 Commands::Cost { input, task, json, by } => {
527 cost_command(resolve_plan_target(input)?.path(), task.as_deref(), json, by)
528 }
529 Commands::Attach { run, json, since, wait } => {
530 attach_command(run.as_deref(), json, since, wait)
531 }
532 Commands::Runs { json } => runs_command(json),
533 Commands::Stop { run, kill, wait } => stop_command(run.as_deref(), kill, wait),
534 Commands::Intervene { plan, task, slot, message } => {
535 intervene_command(&plan, &task, slot, &message)
536 }
537 Commands::Viz { input, output, open, state_machine } => {
538 let target = resolve_plan_target(input)?;
539 viz_command(
540 target.path(),
541 &target.scope_with(&[]),
542 state_machine.or(before_subcommand).as_deref(),
543 output.as_deref(),
544 open,
545 )
546 }
547 Commands::Snapshot { command, state_machine } => snapshot_command(command, state_machine.or(before_subcommand).as_deref()),
548 Commands::Templates { template, json, source } => {
549 templates::templates_command(json, &source, template.as_deref())
550 }
551 Commands::Instantiate {
552 template,
553 set_values,
554 set_files,
555 values,
556 output,
557 execute,
558 dry_run,
559 keep_on_error,
560 list_inputs,
561 input_args,
562 } => templates::instantiate_command(
563 template.as_deref(),
564 &input_args,
565 &instantiate_execute_args_from_env(),
566 &set_values,
567 &set_files,
568 &values,
569 output.as_deref(),
570 execute,
571 dry_run,
572 keep_on_error,
573 list_inputs,
574 ),
575 Commands::Next { input, task, json, no_callbacks, peek, rhei, state_machine } => {
576 let target = resolve_plan_target(input)?;
577 next_command(
578 target.path(),
579 state_machine.or(before_subcommand).as_deref(),
580 task.as_deref(),
581 json,
582 no_callbacks,
583 peek,
584 &target.scope_with(&rhei),
585 )
586 }
587 Commands::Complete { input, task, result, no_callbacks, state_machine } => {
588 let (input, task) = split_complete_ticket_target(input, task)?;
589 let target = resolve_plan_target(input)?;
590 complete_command(
591 target.path(),
592 &target.scope_with(&[]),
593 state_machine.or(before_subcommand).as_deref(),
594 &task,
595 &result,
596 no_callbacks,
597 )
598 }
599 Commands::Release { input, task, all, rhei, dry_run, state_machine } => {
600 let (input, task) = split_ticket_target(input, task)?;
601 let target = resolve_plan_target(input)?;
602 release_command(
603 target.path(),
604 state_machine.or(before_subcommand).as_deref(),
605 task.as_deref(),
606 all,
607 &target.scope_with(&rhei),
608 dry_run,
609 )
610 }
611 Commands::Reset { input, rhei, dry_run, yes, state_machine } => {
612 let Some(input) = input else {
615 return Err(miette!(
616help = "preview it first: rhei reset <plan-or-project> --dry-run",
617
618 "`rhei reset` rewrites every in-scope ticket to the initial state \
619 and deletes runtime artifacts, so it never infers its target. \
620 Name the plan or project explicitly: `rhei reset <plan-or-project>`"
621 ));
622 };
623 let target = resolve_plan_target(Some(input))?;
626 reset_command(
627 target.path(),
628 state_machine.or(before_subcommand).as_deref(),
629 &target.scope_with(&rhei),
630 dry_run,
631 yes,
632 )
633 }
634 Commands::Version => {
635 print_versions();
636 Ok(())
637 }
638 Commands::InstallSkills { agent, local, link, uninstall, dry_run, skills } => {
639 install_skills_command(agent, local, link, uninstall, dry_run, &skills)
640 }
641 Commands::Completions { shell, install, user: _, system, output, dry_run } => {
642 completions_command(shell, install, system, output.as_deref(), dry_run)
643 }
644 }
645}