cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit d5aaed12621292f487d1465e3f03fbe537e8bcb5
parent 26cdd84e134cfdadef4f24d764a9c25be7cd205c
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 08:46:37 +0000

cli: add human output renderer

- render default human output as concise status text
- preserve structured error codes and messages in human failures
- document json and ndjson output mode semantics
- add process coverage for human success and failure output

Diffstat:
Msrc/main.rs | 40+++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 32++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+), 1 deletion(-)

diff --git a/src/main.rs b/src/main.rs @@ -446,7 +446,10 @@ fn render_envelope( let stdout = std::io::stdout(); let mut handle = stdout.lock(); match format { - TargetOutputFormat::Human | TargetOutputFormat::Json => { + TargetOutputFormat::Human => { + render_human_envelope(&mut handle, envelope)?; + } + TargetOutputFormat::Json => { serde_json::to_writer_pretty(&mut handle, envelope)?; } TargetOutputFormat::Ndjson => { @@ -461,6 +464,41 @@ fn render_envelope( Ok(()) } +fn render_human_envelope( + handle: &mut impl Write, + envelope: &OutputEnvelope, +) -> Result<(), runtime::RuntimeError> { + writeln!( + handle, + "{}: {}", + envelope.operation_id, + human_envelope_status(envelope) + )?; + writeln!(handle, "request_id: {}", envelope.request_id)?; + if let Some(error) = envelope.errors.first() { + writeln!(handle, "error: {}", error.code)?; + writeln!(handle, "message: {}", error.message)?; + } + Ok(()) +} + +fn human_envelope_status(envelope: &OutputEnvelope) -> &str { + if !envelope.errors.is_empty() { + return "error"; + } + if let Some(state) = envelope + .result + .get("state") + .and_then(|value| value.as_str()) + { + return state; + } + if envelope.dry_run { + return "dry_run"; + } + "ok" +} + fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { envelope .errors diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -171,6 +171,38 @@ fn target_command_outputs_standard_json_envelope() { } #[test] +fn default_human_output_is_concise_and_not_json() { + let output = radroots() + .args(["workspace", "get"]) + .output() + .expect("run workspace get"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + + assert!(stdout.starts_with("workspace.get: ok\n")); + assert!(stdout.contains("request_id: req_workspace_get_")); + assert!(serde_json::from_str::<Value>(&stdout).is_err()); +} + +#[test] +fn human_failure_output_preserves_error_code_and_message() { + let output = radroots() + .args(["--format", "human", "order", "submit"]) + .output() + .expect("run order submit"); + + assert_eq!(output.status.code(), Some(6)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + + assert!(stdout.starts_with("order.submit: error\n")); + assert!(stdout.contains("request_id: req_order_submit_")); + assert!(stdout.contains("error: approval_required")); + assert!(stdout.contains("message: missing required `approval_token` input")); + assert!(serde_json::from_str::<Value>(&stdout).is_err()); +} + +#[test] fn request_ids_are_invocation_unique_and_preserve_caller_fields() { let first = radroots() .args([