//! Public dispatch for the `maestro` CLI subcommands. //! //! Every command talks to the local daemon (`[cli].daemon_url`, default //! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill` //! hit its session endpoints, and `attach` opens `WS /api/attach/:id`. use std::path::{Path, PathBuf}; use maestro_protocol::DaemonClient; use crate::attach::{parse_detach_key, run_attach_url}; use crate::cli::Command; use crate::sessions::{run_kill, run_list, run_send, run_spawn}; use maestro_config::{default_config_path, MaestroConfigFile}; #[derive(Debug, thiserror::Error)] #[allow(clippy::result_large_err)] pub enum CliError { #[error("config: {0}")] Config(#[from] maestro_config::ConfigError), #[error("{0}")] Client(#[from] maestro_protocol::ClientError), #[error("{0}")] Attach(#[from] crate::attach::AttachError), #[error("{0}")] Hooks(#[from] maestro_hooks::HooksError), #[error("{0}")] Resolve(#[from] crate::sessions::ResolveError), #[error("nothing to send: pass the text as arguments or pipe it on stdin")] NothingToSend, #[error("could not determine the home directory ($HOME unset?)")] NoHome, #[error("{0}")] Io(#[from] std::io::Error), } /// Dispatch a parsed top-level subcommand. The umbrella binary passes the /// global flags (`--config`, `--daemon-url`) in alongside the variant. #[allow(clippy::result_large_err)] pub async fn dispatch( command: Command, config: Option, daemon_url_override: Option, ) -> Result<(), CliError> { let config_path = match config { Some(p) => p, None => default_config_path()?, }; // `hooks install` is purely local: it must work with the daemon down, so // it never touches the client. if let Command::Hooks(args) = &command { match args.command { crate::cli::HooksCommand::Install => run_hooks_install()?, } return Ok(()); } let daemon = client(&config_path, daemon_url_override)?; match command { Command::List(args) => { let mut stdout = std::io::stdout().lock(); run_list(&daemon, &args, &mut stdout).await?; } Command::Spawn(args) => { let attach_after = args.attach; let session = run_spawn(&daemon, args).await?; println!("spawned {}", session.id); if attach_after { // `spawn --attach` has no detach-key flag; use the attach // default (Ctrl-q). run_attach_url(&daemon.attach_url(session.id)?, CTRL_Q).await?; } } Command::Attach(args) => { let detach = parse_detach_key(&args.detach_key)?; run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?; } Command::Kill(args) => { // #83: warn BEFORE the attempt, so the refusal is not a surprise. // Best-effort: an unreachable list must not block the kill. if !args.force { warn_if_blocked(&daemon, args.session_id).await; } run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); } Command::Send(args) => { let id = run_send(&daemon, &args, &mut std::io::stdin()).await?; println!("sent to {id}"); } Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"), } Ok(()) } /// `maestro hooks install` (#106): write the shipped hook script under the /// user's data dir and register it in every agent's hook file. Idempotent and /// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One /// agent failing is reported and does not stop the others. #[allow(clippy::result_large_err)] fn run_hooks_install() -> Result<(), CliError> { let home = dirs::home_dir().ok_or(CliError::NoHome)?; let script = maestro_hooks::script_path(&home); for (target, result) in maestro_hooks::install_for_home(&home) { let agent = target.label(); let settings = target.settings_path(&home); match result { Ok(true) => { println!( "installed {agent} activity hook: {} → {}", script.display(), settings.display() ); if target == maestro_hooks::Target::Codex { // Codex will not run a hook it has not been told to // trust, and that approval is the operator's to give. println!(" codex will ask you to trust this hook the next time it starts"); } } Ok(false) => println!("{agent} activity hook already up to date"), Err(e) => eprintln!("{agent} activity hook not installed: {e}"), } } Ok(()) } /// Default detach byte, matching `maestro attach --detach-key`'s `^q`. const CTRL_Q: u8 = 0x11; /// #83: say "this will be refused" before the operator commits to it, in the /// same words [`maestro_protocol::InTheWay`] uses everywhere else. async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) { let Ok(sessions) = daemon.list_sessions().await else { return; }; if let Some(b) = sessions .iter() .find(|s| s.id == id) .and_then(|s| s.blocked_work) { if !b.is_clear() { eprintln!( "warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to end it anyway" ); } } } /// HTTP client pointed at the daemon's API. That surface needs no auth — /// reaching `bind_addr` is the authorization. #[allow(clippy::result_large_err)] fn client(config_path: &Path, override_url: Option) -> Result { let cfg = MaestroConfigFile::load(config_path)?; Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url))) }