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/src/main.rs
T
Miguel PalhasandClaude Opus 5 9a48bf51f1 fix(tui): resolve daemon URL from config
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>
2026-09-02 21:07:54 +01:00

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"])));
}
}