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-cli/src/main.rs
T
naps62-yoloandClaude Opus 4.7 b68e39a9e5
ci / rust (push) Successful in 1m7s
feat(maestro): maestro-cli — list/spawn/attach/kill
Closes NAP-216. Thin client for the registry HTTP API + per-session
daemon WebSocket. Becomes the headless test harness for NAP-217 and
unblocks the demo-by-script path before the web client lands (NAP-218).

Crate layout (`crates/maestro-cli/`):

- `cli.rs` — clap-derive parser. Commands: `list`, `spawn`, `attach`,
  `kill`, `set-registry`. Global `--registry-url` (env
  `MAESTRO_REGISTRY_URL`), `--config`, repeatable `-v`.
- `config.rs` — `CliConfig` persisted at `~/.config/maestro/cli.toml`,
  storing the default registry URL + the session cookie issued by the
  registry's cookie middleware.
- `http.rs` — `RegistryClient` wrapping `reqwest`. Attaches the
  persisted cookie, captures `Set-Cookie` responses (first-contact
  bootstrap and rotation), atomically writes the new value back to
  the config file.
- `sessions.rs` — `list/spawn/kill` + connect-info fetch. `list` prints
  a per-daemon grouped table; `--json` for scripting.
- `attach.rs` — raw-TTY WS proxy. Termios cfmakeraw with an RAII guard
  for restore-on-drop, SIGWINCH → `ClientControl::Resize` text frame,
  configurable detach key (default Ctrl-q; accepts `^X` form), echoes
  `Ping`/`Pong` for keepalive symmetry with the daemon side.

Workspace deps added: clap (derive+env), reqwest (rustls+cookies),
nix (term+signal).

Smoke-tested against the live registry at maestro-registry.n62.casa:
`maestro list` issued + persisted the cookie and printed the empty
catalog cleanly. 19 new unit tests (parser, config round-trip,
table-printing, cookie extraction, attach URL/detach-key parsing).
Full workspace `cargo test`: 97 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:49:46 +00:00

120 lines
3.7 KiB
Rust

//! `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};
use maestro_cli::config::{default_config_path, CliConfig, ConfigError};
use maestro_cli::http::RegistryClient;
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),
}
#[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 mut cfg = CliConfig::load(&config_path)?;
cfg.registry_url = Some(args.url.trim_end_matches('/').to_string());
cfg.save(&config_path)?;
println!("registry URL saved to {}", config_path.display());
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(_) => unreachable!("handled above"),
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn build_client(
override_url: Option<String>,
config_path: &Path,
) -> Result<RegistryClient, CliError> {
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(),
))
}