diff --git a/.agents/skills/godaddy-cli/SKILL.md b/.agents/skills/godaddy-cli/SKILL.md index aaa05f3..74f77d3 100644 --- a/.agents/skills/godaddy-cli/SKILL.md +++ b/.agents/skills/godaddy-cli/SKILL.md @@ -8,7 +8,7 @@ tags: [godaddy, cli, commerce, applications, deploy] # Using the GoDaddy CLI -The `godaddy` CLI is an agent-first tool. Every command returns a single JSON envelope to stdout. There is no plain text mode, no `--json` flag, and no table output. Parse stdout as JSON. +The `godaddy` CLI is an agent-first tool. Regular executable commands in JSON mode return a single GoDaddy JSON envelope to stdout. Help and version output remain text, non-JSON formats pass through in their native shape, and streaming commands are outside the regular envelope adapter. ## Quick Start @@ -32,18 +32,21 @@ godaddy application info ## Output Contract -### Every response is JSON +### Regular command responses are JSON -Every command writes exactly one JSON object to stdout followed by a newline. Parse it directly. Debug/verbose messages go to stderr only. +Regular commands write one JSON object to stdout followed by a newline. This includes error envelopes, which are written to stdout while retaining a non-zero exit code. Help, version, and non-JSON diagnostics retain their text format and original stream. ### Success ```json { "ok": true, - "command": "godaddy application list", - "result": { ... }, - "next_actions": [ ... ] + "command": "gddy env get", + "result": { + "apiUrl": "https://api.godaddy.com", + "env": "prod" + }, + "next_actions": [] } ``` @@ -52,19 +55,16 @@ Every command writes exactly one JSON object to stdout followed by a newline. Pa ```json { "ok": false, - "command": "godaddy application info demo", + "command": "gddy env get", "error": { - "message": "Application 'demo' not found", - "code": "NOT_FOUND" + "message": "unknown environment \"not-an-environment\"; known: ote, prod", + "code": "ERROR" }, - "fix": "Use discovery commands such as: godaddy application list or godaddy actions list.", - "next_actions": [ ... ] + "next_actions": [] } ``` -Check `ok` first. On failure, read `error.code` for programmatic handling and `fix` for the suggested recovery step. - -Error codes: `NOT_FOUND`, `AUTH_REQUIRED`, `VALIDATION_ERROR`, `NETWORK_ERROR`, `CONFIG_ERROR`, `SECURITY_BLOCKED`, `COMMAND_NOT_FOUND`, `UNSUPPORTED_OPTION`, `UNEXPECTED_ERROR`. +Check `ok` first. On failure, read `error.code` and `error.message`; error codes are command-specific. ### next_actions (HATEOAS) @@ -124,12 +124,14 @@ If `truncated` is `true`, the complete data is at the `full_output` file path. R | Flag | Alias | Effect | |------|-------|--------| -| `--pretty` | | Pretty-print JSON with 2-space indentation | -| `--env ` | `-e` | Override environment for this command (`ote` or `prod`) | -| `--verbose` | `-v` | Log HTTP requests/responses to stderr | -| `--debug` | `-vv` | Full verbose output to stderr | +| `--env ` | | Override environment for this command (`ote` or `prod`) | +| `--debug` | | Enable debug logging on stderr | +| `--output ` | `-o` | Select `json`, `human`, or `toon` output | +| `--json` | | Shorthand for `--output json` | +| `--human` | | Shorthand for `--output human` | +| `--toon` | | Shorthand for `--output toon` | -These can appear anywhere in the command. They do not affect the JSON structure — only formatting and stderr diagnostics. +These options can appear anywhere in the command. The GoDaddy adapter applies to regular commands only when cli-engine renders JSON. JSON envelopes use two-space indentation by default; `--pretty` is not registered. ## Discovery @@ -246,11 +248,8 @@ All `add` commands accept `--config ` and `--environment ` to target godaddy application release --release-version 1.0.0 godaddy application release --release-version 1.0.0 --description "Initial release" -# Deploy +# Deploy (this command streams progress) godaddy application deploy - -# Deploy with streaming progress (NDJSON) -godaddy application deploy --follow ``` Release and deploy accept `--config ` and `--environment `. @@ -321,28 +320,10 @@ Returns up to 50 events inline; use `full_output` path for the complete list (19 ## NDJSON Streaming -When `--follow` is used (currently on `deploy`), output is multiple JSON lines instead of one envelope. Each line has a `type` field: - -``` -{"type":"start","command":"godaddy application deploy my-app --follow","ts":"..."} -{"type":"step","name":"security-scan","status":"started","ts":"..."} -{"type":"step","name":"security-scan","status":"completed","ts":"..."} -{"type":"step","name":"bundle","status":"started","extension_name":"my-widget","ts":"..."} -{"type":"progress","name":"bundle","percent":50,"ts":"..."} -{"type":"step","name":"bundle","status":"completed","ts":"..."} -{"type":"result","ok":true,"command":"...","result":{...},"next_actions":[...]} -``` - -The **last line is always terminal** (`type: "result"` or `type: "error"`). It has the same shape as a standard envelope. If you only care about the final outcome, read the last line. - -Stream event types: -| Type | Meaning | Terminal? | -|------|---------|-----------| -| `start` | Stream begun | No | -| `step` | Step lifecycle (started/completed/failed) | No | -| `progress` | Progress update (percent, message) | No | -| `result` | Success envelope | Yes | -| `error` | Error envelope | Yes | +Streaming commands are not adapted by the regular-command output envelope. +Their terminal `result` and `error` event contract is tracked separately and is +not guaranteed by the current binary. Do not assume the final line has the +regular envelope shape. ## Typical Workflows @@ -361,7 +342,7 @@ godaddy application add action --name my-action \ # 4. Add action godaddy application validate my-app # 5. Validate godaddy application release my-app \ # 6. Release --release-version 1.0.0 -godaddy application deploy my-app --follow # 7. Deploy +godaddy application deploy my-app # 7. Deploy godaddy application enable my-app --store-id # 8. Enable ``` @@ -373,7 +354,7 @@ godaddy application update my-app --description "New" # 2. Update godaddy application validate my-app # 3. Validate godaddy application release my-app \ # 4. Bump version --release-version 1.1.0 -godaddy application deploy my-app --follow # 5. Deploy +godaddy application deploy my-app # 5. Deploy ``` ### Diagnose failures @@ -388,10 +369,10 @@ godaddy application info # 5. App status? ## Parsing Tips -1. **Always parse stdout as JSON.** The only non-JSON output is `--help` text. +1. **Parse regular command stdout as JSON when using JSON mode.** Help, version, and explicitly selected non-JSON formats are not GoDaddy envelopes. 2. **Check `ok` first.** Branch on `true`/`false` before reading `result` or `error`. 3. **Use `next_actions`** to discover what to do next. Fill template params from context. -4. **Exit code**: 0 = success, 1 = error. But always prefer the JSON `ok` field. -5. **stderr is diagnostic only.** Verbose/debug output goes there. Never parse stderr for data. +4. **Exit code**: 0 = success, non-zero = error. Check the JSON `ok` field as well. +5. **Error envelopes use stdout.** stderr is reserved for tracing and non-envelope diagnostics. 6. **Truncated lists**: check `truncated` field. Read `full_output` file for complete data. -7. **Streaming**: for `--follow` commands, parse each line as an independent JSON object. The last line is the final result. +7. **Streaming**: do not assume a terminal public envelope until that separate contract is implemented. diff --git a/README.md b/README.md index 38d5186..cb8545a 100644 --- a/README.md +++ b/README.md @@ -36,20 +36,26 @@ platform (`gddy.exe` on Windows). If you'd rather install by hand, download the ## Output Contract -All executable commands emit JSON envelopes: +Regular executable commands in JSON mode emit GoDaddy JSON envelopes: ```json -{"ok":true,"command":"gddy env get","result":{"environment":"ote"},"next_actions":[...]} +{"command":"gddy env get","next_actions":[],"ok":true,"result":{"apiUrl":"https://api.godaddy.com","env":"prod"}} ``` ```json -{"ok":false,"command":"gddy application info demo","error":{"message":"Application 'demo' not found","code":"NOT_FOUND"},"fix":"Use discovery commands such as: gddy application list or gddy actions list.","next_actions":[...]} +{"command":"gddy env get","error":{"code":"ERROR","message":"unknown environment \"not-an-environment\"; known: ote, prod"},"next_actions":[],"ok":false} ``` -`--help` remains standard CLI help text. -`--output` has been removed; all executable command paths return JSON envelopes. -Use `--pretty` to format envelopes with 2-space indentation for human readability. -Long-running operations can stream typed NDJSON events with `--follow`, ending with a terminal `result` or `error` event. +Regular command envelopes, including errors, are written to stdout. Failed +commands retain a non-zero exit code. `--help` and `--version` remain standard +CLI text, and non-JSON diagnostics retain their original stream. + +JSON is the default for non-interactive output. Use `--output +` to select a format; `--json`, `--human`, and `--toon` are +shorthands. Human and TOON output pass through without GoDaddy JSON-envelope +adaptation. JSON envelopes use two-space indentation by default. `--pretty` is +not registered. Streaming commands are outside this regular-command adapter; +terminal `result` and `error` events are tracked separately. ## Root Discovery @@ -61,9 +67,10 @@ Returns environment/auth snapshots and the full command tree. ## Global Options -- `-e, --env `: validate target environment (`ote`, `prod`) +- `--env `: validate target environment (`ote`, `prod`) - `--debug`: enable debug logging (stderr only) -- `--pretty`: pretty-print JSON envelopes (2-space indentation) +- `--output `: select the output format +- `--json`, `--human`, `--toon`: output-format shorthands ## Commands @@ -95,7 +102,7 @@ Returns environment/auth snapshots and the full command tree. - `gddy application init [--name ] [--description ] [--url ] [--proxy-url ] [--scopes ] [--config ] [--environment ]` - `--url` and `--proxy-url` must be publicly-resolvable `http(s)` URLs. `localhost`, loopback (`127.0.0.1`, `::1`), link-local, and RFC1918 private IPs are rejected. For local development, expose a tunnel (e.g. [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), [ngrok](https://ngrok.com/)) and register the tunnel hostname. - `gddy application release --release-version [--description ] [--config ] [--environment ]` -- `gddy application deploy [--config ] [--environment ] [--follow]` +- `gddy application deploy [--config ] [--environment ]` #### Application Add diff --git a/rust/src/main.rs b/rust/src/main.rs index 84248de..2e810fc 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -11,6 +11,7 @@ mod environments; mod extension; mod hosting; mod next_action; +mod output_envelope; mod output_schema; mod pat; mod payments; @@ -19,7 +20,7 @@ mod scopes; mod update; mod webhook; -use std::{process::ExitCode, sync::Arc}; +use std::{ffi::OsString, io::Write, process::ExitCode, sync::Arc}; use clap::Arg; use cli_engine::{BuildInfo, Cli, CliConfig}; @@ -112,5 +113,48 @@ async fn main() -> ExitCode { .with_module(webhook::module()), ); - cli.execute().await + let args = std::env::args_os().collect::>(); + let command = output_envelope::command_path(cli.root_command(), &args); + let mut engine_stdout = Vec::new(); + let mut engine_stderr = Vec::new(); + + let exit_code = match cli + .execute_from(args, &mut engine_stdout, &mut engine_stderr) + .await + { + Ok(code) => code, + Err(error) => { + tracing::error!(%error, "failed to execute command"); + return ExitCode::from(1); + } + }; + + let is_success = exit_code == ExitCode::SUCCESS; + let rendered = if is_success { + &engine_stdout + } else { + &engine_stderr + }; + let rendered = String::from_utf8_lossy(rendered); + + if let Some(adapted) = output_envelope::adapt(&rendered, &command, is_success) { + if std::io::stdout() + .lock() + .write_all(adapted.as_bytes()) + .is_err() + { + return ExitCode::from(1); + } + } else { + let write_result = if is_success { + std::io::stdout().lock().write_all(&engine_stdout) + } else { + std::io::stderr().lock().write_all(&engine_stderr) + }; + if write_result.is_err() { + return ExitCode::from(1); + } + } + + exit_code } diff --git a/rust/src/output_envelope.rs b/rust/src/output_envelope.rs new file mode 100644 index 0000000..11d7e2b --- /dev/null +++ b/rust/src/output_envelope.rs @@ -0,0 +1,306 @@ +use serde_json::{Value, json}; + +#[allow(dead_code)] +pub fn adapt(rendered: &str, command: &str, is_success: bool) -> Option { + if command == "gddy application deploy" { + return None; + } + + let source: Value = serde_json::from_str(rendered).ok()?; + let next_actions = source + .get("next_actions") + .cloned() + .unwrap_or_else(|| json!([])); + + let public = if is_success { + json!({ + "ok": true, + "command": command, + "result": source.get("data").cloned().unwrap_or(Value::Null), + "next_actions": next_actions, + }) + } else { + let error = source.get("error")?; + json!({ + "ok": false, + "command": command, + "error": { + "code": error.get("code")?, + "message": error.get("message")?, + }, + "next_actions": next_actions, + }) + }; + + let mut output = serde_json::to_string_pretty(&public).ok()?; + output.push('\n'); + Some(output) +} + +#[allow(dead_code)] +pub fn command_path(root: &clap::Command, args: &[std::ffi::OsString]) -> String { + let mut names = Vec::new(); + let text_args = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let normalized = normalize_optional_global_flags(root, &text_args); + let mut current = root; + let mut index = usize::from( + normalized + .first() + .is_some_and(|arg| argv0_matches_root(arg, root.get_name())), + ); + + while let Some(arg) = normalized.get(index) { + if arg == "--" { + break; + } + + if let Some(subcommand) = direct_subcommand(current, arg) { + names.push(subcommand.get_name()); + current = subcommand; + index += 1; + continue; + } + + if arg.starts_with('-') { + let consumes_value = !arg.contains('=') + && flag_takes_value(current, root, arg) + && normalized.get(index + 1).is_some(); + index += if consumes_value { 2 } else { 1 }; + continue; + } + + if current.get_subcommands().next().is_some() { + break; + } + index += 1; + } + + if names.is_empty() { + "gddy".to_owned() + } else { + format!("gddy {}", names.join(" ")) + } +} + +fn argv0_matches_root(arg: &str, root_name: &str) -> bool { + arg == root_name + || std::path::Path::new(arg) + .file_stem() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == root_name) +} + +fn normalize_optional_global_flags(root: &clap::Command, args: &[String]) -> Vec { + let mut normalized = Vec::with_capacity(args.len()); + let mut current = root; + let mut index = 0; + + while let Some(arg) = args.get(index) { + if matches!(arg.as_str(), "--debug" | "--verbose") { + let default = if arg == "--debug" { "*" } else { "all" }; + let next = args.get(index + 1); + if current.get_name() == root.get_name() + || next.is_none_or(|value| { + value.starts_with('-') || direct_subcommand(current, value).is_some() + }) + { + normalized.push(format!("{arg}={default}")); + index += 1; + continue; + } + } + + normalized.push(arg.clone()); + if !arg.starts_with('-') + && let Some(subcommand) = direct_subcommand(current, arg) + { + current = subcommand; + } + index += 1; + } + + normalized +} + +fn direct_subcommand<'command>( + command: &'command clap::Command, + token: &str, +) -> Option<&'command clap::Command> { + command.get_subcommands().find(|child| { + child.get_name() == token || child.get_all_aliases().any(|alias| alias == token) + }) +} + +fn flag_takes_value(current: &clap::Command, root: &clap::Command, token: &str) -> bool { + let long = token.strip_prefix("--"); + let short = token + .strip_prefix('-') + .filter(|value| value.len() == 1) + .and_then(|value| value.chars().next()); + + current + .get_arguments() + .chain(root.get_arguments()) + .find(|argument| { + long.is_some_and(|name| argument.get_long() == Some(name)) + || short.is_some_and(|name| argument.get_short() == Some(name)) + }) + .is_some_and(|argument| argument.get_action().takes_values()) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + #[test] + fn adapts_success_to_public_contract() { + let source = json!({ + "data": {"environment": "ote"}, + "metadata": {"system": "gddy"}, + "warnings": ["hidden"], + "next_actions": [{"command": "gddy env set ", "description": "Change env"}] + }) + .to_string(); + + let rendered = + super::adapt(&source, "gddy env get", true).expect("JSON envelope should adapt"); + let actual: Value = serde_json::from_str(&rendered).expect("valid JSON"); + + assert_eq!( + actual, + json!({ + "ok": true, + "command": "gddy env get", + "result": {"environment": "ote"}, + "next_actions": [{"command": "gddy env set ", "description": "Change env"}] + }) + ); + assert!(rendered.ends_with('\n')); + } + + #[test] + fn adapts_failure_to_public_contract() { + let source = json!({ + "error": { + "code": "INVALID_ENV", + "message": "unknown environment", + "system": "gddy", + "details": {"env": "bad"} + } + }) + .to_string(); + + let rendered = + super::adapt(&source, "gddy env get", false).expect("JSON error envelope should adapt"); + let actual: Value = serde_json::from_str(&rendered).expect("valid JSON"); + + assert_eq!( + actual, + json!({ + "ok": false, + "command": "gddy env get", + "error": {"code": "INVALID_ENV", "message": "unknown environment"}, + "next_actions": [] + }) + ); + } + + #[test] + fn defaults_missing_next_actions_to_empty_array() { + let rendered = super::adapt(r#"{"data":null}"#, "gddy env get", true) + .expect("JSON envelope should adapt"); + let actual: Value = serde_json::from_str(&rendered).expect("valid JSON"); + assert_eq!(actual["next_actions"], json!([])); + } + + #[test] + fn leaves_non_json_output_unadapted() { + assert!(super::adapt("Usage: gddy \n", "gddy", true).is_none()); + } + + #[test] + fn leaves_streaming_failures_unadapted() { + let native = json!({ + "error": { + "code": "AUTH_REQUIRED", + "message": "authentication required" + }, + "next_actions": [] + }) + .to_string(); + + assert!(super::adapt(&native, "gddy application deploy", false).is_none()); + } + + fn command_root() -> clap::Command { + clap::Command::new("gddy") + .arg( + clap::Arg::new("debug") + .long("debug") + .global(true) + .num_args(0..=1) + .default_missing_value("*"), + ) + .subcommand( + clap::Command::new("application").subcommand( + clap::Command::new("info").arg(clap::Arg::new("name").required(true)), + ), + ) + .subcommand(clap::Command::new("env").subcommand(clap::Command::new("get"))) + } + + fn args(values: &[&str]) -> Vec { + values.iter().map(std::ffi::OsString::from).collect() + } + + #[test] + fn command_path_contains_only_recognized_commands() { + assert_eq!( + super::command_path( + &command_root(), + &args(&["gddy", "application", "info", "secret-app"]), + ), + "gddy application info" + ); + } + + #[test] + fn command_path_handles_optional_global_flag_before_commands() { + assert_eq!( + super::command_path(&command_root(), &args(&["gddy", "--debug", "env", "get"]),), + "gddy env get" + ); + } + + #[test] + fn command_path_handles_optional_global_flag_between_commands() { + assert_eq!( + super::command_path( + &command_root(), + &args(&["gddy", "application", "--debug", "info", "secret-app"]), + ), + "gddy application info" + ); + } + + #[test] + fn command_path_survives_invalid_leaf_arguments_without_exposing_values() { + assert_eq!( + super::command_path( + &command_root(), + &args(&[ + "gddy", + "application", + "info", + "secret-app", + "--invalid", + "secret-value", + ]), + ), + "gddy application info" + ); + } +} diff --git a/rust/tests/output_contract.rs b/rust/tests/output_contract.rs new file mode 100644 index 0000000..d8169c0 --- /dev/null +++ b/rust/tests/output_contract.rs @@ -0,0 +1,69 @@ +use std::process::{Command, Output}; + +use serde_json::Value; + +fn run_gddy(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_gddy")) + .args(args) + .env("GDDY_ENV", "ote") + .output() + .expect("gddy should execute") +} + +#[test] +fn success_uses_public_envelope_on_stdout() { + let output = run_gddy(&["env", "get"]); + assert!(output.status.success()); + + let actual: Value = serde_json::from_slice(&output.stdout).expect("stdout should contain JSON"); + assert_eq!(actual["ok"], true); + assert_eq!(actual["command"], "gddy env get"); + assert!(actual.get("result").is_some()); + assert!(actual["next_actions"].is_array()); + assert_eq!( + actual + .as_object() + .expect("object") + .keys() + .collect::>(), + vec!["command", "next_actions", "ok", "result"] + ); +} + +#[test] +fn failure_uses_public_envelope_on_stdout_and_preserves_exit_code() { + let output = run_gddy(&["--env", "not-an-environment", "env", "get"]); + assert!(!output.status.success()); + + let actual: Value = serde_json::from_slice(&output.stdout).expect("stdout should contain JSON"); + assert_eq!(actual["ok"], false); + assert_eq!(actual["command"], "gddy env get"); + assert_eq!( + actual + .as_object() + .expect("object") + .keys() + .collect::>(), + vec!["command", "error", "next_actions", "ok"] + ); + assert!(actual["error"]["code"].is_string()); + assert!(actual["error"]["message"].is_string()); + assert!(!String::from_utf8_lossy(&output.stderr).contains(r#""ok""#)); +} + +#[test] +fn help_remains_plain_text() { + let output = run_gddy(&["--help"]); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); +} + +#[test] +fn version_remains_plain_text() { + let output = run_gddy(&["--version"]); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.starts_with("gddy version ")); + assert!(serde_json::from_slice::(&output.stdout).is_err()); +}