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:
@@ -4,6 +4,7 @@ pub mod diff;
|
|||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
pub mod robot_docs;
|
||||||
pub mod schemas;
|
pub mod schemas;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod show;
|
pub mod show;
|
||||||
@@ -21,10 +22,14 @@ pub struct Cli {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
|
|
||||||
/// Output machine-readable JSON
|
/// Output machine-readable JSON (alias: --json)
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true, visible_alias = "json")]
|
||||||
pub robot: bool,
|
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
|
/// Pretty-print JSON output
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub pretty: bool,
|
pub pretty: bool,
|
||||||
@@ -44,12 +49,15 @@ pub enum Commands {
|
|||||||
Fetch(fetch::Args),
|
Fetch(fetch::Args),
|
||||||
|
|
||||||
/// List endpoints from a cached spec
|
/// List endpoints from a cached spec
|
||||||
|
#[command(visible_alias = "ls")]
|
||||||
List(list::Args),
|
List(list::Args),
|
||||||
|
|
||||||
/// Show details of a specific endpoint
|
/// Show details of a specific endpoint
|
||||||
|
#[command(visible_alias = "info")]
|
||||||
Show(show::Args),
|
Show(show::Args),
|
||||||
|
|
||||||
/// Search endpoints by keyword
|
/// Search endpoints by keyword
|
||||||
|
#[command(visible_alias = "find")]
|
||||||
Search(search::Args),
|
Search(search::Args),
|
||||||
|
|
||||||
/// List or show schemas from a cached spec
|
/// List or show schemas from a cached spec
|
||||||
@@ -72,4 +80,8 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// Compare two versions of a spec
|
/// Compare two versions of a spec
|
||||||
Diff(diff::Args),
|
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
611
src/cli/robot_docs.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main.rs
58
src/main.rs
@@ -1,5 +1,6 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::io::IsTerminal;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -9,8 +10,51 @@ use swagger_cli::cli::{Cli, Commands};
|
|||||||
use swagger_cli::errors::SwaggerCliError;
|
use swagger_cli::errors::SwaggerCliError;
|
||||||
use swagger_cli::output::robot;
|
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 {
|
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 {
|
fn command_name(cli: &Cli) -> &'static str {
|
||||||
@@ -26,6 +70,7 @@ fn command_name(cli: &Cli) -> &'static str {
|
|||||||
Commands::Doctor(_) => "doctor",
|
Commands::Doctor(_) => "doctor",
|
||||||
Commands::Cache(_) => "cache",
|
Commands::Cache(_) => "cache",
|
||||||
Commands::Diff(_) => "diff",
|
Commands::Diff(_) => "diff",
|
||||||
|
Commands::RobotDocs(_) => "robot-docs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +96,12 @@ async fn main() -> ExitCode {
|
|||||||
let cli = match Cli::try_parse() {
|
let cli = match Cli::try_parse() {
|
||||||
Ok(cli) => cli,
|
Ok(cli) => cli,
|
||||||
Err(err) => {
|
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());
|
let parse_err = SwaggerCliError::Usage(err.to_string());
|
||||||
output_robot_error(&parse_err, "unknown", start.elapsed());
|
output_robot_error(&parse_err, "unknown", start.elapsed());
|
||||||
return parse_err.to_exit_code();
|
return parse_err.to_exit_code();
|
||||||
@@ -61,7 +111,8 @@ async fn main() -> ExitCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cmd = command_name(&cli);
|
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 network_flag = cli.network.as_str();
|
||||||
let config_override = cli.config.as_deref();
|
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::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
|
||||||
Commands::Cache(args) => swagger_cli::cli::cache_cmd::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::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,
|
||||||
|
Commands::RobotDocs(args) => swagger_cli::cli::robot_docs::execute(args, pretty),
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ pub fn robot_success<T: Serialize>(data: T, command: &str, duration: Duration) {
|
|||||||
println!("{json}");
|
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(
|
pub fn robot_error(
|
||||||
code: &str,
|
code: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user