Robot mode: TTY auto-detect, env var, --no-robot flag, robot-docs command

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.
This commit is contained in:
teernisse
2026-02-12 16:14:01 -05:00
parent cc04772792
commit 8455bca71b
4 changed files with 687 additions and 5 deletions

View File

@@ -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),
}

611
src/cli/robot_docs.rs Normal file
View File

@@ -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<CommandBrief>,
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<CommandDetail>,
}
#[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<ExitCodeEntry>,
}
#[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<Workflow>,
}
#[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 <url> --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 <url|file|-> --alias <name>",
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 <ALIAS> <PATH> [-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 <ALIAS> <QUERY> | search --all-aliases <QUERY>",
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 <ALIAS> [--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 <ALIAS>",
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 <LEFT> <RIGHT>",
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<T: Serialize>(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()
);
}
}

View File

@@ -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<String> = 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 {

View File

@@ -30,6 +30,13 @@ pub fn robot_success<T: Serialize>(data: T, command: &str, duration: Duration) {
println!("{json}");
}
pub fn robot_success_pretty<T: Serialize>(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,