feat(maestro): maestro-cli — list/spawn/attach/kill
ci / rust (push) Successful in 1m7s

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>
This commit is contained in:
2026-05-25 09:49:46 +00:00
co-authored by Claude Opus 4.7
parent 8586f46dad
commit b68e39a9e5
10 changed files with 1394 additions and 10 deletions
Generated
+217 -10
View File
@@ -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"
+3
View File
@@ -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"
+37
View File
@@ -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
+279
View File
@@ -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<u8, AttachError> {
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<String, AttachError> {
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<Self, AttachError> {
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::<libc::winsize>::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::<ClientControl>(&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}");
}
}
+215
View File
@@ -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<String>,
/// Path to the CLI config file. Default `~/.config/maestro/cli.toml`.
#[arg(long, global = true, env = "MAESTRO_CLI_CONFIG")]
pub config: Option<PathBuf>,
/// 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<String>,
/// 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<String>,
/// 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<PathBuf>,
/// Branch name for the worktree (required with --worktree-from).
#[arg(long)]
pub branch: Option<String>,
/// 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"));
}
}
+103
View File
@@ -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 <url>`"
)]
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<String>,
/// Persisted `maestro_session` cookie value (a JWT) — opaque blob.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie: Option<String>,
}
impl CliConfig {
/// Load from disk if present; return an empty config if not.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
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<PathBuf, ConfigError> {
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());
}
}
+178
View File
@@ -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<Mutex<Option<String>>>,
/// Where to write the config back when the cookie changes.
config_path: PathBuf,
}
impl RegistryClient {
pub fn new(base: String, cookie: Option<String>, 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<String> {
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<serde_json::Value>,
) -> Result<Response, HttpError> {
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<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
let resp = self.send(Method::GET, path, None).await?;
ok_or_body(resp)
.await?
.json::<T>()
.await
.map_err(Into::into)
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, HttpError> {
let v = serde_json::to_value(body)?;
let resp = self.send(Method::POST, path, Some(v)).await?;
ok_or_body(resp)
.await?
.json::<T>()
.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<Response, HttpError> {
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);
}
}
+12
View File
@@ -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;
+119
View File
@@ -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<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(),
))
}
+231
View File
@@ -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<Session> = client.get_json("/api/sessions").await?;
let daemons: Vec<DaemonInfo> = client
.get_json::<Vec<DaemonInfo>>("/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<Session, HttpError> {
let mut env: HashMap<String, String> = HashMap::new();
for (k, v) in args.env.iter() {
env.insert(k.clone(), v.clone());
}
let mut metadata: HashMap<String, Value> = 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<ConnectInfo, HttpError> {
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}");
}
}