diff --git a/.changeset/cli-improvement-exit-codes.md b/.changeset/cli-improvement-exit-codes.md new file mode 100644 index 00000000..de286ae1 --- /dev/null +++ b/.changeset/cli-improvement-exit-codes.md @@ -0,0 +1,10 @@ +--- +"@googleworkspace/cli": minor +--- + +Add semantic exit codes, structured error hints, and stderr/stdout separation for better CLI agent support + +- Exit codes now reflect error type: 2 (usage), 3 (not found), 4 (auth), 5 (conflict), 75 (transient/retry), 78 (config) +- Error JSON includes new `transient` boolean and `fix` string fields for agent consumption +- Usage text prints to stderr on error paths so stdout stays machine-parseable +- Help text documents exit codes in the EXIT CODES section diff --git a/src/error.rs b/src/error.rs index 25cc9f59..c890e829 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,6 +40,96 @@ pub enum GwsError { } impl GwsError { + /// Returns a semantic exit code for this error. + /// + /// Exit codes follow CLI best practices (loosely based on sysexits.h): + /// - 0: success + /// - 1: general failure + /// - 2: usage/validation error (bad arguments) + /// - 3: resource not found + /// - 4: permission denied / auth failure + /// - 5: conflict (resource already exists) + /// - 75: temporary failure (network timeout, rate limit — retry may help) + /// - 78: configuration error + pub fn exit_code(&self) -> i32 { + match self { + GwsError::Validation(_) => 2, + GwsError::Auth(_) => 4, + GwsError::Discovery(_) => 78, + GwsError::Api { code, .. } => match *code { + 404 => 3, + 401 | 403 => 4, + 409 => 5, + 408 | 429 | 500 | 502 | 503 | 504 => 75, + _ => 1, + }, + GwsError::Other(_) => 1, + } + } + + /// Returns true if this error is transient and retrying may succeed. + pub fn is_transient(&self) -> bool { + matches!( + self, + GwsError::Api { + code: 408 | 429 | 500 | 502 | 503 | 504, + .. + } + ) + } + + /// Returns an actionable fix suggestion for this error, if available. + pub fn fix_hint(&self) -> Option { + match self { + GwsError::Auth(_) => Some( + "Run `gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_TOKEN." + .to_string(), + ), + GwsError::Discovery(_) => Some( + "Check the service name with `gws --help`, or verify network connectivity." + .to_string(), + ), + GwsError::Validation(msg) => { + if msg.contains("Required") && msg.contains("parameter") { + Some("Provide the missing parameter via --params '{\"key\": \"value\"}'.".to_string()) + } else if msg.contains("No service specified") || msg.contains("No resource") { + Some("Run `gws --help` to see available services and usage.".to_string()) + } else { + None + } + } + GwsError::Api { + code, + reason, + enable_url, + .. + } => { + if reason == "accessNotConfigured" { + if let Some(url) = enable_url { + return Some(format!("Enable the API at: {url}")); + } + return Some("Enable the required API in GCP Console > APIs & Services > Library.".to_string()); + } + match *code { + 401 => Some( + "Run `gws auth login` to refresh your credentials.".to_string(), + ), + 403 => Some( + "Check that your account has permission for this operation.".to_string(), + ), + 404 => Some( + "Verify the resource ID. Use `gws schema ..` to inspect parameters.".to_string(), + ), + 429 => Some( + "Rate limited — wait a moment and retry.".to_string(), + ), + _ => None, + } + } + GwsError::Other(_) => None, + } + } + pub fn to_json(&self) -> serde_json::Value { match self { GwsError::Api { @@ -52,26 +142,37 @@ impl GwsError { "code": code, "message": message, "reason": reason, + "transient": self.is_transient(), }); // Include enable_url in JSON output when present (accessNotConfigured errors). // This preserves machine-readable compatibility while adding new optional field. if let Some(url) = enable_url { error_obj["enable_url"] = json!(url); } + if let Some(fix) = self.fix_hint() { + error_obj["fix"] = json!(fix); + } json!({ "error": error_obj }) } - GwsError::Validation(msg) => json!({ - "error": { + GwsError::Validation(msg) => { + let mut error_obj = json!({ "code": 400, "message": msg, "reason": "validationError", + "transient": false, + }); + if let Some(fix) = self.fix_hint() { + error_obj["fix"] = json!(fix); } - }), + json!({ "error": error_obj }) + } GwsError::Auth(msg) => json!({ "error": { "code": 401, "message": msg, "reason": "authError", + "transient": false, + "fix": self.fix_hint(), } }), GwsError::Discovery(msg) => json!({ @@ -79,6 +180,8 @@ impl GwsError { "code": 500, "message": msg, "reason": "discoveryError", + "transient": false, + "fix": self.fix_hint(), } }), GwsError::Other(e) => json!({ @@ -86,6 +189,7 @@ impl GwsError { "code": 500, "message": format!("{e:#}"), "reason": "internalError", + "transient": false, } }), } @@ -94,9 +198,9 @@ impl GwsError { /// Formats any error as a JSON object and prints to stdout. /// -/// For `accessNotConfigured` errors (HTTP 403, reason `accessNotConfigured`), -/// additional human-readable guidance is printed to stderr explaining how to -/// enable the API in GCP. The JSON output on stdout is unchanged (machine-readable). +/// Human-readable guidance (fix hints) is printed to stderr so that stdout +/// remains machine-parseable. The JSON output on stdout includes `fix` and +/// `transient` fields for agent consumption. pub fn print_error_json(err: &GwsError) { let json = err.to_json(); println!( @@ -111,15 +215,22 @@ pub fn print_error_json(err: &GwsError) { { if reason == "accessNotConfigured" { eprintln!(); - eprintln!("💡 API not enabled for your GCP project."); + eprintln!("API not enabled for your GCP project."); if let Some(url) = enable_url { eprintln!(" Enable it at: {url}"); } else { - eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API."); + eprintln!(" Visit the GCP Console > APIs & Services > Library to enable the required API."); } eprintln!(" After enabling, wait a few seconds and retry your command."); + return; } } + + // Print fix hint to stderr for other error types + if let Some(fix) = err.fix_hint() { + eprintln!(); + eprintln!("Fix: {fix}"); + } } #[cfg(test)] @@ -139,6 +250,8 @@ mod tests { assert_eq!(json["error"]["message"], "Not Found"); assert_eq!(json["error"]["reason"], "notFound"); assert!(json["error"]["enable_url"].is_null()); + assert_eq!(json["error"]["transient"], false); + assert!(json["error"]["fix"].is_string(), "404 should include a fix hint"); } #[test] @@ -148,6 +261,7 @@ mod tests { assert_eq!(json["error"]["code"], 400); assert_eq!(json["error"]["message"], "Invalid input"); assert_eq!(json["error"]["reason"], "validationError"); + assert_eq!(json["error"]["transient"], false); } #[test] @@ -157,6 +271,8 @@ mod tests { assert_eq!(json["error"]["code"], 401); assert_eq!(json["error"]["message"], "Token expired"); assert_eq!(json["error"]["reason"], "authError"); + assert_eq!(json["error"]["transient"], false); + assert!(json["error"]["fix"].is_string(), "auth errors should include a fix hint"); } #[test] @@ -166,6 +282,8 @@ mod tests { assert_eq!(json["error"]["code"], 500); assert_eq!(json["error"]["message"], "Failed to fetch doc"); assert_eq!(json["error"]["reason"], "discoveryError"); + assert_eq!(json["error"]["transient"], false); + assert!(json["error"]["fix"].is_string(), "discovery errors should include a fix hint"); } #[test] @@ -175,6 +293,7 @@ mod tests { assert_eq!(json["error"]["code"], 500); assert_eq!(json["error"]["message"], "Something went wrong"); assert_eq!(json["error"]["reason"], "internalError"); + assert_eq!(json["error"]["transient"], false); } // --- accessNotConfigured tests --- @@ -194,6 +313,7 @@ mod tests { json["error"]["enable_url"], "https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482" ); + assert!(json["error"]["fix"].is_string(), "accessNotConfigured should include fix with URL"); } #[test] @@ -209,5 +329,173 @@ mod tests { assert_eq!(json["error"]["reason"], "accessNotConfigured"); // enable_url key should not appear in JSON when None assert!(json["error"]["enable_url"].is_null()); + assert!(json["error"]["fix"].is_string(), "accessNotConfigured should include fix even without URL"); + } + + // --- exit code tests --- + + #[test] + fn test_exit_code_validation() { + assert_eq!(GwsError::Validation("bad".to_string()).exit_code(), 2); + } + + #[test] + fn test_exit_code_auth() { + assert_eq!(GwsError::Auth("denied".to_string()).exit_code(), 4); + } + + #[test] + fn test_exit_code_discovery() { + assert_eq!(GwsError::Discovery("failed".to_string()).exit_code(), 78); + } + + #[test] + fn test_exit_code_api_not_found() { + let err = GwsError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 3); + } + + #[test] + fn test_exit_code_api_forbidden() { + let err = GwsError::Api { + code: 403, + message: "Forbidden".to_string(), + reason: "forbidden".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 4); + } + + #[test] + fn test_exit_code_api_conflict() { + let err = GwsError::Api { + code: 409, + message: "Conflict".to_string(), + reason: "conflict".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 5); + } + + #[test] + fn test_exit_code_api_rate_limit() { + let err = GwsError::Api { + code: 429, + message: "Rate limited".to_string(), + reason: "rateLimitExceeded".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 75); + } + + #[test] + fn test_exit_code_api_server_error() { + for code in [500, 502, 503, 504] { + let err = GwsError::Api { + code, + message: "Server error".to_string(), + reason: "backendError".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 75, "HTTP {code} should be exit code 75"); + } + } + + #[test] + fn test_exit_code_other() { + let err = GwsError::Other(anyhow::anyhow!("oops")); + assert_eq!(err.exit_code(), 1); + } + + // --- transient tests --- + + #[test] + fn test_is_transient_true() { + for code in [408, 429, 500, 502, 503, 504] { + let err = GwsError::Api { + code, + message: "err".to_string(), + reason: "err".to_string(), + enable_url: None, + }; + assert!(err.is_transient(), "HTTP {code} should be transient"); + } + } + + #[test] + fn test_is_transient_false() { + for code in [400, 401, 403, 404, 409] { + let err = GwsError::Api { + code, + message: "err".to_string(), + reason: "err".to_string(), + enable_url: None, + }; + assert!(!err.is_transient(), "HTTP {code} should not be transient"); + } + assert!(!GwsError::Validation("x".to_string()).is_transient()); + assert!(!GwsError::Auth("x".to_string()).is_transient()); + assert!(!GwsError::Discovery("x".to_string()).is_transient()); + } + + // --- fix hint tests --- + + #[test] + fn test_fix_hint_auth() { + let hint = GwsError::Auth("expired".to_string()).fix_hint(); + assert!(hint.is_some()); + assert!(hint.unwrap().contains("gws auth login")); + } + + #[test] + fn test_fix_hint_discovery() { + let hint = GwsError::Discovery("fail".to_string()).fix_hint(); + assert!(hint.is_some()); + assert!(hint.unwrap().contains("gws --help")); + } + + #[test] + fn test_fix_hint_validation_missing_param() { + let hint = GwsError::Validation("Required parameter 'fileId' is missing".to_string()) + .fix_hint(); + assert!(hint.is_some()); + assert!(hint.unwrap().contains("--params")); + } + + #[test] + fn test_fix_hint_validation_no_service() { + let hint = GwsError::Validation("No service specified".to_string()).fix_hint(); + assert!(hint.is_some()); + assert!(hint.unwrap().contains("gws --help")); + } + + #[test] + fn test_fix_hint_validation_generic() { + // Generic validation errors may not have a fix hint + let hint = GwsError::Validation("something weird".to_string()).fix_hint(); + assert!(hint.is_none()); + } + + #[test] + fn test_fix_hint_api_rate_limit() { + let err = GwsError::Api { + code: 429, + message: "Rate limited".to_string(), + reason: "rateLimitExceeded".to_string(), + enable_url: None, + }; + let hint = err.fix_hint(); + assert!(hint.is_some()); + assert!(hint.unwrap().contains("retry")); + } + + #[test] + fn test_fix_hint_other_has_none() { + assert!(GwsError::Other(anyhow::anyhow!("oops")).fix_hint().is_none()); } } diff --git a/src/main.rs b/src/main.rs index 89d73d9e..eec31854 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,8 @@ mod text; mod token_storage; pub(crate) mod validate; +use std::io::Write; + use error::{print_error_json, GwsError}; #[tokio::main] @@ -49,7 +51,7 @@ async fn main() { if let Err(err) = run().await { print_error_json(&err); - std::process::exit(1); + std::process::exit(err.exit_code()); } } @@ -57,7 +59,7 @@ async fn run() -> Result<(), GwsError> { let args: Vec = std::env::args().collect(); if args.len() < 2 { - print_usage(); + eprint_usage(); return Err(GwsError::Validation( "No service specified. Usage: gws [sub-resource] [flags]" .to_string(), @@ -404,31 +406,40 @@ fn resolve_method_from_matches<'a>( } fn print_usage() { - println!("gws — Google Workspace CLI"); - println!(); - println!("USAGE:"); - println!(" gws [sub-resource] [flags]"); - println!(" gws schema [--resolve-refs]"); - println!(); - println!("EXAMPLES:"); - println!(" gws drive files list --params '{{\"pageSize\": 10}}'"); - println!(" gws drive files get --params '{{\"fileId\": \"abc123\"}}'"); - println!(" gws sheets spreadsheets get --params '{{\"spreadsheetId\": \"...\"}}'"); - println!(" gws gmail users messages list --params '{{\"userId\": \"me\"}}'"); - println!(" gws schema drive.files.list"); - println!(); - println!("FLAGS:"); - println!(" --params URL/Query parameters as JSON"); - println!(" --json Request body as JSON (POST/PATCH/PUT)"); - println!(" --upload Local file to upload as media content (multipart)"); - println!(" --output Output file path for binary responses"); - println!(" --format Output format: json (default), table, yaml, csv"); - println!(" --api-version Override the API version (e.g., v2, v3)"); - println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)"); - println!(" --page-limit Max pages to fetch with --page-all (default: 10)"); - println!(" --page-delay Delay between pages in ms (default: 100)"); - println!(); - println!("SERVICES:"); + write_usage(&mut std::io::stdout()); +} + +/// Print usage text to stderr (for error contexts, so stdout stays clean for agents). +fn eprint_usage() { + write_usage(&mut std::io::stderr()); +} + +fn write_usage(w: &mut dyn std::io::Write) { + let _ = writeln!(w, "gws — Google Workspace CLI"); + let _ = writeln!(w); + let _ = writeln!(w, "USAGE:"); + let _ = writeln!(w, " gws [sub-resource] [flags]"); + let _ = writeln!(w, " gws schema [--resolve-refs]"); + let _ = writeln!(w); + let _ = writeln!(w, "EXAMPLES:"); + let _ = writeln!(w, " gws drive files list --params '{{\"pageSize\": 10}}'"); + let _ = writeln!(w, " gws drive files get --params '{{\"fileId\": \"abc123\"}}'"); + let _ = writeln!(w, " gws sheets spreadsheets get --params '{{\"spreadsheetId\": \"...\"}}'"); + let _ = writeln!(w, " gws gmail users messages list --params '{{\"userId\": \"me\"}}'"); + let _ = writeln!(w, " gws schema drive.files.list"); + let _ = writeln!(w); + let _ = writeln!(w, "FLAGS:"); + let _ = writeln!(w, " --params URL/Query parameters as JSON"); + let _ = writeln!(w, " --json Request body as JSON (POST/PATCH/PUT)"); + let _ = writeln!(w, " --upload Local file to upload as media content (multipart)"); + let _ = writeln!(w, " --output Output file path for binary responses"); + let _ = writeln!(w, " --format Output format: json (default), table, yaml, csv"); + let _ = writeln!(w, " --api-version Override the API version (e.g., v2, v3)"); + let _ = writeln!(w, " --page-all Auto-paginate, one JSON line per page (NDJSON)"); + let _ = writeln!(w, " --page-limit Max pages to fetch with --page-all (default: 10)"); + let _ = writeln!(w, " --page-delay Delay between pages in ms (default: 100)"); + let _ = writeln!(w); + let _ = writeln!(w, "SERVICES:"); for entry in services::SERVICES { let name = entry.aliases[0]; let aliases = if entry.aliases.len() > 1 { @@ -436,34 +447,36 @@ fn print_usage() { } else { String::new() }; - println!(" {:<20} {}{}", name, entry.description, aliases); + let _ = writeln!(w, " {:<20} {}{}", name, entry.description, aliases); } - println!(); - println!("ENVIRONMENT:"); - println!(" GOOGLE_WORKSPACE_CLI_TOKEN Pre-obtained OAuth2 access token (highest priority)"); - println!(" GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE Path to OAuth credentials JSON file"); - println!(" GOOGLE_WORKSPACE_CLI_CLIENT_ID OAuth client ID (for gws auth login)"); - println!( - " GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)" - ); - println!( - " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)" - ); - println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template"); - println!( - " GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block" - ); - println!( - " GOOGLE_WORKSPACE_PROJECT_ID GCP project ID fallback for helper commands" - ); - println!(); - println!("COMMUNITY:"); - println!(" Star the repo: https://github.com/googleworkspace/cli"); - println!(" Report bugs / request features: https://github.com/googleworkspace/cli/issues"); - println!(" Please search existing issues first; if one already exists, comment there."); - println!(); - println!("DISCLAIMER:"); - println!(" This is not an officially supported Google product."); + let _ = writeln!(w); + let _ = writeln!(w, "ENVIRONMENT:"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_TOKEN Pre-obtained OAuth2 access token (highest priority)"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE Path to OAuth credentials JSON file"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_CLIENT_ID OAuth client ID (for gws auth login)"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block"); + let _ = writeln!(w, " GOOGLE_WORKSPACE_PROJECT_ID GCP project ID fallback for helper commands"); + let _ = writeln!(w); + let _ = writeln!(w, "EXIT CODES:"); + let _ = writeln!(w, " 0 Success"); + let _ = writeln!(w, " 1 General failure"); + let _ = writeln!(w, " 2 Usage error (bad arguments or missing parameters)"); + let _ = writeln!(w, " 3 Resource not found"); + let _ = writeln!(w, " 4 Permission denied / authentication failure"); + let _ = writeln!(w, " 5 Conflict (resource already exists)"); + let _ = writeln!(w, " 75 Temporary failure (network timeout, rate limit — retry may help)"); + let _ = writeln!(w, " 78 Configuration error (e.g., unknown service)"); + let _ = writeln!(w); + let _ = writeln!(w, "COMMUNITY:"); + let _ = writeln!(w, " Star the repo: https://github.com/googleworkspace/cli"); + let _ = writeln!(w, " Report bugs / request features: https://github.com/googleworkspace/cli/issues"); + let _ = writeln!(w, " Please search existing issues first; if one already exists, comment there."); + let _ = writeln!(w); + let _ = writeln!(w, "DISCLAIMER:"); + let _ = writeln!(w, " This is not an officially supported Google product."); } fn is_help_flag(arg: &str) -> bool {