//! 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 on the local maestro daemon", arg_required_else_help = true )] pub struct Cli { /// Override the daemon HTTP base for this invocation (e.g. /// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the /// unified config file is used. #[arg(long, global = true, env = "MAESTRO_DAEMON_URL")] pub daemon_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. List(ListArgs), /// Spawn a new session. Spawn(SpawnArgs), /// Attach to a session over WebSocket. Bytes flow through the local TTY. Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), } #[derive(Debug, clap::Args)] pub struct ListArgs { /// Output as JSON instead of a table. #[arg(long)] pub json: bool, } #[derive(Debug, Clone, clap::Args)] pub struct SpawnArgs { /// 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, /// 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, } 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", "--json"]).unwrap(); match cli.command { Command::List(args) => { assert!(args.json); } _ => panic!("wrong subcommand"), } } #[test] fn parses_spawn_with_worktree() { let cli = Cli::try_parse_from([ "maestro", "spawn", "--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.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", "--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 rejects_env_without_equals() { let err = Cli::try_parse_from([ "maestro", "spawn", "--cwd", "/", "--command", "bash", "-e", "BARE", ]) .unwrap_err(); assert!(err.to_string().contains("KEY=VALUE")); } }