//! 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), /// List retained worktrees, or reclaim one by ID (#86). Worktrees(WorktreesArgs), /// Install or update the agent activity hook (#106). Hooks(HooksArgs), } /// `maestro hooks ` #[derive(Debug, clap::Args)] pub struct HooksArgs { #[command(subcommand)] pub command: HooksCommand, } #[derive(Debug, Subcommand)] pub enum HooksCommand { /// Write the Claude Code activity hook into ~/.claude/settings.json. /// Idempotent: an older maestro hook is updated in place, every other /// hook is left untouched. Install, } /// `maestro worktrees ` #[derive(Debug, clap::Args)] pub struct WorktreesArgs { #[command(subcommand)] pub command: WorktreesCommand, } #[derive(Debug, Subcommand)] pub enum WorktreesCommand { /// List retained worktrees and what is in the way of reclaiming each. List(ListWorktreesArgs), /// Reclaim one retained worktree: removes the directory, never the branch. Reclaim(ReclaimArgs), } #[derive(Debug, clap::Args)] pub struct ListWorktreesArgs { /// Output as JSON instead of a table. #[arg(long)] pub json: bool, } #[derive(Debug, clap::Args)] pub struct ReclaimArgs { /// Retained worktree ID to reclaim. Required — there is no reclaim-all. pub id: Uuid, } #[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, /// Check out an existing branch instead of creating one. Without it the /// daemon runs `worktree add -b`, which fails if the branch is already /// there. #[arg(long)] pub existing_branch: 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, /// End the session even when its worktree holds unsaved work (#81). The /// worktree is retained — never removed — and enters the retained store; /// the output says where the work went. #[arg(long)] pub force: bool, } 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", "-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_eq!( a.env, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),] ); } _ => panic!("wrong subcommand"), } } /// Review fix: without a flag the CLI always sent `create_branch: true`, /// so the daemon's existing-branch path was unreachable and spawning onto /// a branch that already existed just failed. #[test] fn parses_spawn_onto_an_existing_branch() { let cli = Cli::try_parse_from([ "maestro", "spawn", "--cwd", "/tmp", "--command", "bash", "--worktree-from", "/repo", "--branch", "feat/existing", "--existing-branch", ]) .unwrap(); let Command::Spawn(a) = cli.command else { panic!("expected spawn") }; assert!(a.existing_branch); } #[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 parses_kill_with_force() { let id = "01234567-89ab-cdef-0123-456789abcdef"; let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap(); match cli.command { Command::Kill(a) => { assert!(a.force); assert_eq!(a.session_id.to_string(), id); } _ => panic!("wrong subcommand"), } } #[test] fn kill_force_defaults_false() { let id = "01234567-89ab-cdef-0123-456789abcdef"; let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap(); match cli.command { Command::Kill(a) => { assert!(!a.force); 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")); } #[test] fn parses_hooks_install() { let cli = Cli::try_parse_from(["maestro", "hooks", "install"]).unwrap(); match cli.command { Command::Hooks(h) => { assert!(matches!(h.command, HooksCommand::Install)); } _ => panic!("wrong subcommand"), } // No subcommand → help, like `worktrees`. let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err(); assert!(matches!( err.kind(), clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand )); } #[test] fn parses_worktrees_list() { let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap(); match cli.command { Command::Worktrees(w) => match w.command { WorktreesCommand::List(a) => assert!(a.json), _ => panic!("wrong worktrees subcommand"), }, _ => panic!("wrong subcommand"), } } #[test] fn parses_worktrees_reclaim() { let id = "01234567-89ab-cdef-0123-456789abcdef"; let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap(); match cli.command { Command::Worktrees(w) => match w.command { WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id), _ => panic!("wrong worktrees subcommand"), }, _ => panic!("wrong subcommand"), } } /// Contract rule: reclaim refuses to run without an explicit target. /// There is no reclaim-everything convenience. #[test] fn reclaim_requires_an_explicit_target() { let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err(); assert!( matches!( err.kind(), clap::error::ErrorKind::InvalidSubcommand | clap::error::ErrorKind::MissingRequiredArgument ), "{err}" ); } #[test] fn worktrees_without_subcommand_shows_help() { let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err(); assert!( matches!( err.kind(), clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand ), "{err}" ); } }