#[derive(Subcommand, Debug)]
enum SnapshotCommand {
List {
#[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
plan: PathBuf,
#[arg(long, value_name = "ID", add = ArgValueCompleter::new(complete_task_id))]
task: Option<String>,
#[arg(long, value_name = "SNAPSHOT")]
name: Option<String>,
#[arg(long, value_name = "STATE", add = ArgValueCompleter::new(complete_state_name))]
state: Option<String>,
#[arg(long, value_enum, default_value = "orchestrator")]
produced_by: SnapshotProducedByFilter,
#[arg(long)]
orphaned: bool,
#[arg(long, value_enum, default_value = "text")]
format: SnapshotListFormat,
},
Show {
#[arg(value_name = "REF")]
reference: String,
#[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
plan: PathBuf,
},
Gc {
#[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
plan: PathBuf,
#[arg(long, value_name = "ID", add = ArgValueCompleter::new(complete_task_id))]
task: Option<String>,
#[arg(long, value_name = "SNAPSHOT")]
name: Option<String>,
#[arg(long, value_name = "DURATION")]
older_than: Option<String>,
#[arg(long, value_name = "N")]
keep_generations: Option<usize>,
#[arg(long)]
include_operator: bool,
#[arg(long)]
orphaned: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
},
Continue {
#[arg(value_name = "REF")]
reference: String,
#[arg(long, value_name = "RHEI_PLAN", default_value = ".", add = ArgValueCompleter::new(complete_rhei_plan_path))]
plan: PathBuf,
#[arg(long, value_name = "SLUG")]
target: Option<String>,
#[arg(long, value_name = "N")]
generation: Option<u64>,
#[arg(long)]
no_capture: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum SnapshotProducedByFilter {
Orchestrator,
Operator,
All,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum SnapshotListFormat {
Text,
Json,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum RenderFormat {
Json,
Github,
Progress,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum Agent {
ClaudeCode,
Cursor,
Windsurf,
Copilot,
Kilocode,
Pi,
Codex,
Antigravity,
All,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum CompletionShell {
Bash,
Zsh,
Fish,
#[value(name = "powershell")]
PowerShell,
Elvish,
}
impl CompletionShell {
fn as_str(self) -> &'static str {
match self {
CompletionShell::Bash => "bash",
CompletionShell::Zsh => "zsh",
CompletionShell::Fish => "fish",
CompletionShell::PowerShell => "powershell",
CompletionShell::Elvish => "elvish",
}
}
}
fn install_diagnostic_handler() {
let _ = miette::set_hook(Box::new(|_| {
Box::new(
miette::MietteHandlerOpts::new()
.break_words(false)
.word_separator(textwrap::WordSeparator::AsciiSpace)
.word_splitter(textwrap::WordSplitter::NoHyphenation)
.build(),
)
}));
}
fn is_bare_invocation() -> bool {
std::env::args_os().count() <= 1
}
const EXIT_BROKEN_PIPE: i32 = 141;
fn install_quiet_broken_pipe_exit() {
record_startup_terminals();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if is_lost_output_panic(info) {
terminate_all_live_groups();
let code = interrupt_exit_code().unwrap_or(EXIT_BROKEN_PIPE);
finalize_run_descriptor(code);
std::process::exit(code);
}
previous(info);
}));
}
const BROKEN_PIPE_MARKERS: [&str; 2] = ["Broken pipe", "(os error 32)"];
const IO_ERROR_MARKERS: [&str; 2] = ["Input/output error", "(os error 5)"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LostStream {
Stdout,
Stderr,
}
fn printing_failure_stream(message: &str) -> Option<LostStream> {
let rest = message.strip_prefix("failed printing to ")?;
if rest.starts_with("stdout") {
Some(LostStream::Stdout)
} else if rest.starts_with("stderr") {
Some(LostStream::Stderr)
} else {
None
}
}
static STARTUP_TERMINALS: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new();
fn record_startup_terminals() -> (bool, bool) {
*STARTUP_TERMINALS.get_or_init(|| {
use std::io::IsTerminal as _;
(std::io::stdout().is_terminal(), std::io::stderr().is_terminal())
})
}
fn stream_is_terminal(stream: LostStream) -> bool {
let (stdout, stderr) = record_startup_terminals();
match stream {
LostStream::Stdout => stdout,
LostStream::Stderr => stderr,
}
}
fn is_lost_output_panic(info: &std::panic::PanicHookInfo<'_>) -> bool {
let payload = info.payload();
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied());
message.is_some_and(message_is_lost_output)
}
fn message_is_lost_output(message: &str) -> bool {
lost_output_verdict(message, stream_is_terminal)
}
fn lost_output_verdict(message: &str, is_terminal: impl Fn(LostStream) -> bool) -> bool {
let Some(stream) = printing_failure_stream(message) else {
return false;
};
if BROKEN_PIPE_MARKERS.iter().any(|marker| message.contains(marker)) {
return true;
}
IO_ERROR_MARKERS.iter().any(|marker| message.contains(marker)) && is_terminal(stream)
}
pub fn run() {
install_quiet_broken_pipe_exit();
install_diagnostic_handler();
CompleteEnv::with_factory(cli_command).bin(invoked_bin_name()).complete();
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err)
if is_bare_invocation()
&& matches!(
err.kind(),
ErrorKind::MissingSubcommand
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) =>
{
let mut cmd = cli_command();
if let Err(io_err) = cmd.print_help() {
eprintln!("failed to write CLI help: {io_err}");
std::process::exit(1);
}
println!();
return;
}
Err(err) => err.exit(),
};
let json_mode = command_wants_json(&cli.command);
if let Err(err) = dispatch(cli) {
if json_mode {
emit_json_error(&err);
} else {
eprintln!("{err:?}");
}
let code = interrupt_exit_code().unwrap_or(1);
finalize_run_descriptor(code);
std::process::exit(code);
}
if let Some(code) = interrupt_exit_code() {
finalize_run_descriptor(code);
std::process::exit(code);
}
finalize_run_descriptor(0);
}
fn command_wants_json(command: &Commands) -> bool {
match command {
Commands::Next { json, .. } => *json,
Commands::States { json, .. } => *json,
Commands::List { json, .. } => *json,
Commands::Snapshot { command: SnapshotCommand::List { format, .. }, .. } => {
matches!(format, SnapshotListFormat::Json)
}
Commands::Templates { json, .. } => *json,
Commands::Cost { json, .. } => *json,
Commands::Runs { json } => *json,
Commands::Attach { json, .. } => *json,
Commands::Run { standalone, .. } => standalone.json,
Commands::Render { format, .. } => matches!(format, RenderFormat::Json),
_ => false,
}
}
fn emit_json_error(err: &miette::Report) {
let mut error = serde_json::json!({ "message": err.to_string() });
if let Some(help) = err.help() {
error["help"] = serde_json::Value::String(help.to_string());
}
let payload = serde_json::json!({ "error": error });
let serialized = serde_json::to_string(&payload)
.unwrap_or_else(|_| format!("{{\"error\":{{\"message\":{:?}}}}}", err.to_string()));
eprintln!("{serialized}");
}
fn dispatch(cli: Cli) -> MietteResult<()> {
let before_subcommand = cli.state_machine;
match cli.command {
Commands::Init { dir, here, title, no_agents, force } => {
init_command(dir.as_deref(), title.as_deref(), no_agents, force, here)
}
Commands::New { options } => new_command(&options),
Commands::Validate { watch, input, state_machine } => {
let target = resolve_plan_target(input)?;
report_validation_widened(&target);
validate_command(target.path(), state_machine.or(before_subcommand).as_deref(), watch)
}
Commands::Render { input, format, pretty, no_color, no_metadata, no_content, state_machine } => {
let target = resolve_plan_target(input)?;
render_command(
target.path(),
&target.scope_with(&[]),
state_machine.or(before_subcommand).as_deref(),
format,
pretty,
no_color,
no_metadata,
no_content,
)
}
Commands::States { input, rhei, json, state_machine } => {
states_command(input, state_machine.or(before_subcommand).as_deref(), &rhei, json)
}
Commands::List {
input,
rhei,
state,
assignee,
no_assignee,
kind,
has_prior,
parent,
root,
contains,
terminal,
non_terminal,
ready,
blocked,
limit,
json,
state_machine,
} => {
let target = resolve_plan_target(input)?;
let rhei = target.scope_with(&rhei);
list_command(
target.path(),
state_machine.or(before_subcommand).as_deref(),
ListFilters {
rhei,
states: state,
assignee,
no_assignee,
kind,
has_prior,
parent,
root,
contains,
terminal,
non_terminal,
ready,
blocked,
limit,
},
json,
)
}
Commands::Transition { input, task, from, to, result, no_callbacks, state_machine } => {
let (input, task) = split_transition_ticket_target(input, task)?;
let target = resolve_plan_target(input)?;
transition_command(
target.path(),
&target.scope_with(&[]),
state_machine.or(before_subcommand).as_deref(),
&task,
&from,
&to,
result.as_deref(),
no_callbacks,
)
}
Commands::Run { input, standalone, agent, program, snapshot, state_machine } => {
let target = resolve_plan_target(input)?;
let mut opts: RunOptions = (standalone, agent, program, snapshot).into();
opts.narrow_to(target.scope_with(opts.rhei_scope()));
run_command(target.path(), state_machine.or(before_subcommand).as_deref(), opts)
}
Commands::Cost { input, task, json, by } => {
cost_command(resolve_plan_target(input)?.path(), task.as_deref(), json, by)
}
Commands::Attach { run, json, since, wait } => {
attach_command(run.as_deref(), json, since, wait)
}
Commands::Runs { json } => runs_command(json),
Commands::Stop { run, kill, wait } => stop_command(run.as_deref(), kill, wait),
Commands::Intervene { plan, task, slot, message } => {
intervene_command(&plan, &task, slot, &message)
}
Commands::Viz { input, output, open, state_machine } => {
let target = resolve_plan_target(input)?;
viz_command(
target.path(),
&target.scope_with(&[]),
state_machine.or(before_subcommand).as_deref(),
output.as_deref(),
open,
)
}
Commands::Snapshot { command, state_machine } => snapshot_command(command, state_machine.or(before_subcommand).as_deref()),
Commands::Templates { template, json, source } => {
templates::templates_command(json, &source, template.as_deref())
}
Commands::Instantiate {
template,
set_values,
set_files,
values,
output,
execute,
dry_run,
keep_on_error,
list_inputs,
input_args,
} => templates::instantiate_command(
template.as_deref(),
&input_args,
&instantiate_execute_args_from_env(),
&set_values,
&set_files,
&values,
output.as_deref(),
execute,
dry_run,
keep_on_error,
list_inputs,
),
Commands::Next { input, task, json, no_callbacks, peek, rhei, state_machine } => {
let target = resolve_plan_target(input)?;
next_command(
target.path(),
state_machine.or(before_subcommand).as_deref(),
task.as_deref(),
json,
no_callbacks,
peek,
&target.scope_with(&rhei),
)
}
Commands::Complete { input, task, result, no_callbacks, state_machine } => {
let (input, task) = split_complete_ticket_target(input, task)?;
let target = resolve_plan_target(input)?;
complete_command(
target.path(),
&target.scope_with(&[]),
state_machine.or(before_subcommand).as_deref(),
&task,
&result,
no_callbacks,
)
}
Commands::Release { input, task, all, rhei, dry_run, state_machine } => {
let (input, task) = split_ticket_target(input, task)?;
let target = resolve_plan_target(input)?;
release_command(
target.path(),
state_machine.or(before_subcommand).as_deref(),
task.as_deref(),
all,
&target.scope_with(&rhei),
dry_run,
)
}
Commands::Reset { input, rhei, dry_run, yes, state_machine } => {
let Some(input) = input else {
return Err(miette!(
help = "preview it first: rhei reset <plan-or-project> --dry-run",
"`rhei reset` rewrites every in-scope ticket to the initial state \
and deletes runtime artifacts, so it never infers its target. \
Name the plan or project explicitly: `rhei reset <plan-or-project>`"
));
};
let target = resolve_plan_target(Some(input))?;
reset_command(
target.path(),
state_machine.or(before_subcommand).as_deref(),
&target.scope_with(&rhei),
dry_run,
yes,
)
}
Commands::Version => {
print_versions();
Ok(())
}
Commands::InstallSkills { agent, local, link, uninstall, dry_run, skills } => {
install_skills_command(agent, local, link, uninstall, dry_run, &skills)
}
Commands::Completions { shell, install, user: _, system, output, dry_run } => {
completions_command(shell, install, system, output.as_deref(), dry_run)
}
}
}