Skip to main content

skiff_cli/cli/
args.rs

1//! Global CLI flags parsed after [`crate::cli::split_at_subcommand`].
2//!
3//! Source flags are mutually exclusive at dispatch time. Session and OAuth
4//! helpers live here so clap help stays the single source of user-facing docs.
5
6use clap::Parser;
7
8use crate::model::ListDetail;
9use crate::paths::DEFAULT_CACHE_TTL;
10use crate::spool::DEFAULT_AGENT_MAX_BYTES;
11
12/// Default `--top` when `--agent` + `--search` and user did not set `--top`.
13pub const DEFAULT_AGENT_SEARCH_TOP: usize = 20;
14
15#[derive(Debug, Clone, Parser)]
16#[command(
17    name = "skiff",
18    about = "Turn any MCP server, OpenAPI spec, or GraphQL endpoint into a CLI",
19    version,
20    disable_help_subcommand = true,
21    allow_hyphen_values = true
22)]
23pub struct GlobalArgs {
24    /// OpenAPI spec URL or local file
25    #[arg(long)]
26    pub spec: Option<String>,
27
28    /// MCP server URL (HTTP streamable / SSE)
29    #[arg(long)]
30    pub mcp: Option<String>,
31
32    /// MCP server as a shell command (stdio transport)
33    #[arg(long)]
34    pub mcp_stdio: Option<String>,
35
36    /// GraphQL endpoint URL
37    #[arg(long)]
38    pub graphql: Option<String>,
39
40    /// HTTP header as Name:Value (repeatable; value may use env:/file:)
41    #[arg(long = "auth-header", value_name = "Name:Value")]
42    pub auth_header: Vec<String>,
43
44    /// Override base URL from OpenAPI spec
45    #[arg(long)]
46    pub base_url: Option<String>,
47
48    /// Custom cache key
49    #[arg(long)]
50    pub cache_key: Option<String>,
51
52    /// Cache TTL in seconds
53    #[arg(long, default_value_t = DEFAULT_CACHE_TTL)]
54    pub cache_ttl: u64,
55
56    /// Force re-fetch
57    #[arg(long)]
58    pub refresh: bool,
59
60    /// List available subcommands
61    #[arg(long = "list", visible_alias = "list-commands")]
62    pub list_commands: bool,
63
64    /// Search tools by name or description
65    #[arg(long = "search", value_name = "PATTERN")]
66    pub search_pattern: Option<String>,
67
68    /// List / help JSON detail: names|brief|full
69    #[arg(long, value_name = "LEVEL", value_parser = ["names", "brief", "full"])]
70    pub detail: Option<String>,
71
72    /// Emit one tool's full schema as JSON (progressive describe)
73    #[arg(long = "describe", value_name = "TOOL")]
74    pub describe: Option<String>,
75
76    /// Full tool descriptions in --list
77    #[arg(long)]
78    pub verbose: bool,
79
80    /// Sort --list: usage|recent|alpha|default
81    #[arg(long, value_parser = ["usage", "recent", "alpha", "default"])]
82    pub sort: Option<String>,
83
84    /// Show only top N tools
85    #[arg(long, value_name = "N")]
86    pub top: Option<usize>,
87
88    /// Space-separated tool names only (alias for --detail names)
89    #[arg(long)]
90    pub compact: bool,
91
92    /// Pretty-print JSON
93    #[arg(long)]
94    pub pretty: bool,
95
96    /// Print raw response body
97    #[arg(long)]
98    pub raw: bool,
99
100    /// Force valid JSON output (content-only for MCP; see --envelope)
101    #[arg(long = "json")]
102    pub json_output: bool,
103
104    /// Return full MCP CallToolResult envelope (instead of content-only)
105    #[arg(long = "envelope", visible_alias = "full")]
106    pub envelope: bool,
107
108    /// TOON encoding (native; falls back to JSON on encode failure)
109    #[arg(long)]
110    pub toon: bool,
111
112    /// Limit output to first N array records
113    #[arg(long, value_name = "N")]
114    pub head: Option<usize>,
115
116    /// Spill stdout to spool when rendered size exceeds N bytes (0 = never)
117    #[arg(long = "max-bytes", value_name = "N")]
118    pub max_bytes: Option<usize>,
119
120    /// Never spill; always print full stdout
121    #[arg(long)]
122    pub inline: bool,
123
124    /// Agent defaults: JSON, brief discovery, spool oversize (or SKIFF_AGENT=1)
125    #[arg(long)]
126    pub agent: bool,
127
128    /// Delete expired spool files and exit
129    #[arg(long = "spool-clean")]
130    pub spool_clean: bool,
131
132    /// GraphQL selection set override
133    #[arg(long)]
134    pub fields: Option<String>,
135
136    /// MCP HTTP transport: auto|sse|streamable
137    #[arg(long, default_value = "auto", value_parser = ["auto", "sse", "streamable"])]
138    pub transport: String,
139
140    /// Env KEY=VALUE for MCP stdio (repeatable)
141    #[arg(long = "env", value_name = "KEY=VALUE")]
142    pub env: Vec<String>,
143
144    /// Enable OAuth (also implied by --oauth-client-id / --oauth-client-secret)
145    #[arg(long)]
146    pub oauth: bool,
147
148    /// OAuth client ID (supports env:/file: secrets)
149    #[arg(long)]
150    pub oauth_client_id: Option<String>,
151
152    /// OAuth client secret (supports env:/file: secrets)
153    #[arg(long)]
154    pub oauth_client_secret: Option<String>,
155
156    /// OAuth client name for DCR
157    #[arg(long, default_value = "skiff")]
158    pub oauth_client_name: String,
159
160    /// OAuth scope string
161    #[arg(long)]
162    pub oauth_scope: Option<String>,
163
164    /// Loopback redirect URI (http://127.0.0.1:<port>/callback)
165    #[arg(long)]
166    pub oauth_redirect_uri: Option<String>,
167
168    /// OAuth flow: auto|authorization_code|client_credentials
169    #[arg(long, default_value = "auto", value_parser = ["auto", "authorization_code", "client_credentials"])]
170    pub oauth_flow: String,
171
172    /// Clear cached OAuth credentials for the discovery URL and exit
173    #[arg(long = "oauth-clear")]
174    pub oauth_clear: bool,
175
176    /// Start a named MCP session daemon (requires --mcp or --mcp-stdio; Unix)
177    #[arg(long = "session-start", value_name = "NAME")]
178    pub session_start: Option<String>,
179
180    /// Use an existing session daemon instead of a one-shot MCP connect (Unix)
181    #[arg(long = "session", value_name = "NAME")]
182    pub session: Option<String>,
183
184    /// Stop a named session daemon (SIGTERM, then SIGKILL; Unix)
185    #[arg(long = "session-stop", value_name = "NAME")]
186    pub session_stop: Option<String>,
187
188    /// List session daemons (use --json for machine-readable output)
189    #[arg(long = "session-list")]
190    pub session_list: bool,
191
192    /// Session idle exit after N seconds of no IPC (default 1800; 0 = never)
193    #[arg(long = "session-idle-secs", value_name = "SECS")]
194    pub session_idle_secs: Option<u64>,
195
196    /// For stdio sessions: child gets only PATH/HOME/LANG/TMP* plus --env
197    #[arg(long = "session-clean-env")]
198    pub session_clean_env: bool,
199
200    /// List MCP resources (via --session today)
201    #[arg(long = "list-resources")]
202    pub list_resources: bool,
203
204    /// List MCP resource templates
205    #[arg(long = "list-resource-templates")]
206    pub list_resource_templates: bool,
207
208    /// Read an MCP resource by URI
209    #[arg(long = "read-resource", value_name = "URI")]
210    pub read_resource: Option<String>,
211
212    /// List MCP prompts
213    #[arg(long = "list-prompts")]
214    pub list_prompts: bool,
215
216    /// Get an MCP prompt by name
217    #[arg(long = "get-prompt", value_name = "NAME")]
218    pub get_prompt: Option<String>,
219
220    /// Prompt argument as key=value (repeatable, with --get-prompt)
221    #[arg(long = "prompt-arg", value_name = "KEY=VALUE")]
222    pub prompt_arg: Vec<String>,
223}
224
225impl GlobalArgs {
226    /// Apply `--agent` / `SKIFF_AGENT=1` defaults (idempotent).
227    pub fn apply_agent_defaults(&mut self) {
228        let env_agent = std::env::var("SKIFF_AGENT")
229            .map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
230            .unwrap_or(false);
231        if !self.agent && !env_agent {
232            return;
233        }
234        self.agent = true;
235        if !self.raw && !self.toon {
236            self.json_output = true;
237        }
238        if self.detail.is_none() && !self.compact {
239            // Search: names only (then --top). Browse: brief.
240            if self.search_pattern.is_some() {
241                self.detail = Some("names".into());
242            } else {
243                self.detail = Some("brief".into());
244            }
245        }
246        if self.search_pattern.is_some() && self.top.is_none() {
247            self.top = Some(DEFAULT_AGENT_SEARCH_TOP);
248        }
249        if self.max_bytes.is_none() {
250            self.max_bytes = Some(DEFAULT_AGENT_MAX_BYTES);
251        }
252    }
253
254    pub fn list_detail(&self) -> ListDetail {
255        if self.compact {
256            return ListDetail::Names;
257        }
258        if let Some(d) = self.detail.as_deref().and_then(ListDetail::parse) {
259            return d;
260        }
261        // Agent default: brief. Plain `--json`/`--toon`: full (pre-agent parity).
262        if self.agent {
263            ListDetail::Brief
264        } else if self.json_output || self.toon {
265            ListDetail::Full
266        } else {
267            ListDetail::Brief
268        }
269    }
270
271    /// Suppress human list banners.
272    pub fn quiet_list(&self) -> bool {
273        self.agent || self.json_output || self.compact || self.toon
274    }
275
276    pub fn output_options(&self) -> crate::output::OutputOptions {
277        // `apply_agent_defaults` already sets `json_output` when agent && !raw && !toon.
278        // Do not force JSON over `--raw`.
279        crate::output::OutputOptions {
280            pretty: self.pretty,
281            raw: self.raw,
282            toon: self.toon,
283            head: self.head,
284            json_output: self.json_output,
285            max_bytes: self.max_bytes,
286            inline: self.inline,
287        }
288    }
289
290    /// MCP call: content-only unless `--envelope`.
291    pub fn full_envelope(&self) -> bool {
292        self.envelope
293    }
294
295    pub fn parse_auth_headers(&self) -> crate::error::Result<Vec<(String, String)>> {
296        let mut out = Vec::new();
297        for item in &self.auth_header {
298            let Some((k, v)) = item.split_once(':') else {
299                return Err(crate::error::Error::usage(format!(
300                    "invalid auth header format: {item:?}"
301                )));
302            };
303            let v = crate::coerce::resolve_secret(v.trim())?;
304            out.push((k.trim().to_string(), v));
305        }
306        Ok(out)
307    }
308
309    pub fn parse_env_vars(
310        &self,
311    ) -> crate::error::Result<std::collections::BTreeMap<String, String>> {
312        let mut out = std::collections::BTreeMap::new();
313        for item in &self.env {
314            let Some((k, v)) = item.split_once('=') else {
315                return Err(crate::error::Error::usage(format!(
316                    "invalid env format: {item:?}"
317                )));
318            };
319            out.insert(k.trim().to_string(), v.to_string());
320        }
321        Ok(out)
322    }
323}