Skip to main content

via/
cli.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use clap::{Arg, ArgAction, Command as ClapCommand};
5
6use crate::error::ViaError;
7
8pub struct Cli {
9    pub config_path: Option<PathBuf>,
10    pub command: Command,
11}
12
13pub enum Command {
14    Help,
15    Capabilities {
16        json: bool,
17    },
18    Config(ConfigCommand),
19    SkillPrint,
20    Invoke {
21        service: String,
22        capability: String,
23        args: Vec<String>,
24    },
25}
26
27pub enum ConfigCommand {
28    Configure,
29    Path,
30    Doctor { service: Option<String> },
31}
32
33pub fn print_help() {
34    let _ = command().print_help();
35    println!();
36}
37
38impl Cli {
39    pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, ViaError> {
40        let matches = command().try_get_matches_from(args)?;
41        let config_path = matches.get_one::<PathBuf>("config_path").cloned();
42
43        let command = match matches.subcommand() {
44            Some(("capabilities", submatches)) => Command::Capabilities {
45                json: submatches.get_flag("json"),
46            },
47            Some(("config", submatches)) => Command::Config(parse_config_command(submatches)?),
48            Some(("skill", submatches)) => match submatches.subcommand() {
49                Some(("print", _)) => Command::SkillPrint,
50                _ => {
51                    return Err(ViaError::InvalidCli(
52                        "expected `via skill print`".to_owned(),
53                    ))
54                }
55            },
56            Some((service, submatches)) => {
57                let mut args = submatches
58                    .get_many::<String>("")
59                    .map(|values| values.cloned().collect::<Vec<_>>())
60                    .unwrap_or_default()
61                    .into_iter();
62                let capability = args
63                    .next()
64                    .ok_or_else(|| ViaError::MissingArgument("capability".to_owned()))?;
65
66                Command::Invoke {
67                    service: service.to_owned(),
68                    capability,
69                    args: args.collect(),
70                }
71            }
72            None => Command::Help,
73        };
74
75        Ok(Self {
76            config_path,
77            command,
78        })
79    }
80}
81
82fn parse_config_command(matches: &clap::ArgMatches) -> Result<ConfigCommand, ViaError> {
83    match matches.subcommand() {
84        Some(("path", _)) => Ok(ConfigCommand::Path),
85        Some(("doctor", submatches)) => Ok(ConfigCommand::Doctor {
86            service: submatches.get_one::<String>("service").cloned(),
87        }),
88        None => Ok(ConfigCommand::Configure),
89        _ => Err(ViaError::InvalidCli(
90            "expected `via config`, `via config path`, or `via config doctor`".to_owned(),
91        )),
92    }
93}
94
95fn command() -> ClapCommand {
96    ClapCommand::new("via")
97        .about("Run commands and API requests with 1Password-backed credentials without exposing secrets to your shell")
98        .disable_help_subcommand(true)
99        .allow_external_subcommands(true)
100        .external_subcommand_value_parser(clap::value_parser!(String))
101        .arg(
102            Arg::new("config_path")
103                .long("config")
104                .short('c')
105                .value_name("PATH")
106                .value_parser(clap::value_parser!(PathBuf))
107                .global(true)
108                .help("Path to via.toml"),
109        )
110        .subcommand(
111            ClapCommand::new("capabilities")
112                .about("List configured services and capabilities")
113                .arg(
114                    Arg::new("json")
115                        .long("json")
116                        .action(ArgAction::SetTrue)
117                        .help("Print machine-readable JSON"),
118                ),
119        )
120        .subcommand(
121            ClapCommand::new("config")
122                .about("Create, locate, and check via configuration")
123                .subcommand(ClapCommand::new("path").about("Print the resolved config path"))
124                .subcommand(
125                    ClapCommand::new("doctor")
126                        .about("Check configuration, providers, secrets, and delegated tools")
127                        .arg(Arg::new("service").help("Only check one service")),
128                ),
129        )
130        .subcommand(
131            ClapCommand::new("skill")
132                .about("Agent skill helpers")
133                .subcommand(ClapCommand::new("print").about("Print the via skill instructions")),
134        )
135        .arg_required_else_help(false)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn parse(args: &[&str]) -> Cli {
143        Cli::parse(args.iter().map(OsString::from)).unwrap()
144    }
145
146    #[test]
147    fn no_args_is_help() {
148        let cli = parse(&["via"]);
149
150        assert!(matches!(cli.command, Command::Help));
151    }
152
153    #[test]
154    fn parses_global_config_before_service_invocation() {
155        let cli = parse(&[
156            "via",
157            "--config",
158            "examples/github.toml",
159            "github",
160            "api",
161            "POST",
162            "/user",
163            "--json",
164            "{}",
165        ]);
166
167        assert_eq!(
168            cli.config_path.unwrap(),
169            PathBuf::from("examples/github.toml")
170        );
171        match cli.command {
172            Command::Invoke {
173                service,
174                capability,
175                args,
176            } => {
177                assert_eq!(service, "github");
178                assert_eq!(capability, "api");
179                assert_eq!(args, ["POST", "/user", "--json", "{}"]);
180            }
181            _ => panic!("expected invoke"),
182        }
183    }
184
185    #[test]
186    fn parses_capabilities_json() {
187        let cli = parse(&["via", "capabilities", "--json"]);
188
189        assert!(matches!(cli.command, Command::Capabilities { json: true }));
190    }
191
192    #[test]
193    fn parses_doctor_service() {
194        let cli = parse(&["via", "config", "doctor", "github"]);
195
196        assert!(matches!(
197            cli.command,
198            Command::Config(ConfigCommand::Doctor {
199                service: Some(service)
200            }) if service == "github"
201        ));
202    }
203
204    #[test]
205    fn parses_config_path() {
206        let cli = parse(&["via", "config", "path"]);
207
208        assert!(matches!(cli.command, Command::Config(ConfigCommand::Path)));
209    }
210
211    #[test]
212    fn parses_config_configure() {
213        let cli = parse(&["via", "config"]);
214
215        assert!(matches!(
216            cli.command,
217            Command::Config(ConfigCommand::Configure)
218        ));
219    }
220
221    #[test]
222    fn parses_skill_print() {
223        let cli = parse(&["via", "skill", "print"]);
224
225        assert!(matches!(cli.command, Command::SkillPrint));
226    }
227
228    #[test]
229    fn rejects_missing_capability() {
230        let error = match Cli::parse([OsString::from("via"), OsString::from("github")]) {
231            Ok(_) => panic!("expected missing capability error"),
232            Err(error) => error,
233        };
234
235        assert!(matches!(error, ViaError::MissingArgument(argument) if argument == "capability"));
236    }
237}