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    Version,
16    Login {
17        provider: Option<String>,
18    },
19    Capabilities {
20        json: bool,
21    },
22    Config(ConfigCommand),
23    Daemon(DaemonCommand),
24    SkillPrint,
25    Invoke {
26        service: String,
27        capability: String,
28        args: Vec<String>,
29    },
30}
31
32pub enum ConfigCommand {
33    Configure,
34    Path,
35    Doctor { service: Option<String> },
36}
37
38pub enum DaemonCommand {
39    Status,
40    Clear,
41    Stop,
42    Serve,
43}
44
45pub fn print_help() {
46    let _ = command().print_help();
47    println!();
48}
49
50impl Cli {
51    pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, ViaError> {
52        let matches = command().try_get_matches_from(args)?;
53        let config_path = matches.get_one::<PathBuf>("config_path").cloned();
54
55        let command = match matches.subcommand() {
56            Some(("version", _)) => Command::Version,
57            Some(("login", submatches)) => Command::Login {
58                provider: submatches.get_one::<String>("provider").cloned(),
59            },
60            Some(("capabilities", submatches)) => Command::Capabilities {
61                json: submatches.get_flag("json"),
62            },
63            Some(("config", submatches)) => Command::Config(parse_config_command(submatches)?),
64            Some(("daemon", submatches)) => Command::Daemon(parse_daemon_command(submatches)?),
65            Some(("skill", submatches)) => match submatches.subcommand() {
66                Some(("print", _)) => Command::SkillPrint,
67                _ => {
68                    return Err(ViaError::InvalidCli(
69                        "expected `via skill print`".to_owned(),
70                    ))
71                }
72            },
73            Some((service, submatches)) => {
74                let mut args = submatches
75                    .get_many::<String>("")
76                    .map(|values| values.cloned().collect::<Vec<_>>())
77                    .unwrap_or_default()
78                    .into_iter();
79                let capability = args
80                    .next()
81                    .ok_or_else(|| ViaError::MissingArgument("capability".to_owned()))?;
82
83                Command::Invoke {
84                    service: service.to_owned(),
85                    capability,
86                    args: args.collect(),
87                }
88            }
89            None => Command::Help,
90        };
91
92        Ok(Self {
93            config_path,
94            command,
95        })
96    }
97}
98
99fn parse_config_command(matches: &clap::ArgMatches) -> Result<ConfigCommand, ViaError> {
100    match matches.subcommand() {
101        Some(("path", _)) => Ok(ConfigCommand::Path),
102        Some(("doctor", submatches)) => Ok(ConfigCommand::Doctor {
103            service: submatches.get_one::<String>("service").cloned(),
104        }),
105        None => Ok(ConfigCommand::Configure),
106        _ => Err(ViaError::InvalidCli(
107            "expected `via config`, `via config path`, or `via config doctor`".to_owned(),
108        )),
109    }
110}
111
112fn parse_daemon_command(matches: &clap::ArgMatches) -> Result<DaemonCommand, ViaError> {
113    match matches.subcommand() {
114        Some(("status", _)) => Ok(DaemonCommand::Status),
115        Some(("clear", _)) => Ok(DaemonCommand::Clear),
116        Some(("stop", _)) => Ok(DaemonCommand::Stop),
117        Some(("serve", _)) => Ok(DaemonCommand::Serve),
118        _ => Err(ViaError::InvalidCli(
119            "expected `via daemon status`, `via daemon clear`, or `via daemon stop`".to_owned(),
120        )),
121    }
122}
123
124fn command() -> ClapCommand {
125    ClapCommand::new("via")
126        .about("Run commands and API requests with 1Password-backed credentials without exposing secrets to your shell")
127        .version(env!("CARGO_PKG_VERSION"))
128        .disable_help_subcommand(true)
129        .allow_external_subcommands(true)
130        .external_subcommand_value_parser(clap::value_parser!(String))
131        .arg(
132            Arg::new("config_path")
133                .long("config")
134                .short('c')
135                .value_name("PATH")
136                .value_parser(clap::value_parser!(PathBuf))
137                .global(true)
138                .help("Path to via.toml"),
139        )
140        .subcommand(
141            ClapCommand::new("version").about("Print the via version"),
142        )
143        .subcommand(
144            ClapCommand::new("login")
145                .about("Authenticate configured secret providers")
146                .arg(Arg::new("provider").help("Only authenticate one provider")),
147        )
148        .subcommand(
149            ClapCommand::new("capabilities")
150                .about("List configured services and capabilities")
151                .arg(
152                    Arg::new("json")
153                        .long("json")
154                        .action(ArgAction::SetTrue)
155                        .help("Print machine-readable JSON"),
156                ),
157        )
158        .subcommand(
159            ClapCommand::new("config")
160                .about("Create, locate, and check via configuration")
161                .subcommand(ClapCommand::new("path").about("Print the resolved config path"))
162                .subcommand(
163                    ClapCommand::new("doctor")
164                        .about("Check configuration, providers, secrets, and delegated tools")
165                        .arg(Arg::new("service").help("Only check one service")),
166                ),
167        )
168        .subcommand(
169            ClapCommand::new("daemon")
170                .about("Manage the local via secret cache daemon")
171                .subcommand(ClapCommand::new("status").about("Show daemon status"))
172                .subcommand(ClapCommand::new("clear").about("Clear cached daemon secrets"))
173                .subcommand(ClapCommand::new("stop").about("Stop the daemon"))
174                .subcommand(ClapCommand::new("serve").hide(true)),
175        )
176        .subcommand(
177            ClapCommand::new("skill")
178                .about("Agent skill helpers")
179                .subcommand(ClapCommand::new("print").about("Print the via skill instructions")),
180        )
181        .arg_required_else_help(false)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn parse(args: &[&str]) -> Cli {
189        Cli::parse(args.iter().map(OsString::from)).unwrap()
190    }
191
192    #[test]
193    fn no_args_is_help() {
194        let cli = parse(&["via"]);
195
196        assert!(matches!(cli.command, Command::Help));
197    }
198
199    #[test]
200    fn parses_global_config_before_service_invocation() {
201        let cli = parse(&[
202            "via",
203            "--config",
204            "examples/github.toml",
205            "github",
206            "api",
207            "POST",
208            "/user",
209            "--json",
210            "{}",
211        ]);
212
213        assert_eq!(
214            cli.config_path.unwrap(),
215            PathBuf::from("examples/github.toml")
216        );
217        match cli.command {
218            Command::Invoke {
219                service,
220                capability,
221                args,
222            } => {
223                assert_eq!(service, "github");
224                assert_eq!(capability, "api");
225                assert_eq!(args, ["POST", "/user", "--json", "{}"]);
226            }
227            _ => panic!("expected invoke"),
228        }
229    }
230
231    #[test]
232    fn parses_capabilities_json() {
233        let cli = parse(&["via", "capabilities", "--json"]);
234
235        assert!(matches!(cli.command, Command::Capabilities { json: true }));
236    }
237
238    #[test]
239    fn parses_version() {
240        let cli = parse(&["via", "version"]);
241
242        assert!(matches!(cli.command, Command::Version));
243    }
244
245    #[test]
246    fn parses_login() {
247        let cli = parse(&["via", "login"]);
248
249        assert!(matches!(cli.command, Command::Login { provider: None }));
250    }
251
252    #[test]
253    fn parses_login_provider() {
254        let cli = parse(&["via", "login", "onepassword"]);
255
256        assert!(matches!(
257            cli.command,
258            Command::Login {
259                provider: Some(provider)
260            } if provider == "onepassword"
261        ));
262    }
263
264    #[test]
265    fn parses_doctor_service() {
266        let cli = parse(&["via", "config", "doctor", "github"]);
267
268        assert!(matches!(
269            cli.command,
270            Command::Config(ConfigCommand::Doctor {
271                service: Some(service)
272            }) if service == "github"
273        ));
274    }
275
276    #[test]
277    fn parses_config_path() {
278        let cli = parse(&["via", "config", "path"]);
279
280        assert!(matches!(cli.command, Command::Config(ConfigCommand::Path)));
281    }
282
283    #[test]
284    fn parses_config_configure() {
285        let cli = parse(&["via", "config"]);
286
287        assert!(matches!(
288            cli.command,
289            Command::Config(ConfigCommand::Configure)
290        ));
291    }
292
293    #[test]
294    fn parses_skill_print() {
295        let cli = parse(&["via", "skill", "print"]);
296
297        assert!(matches!(cli.command, Command::SkillPrint));
298    }
299
300    #[test]
301    fn parses_daemon_status() {
302        let cli = parse(&["via", "daemon", "status"]);
303
304        assert!(matches!(
305            cli.command,
306            Command::Daemon(DaemonCommand::Status)
307        ));
308    }
309
310    #[test]
311    fn parses_daemon_clear() {
312        let cli = parse(&["via", "daemon", "clear"]);
313
314        assert!(matches!(cli.command, Command::Daemon(DaemonCommand::Clear)));
315    }
316
317    #[test]
318    fn parses_daemon_stop() {
319        let cli = parse(&["via", "daemon", "stop"]);
320
321        assert!(matches!(cli.command, Command::Daemon(DaemonCommand::Stop)));
322    }
323
324    #[test]
325    fn rejects_missing_capability() {
326        let error = match Cli::parse([OsString::from("via"), OsString::from("github")]) {
327            Ok(_) => panic!("expected missing capability error"),
328            Err(error) => error,
329        };
330
331        assert!(matches!(error, ViaError::MissingArgument(argument) if argument == "capability"));
332    }
333}