//! Clap-derive command-line definitions. use std::path::PathBuf; use clap::{Parser, Subcommand}; use uuid::Uuid; #[derive(Debug, Parser)] #[command( name = "maestro", version, about = "Control terminal sessions across one or more maestrod hosts", arg_required_else_help = true )] pub struct Cli { /// Override the registry URL for this invocation (e.g. `https://maestro.example.com`). /// If omitted, `[shared].registry_url` from the unified config file is used. #[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")] pub registry_url: Option, /// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`. #[arg(long, global = true, env = "MAESTRO_CONFIG")] pub config: Option, /// Increase log verbosity. Repeat for more: `-v` = debug, `-vv` = trace. #[arg(short, long, global = true, action = clap::ArgAction::Count)] pub verbose: u8, #[command(subcommand)] pub command: Command, } #[derive(Debug, Subcommand)] pub enum Command { /// List all sessions, grouped by daemon. List(ListArgs), /// Spawn a new session on a given daemon. Spawn(SpawnArgs), /// Attach to a session over WebSocket. Bytes flow through the local TTY. Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), /// Set the default registry URL persisted in the CLI config. SetRegistry(SetRegistryArgs), /// Inspect or hot-reload the local daemon's `[daemon.policy]` (NAP-237). #[command(subcommand)] Policy(PolicyCmd), } #[derive(Debug, Subcommand)] pub enum PolicyCmd { /// Re-read `~/.config/maestro/config.toml` and apply /// `[daemon.policy].allow_inbound` without restarting the daemon. Affects /// only new attach attempts — in-flight sessions keep their connection. Reload, /// Print the daemon's current effective `allow_inbound` list. Show { /// Output as JSON instead of one entry per line. #[arg(long)] json: bool, }, } #[derive(Debug, clap::Args)] pub struct ListArgs { /// Show only sessions on this daemon. #[arg(long)] pub daemon: Option, /// Output as JSON instead of a table. #[arg(long)] pub json: bool, } #[derive(Debug, clap::Args)] pub struct SpawnArgs { /// Daemon ID to spawn on. #[arg(long)] pub daemon: String, /// Working directory for the session (must exist on the daemon's host). #[arg(long)] pub cwd: PathBuf, /// Command to launch (e.g. `bash`, `claude`). #[arg(long)] pub command: String, /// Human-readable label, persisted in session metadata (`label`). #[arg(long)] pub label: Option, /// Set an env var on the session (`KEY=VALUE`). Repeatable. #[arg(long = "env", short = 'e', value_parser = parse_kv, value_name = "KEY=VALUE")] pub env: Vec<(String, String)>, /// Base repo path for a git worktree spawn. #[arg(long, requires = "branch")] pub worktree_from: Option, /// Branch name for the worktree (required with --worktree-from). #[arg(long)] pub branch: Option, /// Remove the worktree when the session is killed. #[arg(long)] pub cleanup_on_kill: bool, /// After spawning, attach immediately. #[arg(long, short = 'a')] pub attach: bool, } #[derive(Debug, clap::Args)] pub struct AttachArgs { /// Session ID to attach to. pub session_id: Uuid, /// Detach key (single byte). Default Ctrl-q (`\x11`). Accepts `^X` form. #[arg(long, default_value = "^q")] pub detach_key: String, } #[derive(Debug, clap::Args)] pub struct KillArgs { /// Session ID to kill. pub session_id: Uuid, } #[derive(Debug, clap::Args)] pub struct SetRegistryArgs { /// Registry base URL to persist. pub url: String, } fn parse_kv(s: &str) -> Result<(String, String), String> { match s.split_once('=') { Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())), _ => Err(format!("expected KEY=VALUE, got {s:?}")), } } #[cfg(test)] mod tests { use super::*; use clap::Parser; #[test] fn parses_list() { let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap(); match cli.command { Command::List(args) => { assert_eq!(args.daemon.as_deref(), Some("laptop")); assert!(args.json); } _ => panic!("wrong subcommand"), } } #[test] fn parses_spawn_with_worktree() { let cli = Cli::try_parse_from([ "maestro", "spawn", "--daemon", "vm1", "--cwd", "/tmp", "--command", "bash", "--worktree-from", "/repos/foo", "--branch", "feat/x", "--cleanup-on-kill", "-e", "FOO=bar", "-e", "BAZ=qux", ]) .unwrap(); match cli.command { Command::Spawn(a) => { assert_eq!(a.daemon, "vm1"); assert_eq!(a.command, "bash"); assert_eq!(a.branch.as_deref(), Some("feat/x")); assert!(a.cleanup_on_kill); assert_eq!( a.env, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),] ); } _ => panic!("wrong subcommand"), } } #[test] fn spawn_worktree_requires_branch() { let err = Cli::try_parse_from([ "maestro", "spawn", "--daemon", "vm1", "--cwd", "/tmp", "--command", "bash", "--worktree-from", "/repos/foo", ]) .unwrap_err(); assert!(err.to_string().contains("--branch")); } #[test] fn parses_attach() { let id = "01234567-89ab-cdef-0123-456789abcdef"; let cli = Cli::try_parse_from(["maestro", "attach", id]).unwrap(); match cli.command { Command::Attach(a) => assert_eq!(a.session_id.to_string(), id), _ => panic!("wrong subcommand"), } } #[test] fn parses_policy_reload() { let cli = Cli::try_parse_from(["maestro", "policy", "reload"]).unwrap(); assert!(matches!(cli.command, Command::Policy(PolicyCmd::Reload))); } #[test] fn parses_policy_show_json() { let cli = Cli::try_parse_from(["maestro", "policy", "show", "--json"]).unwrap(); match cli.command { Command::Policy(PolicyCmd::Show { json }) => assert!(json), other => panic!("wrong subcommand: {other:?}"), } } #[test] fn rejects_env_without_equals() { let err = Cli::try_parse_from([ "maestro", "spawn", "--daemon", "x", "--cwd", "/", "--command", "bash", "-e", "BARE", ]) .unwrap_err(); assert!(err.to_string().contains("KEY=VALUE")); } }