via-cli 0.1.0

Run commands and API requests with 1Password-backed credentials without exposing secrets to your shell
Documentation
use std::ffi::OsString;
use std::path::PathBuf;

use clap::{Arg, ArgAction, Command as ClapCommand};

use crate::error::ViaError;

pub struct Cli {
    pub config_path: Option<PathBuf>,
    pub command: Command,
}

pub enum Command {
    Help,
    Capabilities {
        json: bool,
    },
    Config(ConfigCommand),
    SkillPrint,
    Invoke {
        service: String,
        capability: String,
        args: Vec<String>,
    },
}

pub enum ConfigCommand {
    Configure,
    Path,
    Doctor { service: Option<String> },
}

pub fn print_help() {
    let _ = command().print_help();
    println!();
}

impl Cli {
    pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, ViaError> {
        let matches = command().try_get_matches_from(args)?;
        let config_path = matches.get_one::<PathBuf>("config_path").cloned();

        let command = match matches.subcommand() {
            Some(("capabilities", submatches)) => Command::Capabilities {
                json: submatches.get_flag("json"),
            },
            Some(("config", submatches)) => Command::Config(parse_config_command(submatches)?),
            Some(("skill", submatches)) => match submatches.subcommand() {
                Some(("print", _)) => Command::SkillPrint,
                _ => {
                    return Err(ViaError::InvalidCli(
                        "expected `via skill print`".to_owned(),
                    ))
                }
            },
            Some((service, submatches)) => {
                let mut args = submatches
                    .get_many::<String>("")
                    .map(|values| values.cloned().collect::<Vec<_>>())
                    .unwrap_or_default()
                    .into_iter();
                let capability = args
                    .next()
                    .ok_or_else(|| ViaError::MissingArgument("capability".to_owned()))?;

                Command::Invoke {
                    service: service.to_owned(),
                    capability,
                    args: args.collect(),
                }
            }
            None => Command::Help,
        };

        Ok(Self {
            config_path,
            command,
        })
    }
}

fn parse_config_command(matches: &clap::ArgMatches) -> Result<ConfigCommand, ViaError> {
    match matches.subcommand() {
        Some(("path", _)) => Ok(ConfigCommand::Path),
        Some(("doctor", submatches)) => Ok(ConfigCommand::Doctor {
            service: submatches.get_one::<String>("service").cloned(),
        }),
        None => Ok(ConfigCommand::Configure),
        _ => Err(ViaError::InvalidCli(
            "expected `via config`, `via config path`, or `via config doctor`".to_owned(),
        )),
    }
}

fn command() -> ClapCommand {
    ClapCommand::new("via")
        .about("Run commands and API requests with 1Password-backed credentials without exposing secrets to your shell")
        .disable_help_subcommand(true)
        .allow_external_subcommands(true)
        .external_subcommand_value_parser(clap::value_parser!(String))
        .arg(
            Arg::new("config_path")
                .long("config")
                .short('c')
                .value_name("PATH")
                .value_parser(clap::value_parser!(PathBuf))
                .global(true)
                .help("Path to via.toml"),
        )
        .subcommand(
            ClapCommand::new("capabilities")
                .about("List configured services and capabilities")
                .arg(
                    Arg::new("json")
                        .long("json")
                        .action(ArgAction::SetTrue)
                        .help("Print machine-readable JSON"),
                ),
        )
        .subcommand(
            ClapCommand::new("config")
                .about("Create, locate, and check via configuration")
                .subcommand(ClapCommand::new("path").about("Print the resolved config path"))
                .subcommand(
                    ClapCommand::new("doctor")
                        .about("Check configuration, providers, secrets, and delegated tools")
                        .arg(Arg::new("service").help("Only check one service")),
                ),
        )
        .subcommand(
            ClapCommand::new("skill")
                .about("Agent skill helpers")
                .subcommand(ClapCommand::new("print").about("Print the via skill instructions")),
        )
        .arg_required_else_help(false)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(args: &[&str]) -> Cli {
        Cli::parse(args.iter().map(OsString::from)).unwrap()
    }

    #[test]
    fn no_args_is_help() {
        let cli = parse(&["via"]);

        assert!(matches!(cli.command, Command::Help));
    }

    #[test]
    fn parses_global_config_before_service_invocation() {
        let cli = parse(&[
            "via",
            "--config",
            "examples/github.toml",
            "github",
            "api",
            "POST",
            "/user",
            "--json",
            "{}",
        ]);

        assert_eq!(
            cli.config_path.unwrap(),
            PathBuf::from("examples/github.toml")
        );
        match cli.command {
            Command::Invoke {
                service,
                capability,
                args,
            } => {
                assert_eq!(service, "github");
                assert_eq!(capability, "api");
                assert_eq!(args, ["POST", "/user", "--json", "{}"]);
            }
            _ => panic!("expected invoke"),
        }
    }

    #[test]
    fn parses_capabilities_json() {
        let cli = parse(&["via", "capabilities", "--json"]);

        assert!(matches!(cli.command, Command::Capabilities { json: true }));
    }

    #[test]
    fn parses_doctor_service() {
        let cli = parse(&["via", "config", "doctor", "github"]);

        assert!(matches!(
            cli.command,
            Command::Config(ConfigCommand::Doctor {
                service: Some(service)
            }) if service == "github"
        ));
    }

    #[test]
    fn parses_config_path() {
        let cli = parse(&["via", "config", "path"]);

        assert!(matches!(cli.command, Command::Config(ConfigCommand::Path)));
    }

    #[test]
    fn parses_config_configure() {
        let cli = parse(&["via", "config"]);

        assert!(matches!(
            cli.command,
            Command::Config(ConfigCommand::Configure)
        ));
    }

    #[test]
    fn parses_skill_print() {
        let cli = parse(&["via", "skill", "print"]);

        assert!(matches!(cli.command, Command::SkillPrint));
    }

    #[test]
    fn rejects_missing_capability() {
        let error = match Cli::parse([OsString::from("via"), OsString::from("github")]) {
            Ok(_) => panic!("expected missing capability error"),
            Err(error) => error,
        };

        assert!(matches!(error, ViaError::MissingArgument(argument) if argument == "capability"));
    }
}