Skip to main content

skiff_cli/cli/
dynamic.rs

1//! Dynamic tool/subcommand argument parsing from CommandDef.
2
3use std::collections::BTreeMap;
4
5use serde_json::{json, Value};
6
7use crate::coerce::coerce_value;
8use crate::error::{Error, Result};
9use crate::model::{CommandDef, ParamType};
10
11#[derive(Debug, Clone)]
12pub struct ParsedToolArgs {
13    pub command: CommandDef,
14    pub values: BTreeMap<String, Value>,
15    pub stdin: bool,
16}
17
18/// Parse `remaining` as `<subcommand> [--flag value ...]`.
19pub fn parse_tool_args(commands: &[CommandDef], remaining: &[String]) -> Result<ParsedToolArgs> {
20    if remaining.is_empty() {
21        return Err(Error::usage(
22            "no subcommand specified. Use --list to see available tools.",
23        ));
24    }
25    if remaining[0] == "-h" || remaining[0] == "--help" {
26        return Err(Error::usage("use --list to see available commands"));
27    }
28
29    let name = &remaining[0];
30    let cmd = commands
31        .iter()
32        .find(|c| &c.name == name)
33        .cloned()
34        .ok_or_else(|| Error::usage(format!("unknown command: {name}")))?;
35
36    if remaining.get(1).map(String::as_str) == Some("-h")
37        || remaining.get(1).map(String::as_str) == Some("--help")
38    {
39        print_command_help(&cmd);
40        return Err(Error::usage("__help__"));
41    }
42
43    let mut values = BTreeMap::new();
44    let mut stdin = false;
45    let mut i = 1;
46    while i < remaining.len() {
47        let arg = &remaining[i];
48        if arg == "--stdin" {
49            stdin = true;
50            i += 1;
51            continue;
52        }
53        if !arg.starts_with("--") {
54            return Err(Error::usage(format!("unexpected argument: {arg}")));
55        }
56        let flag = arg.trim_start_matches("--");
57        let param = cmd
58            .params
59            .iter()
60            .find(|p| p.name == flag)
61            .ok_or_else(|| Error::usage(format!("unknown option: {arg}")))?;
62
63        match param.python_type {
64            ParamType::Boolean if param.location == crate::model::ParamLocation::GraphqlArg => {
65                // GraphQL booleans: bare `--flag` ⇒ true; `--flag true|false` also accepted.
66                i += 1;
67                if let Some(raw) = remaining.get(i) {
68                    let lower = raw.to_lowercase();
69                    if matches!(lower.as_str(), "true" | "false" | "1" | "0" | "yes" | "no") {
70                        let coerced = coerce_value(Some(Value::String(raw.clone())), &param.schema)
71                            .unwrap_or(Value::Bool(matches!(lower.as_str(), "true" | "1" | "yes")));
72                        values.insert(param.original_name.clone(), coerced);
73                        i += 1;
74                        continue;
75                    }
76                }
77                values.insert(param.original_name.clone(), Value::Bool(true));
78            }
79            ParamType::Boolean => {
80                values.insert(param.original_name.clone(), Value::Bool(true));
81                i += 1;
82            }
83            other => {
84                i += 1;
85                let Some(raw) = remaining.get(i) else {
86                    return Err(Error::usage(format!("option {arg} requires a value")));
87                };
88                if let Some(choices) = &param.choices {
89                    if !choices.iter().any(|c| c == raw) {
90                        return Err(Error::usage(format!(
91                            "invalid value {raw:?} for {arg}; choices: {choices:?}"
92                        )));
93                    }
94                }
95                let as_value = match other {
96                    ParamType::Integer => json!(raw.parse::<i64>().map_err(|_| {
97                        Error::usage(format!("invalid integer for {arg}: {raw}"))
98                    })?),
99                    ParamType::Float => json!(raw.parse::<f64>().map_err(|_| {
100                        Error::usage(format!("invalid number for {arg}: {raw}"))
101                    })?),
102                    ParamType::String | ParamType::Boolean => Value::String(raw.clone()),
103                };
104                let coerced = coerce_value(Some(as_value), &param.schema)
105                    .unwrap_or(Value::String(raw.clone()));
106                values.insert(param.original_name.clone(), coerced);
107                i += 1;
108            }
109        }
110    }
111
112    // Required path/query/header params (body/tool_input can use --stdin)
113    for p in &cmd.params {
114        if !p.required {
115            continue;
116        }
117        if matches!(
118            p.location,
119            crate::model::ParamLocation::Body
120                | crate::model::ParamLocation::ToolInput
121                | crate::model::ParamLocation::GraphqlArg
122        ) {
123            continue;
124        }
125        if p.python_type == ParamType::Boolean {
126            continue;
127        }
128        if !values.contains_key(&p.original_name) && !stdin {
129            return Err(Error::usage(format!(
130                "missing required argument: --{}",
131                p.name
132            )));
133        }
134    }
135
136    Ok(ParsedToolArgs {
137        command: cmd,
138        values,
139        stdin,
140    })
141}
142
143pub fn print_command_help(cmd: &CommandDef) {
144    println!("{}", cmd.name);
145    if !cmd.description.is_empty() {
146        println!("  {}", cmd.description);
147    }
148    println!();
149    if cmd.has_body {
150        println!("  --stdin    Read JSON body/arguments from stdin");
151    }
152    for p in &cmd.params {
153        let req = if p.required { " (required)" } else { "" };
154        let ty = p.python_type.type_name();
155        println!("  --{:<20} [{}]{} {}", p.name, ty, req, p.description);
156    }
157}
158
159pub fn read_stdin_json(context: &str) -> Result<Value> {
160    use std::io::Read;
161    let mut raw = String::new();
162    std::io::stdin()
163        .read_to_string(&mut raw)
164        .map_err(Error::from)?;
165    if raw.trim().is_empty() {
166        return Err(Error::runtime(format!(
167            "--stdin expects JSON for {context}, but stdin was empty."
168        )));
169    }
170    serde_json::from_str(&raw).map_err(|exc| {
171        Error::runtime(format!(
172            "invalid JSON on stdin for {context} (line {}, column {}).",
173            exc.line(),
174            exc.column()
175        ))
176    })
177}