//! Public dispatch for the `maestro` CLI subcommands. //! //! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro` //! binary can dispatch to the same logic. //! //! ## Transport (NAP-244) //! //! The CLI talks to its **home daemon** (`[cli].home_daemon_url`, default //! `http://127.0.0.1:7070`) and lets the daemon broker the rest: //! //! * `list` reads the daemon's local catalog mirror (own ∪ peer sessions). //! * `spawn` / `kill` hit the daemon's local session endpoints. //! * `attach` opens the daemon's `WS /api/attach/:id` broker, which picks the //! transport and enforces `allow_inbound` on the target side. //! //! If the home daemon is **unreachable** (connection refused / not running), //! every command falls back to the legacy registry path (registry HTTP + //! direct WS) with a one-line stderr warning, so migration is painless. A few //! operations the daemon can't yet satisfy locally (cross-daemon spawn → 501, //! cross-daemon kill → 404) also fall back to the registry, which forwards //! them the old way. The fallback is removed in a future ticket. use std::path::{Path, PathBuf}; use crate::attach::{broker_attach_url, parse_detach_key, run_attach, run_attach_url}; use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, PolicyCmd, SetRegistryArgs, SpawnArgs}; use crate::config::{default_config_path, CliConfig, ConfigError}; use crate::http::{HttpError, RegistryClient}; use crate::policy::{run_reload as run_policy_reload, run_show as run_policy_show}; use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn}; #[derive(Debug, thiserror::Error)] #[allow(clippy::result_large_err)] pub enum CliError { #[error("config: {0}")] Config(#[from] ConfigError), #[error("{0}")] Http(#[from] crate::http::HttpError), #[error("{0}")] Attach(#[from] crate::attach::AttachError), #[error("{0}")] Io(#[from] std::io::Error), #[error("{0}")] Policy(#[from] crate::policy::PolicyError), } /// Dispatch a parsed top-level subcommand. The umbrella binary passes the /// global flags (`--config`, `--registry-url`, `--home-daemon-url`) in /// alongside the variant. #[allow(clippy::result_large_err)] pub async fn dispatch( command: Command, config: Option, registry_url_override: Option, home_daemon_url_override: Option, ) -> Result<(), CliError> { let config_path = match config { Some(p) => p, None => default_config_path()?, }; match command { Command::SetRegistry(args) => set_registry(&config_path, args), Command::Policy(sub) => policy(&config_path, sub).await, Command::List(args) => { list( &config_path, registry_url_override, home_daemon_url_override, args, ) .await } Command::Spawn(args) => { spawn( &config_path, registry_url_override, home_daemon_url_override, args, ) .await } Command::Attach(args) => { attach( &config_path, registry_url_override, home_daemon_url_override, args, ) .await } Command::Kill(args) => { kill( &config_path, registry_url_override, home_daemon_url_override, args, ) .await } } } #[allow(clippy::result_large_err)] fn set_registry(config_path: &Path, args: SetRegistryArgs) -> Result<(), CliError> { let url = args.url.trim_end_matches('/'); CliConfig::save_registry_url(config_path, url)?; println!("registry URL saved to {}", config_path.display()); Ok(()) } #[allow(clippy::result_large_err)] async fn policy(config_path: &Path, sub: PolicyCmd) -> Result<(), CliError> { let mut stdout = std::io::stdout().lock(); match sub { PolicyCmd::Reload => run_policy_reload(config_path, &mut stdout).await?, PolicyCmd::Show { json } => run_policy_show(config_path, json, &mut stdout).await?, } Ok(()) } #[allow(clippy::result_large_err)] async fn list( config_path: &Path, registry_url_override: Option, home_daemon_url_override: Option, args: ListArgs, ) -> Result<(), CliError> { let cfg = CliConfig::load(config_path)?; let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override); let daemon = daemon_client(&home_url, config_path); let mut stdout = std::io::stdout().lock(); match run_list(&daemon, &args, &mut stdout).await { Ok(()) => Ok(()), Err(e) if e.is_unreachable() => { warn_fallback(&home_url, &e); let registry = registry_client(registry_url_override, &cfg, config_path)?; run_list(®istry, &args, &mut stdout).await?; Ok(()) } Err(e) => Err(e.into()), } } #[allow(clippy::result_large_err)] async fn spawn( config_path: &Path, registry_url_override: Option, home_daemon_url_override: Option, args: SpawnArgs, ) -> Result<(), CliError> { let cfg = CliConfig::load(config_path)?; let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone()); let daemon = daemon_client(&home_url, config_path); let attach_after = args.attach; // `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q). let detach_key = 0x11; // Cross-daemon create isn't brokered by the local daemon yet (501), so we // fall back to the registry on that as well as on unreachable. let session = match run_spawn(&daemon, args.clone()).await { Ok(s) => s, Err(e) if e.is_unreachable() || e.is_remote_op_unsupported() => { warn_fallback(&home_url, &e); let registry = registry_client(registry_url_override.clone(), &cfg, config_path)?; run_spawn(®istry, args).await? } Err(e) => return Err(e.into()), }; println!("spawned {}", session.id); if attach_after { attach_session( &home_url, registry_url_override, &cfg, config_path, session.id, detach_key, ) .await?; } Ok(()) } #[allow(clippy::result_large_err)] async fn attach( config_path: &Path, registry_url_override: Option, home_daemon_url_override: Option, args: AttachArgs, ) -> Result<(), CliError> { let cfg = CliConfig::load(config_path)?; let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override); let detach = parse_detach_key(&args.detach_key)?; attach_session( &home_url, registry_url_override, &cfg, config_path, args.session_id, detach, ) .await } #[allow(clippy::result_large_err)] async fn kill( config_path: &Path, registry_url_override: Option, home_daemon_url_override: Option, args: KillArgs, ) -> Result<(), CliError> { let cfg = CliConfig::load(config_path)?; let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override); let daemon = daemon_client(&home_url, config_path); // The local daemon only kills sessions it owns (peer sessions → 404); the // registry forwards cross-daemon kills, so fall back on 404 too. match run_kill(&daemon, &args).await { Ok(()) => {} Err(e) if e.is_unreachable() || e.is_not_found() => { warn_fallback(&home_url, &e); let registry = registry_client(registry_url_override, &cfg, config_path)?; run_kill(®istry, &args).await?; } Err(e) => return Err(e.into()), } println!("killed {}", args.session_id); Ok(()) } /// Attach to `session_id` through the home-daemon broker, falling back to the /// registry's connect-info + direct WS path if the daemon is unreachable. #[allow(clippy::result_large_err)] async fn attach_session( home_url: &str, registry_url_override: Option, cfg: &CliConfig, config_path: &Path, session_id: uuid::Uuid, detach: u8, ) -> Result<(), CliError> { let broker_url = broker_attach_url(home_url, session_id)?; match run_attach_url(&broker_url, detach).await { Ok(()) => Ok(()), Err(e) if e.is_unreachable() => { eprintln!( "[maestro] home daemon at {home_url} unreachable; \ falling back to the registry (legacy direct attach)" ); let registry = registry_client(registry_url_override, cfg, config_path)?; let info = fetch_connect_info(®istry, session_id).await?; run_attach(info, session_id, detach).await?; Ok(()) } Err(e) => Err(e.into()), } } /// HTTP client pointed at the home daemon's local API. The daemon's local /// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent. fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient { RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf()) } /// HTTP client pointed at the registry (legacy / fallback path). #[allow(clippy::result_large_err)] fn registry_client( override_url: Option, cfg: &CliConfig, config_path: &Path, ) -> Result { let url = override_url .or_else(|| cfg.registry_url.clone()) .ok_or(ConfigError::NoRegistryUrl)?; Ok(RegistryClient::new( url, cfg.cookie.clone(), config_path.to_path_buf(), )) } fn warn_fallback(home_url: &str, err: &HttpError) { eprintln!( "[maestro] home daemon at {home_url} unavailable ({err}); \ falling back to the registry" ); }