Previously --force left the worktree on disk so the operator could inspect it. Now it removes the worktree unconditionally — the confirmation prompt already shows the breakdown, so the decision happens before the delete, not after. Natural exit still leaves dirty worktrees on disk (no operator confirmation in that path). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
340 lines
10 KiB
Rust
340 lines
10 KiB
Rust
//! 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<String>,
|
|
|
|
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
|
|
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
|
pub config: Option<PathBuf>,
|
|
|
|
/// 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 <install>`
|
|
#[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<PathBuf>,
|
|
/// Branch name for the worktree (required with --worktree-from).
|
|
#[arg(long)]
|
|
pub branch: Option<String>,
|
|
/// 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 <session> <text...>`
|
|
#[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<String>,
|
|
/// 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 removed along with the session.
|
|
#[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
|
|
));
|
|
}
|
|
}
|