This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-cli/src/cli.rs
T
Miguel Palhas 8376122dfe fix(maestro): second-review findings across daemon, tui and web
Ten findings from reviewing the accumulated blitz/spawn-v2 diff. The two
that could wedge a repo:

- The cleanup guard refused any worktree whose commits were on no other
  ref, which is every ordinary unpushed branch. Paths are deterministic
  now, so the leftover tree blocked the next spawn and each branch became
  a one-shot. `worktree remove` keeps refs/heads/<branch>, so committed
  work is never at risk: guard on an unclean working tree alone.
- A worktree whose directory had vanished failed the git probe and was
  reported as unsaved work, stranding the registration forever. Prune it
  instead, and prune before `worktree add` tests the target path.

Also:

- The CLI hardcoded create_branch: true, so the daemon's existing-branch
  path was unreachable. Added --existing-branch.
- Pastes were forwarded without their DECSET 2004 markers, so a remote
  shell ran a multi-line paste line by line.
- spawnForm emitted before its seed was restored and after destroy(), so
  a rebuilt draft blanked its cwd and a dead form clobbered the live one.
- showRawCwd and the probe's worktree untick never reached the leaf.
- A late autocomplete fetch reopened the popup under a blurred input.
- The "remove worktree when killed" checkbox promised something the
  daemon no longer honours; removed.
2026-08-23 20:54:21 +01:00

245 lines
6.7 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),
}
#[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,
/// Remove the worktree when the session is killed. Kept for wire
/// compatibility (#56): the daemon now always cleans up on kill, guarded
/// against unsaved work.
#[arg(long, default_value_t = true)]
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 cleanup_on_kill_defaults_true_for_the_wire() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repos/foo",
"--branch",
"feat/x",
])
.unwrap();
match cli.command {
Command::Spawn(a) => assert!(a.cleanup_on_kill),
_ => 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 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"));
}
}