From 8455bca71b7fefe1bc1a79b8eb594c79f01707c9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 12 Feb 2026 16:14:01 -0500 Subject: [PATCH] Robot mode: TTY auto-detect, env var, --no-robot flag, robot-docs command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robot mode resolution is now a 4-tier cascade: 1. --no-robot (explicit off, conflicts_with robot) 2. --robot / --json (explicit on) 3. SWAGGER_CLI_ROBOT=1|true env var 4. TTY auto-detection (non-TTY stdout -> robot mode) Pre-scan updated to match the same resolution logic so parse errors get the correct output format before clap finishes parsing. New robot-docs subcommand (alias: docs) provides machine-readable self-documentation for AI agents: guide, commands, exit-codes, and workflows topics. Designed for LLM consumption — structured JSON with all flags, aliases, and usage patterns in one call. robot_success_pretty added for --pretty support on robot-docs output. Visible aliases added to commands: list->ls, show->info, search->find. --- src/cli/mod.rs | 16 +- src/cli/robot_docs.rs | 611 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 58 +++- src/output/robot.rs | 7 + 4 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 src/cli/robot_docs.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3269ea1..b4dcfc3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,6 +4,7 @@ pub mod diff; pub mod doctor; pub mod fetch; pub mod list; +pub mod robot_docs; pub mod schemas; pub mod search; pub mod show; @@ -21,10 +22,14 @@ pub struct Cli { #[command(subcommand)] pub command: Commands, - /// Output machine-readable JSON - #[arg(long, global = true)] + /// Output machine-readable JSON (alias: --json) + #[arg(long, global = true, visible_alias = "json")] pub robot: bool, + /// Force human-readable output (overrides TTY auto-detection and env var) + #[arg(long, global = true, conflicts_with = "robot")] + pub no_robot: bool, + /// Pretty-print JSON output #[arg(long, global = true)] pub pretty: bool, @@ -44,12 +49,15 @@ pub enum Commands { Fetch(fetch::Args), /// List endpoints from a cached spec + #[command(visible_alias = "ls")] List(list::Args), /// Show details of a specific endpoint + #[command(visible_alias = "info")] Show(show::Args), /// Search endpoints by keyword + #[command(visible_alias = "find")] Search(search::Args), /// List or show schemas from a cached spec @@ -72,4 +80,8 @@ pub enum Commands { /// Compare two versions of a spec Diff(diff::Args), + + /// Machine-readable documentation for AI agents + #[command(visible_alias = "docs")] + RobotDocs(robot_docs::Args), } diff --git a/src/cli/robot_docs.rs b/src/cli/robot_docs.rs new file mode 100644 index 0000000..77f1302 --- /dev/null +++ b/src/cli/robot_docs.rs @@ -0,0 +1,611 @@ +use std::time::Instant; + +use clap::Args as ClapArgs; +use serde::Serialize; + +use crate::errors::SwaggerCliError; +use crate::output::robot; + +/// Machine-readable documentation for AI agents +#[derive(Debug, ClapArgs)] +pub struct Args { + /// Topic: guide (default), commands, exit-codes, workflows + #[arg(default_value = "guide")] + pub topic: String, +} + +// --------------------------------------------------------------------------- +// Output types — guide +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct GuideOutput { + tool: &'static str, + version: &'static str, + about: &'static str, + robot_activation: RobotActivation, + commands: Vec, + exit_codes: Vec<(u8, &'static str)>, + quick_start: &'static str, + output_envelope: &'static str, + topics: Vec<&'static str>, +} + +#[derive(Debug, Serialize)] +struct RobotActivation { + flags: Vec<&'static str>, + env: &'static str, + auto: &'static str, + disable: &'static str, +} + +#[derive(Debug, Serialize)] +struct CommandBrief { + cmd: &'static str, + does: &'static str, + #[serde(skip_serializing_if = "Vec::is_empty")] + aliases: Vec<&'static str>, +} + +// --------------------------------------------------------------------------- +// Output types — commands +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct CommandsOutput { + commands: Vec, +} + +#[derive(Debug, Serialize)] +struct CommandDetail { + name: &'static str, + usage: &'static str, + description: &'static str, + key_flags: Vec<&'static str>, +} + +// --------------------------------------------------------------------------- +// Output types — exit-codes +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct ExitCodesOutput { + exit_codes: Vec, +} + +#[derive(Debug, Serialize)] +struct ExitCodeEntry { + code: u8, + name: &'static str, + description: &'static str, + retryable: bool, +} + +// --------------------------------------------------------------------------- +// Output types — workflows +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct WorkflowsOutput { + workflows: Vec, +} + +#[derive(Debug, Serialize)] +struct Workflow { + name: &'static str, + description: &'static str, + steps: Vec<&'static str>, +} + +// --------------------------------------------------------------------------- +// Data builders +// --------------------------------------------------------------------------- + +fn build_guide() -> GuideOutput { + GuideOutput { + tool: "swagger-cli", + version: env!("CARGO_PKG_VERSION"), + about: "Cache-first CLI for exploring OpenAPI specs. <50ms queries after initial fetch.", + robot_activation: RobotActivation { + flags: vec!["--robot", "--json"], + env: "SWAGGER_CLI_ROBOT=1", + auto: "JSON when stdout is not a TTY", + disable: "--no-robot", + }, + commands: vec![ + CommandBrief { + cmd: "fetch --alias NAME", + does: "Download + cache a spec", + aliases: vec![], + }, + CommandBrief { + cmd: "list [ALIAS]", + does: "List endpoints (filterable by method/tag/path)", + aliases: vec!["ls"], + }, + CommandBrief { + cmd: "show ALIAS PATH [-m METHOD]", + does: "Endpoint detail with params/body/responses", + aliases: vec!["info"], + }, + CommandBrief { + cmd: "search ALIAS QUERY", + does: "Full-text search endpoints + schemas", + aliases: vec!["find"], + }, + CommandBrief { + cmd: "schemas ALIAS [--show NAME]", + does: "List or inspect schema definitions", + aliases: vec![], + }, + CommandBrief { + cmd: "tags ALIAS", + does: "List tags with endpoint counts", + aliases: vec![], + }, + CommandBrief { + cmd: "aliases [--list|--show|--rename|--delete|--set-default]", + does: "Manage cached spec aliases", + aliases: vec![], + }, + CommandBrief { + cmd: "sync [ALIAS|--all]", + does: "Re-fetch + update cached specs", + aliases: vec![], + }, + CommandBrief { + cmd: "doctor [--fix]", + does: "Check + repair cache health", + aliases: vec![], + }, + CommandBrief { + cmd: "cache [--stats|--path|--prune-stale]", + does: "Cache statistics + cleanup", + aliases: vec![], + }, + CommandBrief { + cmd: "diff LEFT RIGHT", + does: "Compare two spec versions", + aliases: vec![], + }, + CommandBrief { + cmd: "robot-docs [TOPIC]", + does: "This documentation (always JSON)", + aliases: vec!["docs"], + }, + ], + exit_codes: vec![ + (0, "SUCCESS"), + (2, "USAGE_ERROR"), + (4, "NETWORK_ERROR"), + (5, "INVALID_SPEC"), + (6, "ALIAS_EXISTS"), + (7, "AUTH_ERROR"), + (8, "ALIAS_NOT_FOUND"), + (9, "CACHE_LOCKED"), + (10, "CACHE_ERROR"), + (11, "CONFIG_ERROR"), + (12, "IO_ERROR"), + (13, "JSON_ERROR"), + (14, "CACHE_INTEGRITY"), + (15, "OFFLINE_MODE"), + (16, "POLICY_BLOCKED"), + ], + quick_start: "swagger-cli fetch URL --alias NAME && swagger-cli list NAME", + output_envelope: "{ok,data?,error?{code,message,suggestion?},meta{schema_version,tool_version,command,duration_ms}}", + topics: vec!["guide", "commands", "exit-codes", "workflows"], + } +} + +fn build_commands() -> CommandsOutput { + CommandsOutput { + commands: vec![ + CommandDetail { + name: "fetch", + usage: "fetch --alias ", + description: "Download and cache an OpenAPI spec from URL, local file, or stdin", + key_flags: vec![ + "--alias NAME (required)", + "--force: overwrite existing", + "--bearer TOKEN: auth header", + "--auth-profile NAME: config auth profile", + "-H 'Key: Val': extra headers (repeatable)", + "--timeout-ms N (default: 10000)", + "--retries N (default: 2)", + "--resolve-external-refs: inline external $ref entries", + "--allow-private-host HOST (repeatable)", + "--allow-insecure-http", + ], + }, + CommandDetail { + name: "list", + usage: "list [ALIAS] [--all-aliases]", + description: "List endpoints from cached spec(s) with filtering and sorting", + key_flags: vec![ + "-m METHOD: filter by HTTP method", + "-t TAG: filter by tag", + "-p REGEX: filter by path pattern", + "--sort path|method|tag (default: path)", + "-n LIMIT (default: 50)", + "-a/--all: show all (no limit)", + "--all-aliases: query every cached spec", + ], + }, + CommandDetail { + name: "show", + usage: "show [-m METHOD]", + description: "Show full endpoint detail: parameters, request body, responses", + key_flags: vec![ + "-m METHOD: required when path has multiple methods", + "--expand-refs: inline $ref entries", + "--max-depth N (default: 3)", + ], + }, + CommandDetail { + name: "search", + usage: "search | search --all-aliases ", + description: "Full-text search across endpoints and schemas with ranked results", + key_flags: vec![ + "--all-aliases: search all cached specs", + "--case-sensitive", + "--exact: match as exact phrase", + "--in all|paths|descriptions|schemas", + "--limit N (default: 20)", + ], + }, + CommandDetail { + name: "schemas", + usage: "schemas [--show NAME]", + description: "List schema names or show a specific schema definition", + key_flags: vec![ + "--name REGEX: filter names", + "--list: list mode (default)", + "--show NAME: show specific schema", + "--expand-refs: inline $ref", + "--max-depth N (default: 3)", + ], + }, + CommandDetail { + name: "tags", + usage: "tags ", + description: "List tags from spec with endpoint counts", + key_flags: vec![], + }, + CommandDetail { + name: "aliases", + usage: "aliases [--list|--show A|--rename OLD NEW|--delete A|--set-default A]", + description: "Manage cached spec aliases", + key_flags: vec![ + "--list (default)", + "--show ALIAS: full details", + "--rename OLD NEW", + "--delete ALIAS", + "--set-default ALIAS", + ], + }, + CommandDetail { + name: "sync", + usage: "sync [ALIAS] [--all]", + description: "Re-fetch and update cached specs from upstream", + key_flags: vec![ + "--all: sync every alias", + "--dry-run: check without writing", + "--force: re-fetch regardless of freshness", + "--details: include change lists", + "--auth PROFILE", + "--jobs N (default: 4)", + "--per-host N (default: 2)", + ], + }, + CommandDetail { + name: "doctor", + usage: "doctor [--fix] [--alias ALIAS]", + description: "Check cache integrity and diagnose issues", + key_flags: vec![ + "--fix: attempt automatic repair", + "--alias ALIAS: check one alias only", + ], + }, + CommandDetail { + name: "cache", + usage: "cache [--stats|--path|--prune-stale|--max-total-mb N]", + description: "Cache statistics, location, and cleanup", + key_flags: vec![ + "--stats (default)", + "--path: print cache directory", + "--prune-stale: remove stale aliases", + "--prune-threshold DAYS (default: 90)", + "--max-total-mb N: LRU eviction", + "--dry-run: report without deleting", + ], + }, + CommandDetail { + name: "diff", + usage: "diff ", + description: "Compare two cached spec versions for breaking/non-breaking changes", + key_flags: vec![ + "--fail-on breaking|non-breaking: exit non-zero on changes", + "--details: per-item change descriptions", + ], + }, + ], + } +} + +fn build_exit_codes() -> ExitCodesOutput { + ExitCodesOutput { + exit_codes: vec![ + ExitCodeEntry { + code: 0, + name: "SUCCESS", + description: "Operation completed successfully", + retryable: false, + }, + ExitCodeEntry { + code: 2, + name: "USAGE_ERROR", + description: "Invalid arguments or flags", + retryable: false, + }, + ExitCodeEntry { + code: 4, + name: "NETWORK_ERROR", + description: "HTTP request failed", + retryable: true, + }, + ExitCodeEntry { + code: 5, + name: "INVALID_SPEC", + description: "Not a valid OpenAPI 3.x spec", + retryable: false, + }, + ExitCodeEntry { + code: 6, + name: "ALIAS_EXISTS", + description: "Alias already cached (use --force)", + retryable: false, + }, + ExitCodeEntry { + code: 7, + name: "AUTH_ERROR", + description: "Authentication failed", + retryable: false, + }, + ExitCodeEntry { + code: 8, + name: "ALIAS_NOT_FOUND", + description: "No cached spec with that alias", + retryable: false, + }, + ExitCodeEntry { + code: 9, + name: "CACHE_LOCKED", + description: "Another process holds the lock", + retryable: true, + }, + ExitCodeEntry { + code: 10, + name: "CACHE_ERROR", + description: "Cache read/write failure", + retryable: true, + }, + ExitCodeEntry { + code: 11, + name: "CONFIG_ERROR", + description: "Config file invalid", + retryable: false, + }, + ExitCodeEntry { + code: 12, + name: "IO_ERROR", + description: "Filesystem I/O failure", + retryable: true, + }, + ExitCodeEntry { + code: 13, + name: "JSON_ERROR", + description: "JSON parse/serialize failure", + retryable: false, + }, + ExitCodeEntry { + code: 14, + name: "CACHE_INTEGRITY", + description: "Cache data corrupted (run doctor --fix)", + retryable: false, + }, + ExitCodeEntry { + code: 15, + name: "OFFLINE_MODE", + description: "Network required but offline mode active", + retryable: false, + }, + ExitCodeEntry { + code: 16, + name: "POLICY_BLOCKED", + description: "Request blocked by network policy", + retryable: false, + }, + ], + } +} + +fn build_workflows() -> WorkflowsOutput { + WorkflowsOutput { + workflows: vec![ + Workflow { + name: "first-use", + description: "Fetch a spec and start exploring", + steps: vec![ + "swagger-cli fetch https://petstore3.swagger.io/api/v3/openapi.json --alias petstore", + "swagger-cli list petstore", + "swagger-cli search petstore 'pet'", + "swagger-cli show petstore /pet/{petId} -m GET", + ], + }, + Workflow { + name: "api-discovery", + description: "Explore an unfamiliar API systematically", + steps: vec![ + "swagger-cli tags ALIAS", + "swagger-cli list ALIAS -t TAG_NAME", + "swagger-cli show ALIAS PATH -m METHOD --expand-refs", + "swagger-cli schemas ALIAS --show SCHEMA_NAME", + ], + }, + Workflow { + name: "multi-spec", + description: "Work across multiple cached APIs", + steps: vec![ + "swagger-cli aliases --list", + "swagger-cli search --all-aliases 'user'", + "swagger-cli list --all-aliases -m POST", + "swagger-cli diff api-v1 api-v2", + ], + }, + Workflow { + name: "maintenance", + description: "Keep cache healthy and up-to-date", + steps: vec![ + "swagger-cli doctor", + "swagger-cli sync --all --dry-run", + "swagger-cli sync --all", + "swagger-cli cache --stats", + "swagger-cli cache --prune-stale --prune-threshold 60", + ], + }, + ], + } +} + +// --------------------------------------------------------------------------- +// Execute +// --------------------------------------------------------------------------- + +pub fn execute(args: &Args, pretty: bool) -> Result<(), SwaggerCliError> { + let start = Instant::now(); + let topic = args.topic.as_str(); + + match topic { + "guide" => emit(build_guide(), pretty, start), + "commands" => emit(build_commands(), pretty, start), + "exit-codes" => emit(build_exit_codes(), pretty, start), + "workflows" => emit(build_workflows(), pretty, start), + unknown => Err(SwaggerCliError::Usage(format!( + "Unknown robot-docs topic '{unknown}'. Valid: guide, commands, exit-codes, workflows" + ))), + } +} + +fn emit(data: T, pretty: bool, start: Instant) -> Result<(), SwaggerCliError> { + let duration = start.elapsed(); + if pretty { + robot::robot_success_pretty(data, "robot-docs", duration); + } else { + robot::robot_success(data, "robot-docs", duration); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guide_serializes_without_panic() { + let guide = build_guide(); + let json = serde_json::to_string(&guide).unwrap(); + assert!(json.contains("swagger-cli")); + assert!(json.contains("robot_activation")); + assert!(json.contains("exit_codes")); + } + + #[test] + fn commands_serializes_without_panic() { + let cmds = build_commands(); + let json = serde_json::to_string(&cmds).unwrap(); + assert!(json.contains("\"fetch\"")); + assert!(json.contains("\"list\"")); + assert!(json.contains("\"show\"")); + } + + #[test] + fn exit_codes_serializes_without_panic() { + let codes = build_exit_codes(); + let json = serde_json::to_string(&codes).unwrap(); + assert!(json.contains("USAGE_ERROR")); + assert!(json.contains("NETWORK_ERROR")); + } + + #[test] + fn workflows_serializes_without_panic() { + let wf = build_workflows(); + let json = serde_json::to_string(&wf).unwrap(); + assert!(json.contains("first-use")); + assert!(json.contains("api-discovery")); + } + + #[test] + fn guide_command_count_matches_enum() { + let guide = build_guide(); + // 11 main commands + robot-docs itself = 12 + assert_eq!(guide.commands.len(), 12); + } + + #[test] + fn exit_codes_has_no_gaps_in_critical_range() { + let codes = build_exit_codes(); + // Verify code 0 and 2 exist (most important for agents) + assert!(codes.exit_codes.iter().any(|c| c.code == 0)); + assert!(codes.exit_codes.iter().any(|c| c.code == 2)); + } + + #[test] + fn guide_aliases_populated_for_aliased_commands() { + let guide = build_guide(); + let list_cmd = guide + .commands + .iter() + .find(|c| c.cmd.starts_with("list")) + .unwrap(); + assert!(list_cmd.aliases.contains(&"ls")); + + let search_cmd = guide + .commands + .iter() + .find(|c| c.cmd.starts_with("search")) + .unwrap(); + assert!(search_cmd.aliases.contains(&"find")); + } + + #[test] + fn unknown_topic_returns_usage_error() { + let args = Args { + topic: "nonexistent".to_string(), + }; + let result = execute(&args, false); + assert!(result.is_err()); + match result.unwrap_err() { + SwaggerCliError::Usage(msg) => { + assert!(msg.contains("nonexistent")); + assert!(msg.contains("guide")); + } + other => panic!("expected Usage error, got: {other:?}"), + } + } + + #[test] + fn guide_token_efficiency() { + let guide = build_guide(); + let json = serde_json::to_string(&guide).unwrap(); + // Compact JSON should be under 2500 chars (~400 tokens) + assert!( + json.len() < 2500, + "guide JSON is {} chars, should be <2500 for token efficiency", + json.len() + ); + } +} diff --git a/src/main.rs b/src/main.rs index da3063c..d2d5731 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +use std::io::IsTerminal; use std::process::ExitCode; use std::time::{Duration, Instant}; @@ -9,8 +10,51 @@ use swagger_cli::cli::{Cli, Commands}; use swagger_cli::errors::SwaggerCliError; use swagger_cli::output::robot; +/// Pre-scan for robot mode before clap parses, so parse errors get the right +/// output format. Mirrors the resolution logic in `resolve_robot_mode`. fn pre_scan_robot() -> bool { - std::env::args().any(|a| a == "--robot") + let args: Vec = std::env::args().collect(); + + // --no-robot always wins + if args.iter().any(|a| a == "--no-robot") { + return false; + } + + // Explicit --robot or --json + if args.iter().any(|a| a == "--robot" || a == "--json") { + return true; + } + + // Environment variable + if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) + { + return true; + } + + // TTY auto-detection: JSON when stdout is not a TTY + !std::io::stdout().is_terminal() +} + +/// Resolve robot mode after clap parses. Resolution order: +/// 1. --no-robot (explicit off) +/// 2. --robot / --json (explicit on) +/// 3. SWAGGER_CLI_ROBOT env var +/// 4. TTY auto-detection (not a TTY → robot mode) +fn resolve_robot_mode(cli: &Cli) -> bool { + if cli.no_robot { + return false; + } + + if cli.robot { + return true; + } + + if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) + { + return true; + } + + !std::io::stdout().is_terminal() } fn command_name(cli: &Cli) -> &'static str { @@ -26,6 +70,7 @@ fn command_name(cli: &Cli) -> &'static str { Commands::Doctor(_) => "doctor", Commands::Cache(_) => "cache", Commands::Diff(_) => "diff", + Commands::RobotDocs(_) => "robot-docs", } } @@ -51,7 +96,12 @@ async fn main() -> ExitCode { let cli = match Cli::try_parse() { Ok(cli) => cli, Err(err) => { - if is_robot { + // Help and version requests always use human output, even when piped + let is_info = matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ); + if is_robot && !is_info { let parse_err = SwaggerCliError::Usage(err.to_string()); output_robot_error(&parse_err, "unknown", start.elapsed()); return parse_err.to_exit_code(); @@ -61,7 +111,8 @@ async fn main() -> ExitCode { }; let cmd = command_name(&cli); - let robot = cli.robot; + let robot = resolve_robot_mode(&cli); + let pretty = cli.pretty; let network_flag = cli.network.as_str(); let config_override = cli.config.as_deref(); @@ -82,6 +133,7 @@ async fn main() -> ExitCode { Commands::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await, Commands::Cache(args) => swagger_cli::cli::cache_cmd::execute(args, robot).await, Commands::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await, + Commands::RobotDocs(args) => swagger_cli::cli::robot_docs::execute(args, pretty), }; match result { diff --git a/src/output/robot.rs b/src/output/robot.rs index 75a1870..ec6020a 100644 --- a/src/output/robot.rs +++ b/src/output/robot.rs @@ -30,6 +30,13 @@ pub fn robot_success(data: T, command: &str, duration: Duration) { println!("{json}"); } +pub fn robot_success_pretty(data: T, command: &str, duration: Duration) { + let meta = build_meta(command, duration); + let envelope = RobotEnvelope::success(data, meta); + let json = serde_json::to_string_pretty(&envelope).expect("serialization should not fail"); + println!("{json}"); +} + pub fn robot_error( code: &str, message: &str,