//! Command-line argument parsing for the huskies binary. use std::path::PathBuf; /// Parsed CLI arguments. #[derive(Debug, PartialEq)] pub(crate) struct CliArgs { /// Value from `--port ` flag, if supplied. pub(crate) port: Option, /// Positional project path argument, if supplied. pub(crate) path: Option, /// Whether the `init` subcommand was given. pub(crate) init: bool, /// Whether the `agent` subcommand was given. pub(crate) agent: bool, /// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`). pub(crate) rendezvous: Option, /// Whether `--gateway` mode was requested (proxy MCP calls to per-project containers). pub(crate) gateway: bool, /// One-time join token for registering this build agent with a gateway (`--join-token`). pub(crate) join_token: Option, /// HTTP URL of the gateway to register with when a join token is provided (`--gateway-url`). pub(crate) gateway_url: Option, } /// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`. pub(crate) fn parse_cli_args(args: &[String]) -> Result { let mut port: Option = None; let mut path: Option = None; let mut init = false; let mut agent = false; let mut gateway = false; let mut rendezvous: Option = None; let mut join_token: Option = None; let mut gateway_url: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { "--help" | "-h" => { print_help(); std::process::exit(0); } "--version" | "-V" => { println!("huskies {}", env!("CARGO_PKG_VERSION")); std::process::exit(0); } "--port" => { i += 1; if i >= args.len() { return Err("--port requires a value".to_string()); } match args[i].parse::() { Ok(p) => port = Some(p), Err(_) => return Err(format!("invalid port value: '{}'", args[i])), } } a if a.starts_with("--port=") => { let val = &a["--port=".len()..]; match val.parse::() { Ok(p) => port = Some(p), Err(_) => return Err(format!("invalid port value: '{val}'")), } } "--rendezvous" => { i += 1; if i >= args.len() { return Err("--rendezvous requires a value".to_string()); } rendezvous = Some(args[i].clone()); } a if a.starts_with("--rendezvous=") => { let val = &a["--rendezvous=".len()..]; rendezvous = Some(val.to_string()); } "--join-token" => { i += 1; if i >= args.len() { return Err("--join-token requires a value".to_string()); } join_token = Some(args[i].clone()); } a if a.starts_with("--join-token=") => { join_token = Some(a["--join-token=".len()..].to_string()); } "--gateway-url" => { i += 1; if i >= args.len() { return Err("--gateway-url requires a value".to_string()); } gateway_url = Some(args[i].clone()); } a if a.starts_with("--gateway-url=") => { gateway_url = Some(a["--gateway-url=".len()..].to_string()); } "--gateway" => { gateway = true; } "init" => { init = true; } "agent" => { agent = true; } a if a.starts_with('-') => { return Err(format!("unknown option: {a}")); } a => { if path.is_some() { return Err(format!("unexpected argument: {a}")); } path = Some(a.to_string()); } } i += 1; } if agent && rendezvous.is_none() { return Err("agent mode requires --rendezvous ".to_string()); } Ok(CliArgs { port, path, init, agent, rendezvous, gateway, join_token, gateway_url, }) } pub(crate) fn print_help() { println!("huskies [OPTIONS] [PATH]"); println!("huskies init [OPTIONS] [PATH]"); println!("huskies agent --rendezvous [OPTIONS] [PATH]"); println!("huskies --gateway [OPTIONS] [PATH]"); println!(); println!("Serve a huskies project."); println!(); println!("COMMANDS:"); println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard."); println!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work."); println!(); println!("ARGS:"); println!( " PATH Path to an existing project directory. \ If omitted, huskies searches parent directories for a .huskies/ root." ); println!(); println!("OPTIONS:"); println!(" -h, --help Print this help and exit"); println!(" -V, --version Print the version and exit"); println!( " --port Port to listen on (default: 3001). Persisted to project.toml." ); println!(" --rendezvous WebSocket URL of the rendezvous peer (agent mode only)."); println!(" Example: ws://server:3001/crdt-sync"); println!(" --gateway Start in gateway mode. Reads projects.toml from PATH"); println!( " (or cwd) and proxies MCP calls to per-project containers." ); println!(" --join-token One-time token for registering this build agent with a"); println!(" gateway. Also readable from HUSKIES_JOIN_TOKEN env var."); println!(" --gateway-url HTTP URL of the gateway to register with when"); println!(" --join-token is provided (agent mode only)."); println!(" Also readable from HUSKIES_GATEWAY_URL env var."); } /// Resolve the optional positional path argument into an absolute `PathBuf`. pub(crate) fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option { path_str.map(|s| crate::io::fs::resolve_cli_path(cwd, s)) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_no_args() { let result = parse_cli_args(&[]).unwrap(); assert_eq!(result.port, None); assert_eq!(result.path, None); assert!(!result.init); assert!(!result.agent); assert!(!result.gateway); assert_eq!(result.rendezvous, None); } #[test] fn parse_gateway_flag() { let args = vec!["--gateway".to_string()]; let result = parse_cli_args(&args).unwrap(); assert!(result.gateway); assert!(!result.init); assert!(!result.agent); } #[test] fn parse_gateway_with_port_and_path() { let args = vec![ "--gateway".to_string(), "--port".to_string(), "5000".to_string(), "/my/config".to_string(), ]; let result = parse_cli_args(&args).unwrap(); assert!(result.gateway); assert_eq!(result.port, Some(5000)); assert_eq!(result.path, Some("/my/config".to_string())); } #[test] fn parse_unknown_flag_is_error() { let args = vec!["--serve".to_string()]; assert!(parse_cli_args(&args).is_err()); } #[test] fn parse_path_only() { let args = vec!["/some/path".to_string()]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.path, Some("/some/path".to_string())); assert_eq!(result.port, None); assert!(!result.init); } #[test] fn parse_port_flag() { let args = vec!["--port".to_string(), "4000".to_string()]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.port, Some(4000)); assert_eq!(result.path, None); } #[test] fn parse_port_equals_syntax() { let args = vec!["--port=5000".to_string()]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.port, Some(5000)); } #[test] fn parse_port_with_path() { let args = vec![ "--port".to_string(), "4200".to_string(), "/some/path".to_string(), ]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.port, Some(4200)); assert_eq!(result.path, Some("/some/path".to_string())); } #[test] fn parse_port_missing_value_is_error() { let args = vec!["--port".to_string()]; assert!(parse_cli_args(&args).is_err()); } #[test] fn parse_port_invalid_value_is_error() { let args = vec!["--port".to_string(), "abc".to_string()]; assert!(parse_cli_args(&args).is_err()); } #[test] fn parse_init_subcommand() { let args = vec!["init".to_string()]; let result = parse_cli_args(&args).unwrap(); assert!(result.init); assert_eq!(result.path, None); } #[test] fn parse_init_with_path_and_port() { let args = vec![ "init".to_string(), "--port".to_string(), "3000".to_string(), "/my/project".to_string(), ]; let result = parse_cli_args(&args).unwrap(); assert!(result.init); assert_eq!(result.port, Some(3000)); assert_eq!(result.path, Some("/my/project".to_string())); } #[test] fn parse_join_token_flag() { let args = vec![ "agent".to_string(), "--rendezvous".to_string(), "ws://host:3001/crdt-sync".to_string(), "--join-token".to_string(), "my-secret-token".to_string(), ]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.join_token, Some("my-secret-token".to_string())); } #[test] fn parse_join_token_equals_syntax() { let args = vec![ "agent".to_string(), "--rendezvous".to_string(), "ws://host:3001/crdt-sync".to_string(), "--join-token=abc123".to_string(), ]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.join_token, Some("abc123".to_string())); } #[test] fn parse_gateway_url_flag() { let args = vec![ "agent".to_string(), "--rendezvous".to_string(), "ws://host:3001/crdt-sync".to_string(), "--gateway-url".to_string(), "http://gateway:3000".to_string(), ]; let result = parse_cli_args(&args).unwrap(); assert_eq!(result.gateway_url, Some("http://gateway:3000".to_string())); } #[test] fn parse_join_token_missing_value_is_error() { let args = vec!["--join-token".to_string()]; assert!(parse_cli_args(&args).is_err()); } #[test] fn parse_gateway_url_missing_value_is_error() { let args = vec!["--gateway-url".to_string()]; assert!(parse_cli_args(&args).is_err()); } #[test] fn parse_no_args_join_token_and_gateway_url_are_none() { let result = parse_cli_args(&[]).unwrap(); assert_eq!(result.join_token, None); assert_eq!(result.gateway_url, None); } // ── resolve_path_arg ──────────────────────────────────────────── #[test] fn resolve_path_arg_none_when_no_path() { let cwd = PathBuf::from("/home/user/project"); let result = resolve_path_arg(None, &cwd); assert!(result.is_none()); } #[test] fn resolve_path_arg_returns_path_for_absolute_arg() { let cwd = PathBuf::from("/home/user/project"); let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap(); assert!( result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path") ); } #[test] fn resolve_path_arg_resolves_dot_to_cwd() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path().to_path_buf(); let result = resolve_path_arg(Some("."), &cwd).unwrap(); assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); } }