diff --git a/Cargo.lock b/Cargo.lock index ff2a09a..e059ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,18 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "maestro" +version = "0.1.0" +dependencies = [ + "clap", + "maestro-cli", + "maestro-daemon", + "maestro-registry", + "tokio", + "tracing-subscriber", +] + [[package]] name = "maestro-cli" version = "0.1.0" diff --git a/crates/maestro-cli/Cargo.toml b/crates/maestro-cli/Cargo.toml index 87c8f89..54a6a7a 100644 --- a/crates/maestro-cli/Cargo.toml +++ b/crates/maestro-cli/Cargo.toml @@ -10,10 +10,6 @@ description = "`maestro` CLI: list, spawn, attach, kill sessions across daemons" [lib] path = "src/lib.rs" -[[bin]] -name = "maestro" -path = "src/main.rs" - [dependencies] chrono.workspace = true clap.workspace = true diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs new file mode 100644 index 0000000..dba6b93 --- /dev/null +++ b/crates/maestro-cli/src/dispatch.rs @@ -0,0 +1,140 @@ +//! 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. + +use std::path::{Path, PathBuf}; + +use crate::attach::{parse_detach_key, run_attach}; +use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, PolicyCmd, SetRegistryArgs, SpawnArgs}; +use crate::config::{default_config_path, CliConfig, ConfigError}; +use crate::http::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`) in alongside the variant. +#[allow(clippy::result_large_err)] +pub async fn dispatch( + command: Command, + config: Option, + registry_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, args).await, + Command::Spawn(args) => spawn(&config_path, registry_url_override, args).await, + Command::Attach(args) => attach(&config_path, registry_url_override, args).await, + Command::Kill(args) => kill(&config_path, registry_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, + args: ListArgs, +) -> Result<(), CliError> { + let client = build_client(registry_url_override, config_path)?; + let mut stdout = std::io::stdout().lock(); + run_list(&client, &args, &mut stdout).await?; + Ok(()) +} + +#[allow(clippy::result_large_err)] +async fn spawn( + config_path: &Path, + registry_url_override: Option, + args: SpawnArgs, +) -> Result<(), CliError> { + let client = build_client(registry_url_override, config_path)?; + let attach_after = args.attach; + let session = run_spawn(&client, args).await?; + println!("spawned {}", session.id); + if attach_after { + let info = fetch_connect_info(&client, session.id).await?; + let detach = 0x11; // Ctrl-q + run_attach(info, session.id, detach).await?; + } + Ok(()) +} + +#[allow(clippy::result_large_err)] +async fn attach( + config_path: &Path, + registry_url_override: Option, + args: AttachArgs, +) -> Result<(), CliError> { + let client = build_client(registry_url_override, config_path)?; + let info = fetch_connect_info(&client, args.session_id).await?; + let detach = parse_detach_key(&args.detach_key)?; + run_attach(info, args.session_id, detach).await?; + Ok(()) +} + +#[allow(clippy::result_large_err)] +async fn kill( + config_path: &Path, + registry_url_override: Option, + args: KillArgs, +) -> Result<(), CliError> { + let client = build_client(registry_url_override, config_path)?; + run_kill(&client, &args).await?; + println!("killed {}", args.session_id); + Ok(()) +} + +#[allow(clippy::result_large_err)] +fn build_client( + override_url: Option, + config_path: &Path, +) -> Result { + let cfg = CliConfig::load(config_path)?; + let url = override_url + .or(cfg.registry_url.clone()) + .ok_or(ConfigError::NoRegistryUrl)?; + Ok(RegistryClient::new( + url, + cfg.cookie, + config_path.to_path_buf(), + )) +} diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index ccf03ec..8e5b116 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -8,6 +8,9 @@ pub mod attach; pub mod cli; pub mod config; +pub mod dispatch; pub mod http; pub mod policy; pub mod sessions; + +pub use dispatch::{dispatch, CliError}; diff --git a/crates/maestro-cli/src/main.rs b/crates/maestro-cli/src/main.rs deleted file mode 100644 index 683441f..0000000 --- a/crates/maestro-cli/src/main.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! `maestro` — CLI binary entry point. - -use std::path::Path; -use std::process::ExitCode; - -use clap::Parser; -use maestro_cli::attach::{parse_detach_key, run_attach}; -use maestro_cli::cli::{Cli, Command, PolicyCmd}; -use maestro_cli::config::{default_config_path, CliConfig, ConfigError}; -use maestro_cli::http::RegistryClient; -use maestro_cli::policy::{run_reload as run_policy_reload, run_show as run_policy_show}; -use maestro_cli::sessions::{fetch_connect_info, run_kill, run_list, run_spawn}; -use tracing_subscriber::EnvFilter; - -#[tokio::main(flavor = "current_thread")] -async fn main() -> ExitCode { - let cli = Cli::parse(); - init_tracing(cli.verbose); - - match dispatch(cli).await { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("maestro: {e}"); - ExitCode::FAILURE - } - } -} - -fn init_tracing(verbose: u8) { - let filter = match (std::env::var_os("RUST_LOG"), verbose) { - (Some(_), _) => { - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")) - } - (None, 0) => EnvFilter::new("warn"), - (None, 1) => EnvFilter::new("info"), - (None, 2) => EnvFilter::new("debug"), - (None, _) => EnvFilter::new("trace"), - }; - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .init(); -} - -#[derive(Debug, thiserror::Error)] -#[allow(clippy::result_large_err)] -enum CliError { - #[error("config: {0}")] - Config(#[from] ConfigError), - #[error("{0}")] - Http(#[from] maestro_cli::http::HttpError), - #[error("{0}")] - Attach(#[from] maestro_cli::attach::AttachError), - #[error("{0}")] - Io(#[from] std::io::Error), - #[error("{0}")] - Policy(#[from] maestro_cli::policy::PolicyError), -} - -#[allow(clippy::result_large_err)] -async fn dispatch(cli: Cli) -> Result<(), CliError> { - let config_path = match cli.config.clone() { - Some(p) => p, - None => default_config_path()?, - }; - - // `set-registry` is the only command that writes config first and skips - // the registry-required path below. - if let Command::SetRegistry(args) = &cli.command { - let url = args.url.trim_end_matches('/'); - CliConfig::save_registry_url(&config_path, url)?; - println!("registry URL saved to {}", config_path.display()); - return Ok(()); - } - - // `policy` talks to the local daemon's HTTP API, not the registry. Handle - // it here so it works even without a registry URL configured. - if let Command::Policy(sub) = &cli.command { - 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?, - } - return Ok(()); - } - - let client = build_client(cli.registry_url.clone(), &config_path)?; - - match cli.command { - Command::List(args) => { - let mut stdout = std::io::stdout().lock(); - run_list(&client, &args, &mut stdout).await?; - } - Command::Spawn(args) => { - let attach_after = args.attach; - let session = run_spawn(&client, args).await?; - println!("spawned {}", session.id); - if attach_after { - let info = fetch_connect_info(&client, session.id).await?; - let detach = 0x11; // Ctrl-q - run_attach(info, session.id, detach).await?; - } - } - Command::Attach(args) => { - let info = fetch_connect_info(&client, args.session_id).await?; - let detach = parse_detach_key(&args.detach_key)?; - run_attach(info, args.session_id, detach).await?; - } - Command::Kill(args) => { - run_kill(&client, &args).await?; - println!("killed {}", args.session_id); - } - Command::SetRegistry(_) | Command::Policy(_) => unreachable!("handled above"), - } - Ok(()) -} - -#[allow(clippy::result_large_err)] -fn build_client( - override_url: Option, - config_path: &Path, -) -> Result { - let cfg = CliConfig::load(config_path)?; - let url = override_url - .or(cfg.registry_url.clone()) - .ok_or(ConfigError::NoRegistryUrl)?; - Ok(RegistryClient::new( - url, - cfg.cookie, - config_path.to_path_buf(), - )) -} diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml index 33f9aff..b5f0338 100644 --- a/crates/maestro-daemon/Cargo.toml +++ b/crates/maestro-daemon/Cargo.toml @@ -10,10 +10,6 @@ description = "Per-host tmux wrapper + session orchestrator that talks to the re [lib] path = "src/lib.rs" -[[bin]] -name = "maestrod" -path = "src/main.rs" - [dependencies] axum.workspace = true chrono.workspace = true diff --git a/crates/maestro-daemon/src/main.rs b/crates/maestro-daemon/src/cli.rs similarity index 67% rename from crates/maestro-daemon/src/main.rs rename to crates/maestro-daemon/src/cli.rs index 2a1ff76..4c9e61c 100644 --- a/crates/maestro-daemon/src/main.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -1,102 +1,77 @@ -//! `maestrod` — the Maestro per-host daemon binary. +//! Clap-derive subcommand for the daemon, plus a runner that wires together +//! everything the binary used to do in `main.rs`. //! -//! Two subcommands: -//! - `maestrod` / `maestrod run` — start the daemon. -//! - `maestrod configure` — bootstrap the config file. +//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro` +//! binary can compose this subcommand alongside the CLI and registry. use std::error::Error; use std::path::PathBuf; -use std::process::ExitCode; -use clap::{Parser, Subcommand}; -use maestro_daemon::session::DEFAULT_LIVENESS_INTERVAL; -use maestro_daemon::sidecar::SidecarStore; -use maestro_daemon::tmux::Tmux; -use maestro_daemon::worktree::WorktreeManager; -use maestro_daemon::{ +use clap::Subcommand; +use tokio::net::TcpListener; + +use crate::session::DEFAULT_LIVENESS_INTERVAL; +use crate::sidecar::SidecarStore; +use crate::tmux::Tmux; +use crate::worktree::WorktreeManager; +use crate::{ attach, default_config_path, default_state_dir, load_config, local_api, policy, run_configure, spawn_session_publisher, spawn_sighup_listener, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus, PairAttachVerifier, PolicyState, RegistryClient, SessionManager, }; -use tokio::net::TcpListener; -use tracing_subscriber::EnvFilter; - -#[derive(Debug, Parser)] -#[command( - name = "maestrod", - version, - about = "Per-host Maestro daemon: tmux wrapper + registry client" -)] -struct Cli { - /// Override the unified Maestro config-file path (default `~/.config/maestro/config.toml`). - #[arg(long, global = true, env = "MAESTRO_CONFIG")] - config: Option, - - #[command(subcommand)] - command: Option, -} #[derive(Debug, Subcommand)] -enum Command { +pub enum DaemonCmd { /// Start the daemon. Default when no subcommand is given. Run, /// Write `~/.config/maestro/config.toml` interactively or from flags. - Configure(ConfigureCmd), + Configure(ConfigureArgs), } #[derive(Debug, clap::Args)] -struct ConfigureCmd { +pub struct ConfigureArgs { /// Registry base URL (e.g. `https://maestro.example.com`). The daemon /// derives its WS endpoint by scheme-swapping and appending `/daemon`. #[arg(long)] - registry_url: Option, + pub registry_url: Option, /// Reachable URL clients dial for this daemon's attach WS. #[arg(long)] - daemon_url: Option, + pub daemon_url: Option, /// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`. #[arg(long)] - token: Option, + pub token: Option, /// Stable daemon ID. Defaults to the system hostname. #[arg(long)] - daemon_id: Option, + pub daemon_id: Option, /// Local bind address for the per-session WS endpoint. #[arg(long)] - bind_addr: Option, + pub bind_addr: Option, /// Override the daemon's state directory. #[arg(long)] - state_dir: Option, + pub state_dir: Option, /// Override the worktree root. #[arg(long)] - worktree_root: Option, + pub worktree_root: Option, /// Override the recorded hostname (defaults to `gethostname(2)`). #[arg(long)] - hostname: Option, + pub hostname: Option, /// Overwrite an existing config file. #[arg(long)] - force: bool, + pub force: bool, /// Disable interactive prompts (errors if any required field is missing). #[arg(long)] - non_interactive: bool, + pub non_interactive: bool, } -#[tokio::main] -async fn main() -> ExitCode { - let cli = Cli::parse(); - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::fmt().with_env_filter(filter).init(); - - let result: Result<(), Box> = match cli.command.unwrap_or(Command::Run) { - Command::Run => run(cli.config).await, - Command::Configure(args) => configure(cli.config, args), - }; - - match result { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - tracing::error!(error = %e, "maestrod failed"); - eprintln!("maestrod: {e}"); - ExitCode::FAILURE - } +/// Dispatch a parsed daemon subcommand. `config_override` is the unified +/// `--config` flag from the top-level CLI. +pub async fn dispatch( + config_override: Option, + cmd: Option, +) -> Result<(), Box> { + match cmd.unwrap_or(DaemonCmd::Run) { + DaemonCmd::Run => run(config_override).await, + DaemonCmd::Configure(args) => configure(config_override, args), } } @@ -113,7 +88,7 @@ async fn run(config_override: Option) -> Result<(), Box> { "loaded config" ); - let _ = default_state_dir(); // surface XDG diagnostics early but don't override + let _ = default_state_dir(); std::fs::create_dir_all(config.sidecar_dir())?; std::fs::create_dir_all(config.fifo_dir())?; std::fs::create_dir_all(&config.worktree_root)?; @@ -144,14 +119,9 @@ async fn run(config_override: Option) -> Result<(), Box> { config.allow_inbound.iter().cloned(), ); - // Daemon-local event bus (NAP-248): own session events + peer catalog - // deltas funnel through here for `WS /api/events` subscribers. let events = LocalEventBus::new(); spawn_session_publisher(manager.clone(), events.clone()); - // Build the registry client first so we can hand its catalog mirror to - // the local-API state — both halves share the same Arc>. Hand - // the event bus to the catalog mirror so peer deltas fan out too. let client = RegistryClient::new_with_bus( config.registry_url.clone(), config.daemon_id.clone(), @@ -178,8 +148,6 @@ async fn run(config_override: Option) -> Result<(), Box> { self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()), self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new), }; - // NAP-237: hot-reload `[daemon.policy].allow_inbound` via SIGHUP or - // POST /api/policy/reload. Both swap the same verifier instance. let policy_state = PolicyState { verifier: pair_verifier.clone(), config_path: std::sync::Arc::new(config_path.clone()), @@ -208,7 +176,7 @@ async fn run(config_override: Option) -> Result<(), Box> { Ok(()) } -fn configure(config_override: Option, args: ConfigureCmd) -> Result<(), Box> { +fn configure(config_override: Option, args: ConfigureArgs) -> Result<(), Box> { let path: PathBuf = match config_override { Some(p) => p, None => default_config_path()?, diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 162c01a..f93f871 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -9,6 +9,7 @@ pub mod attach; pub mod broker; pub mod catalog_mirror; +pub mod cli; pub mod config; pub mod configure; pub mod jwt; diff --git a/crates/maestro-registry/Cargo.toml b/crates/maestro-registry/Cargo.toml index 2f64433..e20669b 100644 --- a/crates/maestro-registry/Cargo.toml +++ b/crates/maestro-registry/Cargo.toml @@ -10,10 +10,6 @@ description = "Always-on registry — daemon presence index and session catalog" [lib] path = "src/lib.rs" -[[bin]] -name = "maestro-registry" -path = "src/main.rs" - [dependencies] axum.workspace = true chrono.workspace = true diff --git a/crates/maestro-registry/src/lib.rs b/crates/maestro-registry/src/lib.rs index 1b7dfcf..e4c67e7 100644 --- a/crates/maestro-registry/src/lib.rs +++ b/crates/maestro-registry/src/lib.rs @@ -17,11 +17,14 @@ pub mod client_api; pub mod events; pub mod handler; pub mod jwt; +pub mod run; pub mod server; pub mod state; pub mod token; pub mod web_assets; +pub use run::run; + pub use auth::{issue_cookie, verify_cookie, COOKIE_NAME}; pub use jwt::{issue_attach_jwt, ATTACH_JWT_TTL}; pub use server::{router, router_full, router_with_timeout, RegistryServer}; diff --git a/crates/maestro-registry/src/main.rs b/crates/maestro-registry/src/run.rs similarity index 52% rename from crates/maestro-registry/src/main.rs rename to crates/maestro-registry/src/run.rs index 4411367..cfd17a7 100644 --- a/crates/maestro-registry/src/main.rs +++ b/crates/maestro-registry/src/run.rs @@ -1,34 +1,25 @@ -//! `maestro-registry` — the Maestro always-on registry binary. +//! Public entry point: load config, bind, serve until SIGINT. +//! +//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro` +//! binary can dispatch `maestro registry` here. use std::error::Error; use std::path::PathBuf; -use std::process::ExitCode; -use maestro_registry::{default_config_path, load_config, router, Registry}; use tokio::net::TcpListener; -use tracing_subscriber::EnvFilter; -#[tokio::main] -async fn main() -> ExitCode { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::fmt().with_env_filter(filter).init(); +use crate::{ + default_config_path, load_config, router, Registry, CONFIG_PATH_ENV, ENV_REGISTRY_BIND_ADDR, + ENV_TOKEN, +}; - match run().await { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - tracing::error!(error = %e, "maestro-registry failed"); - ExitCode::FAILURE - } - } -} - -async fn run() -> Result<(), Box> { - let config_path: PathBuf = match std::env::var_os(maestro_registry::CONFIG_PATH_ENV) { +pub async fn run() -> Result<(), Box> { + let config_path: PathBuf = match std::env::var_os(CONFIG_PATH_ENV) { Some(p) => PathBuf::from(p), None => default_config_path()?, }; - let env_token = std::env::var(maestro_registry::ENV_TOKEN).ok(); - let env_bind = std::env::var(maestro_registry::ENV_REGISTRY_BIND_ADDR).ok(); + let env_token = std::env::var(ENV_TOKEN).ok(); + let env_bind = std::env::var(ENV_REGISTRY_BIND_ADDR).ok(); let token_from_env = env_token.is_some(); let bind_from_env = env_bind.is_some(); let config = load_config(&config_path, env_token, env_bind.as_deref())?; diff --git a/crates/maestro/Cargo.toml b/crates/maestro/Cargo.toml new file mode 100644 index 0000000..c8dfac1 --- /dev/null +++ b/crates/maestro/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "maestro" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "Unified `maestro` binary: CLI + per-host daemon + always-on registry, feature-gated." + +[[bin]] +name = "maestro" +path = "src/main.rs" + +[features] +default = ["cli", "daemon", "registry"] +cli = ["dep:maestro-cli"] +daemon = ["dep:maestro-daemon"] +registry = ["dep:maestro-registry"] + +[dependencies] +clap.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } +tracing-subscriber.workspace = true +maestro-cli = { path = "../maestro-cli", optional = true } +maestro-daemon = { path = "../maestro-daemon", optional = true } +maestro-registry = { path = "../maestro-registry", optional = true } diff --git a/crates/maestro/src/main.rs b/crates/maestro/src/main.rs new file mode 100644 index 0000000..1807b51 --- /dev/null +++ b/crates/maestro/src/main.rs @@ -0,0 +1,209 @@ +//! `maestro` — unified binary that hosts the CLI, the per-host daemon, and the +//! always-on registry behind feature-gated subcommands (NAP-245). +//! +//! ```text +//! cargo install --no-default-features --features registry maestro # registry server +//! cargo install --no-default-features --features daemon maestro # daemon-only host +//! cargo install maestro # default: cli + daemon + registry +//! ``` + +#[cfg(not(any(feature = "cli", feature = "daemon", feature = "registry")))] +compile_error!("at least one of the `cli`, `daemon`, `registry` features must be enabled"); + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, Parser)] +#[command( + name = "maestro", + version, + about = "Unified CLI / daemon / registry for the Maestro terminal-session orchestrator", + arg_required_else_help = true +)] +struct Cli { + /// Override the registry URL for this invocation (e.g. `https://maestro.example.com`). + /// Used by CLI subcommands; ignored by `daemon` / `registry`. + #[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")] + registry_url: Option, + + /// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`. + #[arg(long, global = true, env = "MAESTRO_CONFIG")] + config: Option, + + /// Increase log verbosity (CLI subcommands only). Repeat for more: `-v` = debug, `-vv` = trace. + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + verbose: u8, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List all sessions, grouped by daemon. + #[cfg(feature = "cli")] + List(maestro_cli::cli::ListArgs), + /// Spawn a new session on a given daemon. + #[cfg(feature = "cli")] + Spawn(maestro_cli::cli::SpawnArgs), + /// Attach to a session over WebSocket. Bytes flow through the local TTY. + #[cfg(feature = "cli")] + Attach(maestro_cli::cli::AttachArgs), + /// Kill a session by ID. + #[cfg(feature = "cli")] + Kill(maestro_cli::cli::KillArgs), + /// Set the default registry URL persisted in the CLI config. + #[cfg(feature = "cli")] + SetRegistry(maestro_cli::cli::SetRegistryArgs), + /// Inspect or hot-reload the local daemon's `[daemon.policy]` (NAP-237). + #[cfg(feature = "cli")] + #[command(subcommand)] + Policy(maestro_cli::cli::PolicyCmd), + + /// Per-host daemon: tmux wrapper + registry client. + #[cfg(feature = "daemon")] + Daemon { + #[command(subcommand)] + cmd: Option, + }, + + /// Always-on registry: presence index + session catalog. + #[cfg(feature = "registry")] + Registry, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + init_tracing(&cli); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + + match rt.block_on(dispatch(cli)) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("maestro: {e}"); + ExitCode::FAILURE + } + } +} + +async fn dispatch(cli: Cli) -> Result<(), Box> { + match cli.command { + #[cfg(feature = "cli")] + Command::List(args) => { + cli_dispatch( + maestro_cli::cli::Command::List(args), + cli.config, + cli.registry_url, + ) + .await? + } + #[cfg(feature = "cli")] + Command::Spawn(args) => { + cli_dispatch( + maestro_cli::cli::Command::Spawn(args), + cli.config, + cli.registry_url, + ) + .await? + } + #[cfg(feature = "cli")] + Command::Attach(args) => { + cli_dispatch( + maestro_cli::cli::Command::Attach(args), + cli.config, + cli.registry_url, + ) + .await? + } + #[cfg(feature = "cli")] + Command::Kill(args) => { + cli_dispatch( + maestro_cli::cli::Command::Kill(args), + cli.config, + cli.registry_url, + ) + .await? + } + #[cfg(feature = "cli")] + Command::SetRegistry(args) => { + cli_dispatch( + maestro_cli::cli::Command::SetRegistry(args), + cli.config, + cli.registry_url, + ) + .await? + } + #[cfg(feature = "cli")] + Command::Policy(sub) => { + cli_dispatch( + maestro_cli::cli::Command::Policy(sub), + cli.config, + cli.registry_url, + ) + .await? + } + #[cfg(feature = "daemon")] + Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?, + #[cfg(feature = "registry")] + Command::Registry => maestro_registry::run().await?, + } + Ok(()) +} + +#[cfg(feature = "cli")] +async fn cli_dispatch( + command: maestro_cli::cli::Command, + config: Option, + registry_url: Option, +) -> Result<(), maestro_cli::CliError> { + maestro_cli::dispatch(command, config, registry_url).await +} + +fn init_tracing(cli: &Cli) { + let filter = if is_cli_subcommand(&cli.command) { + cli_filter(cli.verbose) + } else { + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) + }; + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); +} + +#[cfg(feature = "cli")] +fn is_cli_subcommand(command: &Command) -> bool { + matches!( + command, + Command::List(_) + | Command::Spawn(_) + | Command::Attach(_) + | Command::Kill(_) + | Command::SetRegistry(_) + | Command::Policy(_) + ) +} + +#[cfg(not(feature = "cli"))] +fn is_cli_subcommand(_command: &Command) -> bool { + false +} + +fn cli_filter(verbose: u8) -> EnvFilter { + match (std::env::var_os("RUST_LOG"), verbose) { + (Some(_), _) => { + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")) + } + (None, 0) => EnvFilter::new("warn"), + (None, 1) => EnvFilter::new("info"), + (None, 2) => EnvFilter::new("debug"), + (None, _) => EnvFilter::new("trace"), + } +}