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}