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() {
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
206fn 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 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
267fn 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 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
298fn dispatch(cli: Cli) -> MietteResult<()> {
300 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 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 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 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 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}