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}