diff --git a/README.md b/README.md index ec79572..2121efe 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,13 @@ Most non-daemon commands talk to the daemon through a local Unix socket at durability settings, Iroh endpoint/relay/discovery state, and native backend health for automation. +For automation, `--json` emits one pretty JSON document and `--jsonl` emits one +compact physical line per result. They are mutually exclusive; both success +and error objects carry a stable top-level `type`, and machine-mode failures +exit nonzero without mixing human prose into stdout. The detailed compatibility +contract and examples are in [`docs/compatibility.md`](docs/compatibility.md) +and [`docs/automation-examples.md`](docs/automation-examples.md). + `geth init --admin-key --signing-key --node-name ` records an owner/admin keychain, the local user/device/node binding, and signs canonical keychain payloads through `ssh-keygen -Y sign` using the diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 98483e3..7de1d51 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -370,12 +370,18 @@ pub struct Cli { help = "Use DIR as geth home instead of GETH_HOME or the OS data directory" )] pub home: Option, - #[arg(long, global = true, help = "Print machine-readable JSON output")] + #[arg( + long, + global = true, + conflicts_with = "jsonl", + help = "Print one pretty machine-readable JSON document" + )] pub json: bool, #[arg( long, global = true, - help = "Print newline-delimited JSON output for streaming commands" + conflicts_with = "json", + help = "Print each result as one compact newline-delimited JSON object" )] pub jsonl: bool, #[command(subcommand)] @@ -1585,14 +1591,50 @@ pub enum SshRevocationCommand { #[derive(Debug, Args)] pub struct EmptyArgs {} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OutputMode { + Human, + Json, + Jsonl, +} + +impl OutputMode { + fn from_flags(json: bool, jsonl: bool) -> Self { + if jsonl { + Self::Jsonl + } else if json { + Self::Json + } else { + Self::Human + } + } + + fn is_machine(self) -> bool { + self != Self::Human + } + + fn encode(self, value: &serde_json::Value) -> Result { + match self { + Self::Json => serde_json::to_string_pretty(value).map_err(Into::into), + Self::Jsonl => serde_json::to_string(value).map_err(Into::into), + Self::Human => bail!("internal error: attempted JSON encoding in human output mode"), + } + } + + fn print(self, value: &serde_json::Value) -> Result<()> { + println!("{}", self.encode(value)?); + Ok(()) + } +} + pub async fn run() -> Result<()> { let matches = documented_cli_command().get_matches(); let cli = Cli::from_arg_matches(&matches).context("parse geth command")?; - let json = cli.json || cli.jsonl; + let output = OutputMode::from_flags(cli.json, cli.jsonl); match run_inner(cli).await { Ok(()) => Ok(()), - Err(error) if json => { - print_json_error(&error)?; + Err(error) if output.is_machine() => { + print_json_error(&error, output)?; std::process::exit(1); } Err(error) => Err(error), @@ -1781,7 +1823,11 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> { } async fn run_inner(cli: Cli) -> Result<()> { + let output = OutputMode::from_flags(cli.json, cli.jsonl); if let Command::Completions { shell } = &cli.command { + if output.is_machine() { + bail!("completion scripts cannot be combined with --json or --jsonl"); + } print_completions(*shell); return Ok(()); } @@ -1805,10 +1851,10 @@ async fn run_inner(cli: Cli) -> Result<()> { .context("resolve geth paths")?; match cli.command { Command::Guide { topic } => { - print_guide(topic, cli.json || cli.jsonl)?; + print_guide(topic, output)?; } Command::Config { command } => { - run_config_command(&paths, command, cli.json || cli.jsonl)?; + run_config_command(&paths, command, output)?; } Command::Init { admin_key, @@ -1828,18 +1874,35 @@ async fn run_inner(cli: Cli) -> Result<()> { }, ) .context("initialize geth node")?; - println!("initialized geth home: {}", node.paths.home().display()); - println!("agent: {}", node.agent_id); - println!("node: {}", node.node_id); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "initialized", + "home": node.paths.home(), + "agent_id": node.agent_id, + "node_id": node.node_id, + }))?; + } else { + println!("initialized geth home: {}", node.paths.home().display()); + println!("agent: {}", node.agent_id); + println!("node: {}", node.node_id); + } } Command::Daemon { command: DaemonCommand::Run { ephemeral: true }, } => { - run_ephemeral_daemon(cli.json || cli.jsonl).await?; + run_ephemeral_daemon(output).await?; } Command::Daemon { command: DaemonCommand::Run { ephemeral: false }, } => { + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "daemon-starting", + "home": paths.home(), + "control_endpoint": paths.control_endpoint(), + }))?; + stdout().flush().context("flush daemon startup details")?; + } geth_node::run_daemon(paths) .await .context("run geth daemon")?; @@ -1878,7 +1941,7 @@ async fn run_inner(cli: Cli) -> Result<()> { .await?, ) }; - print_daemon_install_report(&report, readiness.as_ref(), cli.json || cli.jsonl)?; + print_daemon_install_report(&report, readiness.as_ref(), output)?; if readiness.as_ref().is_some_and(|readiness| !readiness.ready) { std::process::exit(1); } @@ -1888,21 +1951,21 @@ async fn run_inner(cli: Cli) -> Result<()> { } => { let report = run_service_command(&paths, ServiceCommand::Start { manager }) .context("start geth user service; install it first with `geth daemon install`")?; - print_service_report(&report, cli.json || cli.jsonl)?; + print_service_report(&report, output)?; } Command::Daemon { command: DaemonCommand::Stop { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Stop { manager }) .context("stop geth user service")?; - print_service_report(&report, cli.json || cli.jsonl)?; + print_service_report(&report, output)?; } Command::Daemon { command: DaemonCommand::Status { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Status { manager }) .context("query geth user service; install it with `geth daemon install`")?; - print_service_report(&report, cli.json || cli.jsonl)?; + print_service_report(&report, output)?; } Command::Daemon { command: @@ -1912,7 +1975,7 @@ async fn run_inner(cli: Cli) -> Result<()> { follow, }, } => { - if follow && (cli.json || cli.jsonl) { + if follow && output.is_machine() { bail!("--follow cannot be combined with --json or --jsonl"); } let report = geth_node::service::logs_user_service( @@ -1922,7 +1985,7 @@ async fn run_inner(cli: Cli) -> Result<()> { follow, ) .context("read geth user-service logs")?; - print_service_logs_report(&report, cli.json || cli.jsonl)?; + print_service_logs_report(&report, output)?; if matches!( report.state, ServiceLogState::NotInstalled | ServiceLogState::Unavailable @@ -1935,31 +1998,30 @@ async fn run_inner(cli: Cli) -> Result<()> { } => { let report = run_service_command(&paths, ServiceCommand::Uninstall { manager }) .context("uninstall geth user service")?; - print_service_report(&report, cli.json || cli.jsonl)?; + print_service_report(&report, output)?; } Command::Daemon { command: DaemonCommand::Service { command }, } => { let report = run_service_command(&paths, command).context("manage geth user service")?; - print_service_report(&report, cli.json || cli.jsonl)?; + print_service_report(&report, output)?; } Command::Wait { command } => { let report = run_wait_command(&paths, command).await?; - print_wait_report(&report, cli.json || cli.jsonl)?; + print_wait_report(&report, output)?; if !report.ready { std::process::exit(1); } } Command::Backup { command } => { - run_backup_command(&paths, command, cli.json || cli.jsonl) - .context("run backup command")?; + run_backup_command(&paths, command, output).context("run backup command")?; } Command::Doctor => { let report = geth_node::doctor::run_doctor(&paths) .await .context("run doctor")?; - print_doctor_report(&report, cli.json || cli.jsonl)?; + print_doctor_report(&report, output)?; if !report.ok { std::process::exit(1); } @@ -1970,7 +2032,7 @@ async fn run_inner(cli: Cli) -> Result<()> { node, bearer_secret, }, - } if !cli.json && !cli.jsonl => { + } if output == OutputMode::Human => { geth_node::stream_ssh_proxy(&paths, node, bearer_secret) .await .context("stream SSH proxy through geth daemon")?; @@ -1983,7 +2045,7 @@ async fn run_inner(cli: Cli) -> Result<()> { target, bearer_secret, }, - } if !cli.json && !cli.jsonl => { + } if output == OutputMode::Human => { println!("forwarding tcp {listen} -> {node}:{target}"); geth_node::run_tcp_forward(&paths, listen, node, target, bearer_secret) .await @@ -1997,7 +2059,7 @@ async fn run_inner(cli: Cli) -> Result<()> { target, bearer_secret, }, - } if !cli.json && !cli.jsonl => { + } if output == OutputMode::Human => { println!( "forwarding unix {} -> {node}:{}", listen.display(), @@ -2017,25 +2079,22 @@ async fn run_inner(cli: Cli) -> Result<()> { paths.socket_path().display() ) })?; - print_response(response, cli.json || cli.jsonl)?; + print_response(response, output)?; } } Ok(()) } -fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> Result<()> { +fn run_config_command(paths: &GethPaths, command: ConfigCommand, output: OutputMode) -> Result<()> { let path = paths.config_file(); match command { ConfigCommand::Path => { - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "config-path", - "path": path, - "exists": path.exists(), - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "config-path", + "path": path, + "exists": path.exists(), + }))?; } else { println!("{}", path.display()); } @@ -2050,17 +2109,14 @@ fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> }; let config = GethConfig::parse(&text) .with_context(|| format!("validate config at {}", path.display()))?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "config", - "path": path, - "source": if exists { "file" } else { "built-in-defaults" }, - "raw": text, - "effective": config_json(&config), - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "config", + "path": path, + "source": if exists { "file" } else { "built-in-defaults" }, + "raw": text, + "effective": config_json(&config), + }))?; } else { println!("# path: {}", path.display()); if !exists { @@ -2076,17 +2132,14 @@ fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> let exists = path.exists(); let config = GethConfig::load(&path) .with_context(|| format!("validate config at {}", path.display()))?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "config-validation", - "path": path, - "valid": true, - "source": if exists { "file" } else { "built-in-defaults" }, - "effective": config_json(&config), - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "config-validation", + "path": path, + "valid": true, + "source": if exists { "file" } else { "built-in-defaults" }, + "effective": config_json(&config), + }))?; } else if exists { println!("valid config: {}", path.display()); } else { @@ -2100,18 +2153,15 @@ fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> let key = ConfigKey::from(key); let config = GethConfig::set(&path, key, &value) .with_context(|| format!("set {} in {}", key.as_str(), path.display()))?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "config-updated", - "path": path, - "key": key.as_str(), - "value": value, - "restart_required": true, - "effective": config_json(&config), - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "config-updated", + "path": path, + "key": key.as_str(), + "value": value, + "restart_required": true, + "effective": config_json(&config), + }))?; } else { println!("updated {} in {}", key.as_str(), path.display()); println!("restart the daemon for the change to take effect"); @@ -2141,19 +2191,16 @@ fn config_json(config: &GethConfig) -> serde_json::Value { }) } -async fn run_ephemeral_daemon(json: bool) -> Result<()> { +async fn run_ephemeral_daemon(output: OutputMode) -> Result<()> { let (home, paths, node) = create_ephemeral_node()?; - if json { - println!( - "{}", - serde_json::to_string(&serde_json::json!({ - "type": "ephemeral-daemon-starting", - "home": paths.home(), - "node_id": node.node_id, - "control_command": format!("geth --home {} status", paths.home().display()), - "cleanup": "state is removed after normal daemon shutdown", - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "ephemeral-daemon-starting", + "home": paths.home(), + "node_id": node.node_id, + "control_command": format!("geth --home {} status", paths.home().display()), + "cleanup": "state is removed after normal daemon shutdown", + }))?; } else { println!("starting ephemeral geth daemon"); println!("home: {}", paths.home().display()); @@ -2181,21 +2228,17 @@ fn create_ephemeral_node() -> Result<(tempfile::TempDir, GethPaths, geth_node::L Ok((home, paths, node)) } -fn print_json_error(error: &anyhow::Error) -> Result<()> { +fn print_json_error(error: &anyhow::Error, output: OutputMode) -> Result<()> { let message = error.to_string(); let detail = format!("{error:#}"); - let hint = json_error_hint(&detail); - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "error", - "code": json_error_code(&detail), - "message": message, - "detail": detail, - "hint": hint, - }))? - ); - Ok(()) + let hint = json_error_hint(&message); + output.print(&serde_json::json!({ + "type": "error", + "code": json_error_code(&detail), + "message": message, + "detail": detail, + "hint": hint, + })) } fn json_error_code(detail: &str) -> &'static str { @@ -2224,7 +2267,7 @@ fn json_error_hint(detail: &str) -> Option { .map(ToOwned::to_owned) } -fn print_guide(topic: Option, json: bool) -> Result<()> { +fn print_guide(topic: Option, output: OutputMode) -> Result<()> { let (name, body) = match topic { None => ("index", GUIDE_INDEX), Some(GuideTopic::Quickstart) => ("quickstart", GUIDE_QUICKSTART), @@ -2237,14 +2280,12 @@ fn print_guide(topic: Option, json: bool) -> Result<()> { Some(GuideTopic::Completions) => ("completions", GUIDE_COMPLETIONS), Some(GuideTopic::SmokeTest) => ("smoke-test", GUIDE_SMOKE_TEST), }; - if json { - println!( - "{}", - serde_json::json!({ - "topic": name, - "body": body, - }) - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "guide", + "topic": name, + "body": body, + }))?; } else { print!("{body}"); } @@ -3060,21 +3101,18 @@ struct WaitReport { reason: String, } -fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> Result<()> { +fn run_backup_command(paths: &GethPaths, command: BackupCommand, output: OutputMode) -> Result<()> { match command { BackupCommand::Create { out } => { let report = geth_node::backup::create_backup(paths, &out)?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "backup-created", - "backup_dir": report.backup_dir, - "files_copied": report.files_copied, - "bytes_copied": report.bytes_copied, - "manifest": report.manifest, - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "backup-created", + "backup_dir": report.backup_dir, + "files_copied": report.files_copied, + "bytes_copied": report.bytes_copied, + "manifest": report.manifest, + }))?; } else { println!("backup: {}", report.backup_dir.display()); println!("files_copied: {}", report.files_copied); @@ -3093,18 +3131,15 @@ fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> target_home, } => { let report = geth_node::backup::restore_backup(&backup_dir, &target_home)?; - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "backup-restored", - "backup_dir": report.backup_dir, - "target_home": report.target_home, - "files_restored": report.files_restored, - "bytes_restored": report.bytes_restored, - "manifest": report.manifest, - }))? - ); + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "backup-restored", + "backup_dir": report.backup_dir, + "target_home": report.target_home, + "files_restored": report.files_restored, + "bytes_restored": report.bytes_restored, + "manifest": report.manifest, + }))?; } else { println!("restored: {}", report.target_home.display()); println!("backup: {}", report.backup_dir.display()); @@ -3120,16 +3155,13 @@ fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> Ok(()) } -fn print_doctor_report(report: &geth_node::doctor::DoctorReport, json: bool) -> Result<()> { - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "doctor", - "ok": report.ok, - "checks": report.checks, - }))? - ); +fn print_doctor_report(report: &geth_node::doctor::DoctorReport, output: OutputMode) -> Result<()> { + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "doctor", + "ok": report.ok, + "checks": report.checks, + }))?; return Ok(()); } @@ -3279,19 +3311,16 @@ where } } -fn print_wait_report(report: &WaitReport, json: bool) -> Result<()> { - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "wait", - "target": report.target, - "ready": report.ready, - "elapsed_ms": report.elapsed_ms, - "attempts": report.attempts, - "reason": report.reason, - }))? - ); +fn print_wait_report(report: &WaitReport, output: OutputMode) -> Result<()> { + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "wait", + "target": report.target, + "ready": report.ready, + "elapsed_ms": report.elapsed_ms, + "attempts": report.attempts, + "reason": report.reason, + }))?; return Ok(()); } println!("target: {}", report.target); @@ -3451,9 +3480,12 @@ fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport println!("note: {}", report.note); } -fn print_response(response: ControlResponse, json: bool) -> Result<()> { - if json { - println!("{}", serde_json::to_string_pretty(&response)?); +fn print_response(response: ControlResponse, output: OutputMode) -> Result<()> { + if let ControlResponse::Error { message } = &response { + bail!(message.clone()); + } + if output.is_machine() { + output.print(&serde_json::to_value(&response)?)?; return Ok(()); } match response { @@ -5104,21 +5136,18 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { fn print_daemon_install_report( report: &ServiceReport, readiness: Option<&WaitReport>, - json: bool, + output: OutputMode, ) -> Result<()> { - if json { + if output.is_machine() { let readiness = readiness.map(wait_report_json); - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "daemon-install", - "service": service_report_json(report), - "readiness": readiness, - }))? - ); + output.print(&serde_json::json!({ + "type": "daemon-install", + "service": service_report_json(report), + "readiness": readiness, + }))?; return Ok(()); } - print_service_report(report, false)?; + print_service_report(report, OutputMode::Human)?; if let Some(readiness) = readiness { println!("readiness:"); println!(" ready: {}", readiness.ready); @@ -5143,6 +5172,7 @@ fn wait_report_json(report: &WaitReport) -> serde_json::Value { fn service_report_json(report: &ServiceReport) -> serde_json::Value { serde_json::json!({ + "type": "service", "manager": report.manager.to_string(), "action": report.action.as_str(), "service_name": report.service_name, @@ -5154,12 +5184,9 @@ fn service_report_json(report: &ServiceReport) -> serde_json::Value { }) } -fn print_service_report(report: &ServiceReport, json: bool) -> Result<()> { - if json { - println!( - "{}", - serde_json::to_string_pretty(&service_report_json(report))? - ); +fn print_service_report(report: &ServiceReport, output: OutputMode) -> Result<()> { + if output.is_machine() { + output.print(&service_report_json(report))?; return Ok(()); } @@ -5186,21 +5213,18 @@ fn print_service_report(report: &ServiceReport, json: bool) -> Result<()> { Ok(()) } -fn print_service_logs_report(report: &ServiceLogsReport, json: bool) -> Result<()> { - if json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "type": "service-logs", - "manager": report.manager.to_string(), - "service_name": report.service_name, - "state": report.state.as_str(), - "sources": report.sources, - "command": report.command, - "lines": report.lines, - "note": report.note, - }))? - ); +fn print_service_logs_report(report: &ServiceLogsReport, output: OutputMode) -> Result<()> { + if output.is_machine() { + output.print(&serde_json::json!({ + "type": "service-logs", + "manager": report.manager.to_string(), + "service_name": report.service_name, + "state": report.state.as_str(), + "sources": report.sources, + "command": report.command, + "lines": report.lines, + "note": report.note, + }))?; return Ok(()); } println!("service: {}", report.service_name); @@ -5413,7 +5437,7 @@ mod tests { key: ConfigKeyArg::SyncLiveSyncIntervalMs, value: "500".to_owned(), }, - false, + OutputMode::Human, ) .expect("set config"); let config = GethConfig::load(&paths.config_file()).expect("load config"); @@ -5446,6 +5470,23 @@ mod tests { )); } + #[test] + fn json_and_jsonl_have_distinct_single_document_contracts() { + let value = serde_json::json!({ + "type": "contract-test", + "nested": { "ok": true }, + }); + let pretty = OutputMode::Json.encode(&value).expect("pretty json"); + let jsonl = OutputMode::Jsonl.encode(&value).expect("jsonl"); + assert!(pretty.contains('\n')); + assert!(!jsonl.contains('\n')); + assert_eq!( + serde_json::from_str::(&jsonl).expect("decode jsonl"), + value + ); + assert!(Cli::try_parse_from(["geth", "status", "--json", "--jsonl"]).is_err()); + } + #[tokio::test] async fn ephemeral_daemon_rejects_an_explicit_persistent_home() { let parsed = Cli::try_parse_from([ diff --git a/docs/automation-examples.md b/docs/automation-examples.md index 2bd1b7f..fcac8bc 100644 --- a/docs/automation-examples.md +++ b/docs/automation-examples.md @@ -4,6 +4,12 @@ These examples are intended as starting points for local scripts and infrastructure automation. They assume the single `geth` executable is on `PATH`. +Use `--json` when one process invocation produces one result that will be +parsed as a complete document. Use `--jsonl` for line-oriented shell pipelines: +each current command writes one compact object on one physical line. The flags +are mutually exclusive, and failures use the same `type: "error"` envelope and +return a nonzero exit status in either mode. + ## Shell Initialize a home, start the daemon in a user-owned process, wait for control, @@ -25,6 +31,13 @@ geth doctor --json geth status --json ``` +For a compact line suitable for an append-only log or `jq -c` pipeline: + +```sh +geth status --jsonl >>geth-status.jsonl +tail -n 1 geth-status.jsonl | jq -r '.type' +``` + Create a backup into a separate directory and validate that it can restore to a new home: diff --git a/docs/compatibility.md b/docs/compatibility.md index bdb1285..3e1f2fc 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -32,15 +32,28 @@ explicit migration expectation before scripts depend on them. ## JSON Output -`--json` output is the preferred script contract. For stable commands, the +`--json` and `--jsonl` are mutually exclusive machine-output modes: + +- `--json` writes exactly one pretty-printed JSON document for the command + result. Embedded newlines are formatting whitespace, so consumers should + parse the complete stdout stream as one document. +- `--jsonl` writes each result as one compact JSON object followed by a + newline. Current non-streaming commands emit one physical line; a future + streaming command may emit multiple records, one complete object per line. + +Machine output does not mix operator prose into stdout. Successful direct CLI +workflows and local-control responses include a stable top-level `type` field; +type and action names use lowercase kebab-case. For stable commands, the top-level response variant name and existing field names keep their meaning within a major release. Execution failures in `--json` or `--jsonl` mode return a stable JSON error object on stdout with `type: "error"`, a machine-readable `code`, a human `message`, full `detail`, and an optional `hint` derived from operator -recovery output. Common codes include `daemon_unavailable`, `unauthorized`, -`peer_not_found`, `resource_not_found`, `invalid_input`, and `command_failed`. +recovery output, and exit nonzero. The envelope is pretty-printed in `--json` +mode and occupies exactly one physical line in `--jsonl` mode. Common codes +include `daemon_unavailable`, `unauthorized`, `peer_not_found`, +`resource_not_found`, `invalid_input`, and `command_failed`. Backward-compatible JSON changes include: diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e5dff7..4c86145 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -121,6 +121,17 @@ For deployment-readiness work that cuts across feature areas, see - `[x]` Tests cover transient path detection/copying, file-log states, and user-scoped generated definitions. +- `[x]` Normalize machine-readable CLI output. + Acceptance criteria: + - `[x]` `--json` emits one pretty JSON document and `--jsonl` emits each + result as one compact physical line; the flags are mutually exclusive. + - `[x]` Direct CLI results, local-control responses, and service reports use + stable top-level `type` fields without mixing operator prose into stdout. + - `[x]` Machine-mode failures use the stable error envelope, include a + focused recovery hint when available, and exit nonzero. + - `[x]` Tests and automation documentation define the single-document and + line-oriented contracts. + - `[ ]` Publish copy-paste installation entrypoints for release artifacts. Acceptance criteria: - `[ ]` Linux, macOS, and Windows installation instructions verify artifact