diff --git a/Cargo.lock b/Cargo.lock index cac64af..1b3326b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -180,6 +230,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cookie" version = "0.18.1" @@ -529,11 +625,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.40", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -710,6 +806,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -798,6 +900,30 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "maestro-cli" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "dirs", + "futures-util", + "libc", + "maestro-protocol", + "nix", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.21.0", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "maestro-daemon" version = "0.1.0" @@ -894,6 +1020,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -924,6 +1062,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -1013,7 +1157,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.40", "socket2", "thiserror 2.0.18", "tokio", @@ -1033,7 +1177,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1186,14 +1330,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -1201,7 +1345,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -1237,6 +1381,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1246,7 +1404,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -1261,6 +1419,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -1429,6 +1598,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1611,13 +1786,24 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -1629,8 +1815,12 @@ checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" dependencies = [ "futures-util", "log", + "rustls 0.22.4", + "rustls-pki-types", "tokio", + "tokio-rustls 0.25.0", "tungstenite 0.21.0", + "webpki-roots 0.26.11", ] [[package]] @@ -1813,6 +2003,8 @@ dependencies = [ "httparse", "log", "rand 0.8.6", + "rustls 0.22.4", + "rustls-pki-types", "sha1", "thiserror 1.0.69", "url", @@ -1885,6 +2077,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -2051,6 +2249,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index fffd9dc..eb924b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,15 @@ publish = false [workspace.dependencies] axum = { version = "0.7", features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +clap = { version = "4.5", features = ["derive", "env"] } dirs = "5" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } jsonwebtoken = { version = "9", default-features = false } libc = "0.2" maestro-protocol = { path = "crates/maestro-protocol" } +nix = { version = "0.29", default-features = false, features = ["term", "signal"] } rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" diff --git a/crates/maestro-cli/Cargo.toml b/crates/maestro-cli/Cargo.toml new file mode 100644 index 0000000..e1f9846 --- /dev/null +++ b/crates/maestro-cli/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "maestro-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +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 +dirs.workspace = true +futures-util.workspace = true +libc.workspace = true +maestro-protocol.workspace = true +nix.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "sync", "time", "io-util", "io-std", "signal"] } +tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/maestro-cli/src/attach.rs b/crates/maestro-cli/src/attach.rs new file mode 100644 index 0000000..3f511f8 --- /dev/null +++ b/crates/maestro-cli/src/attach.rs @@ -0,0 +1,279 @@ +//! `maestro attach` — raw-TTY proxy over the daemon's per-session WS endpoint. +//! +//! Wire-format mirrors `crates/maestro-daemon/src/attach.rs`: +//! - PTY bytes ride in **binary** frames both directions. +//! - `ClientControl::{Resize, Ping, Pong}` ride in **text** frames. + +use std::io::{self, IsTerminal}; +use std::os::fd::AsFd; +use std::sync::Arc; + +use futures_util::{SinkExt, StreamExt}; +use maestro_protocol::{ClientControl, ConnectInfo}; +use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +#[allow(clippy::result_large_err)] +pub enum AttachError { + #[error("ws: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + #[error("io: {0}")] + Io(#[from] io::Error), + #[error("termios: {0}")] + Termios(#[from] nix::Error), + #[error("invalid daemon_url: {0}")] + InvalidDaemonUrl(String), + #[error("invalid detach key: {0}")] + InvalidDetachKey(String), + #[error("stdin must be a TTY for attach")] + NotATty, +} + +/// Parse a detach key spec. Accepts `^X` for Ctrl-X (case-insensitive) or a +/// single literal character. Returns the resulting byte. +#[allow(clippy::result_large_err)] +pub fn parse_detach_key(spec: &str) -> Result { + let bytes = spec.as_bytes(); + match bytes { + [b'^', c] if c.is_ascii_alphabetic() => Ok(c.to_ascii_uppercase() - b'@'), + [c] => Ok(*c), + _ => Err(AttachError::InvalidDetachKey(spec.to_string())), + } +} + +/// Build the attach WS URL from a `ConnectInfo` and session ID. +#[allow(clippy::result_large_err)] +pub fn attach_url(info: &ConnectInfo, session_id: Uuid) -> Result { + let base = info.daemon_url.trim_end_matches('/'); + if !(base.starts_with("ws://") || base.starts_with("wss://")) { + return Err(AttachError::InvalidDaemonUrl(info.daemon_url.clone())); + } + Ok(format!( + "{base}/sessions/{session_id}/stream?token={token}", + token = urlencoding(&info.jwt) + )) +} + +/// Minimal percent-encoder for the token in a query string. JWTs only contain +/// `A-Z a-z 0-9 - _ .` (URL-safe base64 + dots) so this is paranoia, not need. +fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push_str(&format!("{b:02X}")); + } + } + } + out +} + +/// RAII guard that puts the terminal into raw mode and restores on drop. +#[derive(Debug)] +struct RawTty { + fd: std::os::fd::OwnedFd, + original: Termios, +} + +impl RawTty { + #[allow(clippy::result_large_err)] + fn enable() -> Result { + let stdin = io::stdin(); + if !stdin.is_terminal() { + return Err(AttachError::NotATty); + } + // Dup stdin so we hold an OwnedFd whose lifetime we control. + let fd = stdin + .as_fd() + .try_clone_to_owned() + .map_err(AttachError::Io)?; + let original = tcgetattr(&fd)?; + let mut raw = original.clone(); + cfmakeraw(&mut raw); + tcsetattr(&fd, SetArg::TCSANOW, &raw)?; + Ok(Self { fd, original }) + } +} + +impl Drop for RawTty { + fn drop(&mut self) { + let _ = tcsetattr(&self.fd, SetArg::TCSANOW, &self.original); + } +} + +fn current_winsize() -> Option<(u16, u16)> { + use std::mem::MaybeUninit; + let mut ws = MaybeUninit::::uninit(); + let r = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) }; + if r != 0 { + return None; + } + let ws = unsafe { ws.assume_init() }; + Some((ws.ws_col, ws.ws_row)) +} + +/// Drive the attach loop until the client detaches or the remote closes. +/// +/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned; +/// `detach_byte` is the single byte that exits cleanly without killing the +/// session. +#[allow(clippy::result_large_err)] +pub async fn run_attach( + connect_info: ConnectInfo, + session_id: Uuid, + detach_byte: u8, +) -> Result<(), AttachError> { + let url = attach_url(&connect_info, session_id)?; + let (ws, _resp) = tokio_tungstenite::connect_async(&url).await?; + let (mut sink, mut stream) = ws.split(); + + let _raw = RawTty::enable()?; + + // Send initial window size so the remote PTY matches our terminal. + if let Some((cols, rows)) = current_winsize() { + let frame = + serde_json::to_string(&ClientControl::Resize { cols, rows }).expect("serialize"); + sink.send(Message::Text(frame)).await?; + } + + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + + // Resize signal stream. + let mut sigwinch = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())?; + + let mut in_buf = [0u8; 4096]; + + let detach_msg = Arc::new(format!( + "\r\n[maestro] detached (key 0x{detach_byte:02x})\r\n" + )); + + loop { + tokio::select! { + // Server → local stdout + msg = stream.next() => { + match msg { + None => break, + Some(Err(e)) => return Err(AttachError::WebSocket(e)), + Some(Ok(Message::Binary(bytes))) => { + stdout.write_all(&bytes).await?; + stdout.flush().await?; + } + Some(Ok(Message::Text(text))) => { + if let Ok(ClientControl::Ping) = serde_json::from_str::(&text) { + let pong = serde_json::to_string(&ClientControl::Pong) + .expect("serialize pong"); + sink.send(Message::Text(pong)).await?; + } + } + Some(Ok(Message::Ping(p))) => { + sink.send(Message::Pong(p)).await?; + } + Some(Ok(Message::Close(_))) => break, + Some(Ok(_)) => {} + } + } + + // Local stdin → server + n = stdin.read(&mut in_buf) => { + let n = n?; + if n == 0 { + break; + } + let chunk = &in_buf[..n]; + if let Some(pos) = chunk.iter().position(|b| *b == detach_byte) { + if pos > 0 { + sink.send(Message::Binary(chunk[..pos].to_vec())).await.ok(); + } + let _ = sink.send(Message::Close(None)).await; + // best-effort goodbye on the local terminal + stdout.write_all(detach_msg.as_bytes()).await.ok(); + break; + } + sink.send(Message::Binary(chunk.to_vec())).await?; + } + + // Window resize + _ = sigwinch.recv() => { + if let Some((cols, rows)) = current_winsize() { + let frame = serde_json::to_string(&ClientControl::Resize { cols, rows }) + .expect("serialize"); + sink.send(Message::Text(frame)).await?; + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use maestro_protocol::ConnectInfo; + use uuid::Uuid; + + #[test] + fn parse_detach_key_handles_caret_form() { + assert_eq!(parse_detach_key("^q").unwrap(), 0x11); + assert_eq!(parse_detach_key("^Q").unwrap(), 0x11); + assert_eq!(parse_detach_key("^c").unwrap(), 0x03); + } + + #[test] + fn parse_detach_key_handles_literal() { + assert_eq!(parse_detach_key("x").unwrap(), b'x'); + } + + #[test] + fn parse_detach_key_rejects_multi_char() { + assert!(matches!( + parse_detach_key("ab"), + Err(AttachError::InvalidDetachKey(_)) + )); + } + + #[test] + fn attach_url_uses_ws_path_and_token_query() { + let info = ConnectInfo { + daemon_url: "ws://10.0.0.1:7070".into(), + jwt: "abc.def-ghi_jkl".into(), + }; + let id = Uuid::from_u128(0x1234_5678); + let url = attach_url(&info, id).unwrap(); + assert!(url.starts_with("ws://10.0.0.1:7070/sessions/")); + assert!(url.contains(&id.to_string())); + assert!(url.contains("/stream?token=abc.def-ghi_jkl")); + } + + #[test] + fn attach_url_rejects_non_ws_scheme() { + let info = ConnectInfo { + daemon_url: "http://x".into(), + jwt: "t".into(), + }; + assert!(matches!( + attach_url(&info, Uuid::nil()), + Err(AttachError::InvalidDaemonUrl(_)) + )); + } + + #[test] + fn attach_url_trims_trailing_slash() { + let info = ConnectInfo { + daemon_url: "wss://reg.example.com/".into(), + jwt: "t".into(), + }; + let id = Uuid::nil(); + let url = attach_url(&info, id).unwrap(); + assert!(!url.contains("//sessions"), "{url}"); + } +} diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs new file mode 100644 index 0000000..9727735 --- /dev/null +++ b/crates/maestro-cli/src/cli.rs @@ -0,0 +1,215 @@ +//! Clap-derive command-line definitions. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use uuid::Uuid; + +#[derive(Debug, Parser)] +#[command( + name = "maestro", + version, + about = "Control terminal sessions across one or more maestrod hosts", + arg_required_else_help = true +)] +pub struct Cli { + /// Override the registry URL for this invocation (e.g. `https://maestro.example.com`). + /// If omitted, the URL persisted in `~/.config/maestro/cli.toml` is used. + #[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")] + pub registry_url: Option, + + /// Path to the CLI config file. Default `~/.config/maestro/cli.toml`. + #[arg(long, global = true, env = "MAESTRO_CLI_CONFIG")] + pub config: Option, + + /// Increase log verbosity. Repeat for more: `-v` = debug, `-vv` = trace. + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + pub verbose: u8, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// List all sessions, grouped by daemon. + List(ListArgs), + /// Spawn a new session on a given daemon. + Spawn(SpawnArgs), + /// Attach to a session over WebSocket. Bytes flow through the local TTY. + Attach(AttachArgs), + /// Kill a session by ID. + Kill(KillArgs), + /// Set the default registry URL persisted in the CLI config. + SetRegistry(SetRegistryArgs), +} + +#[derive(Debug, clap::Args)] +pub struct ListArgs { + /// Show only sessions on this daemon. + #[arg(long)] + pub daemon: Option, + /// Output as JSON instead of a table. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, clap::Args)] +pub struct SpawnArgs { + /// Daemon ID to spawn on. + #[arg(long)] + pub daemon: String, + /// Working directory for the session (must exist on the daemon's host). + #[arg(long)] + pub cwd: PathBuf, + /// Command to launch (e.g. `bash`, `claude`). + #[arg(long)] + pub command: String, + /// Human-readable label, persisted in session metadata (`label`). + #[arg(long)] + pub label: Option, + /// Set an env var on the session (`KEY=VALUE`). Repeatable. + #[arg(long = "env", short = 'e', value_parser = parse_kv, value_name = "KEY=VALUE")] + pub env: Vec<(String, String)>, + /// Base repo path for a git worktree spawn. + #[arg(long, requires = "branch")] + pub worktree_from: Option, + /// Branch name for the worktree (required with --worktree-from). + #[arg(long)] + pub branch: Option, + /// Remove the worktree when the session is killed. + #[arg(long)] + pub cleanup_on_kill: bool, + /// After spawning, attach immediately. + #[arg(long, short = 'a')] + pub attach: bool, +} + +#[derive(Debug, clap::Args)] +pub struct AttachArgs { + /// Session ID to attach to. + pub session_id: Uuid, + /// Detach key (single byte). Default Ctrl-q (`\x11`). Accepts `^X` form. + #[arg(long, default_value = "^q")] + pub detach_key: String, +} + +#[derive(Debug, clap::Args)] +pub struct KillArgs { + /// Session ID to kill. + pub session_id: Uuid, +} + +#[derive(Debug, clap::Args)] +pub struct SetRegistryArgs { + /// Registry base URL to persist. + pub url: String, +} + +fn parse_kv(s: &str) -> Result<(String, String), String> { + match s.split_once('=') { + Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())), + _ => Err(format!("expected KEY=VALUE, got {s:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn parses_list() { + let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap(); + match cli.command { + Command::List(args) => { + assert_eq!(args.daemon.as_deref(), Some("laptop")); + assert!(args.json); + } + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn parses_spawn_with_worktree() { + let cli = Cli::try_parse_from([ + "maestro", + "spawn", + "--daemon", + "vm1", + "--cwd", + "/tmp", + "--command", + "bash", + "--worktree-from", + "/repos/foo", + "--branch", + "feat/x", + "--cleanup-on-kill", + "-e", + "FOO=bar", + "-e", + "BAZ=qux", + ]) + .unwrap(); + match cli.command { + Command::Spawn(a) => { + assert_eq!(a.daemon, "vm1"); + assert_eq!(a.command, "bash"); + assert_eq!(a.branch.as_deref(), Some("feat/x")); + assert!(a.cleanup_on_kill); + assert_eq!( + a.env, + vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),] + ); + } + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn spawn_worktree_requires_branch() { + let err = Cli::try_parse_from([ + "maestro", + "spawn", + "--daemon", + "vm1", + "--cwd", + "/tmp", + "--command", + "bash", + "--worktree-from", + "/repos/foo", + ]) + .unwrap_err(); + assert!(err.to_string().contains("--branch")); + } + + #[test] + fn parses_attach() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "attach", id]).unwrap(); + match cli.command { + Command::Attach(a) => assert_eq!(a.session_id.to_string(), id), + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn rejects_env_without_equals() { + let err = Cli::try_parse_from([ + "maestro", + "spawn", + "--daemon", + "x", + "--cwd", + "/", + "--command", + "bash", + "-e", + "BARE", + ]) + .unwrap_err(); + assert!(err.to_string().contains("KEY=VALUE")); + } +} diff --git a/crates/maestro-cli/src/config.rs b/crates/maestro-cli/src/config.rs new file mode 100644 index 0000000..3e58b24 --- /dev/null +++ b/crates/maestro-cli/src/config.rs @@ -0,0 +1,103 @@ +//! CLI config persisted at `~/.config/maestro/cli.toml`. +//! +//! Stores the default registry URL and the auth cookie issued by the +//! registry's cookie middleware (`crates/maestro-registry/src/auth.rs`). The +//! cookie is opaque to the CLI — we just round-trip it as a string. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("toml decode: {0}")] + TomlDecode(#[from] toml::de::Error), + #[error("toml encode: {0}")] + TomlEncode(#[from] toml::ser::Error), + #[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")] + NoConfigDir, + #[error( + "no registry URL configured — set MAESTRO_REGISTRY_URL or run `maestro set-registry `" + )] + NoRegistryUrl, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CliConfig { + /// Default registry base URL (e.g. `https://maestro.example.com`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_url: Option, + + /// Persisted `maestro_session` cookie value (a JWT) — opaque blob. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cookie: Option, +} + +impl CliConfig { + /// Load from disk if present; return an empty config if not. + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let bytes = std::fs::read(path)?; + let s = std::str::from_utf8(&bytes).unwrap_or(""); + Ok(toml::from_str(s)?) + } + + /// Atomically write the config back to disk (write to tmp + rename). + pub fn save(&self, path: &Path) -> Result<(), ConfigError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let body = toml::to_string_pretty(self)?; + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, body)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } +} + +/// Default config path: `~/.config/maestro/cli.toml`. +pub fn default_config_path() -> Result { + let base = dirs::config_dir().ok_or(ConfigError::NoConfigDir)?; + Ok(base.join("maestro").join("cli.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_returns_default() { + let dir = tempfile::tempdir().unwrap(); + let cfg = CliConfig::load(&dir.path().join("cli.toml")).unwrap(); + assert_eq!(cfg, CliConfig::default()); + } + + #[test] + fn round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cli.toml"); + let cfg = CliConfig { + registry_url: Some("https://r.example.com".into()), + cookie: Some("eyJ...".into()), + }; + cfg.save(&path).unwrap(); + assert_eq!(CliConfig::load(&path).unwrap(), cfg); + } + + #[test] + fn save_creates_parent_directory() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/here/cli.toml"); + CliConfig { + registry_url: Some("https://x".into()), + cookie: None, + } + .save(&path) + .unwrap(); + assert!(path.exists()); + } +} diff --git a/crates/maestro-cli/src/http.rs b/crates/maestro-cli/src/http.rs new file mode 100644 index 0000000..10068c4 --- /dev/null +++ b/crates/maestro-cli/src/http.rs @@ -0,0 +1,178 @@ +//! Thin HTTP client wrapper around `reqwest` that persists the +//! `maestro_session` cookie issued by the registry's cookie middleware. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE}; +use reqwest::{Method, Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::config::{CliConfig, ConfigError}; + +const COOKIE_NAME: &str = "maestro_session"; + +#[derive(Debug, thiserror::Error)] +pub enum HttpError { + #[error("http: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("server returned {status}: {body}")] + ServerError { status: StatusCode, body: String }, + #[error("decode: {0}")] + Json(#[from] serde_json::Error), + #[error("config: {0}")] + Config(#[from] ConfigError), + #[error("invalid header: {0}")] + InvalidHeader(#[from] reqwest::header::InvalidHeaderValue), +} + +/// HTTP client bound to a single registry. Persists the auth cookie back to +/// disk when the registry sets one (first-contact bootstrap from +/// `crates/maestro-registry/src/auth.rs`). +#[derive(Debug)] +pub struct RegistryClient { + base: String, + inner: reqwest::Client, + /// Cookie value (the JWT body), shared with the config-persister. + cookie: Arc>>, + /// Where to write the config back when the cookie changes. + config_path: PathBuf, +} + +impl RegistryClient { + pub fn new(base: String, cookie: Option, config_path: PathBuf) -> Self { + let inner = reqwest::Client::builder() + .user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .expect("build reqwest client"); + Self { + base: base.trim_end_matches('/').to_string(), + inner, + cookie: Arc::new(Mutex::new(cookie)), + config_path, + } + } + + pub fn base(&self) -> &str { + &self.base + } + + /// Snapshot of the current cookie (mostly for tests). + pub fn current_cookie(&self) -> Option { + self.cookie.lock().unwrap().clone() + } + + /// Send a request, attaching the persisted cookie. If the response sets a + /// new cookie (first contact, or a rotation), persist it back to disk. + async fn send( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let url = format!("{}{}", self.base, path); + let mut headers = HeaderMap::new(); + if let Some(c) = self.cookie.lock().unwrap().as_ref() { + headers.insert( + COOKIE, + HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?, + ); + } + let mut req = self.inner.request(method, &url).headers(headers); + if let Some(b) = body { + req = req.json(&b); + } + let resp = req.send().await?; + self.capture_cookie(resp.headers())?; + Ok(resp) + } + + fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> { + let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else { + return Ok(()); + }; + let Some(val) = extract_cookie_value(raw, COOKIE_NAME) else { + return Ok(()); + }; + let mut guard = self.cookie.lock().unwrap(); + if guard.as_deref() == Some(val) { + return Ok(()); + } + *guard = Some(val.to_string()); + // Persist; on failure, log but don't abort the request. + let cfg = CliConfig { + registry_url: Some(self.base.clone()), + cookie: Some(val.to_string()), + }; + if let Err(e) = cfg.save(&self.config_path) { + tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie"); + } + Ok(()) + } + + pub async fn get_json(&self, path: &str) -> Result { + let resp = self.send(Method::GET, path, None).await?; + ok_or_body(resp) + .await? + .json::() + .await + .map_err(Into::into) + } + + pub async fn post_json( + &self, + path: &str, + body: &B, + ) -> Result { + let v = serde_json::to_value(body)?; + let resp = self.send(Method::POST, path, Some(v)).await?; + ok_or_body(resp) + .await? + .json::() + .await + .map_err(Into::into) + } + + pub async fn delete(&self, path: &str) -> Result<(), HttpError> { + let resp = self.send(Method::DELETE, path, None).await?; + ok_or_body(resp).await?; + Ok(()) + } +} + +async fn ok_or_body(resp: Response) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + let body = resp.text().await.unwrap_or_default(); + Err(HttpError::ServerError { status, body }) +} + +/// Extract `name=value` from a `Set-Cookie:` value. Ignores attributes. +pub fn extract_cookie_value<'a>(raw: &'a str, name: &str) -> Option<&'a str> { + let first = raw.split(';').next()?.trim(); + let (k, v) = first.split_once('=')?; + (k.trim() == name).then(|| v.trim()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_named_cookie_value() { + let raw = "maestro_session=abc.def.ghi; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000"; + assert_eq!( + extract_cookie_value(raw, "maestro_session"), + Some("abc.def.ghi") + ); + } + + #[test] + fn returns_none_for_other_cookie_name() { + let raw = "other=1; HttpOnly"; + assert_eq!(extract_cookie_value(raw, "maestro_session"), None); + } +} diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs new file mode 100644 index 0000000..6702ea6 --- /dev/null +++ b/crates/maestro-cli/src/lib.rs @@ -0,0 +1,12 @@ +//! `maestro` CLI — thin client for the registry/daemon API. +//! +//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §"Wire protocol" +//! and the registry's `client_api.rs` for the HTTP surface this client targets. + +#![deny(missing_debug_implementations)] + +pub mod attach; +pub mod cli; +pub mod config; +pub mod http; +pub mod sessions; diff --git a/crates/maestro-cli/src/main.rs b/crates/maestro-cli/src/main.rs new file mode 100644 index 0000000..063c1e8 --- /dev/null +++ b/crates/maestro-cli/src/main.rs @@ -0,0 +1,119 @@ +//! `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, + 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/sessions.rs b/crates/maestro-cli/src/sessions.rs new file mode 100644 index 0000000..4ef0499 --- /dev/null +++ b/crates/maestro-cli/src/sessions.rs @@ -0,0 +1,231 @@ +//! `maestro list / spawn / kill` — registry HTTP calls + console formatting. + +use std::collections::HashMap; +use std::io::Write; + +use maestro_protocol::{ + ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus, WorktreeSpec, +}; +use serde_json::Value; +use uuid::Uuid; + +use crate::cli::{KillArgs, ListArgs, SpawnArgs}; +use crate::http::{HttpError, RegistryClient}; + +/// `maestro list` +pub async fn run_list( + client: &RegistryClient, + args: &ListArgs, + out: &mut impl Write, +) -> Result<(), HttpError> { + let mut sessions: Vec = client.get_json("/api/sessions").await?; + let daemons: Vec = client + .get_json::>("/api/daemons") + .await + .unwrap_or_default(); + if let Some(d) = &args.daemon { + sessions.retain(|s| &s.daemon_id == d); + } + + if args.json { + let body = serde_json::json!({ "sessions": sessions, "daemons": daemons }); + writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); + return Ok(()); + } + + print_session_table(out, &sessions, &daemons); + Ok(()) +} + +/// `maestro spawn` +pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result { + let mut env: HashMap = HashMap::new(); + for (k, v) in args.env.iter() { + env.insert(k.clone(), v.clone()); + } + + let mut metadata: HashMap = HashMap::new(); + if let Some(label) = args.label.as_deref() { + metadata.insert("label".into(), Value::String(label.into())); + } + + let worktree = match (args.worktree_from.as_ref(), args.branch.as_ref()) { + (Some(repo), Some(branch)) => Some(WorktreeSpec { + base_repo: repo.clone(), + branch: branch.clone(), + cleanup_on_kill: args.cleanup_on_kill, + }), + _ => None, + }; + + let body = CreateSessionRequest { + daemon_id: args.daemon.clone(), + cwd: args.cwd.clone(), + command: args.command.clone(), + env, + metadata, + worktree, + }; + let session: Session = client.post_json("/api/sessions", &body).await?; + Ok(session) +} + +/// `maestro kill` +pub async fn run_kill(client: &RegistryClient, args: &KillArgs) -> Result<(), HttpError> { + client + .delete(&format!("/api/sessions/{}", args.session_id)) + .await +} + +/// `GET /api/sessions/:id/connect-info` +pub async fn fetch_connect_info( + client: &RegistryClient, + session_id: Uuid, +) -> Result { + client + .get_json(&format!("/api/sessions/{session_id}/connect-info")) + .await +} + +#[allow(clippy::write_literal)] +fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) { + if sessions.is_empty() { + if daemons.is_empty() { + writeln!(out, "no sessions, no daemons connected").ok(); + } else { + writeln!( + out, + "no sessions ({} daemon{} connected)", + daemons.len(), + if daemons.len() == 1 { "" } else { "s" } + ) + .ok(); + } + return; + } + + // Group by daemon, stable order. + let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new(); + for s in sessions { + if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) { + slot.1.push(s); + } else { + by_daemon.push((s.daemon_id.clone(), vec![s])); + } + } + for (_, group) in &mut by_daemon { + group.sort_by_key(|s| s.created_at); + } + + for (daemon_id, group) in &by_daemon { + let hostname = daemons + .iter() + .find(|d| &d.daemon_id == daemon_id) + .map(|d| d.hostname.as_str()) + .unwrap_or("?"); + writeln!(out, "\n{daemon_id} ({hostname})").ok(); + writeln!( + out, + " {:<36} {:<8} {:<20} {}", + "ID", "STATUS", "LABEL", "COMMAND" + ) + .ok(); + for s in group { + let label = s + .metadata + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + let status = match s.status { + SessionStatus::Running => "running", + SessionStatus::Dead => "dead", + }; + writeln!( + out, + " {:<36} {:<8} {:<20} {}", + s.id, status, label, s.command + ) + .ok(); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use chrono::{TimeZone, Utc}; + use maestro_protocol::{DaemonInfo, Session, SessionStatus}; + use serde_json::Value; + use uuid::Uuid; + + fn session(daemon: &str, label: &str, command: &str, id: u128) -> Session { + let mut meta = HashMap::new(); + meta.insert("label".into(), Value::String(label.into())); + Session { + id: Uuid::from_u128(id), + daemon_id: daemon.into(), + cwd: PathBuf::from("/tmp"), + command: command.into(), + env: HashMap::new(), + metadata: meta, + status: SessionStatus::Running, + created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(), + } + } + + #[test] + fn formats_grouped_table() { + let sessions = vec![ + session("laptop", "personal/maestro", "claude", 1), + session("vm1", "work/api", "bash", 2), + session("laptop", "scratch", "nvim", 3), + ]; + let daemons = vec![ + DaemonInfo { + daemon_id: "laptop".into(), + hostname: "naps-laptop".into(), + version: "0.1.0".into(), + daemon_url: "ws://10.0.0.1:7070".into(), + }, + DaemonInfo { + daemon_id: "vm1".into(), + hostname: "vm1.lan".into(), + version: "0.1.0".into(), + daemon_url: "ws://10.0.0.2:7070".into(), + }, + ]; + let mut buf = Vec::new(); + super::print_session_table(&mut buf, &sessions, &daemons); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("laptop (naps-laptop)"), "{s}"); + assert!(s.contains("vm1 (vm1.lan)"), "{s}"); + assert!(s.contains("personal/maestro"), "{s}"); + assert!(s.contains("running"), "{s}"); + } + + #[test] + fn empty_sessions_with_no_daemons() { + let mut buf = Vec::new(); + super::print_session_table(&mut buf, &[], &[]); + assert_eq!( + String::from_utf8(buf).unwrap(), + "no sessions, no daemons connected\n" + ); + } + + #[test] + fn empty_sessions_with_daemons() { + let daemons = vec![DaemonInfo { + daemon_id: "laptop".into(), + hostname: "naps".into(), + version: "0.1.0".into(), + daemon_url: "ws://x".into(), + }]; + let mut buf = Vec::new(); + super::print_session_table(&mut buf, &[], &daemons); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("1 daemon connected"), "{s}"); + } +}