Structured sessions were rejected by `send_input` with NotTerminal. Now `run_send` checks the session view and routes to `send_structured_prompt` for structured sessions. 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 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
|
|
));
|
|
}
|
|
}
|