//! 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), /// Send text to a running session. Terminal sessions receive raw /// keystrokes; structured sessions receive an ACP prompt. Send(SendArgs), /// 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, } #[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, /// Run a Claude Code or Codex session in a plain terminal instead of the /// structured (ACP) view it otherwise gets by default. #[arg(long, conflicts_with = "structured")] pub terminal: bool, /// Ask for the structured (ACP) view explicitly. Only Claude Code and /// Codex commands have an adapter; the daemon refuses anything else. #[arg(long)] pub structured: 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, } /// `maestro send ` #[derive(Debug, clap::Args)] pub struct SendArgs { /// Session to type into: its ID, a unique ID prefix, or its name. pub session: String, /// Text to send. Omit it to read the text from stdin. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub text: Vec, /// Leave the text sitting in the session's prompt instead of pressing /// Enter after it. #[arg(long)] pub no_enter: bool, } #[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 left on disk — never removed. #[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 parses_send_joining_the_message_words() { let cli = Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap(); let Command::Send(a) = cli.command else { panic!("expected send") }; assert_eq!(a.session, "planner"); assert_eq!(a.text, vec!["run", "the", "tests"]); assert!(!a.no_enter); } /// The message is trailing_var_arg, so a word starting with `-` inside it /// is text, not a flag clap should reject. #[test] fn send_message_may_start_with_a_dash() { let cli = Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap(); let Command::Send(a) = cli.command else { panic!("expected send") }; assert!(a.no_enter); assert_eq!(a.text, vec!["--force"]); } #[test] fn send_text_may_be_omitted_for_stdin() { let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap(); let Command::Send(a) = cli.command else { panic!("expected send") }; assert!(a.text.is_empty()); } #[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. let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err(); assert!(matches!( err.kind(), clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand )); } }