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:
Generated
+217
-10
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user