The TUI arm of the umbrella binary hardcoded http://127.0.0.1:7070 instead of going through MaestroConfigFile::resolve_daemon_url, which every CLI subcommand uses. On a host whose [daemon].bind_addr is not the default port, `maestro list` worked and `maestro tui` could not connect. RunConfig.daemon_url is now the override rather than the final URL; the TUI resolves it against the config file it already loads for its UI settings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
313 lines
9.7 KiB
Rust
313 lines
9.7 KiB
Rust
//! `maestro` — unified binary that hosts the CLI, the per-host daemon, and the
|
|
//! TUI behind feature-gated subcommands (NAP-245).
|
|
//!
|
|
//! ```text
|
|
//! cargo install --no-default-features --features daemon maestro # daemon-only host
|
|
//! cargo install maestro # default: cli + daemon + tui
|
|
//! ```
|
|
|
|
#[cfg(not(any(feature = "cli", feature = "daemon", feature = "tui")))]
|
|
compile_error!("at least one of the `cli`, `daemon`, `tui` 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 / TUI for the Maestro terminal-session orchestrator"
|
|
)]
|
|
struct Cli {
|
|
/// Override the daemon HTTP base clients talk to (e.g.
|
|
/// `http://127.0.0.1:7070`). Defaults to `[cli].daemon_url`, then the local
|
|
/// `[daemon].bind_addr`, then `http://127.0.0.1:7070`.
|
|
#[arg(long, global = true, env = "MAESTRO_DAEMON_URL")]
|
|
daemon_url: Option<String>,
|
|
|
|
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
|
|
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
|
config: Option<PathBuf>,
|
|
|
|
/// Increase log verbosity (CLI subcommands only). Repeat for more: `-v` = debug, `-vv` = trace.
|
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
|
verbose: u8,
|
|
|
|
/// A missing subcommand defaults to the TUI (#119): a bare `maestro` opens
|
|
/// the terminal UI. Builds without the `tui` feature have no UI surface,
|
|
/// so they print help and exit 0 instead.
|
|
#[command(subcommand)]
|
|
command: Option<Command>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum Command {
|
|
/// List all sessions.
|
|
#[cfg(feature = "cli")]
|
|
List(maestro_cli::cli::ListArgs),
|
|
/// Spawn a new session.
|
|
#[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),
|
|
/// Type text into a running session, as if it had been typed on an
|
|
/// attached terminal.
|
|
#[cfg(feature = "cli")]
|
|
Send(maestro_cli::cli::SendArgs),
|
|
/// Install or update the agent activity hook (#106).
|
|
#[cfg(feature = "cli")]
|
|
Hooks(maestro_cli::cli::HooksArgs),
|
|
/// Per-host daemon: tmux wrapper + session API.
|
|
#[cfg(feature = "daemon")]
|
|
Daemon {
|
|
#[command(subcommand)]
|
|
cmd: Option<maestro_daemon::cli::DaemonCmd>,
|
|
},
|
|
|
|
/// Open the terminal UI.
|
|
#[cfg(feature = "tui")]
|
|
Tui(maestro_tui::cli::TuiArgs),
|
|
}
|
|
|
|
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<dyn std::error::Error>> {
|
|
let command = match cli.command {
|
|
Some(command) => command,
|
|
None => {
|
|
#[cfg(feature = "tui")]
|
|
{
|
|
Command::Tui(maestro_tui::cli::TuiArgs { daemon_url: None })
|
|
}
|
|
#[cfg(not(feature = "tui"))]
|
|
{
|
|
use clap::CommandFactory;
|
|
let _ = <Cli as CommandFactory>::command().print_help();
|
|
return Ok(());
|
|
}
|
|
}
|
|
};
|
|
match command {
|
|
#[cfg(feature = "cli")]
|
|
Command::List(args) => {
|
|
maestro_cli::dispatch(
|
|
maestro_cli::cli::Command::List(args),
|
|
cli.config,
|
|
cli.daemon_url,
|
|
)
|
|
.await?
|
|
}
|
|
#[cfg(feature = "cli")]
|
|
Command::Spawn(args) => {
|
|
maestro_cli::dispatch(
|
|
maestro_cli::cli::Command::Spawn(args),
|
|
cli.config,
|
|
cli.daemon_url,
|
|
)
|
|
.await?
|
|
}
|
|
#[cfg(feature = "cli")]
|
|
Command::Attach(args) => {
|
|
maestro_cli::dispatch(
|
|
maestro_cli::cli::Command::Attach(args),
|
|
cli.config,
|
|
cli.daemon_url,
|
|
)
|
|
.await?
|
|
}
|
|
#[cfg(feature = "cli")]
|
|
Command::Kill(args) => {
|
|
maestro_cli::dispatch(
|
|
maestro_cli::cli::Command::Kill(args),
|
|
cli.config,
|
|
cli.daemon_url,
|
|
)
|
|
.await?
|
|
}
|
|
#[cfg(feature = "cli")]
|
|
Command::Send(args) => {
|
|
maestro_cli::dispatch(
|
|
maestro_cli::cli::Command::Send(args),
|
|
cli.config,
|
|
cli.daemon_url,
|
|
)
|
|
.await?
|
|
}
|
|
#[cfg(feature = "cli")]
|
|
Command::Hooks(args) => {
|
|
maestro_cli::dispatch(
|
|
maestro_cli::cli::Command::Hooks(args),
|
|
cli.config,
|
|
cli.daemon_url,
|
|
)
|
|
.await?
|
|
}
|
|
#[cfg(feature = "daemon")]
|
|
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
|
|
#[cfg(feature = "tui")]
|
|
Command::Tui(args) => {
|
|
maestro_tui::run(maestro_tui::RunConfig {
|
|
daemon_url: args.daemon_url.or(cli.daemon_url),
|
|
config_path: cli.config,
|
|
})
|
|
.await?
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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: &Option<Command>) -> bool {
|
|
matches!(
|
|
command,
|
|
Some(Command::List(_))
|
|
| Some(Command::Spawn(_))
|
|
| Some(Command::Attach(_))
|
|
| Some(Command::Kill(_))
|
|
| Some(Command::Send(_))
|
|
| Some(Command::Hooks(_))
|
|
)
|
|
}
|
|
|
|
#[cfg(not(feature = "cli"))]
|
|
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
|
|
false
|
|
}
|
|
|
|
fn cli_filter(verbose: u8) -> EnvFilter {
|
|
match std::env::var_os("RUST_LOG") {
|
|
// An explicit `RUST_LOG` always wins over `-v` flags.
|
|
Some(_) => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
|
None => EnvFilter::new(verbosity_directive(verbose)),
|
|
}
|
|
}
|
|
|
|
/// Map a `-v` repeat count to a tracing directive. Pure so it can be unit
|
|
/// tested without touching the process-global `RUST_LOG`.
|
|
fn verbosity_directive(verbose: u8) -> &'static str {
|
|
match verbose {
|
|
0 => "warn",
|
|
1 => "info",
|
|
2 => "debug",
|
|
_ => "trace",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn verbosity_directive_maps_repeat_count() {
|
|
assert_eq!(verbosity_directive(0), "warn");
|
|
assert_eq!(verbosity_directive(1), "info");
|
|
assert_eq!(verbosity_directive(2), "debug");
|
|
// Anything past `-vv` saturates at trace.
|
|
assert_eq!(verbosity_directive(3), "trace");
|
|
assert_eq!(verbosity_directive(u8::MAX), "trace");
|
|
}
|
|
|
|
/// Parse argv the way `main` does, returning just the dispatched subcommand.
|
|
fn command_of(argv: &[&str]) -> Option<Command> {
|
|
Cli::try_parse_from(argv)
|
|
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
|
|
.command
|
|
}
|
|
|
|
#[test]
|
|
fn bare_invocation_parses_to_no_subcommand() {
|
|
// The TUI-vs-help default is applied in `dispatch`; parsing must not
|
|
// reject an empty argv.
|
|
assert!(command_of(&["maestro"]).is_none());
|
|
assert!(command_of(&["maestro", "--verbose"]).is_none());
|
|
}
|
|
|
|
#[cfg(feature = "cli")]
|
|
#[test]
|
|
fn cli_subcommands_are_classified_as_cli() {
|
|
// Stable UUID for the subcommands that take a session id positional.
|
|
const SID: &str = "00000000-0000-0000-0000-000000000000";
|
|
// These four drive `cli_filter`; the tracing init must treat them as CLI.
|
|
for argv in [
|
|
vec!["maestro", "list"],
|
|
vec![
|
|
"maestro",
|
|
"spawn",
|
|
"--daemon-url",
|
|
"d",
|
|
"--cwd",
|
|
"/tmp",
|
|
"--command",
|
|
"bash",
|
|
],
|
|
vec!["maestro", "attach", SID],
|
|
vec!["maestro", "kill", SID],
|
|
] {
|
|
let cmd = command_of(&argv);
|
|
assert!(
|
|
is_cli_subcommand(&cmd),
|
|
"expected {argv:?} to be classified as a CLI subcommand"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "cli", feature = "daemon", feature = "tui"))]
|
|
#[test]
|
|
fn server_subcommands_are_not_classified_as_cli() {
|
|
// daemon / tui get the env-driven filter, not the `-v` one.
|
|
for argv in [vec!["maestro", "daemon"], vec!["maestro", "tui"]] {
|
|
let cmd = command_of(&argv);
|
|
assert!(
|
|
!is_cli_subcommand(&cmd),
|
|
"expected {argv:?} to NOT be classified as a CLI subcommand"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Under a non-`cli` feature build, the classifier is the always-false stub
|
|
/// and every subcommand takes the env-driven filter.
|
|
#[cfg(not(feature = "cli"))]
|
|
#[test]
|
|
fn classifier_is_always_false_without_cli_feature() {
|
|
#[cfg(feature = "daemon")]
|
|
assert!(!is_cli_subcommand(&command_of(&["maestro", "daemon"])));
|
|
#[cfg(feature = "tui")]
|
|
assert!(!is_cli_subcommand(&command_of(&["maestro", "tui"])));
|
|
}
|
|
}
|