From 98b6fabef6ccdda3bd195d6922e3f5fdb19588e8 Mon Sep 17 00:00:00 2001 From: Helios Date: Fri, 6 Mar 2026 03:02:22 +0100 Subject: [PATCH] feat: rewrite remote.py as Rust CLI binary (crates/cli) --- Cargo.toml | 1 + README.md | 44 +-- SKILL.md | 46 +-- crates/cli/Cargo.toml | 16 + crates/cli/build.rs | 11 + crates/cli/src/main.rs | 742 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 815 insertions(+), 45 deletions(-) create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/build.rs create mode 100644 crates/cli/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 40cb1dd..816c174 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,5 +3,6 @@ members = [ "crates/common", "crates/server", "crates/client", + "crates/cli", ] resolver = "2" diff --git a/README.md b/README.md index fb09253..d7e7371 100644 --- a/README.md +++ b/README.md @@ -31,40 +31,40 @@ irm https://raw.githubusercontent.com/agent-helios/helios-remote/master/scripts/ ``` AI Agent │ - ▼ remote.py CLI + ▼ helios CLI helios-server ──WebSocket── helios-client (Windows) ``` 1. The **Windows client** connects to the relay server via WebSocket and registers with its device label. -2. The **AI agent** uses `remote.py` to issue commands — screenshots, shell commands, window management, file transfers. +2. The **AI agent** uses `helios` to issue commands — screenshots, shell commands, window management, file transfers. 3. The relay server forwards everything to the correct client and streams back responses. Device labels are the sole identifier. Only one client instance can run per device. --- -## remote.py CLI +## helios CLI ```bash -python remote.py devices # list connected devices -python remote.py screenshot screen # full-screen screenshot → /tmp/helios-remote-screenshot.png -python remote.py screenshot # screenshot a specific window -python remote.py exec # run shell command (PowerShell) -python remote.py exec --timeout 600 # with custom timeout (seconds) -python remote.py windows # list visible windows -python remote.py focus # focus a window -python remote.py maximize # maximize and focus a window -python remote.py minimize-all # minimize all windows -python remote.py prompt "Please click Save" # show MessageBox, blocks until user confirms -python remote.py prompt "message" --title "Title" # with custom dialog title -python remote.py run [args...] # launch program (fire-and-forget) -python remote.py clipboard-get # get clipboard text -python remote.py clipboard-set # set clipboard text -python remote.py upload # upload file to device -python remote.py download # download file from device -python remote.py version # compare relay/remote.py/client commits -python remote.py logs # fetch last 100 lines of client log -python remote.py logs --lines 200 # custom line count +helios devices # list connected devices +helios screenshot screen # full-screen screenshot → /tmp/helios-remote-screenshot.png +helios screenshot # screenshot a specific window +helios exec # run shell command (PowerShell) +helios exec --timeout 600 # with custom timeout (seconds) +helios windows # list visible windows +helios focus # focus a window +helios maximize # maximize and focus a window +helios minimize-all # minimize all windows +helios prompt "Please click Save" # show MessageBox, blocks until user confirms +helios prompt "message" --title "Title" # with custom dialog title +helios run [args...] # launch program (fire-and-forget) +helios clipboard-get # get clipboard text +helios clipboard-set # set clipboard text +helios upload # upload file to device +helios download # download file from device +helios version # compare relay/helios/client commits +helios logs # fetch last 100 lines of client log +helios logs --lines 200 # custom line count ``` --- diff --git a/SKILL.md b/SKILL.md index ce0a5e7..2e79910 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,7 +15,7 @@ When Moritz asks to do something on a connected PC: ## Setup -- **Script:** `skills/helios-remote/remote.py` +- **Script:** `skills/helios-remote/helios` - **Config:** `skills/helios-remote/config.env` (URL + API key, don't modify) - `SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote` @@ -31,54 +31,54 @@ When Moritz asks to do something on a connected PC: SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote # List connected devices -python $SKILL_DIR/remote.py devices +$SKILL_DIR/helios devices # Screenshot → /tmp/helios-remote-screenshot.png # ALWAYS prefer window screenshots (saves bandwidth)! -python $SKILL_DIR/remote.py screenshot moritz-pc chrome # window by label -python $SKILL_DIR/remote.py screenshot moritz-pc screen # full screen only when no window known +$SKILL_DIR/helios screenshot moritz-pc chrome # window by label +$SKILL_DIR/helios screenshot moritz-pc screen # full screen only when no window known # List visible windows (use labels for screenshot/focus/maximize) -python $SKILL_DIR/remote.py windows moritz-pc +$SKILL_DIR/helios windows moritz-pc # Window labels come from the process name (e.g. chrome, discord, pycharm64) # Duplicates get a number suffix: chrome, chrome2, chrome3 # Use `windows` to discover labels before targeting a specific window # Focus / maximize a window -python $SKILL_DIR/remote.py focus moritz-pc discord -python $SKILL_DIR/remote.py maximize moritz-pc chrome +$SKILL_DIR/helios focus moritz-pc discord +$SKILL_DIR/helios maximize moritz-pc chrome # Minimize all windows -python $SKILL_DIR/remote.py minimize-all moritz-pc +$SKILL_DIR/helios minimize-all moritz-pc # Shell command (PowerShell, no wrapper needed) -python $SKILL_DIR/remote.py exec moritz-pc "Get-Process" -python $SKILL_DIR/remote.py exec moritz-pc "hostname" +$SKILL_DIR/helios exec moritz-pc "Get-Process" +$SKILL_DIR/helios exec moritz-pc "hostname" # With longer timeout for downloads etc. (default: 30s) -python $SKILL_DIR/remote.py exec moritz-pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip" +$SKILL_DIR/helios exec moritz-pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip" # Launch program (fire-and-forget) -python $SKILL_DIR/remote.py run moritz-pc notepad.exe +$SKILL_DIR/helios run moritz-pc notepad.exe # Ask user to do something (shows MessageBox, blocks until OK) -python $SKILL_DIR/remote.py prompt moritz-pc "Please click Save, then OK" -python $SKILL_DIR/remote.py prompt moritz-pc "UAC dialog coming - please confirm" --title "Action required" +$SKILL_DIR/helios prompt moritz-pc "Please click Save, then OK" +$SKILL_DIR/helios prompt moritz-pc "UAC dialog coming - please confirm" --title "Action required" # Clipboard -python $SKILL_DIR/remote.py clipboard-get moritz-pc -python $SKILL_DIR/remote.py clipboard-set moritz-pc "Text for clipboard" +$SKILL_DIR/helios clipboard-get moritz-pc +$SKILL_DIR/helios clipboard-set moritz-pc "Text for clipboard" # File transfer -python $SKILL_DIR/remote.py upload moritz-pc /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt" -python $SKILL_DIR/remote.py download moritz-pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt +$SKILL_DIR/helios upload moritz-pc /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt" +$SKILL_DIR/helios download moritz-pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt -# Version: compare relay + remote.py + client commits (are they in sync?) -python $SKILL_DIR/remote.py version moritz-pc +# Version: compare relay + helios + client commits (are they in sync?) +$SKILL_DIR/helios version moritz-pc # Client log (last 100 lines, --lines for more) -python $SKILL_DIR/remote.py logs moritz-pc -python $SKILL_DIR/remote.py logs moritz-pc --lines 200 +$SKILL_DIR/helios logs moritz-pc +$SKILL_DIR/helios logs moritz-pc --lines 200 ``` ## Typical Workflow: UI Task @@ -94,7 +94,7 @@ python $SKILL_DIR/remote.py logs moritz-pc --lines 200 **Never interact with UI blindly.** When you need the user to click something: ```bash -python $SKILL_DIR/remote.py prompt moritz-pc "Please click [Save], then press OK" +$SKILL_DIR/helios prompt moritz-pc "Please click [Save], then press OK" ``` This blocks until the user confirms. Use it whenever manual interaction is needed. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..1315694 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "helios-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "helios" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +reqwest = { version = "0.12", features = ["blocking", "json"] } +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +urlencoding = "2" diff --git a/crates/cli/build.rs b/crates/cli/build.rs new file mode 100644 index 0000000..113e1c6 --- /dev/null +++ b/crates/cli/build.rs @@ -0,0 +1,11 @@ +fn main() { + // Embed git commit hash at build time + let output = std::process::Command::new("git") + .args(["log", "-1", "--format=%h"]) + .output(); + let commit = match output { + Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(), + Err(_) => "unknown".to_string(), + }; + println!("cargo:rustc-env=GIT_COMMIT={commit}"); +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..d0cdc63 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,742 @@ +use base64::Engine; +use clap::{Parser, Subcommand}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process; + +const GIT_COMMIT: &str = env!("GIT_COMMIT"); + +// ── Config ────────────────────────────────────────────────────────────────── + +struct Config { + base_url: String, + api_key: String, +} + +fn load_config() -> Config { + let exe = std::env::current_exe().unwrap_or_default(); + let dir = exe.parent().unwrap_or(Path::new(".")); + let path = dir.join("config.env"); + + let mut map = HashMap::new(); + if let Ok(content) = std::fs::read_to_string(&path) { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + } + + let base_url = map + .get("HELIOS_REMOTE_URL") + .cloned() + .unwrap_or_default() + .trim_end_matches('/') + .to_string(); + let api_key = map + .get("HELIOS_REMOTE_API_KEY") + .cloned() + .unwrap_or_default(); + + if base_url.is_empty() || api_key.is_empty() { + eprintln!( + "[helios-remote] ERROR: config.env missing or incomplete at {}", + path.display() + ); + process::exit(1); + } + + Config { base_url, api_key } +} + +// ── HTTP helpers ──────────────────────────────────────────────────────────── + +fn client() -> reqwest::blocking::Client { + reqwest::blocking::Client::new() +} + +fn req( + cfg: &Config, + method: &str, + path: &str, + body: Option, + timeout_secs: u64, +) -> Value { + let url = format!("{}{}", cfg.base_url, path); + let c = client(); + let timeout = std::time::Duration::from_secs(timeout_secs); + + let builder = match method { + "GET" => c.get(&url), + "POST" => c.post(&url), + _ => c.get(&url), + }; + + let builder = builder + .header("X-Api-Key", &cfg.api_key) + .header("Content-Type", "application/json") + .timeout(timeout); + + let builder = if let Some(b) = body { + builder.body(b.to_string()) + } else { + builder + }; + + let resp = match builder.send() { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + eprintln!( + "[helios-remote] TIMEOUT: {} did not respond within {} s", + url, timeout_secs + ); + } else { + eprintln!( + "[helios-remote] CONNECTION ERROR: Cannot reach {}\n → {}", + url, e + ); + } + process::exit(1); + } + }; + + let status = resp.status(); + if !status.is_success() { + let body_text = resp.text().unwrap_or_default(); + let body_preview = if body_text.len() > 1000 { + &body_text[..1000] + } else { + &body_text + }; + eprintln!( + "[helios-remote] HTTP {} {}\n URL : {}\n Method : {}\n Body : {}", + status.as_u16(), + status.canonical_reason().unwrap_or(""), + url, + method, + body_preview, + ); + process::exit(1); + } + + resp.json::().unwrap_or(json!({})) +} + +// ── Label validation ──────────────────────────────────────────────────────── + +fn validate_label(label: &str) { + let valid = !label.is_empty() + && label + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + && label.chars().next().unwrap().is_ascii_alphanumeric(); + if !valid { + eprintln!( + "[helios-remote] Invalid label '{}'. Must be lowercase, no whitespace, only a-z 0-9 - _", + label + ); + process::exit(1); + } +} + +// ── Window resolution ─────────────────────────────────────────────────────── + +fn resolve_window(cfg: &Config, device: &str, window_label: &str) -> i64 { + let data = req(cfg, "GET", &format!("/devices/{}/windows", device), None, 30); + let windows = data["windows"].as_array().cloned().unwrap_or_default(); + let query = window_label.to_lowercase(); + + // Exact label match + for w in &windows { + if w["visible"].as_bool() == Some(true) && w["label"].as_str() == Some(&query) { + return w["id"].as_i64().unwrap(); + } + } + + // Substring match on label + let mut matches: Vec<&Value> = windows + .iter() + .filter(|w| { + w["visible"].as_bool() == Some(true) + && w["label"] + .as_str() + .map(|l| l.contains(&query)) + .unwrap_or(false) + }) + .collect(); + + // Fallback: substring on title + if matches.is_empty() { + matches = windows + .iter() + .filter(|w| { + w["visible"].as_bool() == Some(true) + && w["title"] + .as_str() + .map(|t| t.to_lowercase().contains(&query)) + .unwrap_or(false) + }) + .collect(); + } + + if matches.is_empty() { + eprintln!( + "[helios-remote] No visible window matching '{}'", + window_label + ); + process::exit(1); + } + + if matches.len() > 1 { + println!( + "[helios-remote] Multiple matches for '{}', using first:", + window_label + ); + for w in &matches { + println!( + " {:<30} {}", + w["label"].as_str().unwrap_or("?"), + w["title"].as_str().unwrap_or("") + ); + } + } + + matches[0]["id"].as_i64().unwrap() +} + +// ── CLI ───────────────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command( + name = "helios", + about = "Control devices connected to the Helios Remote Relay Server." +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// List all connected devices + Devices, + + /// Capture screenshot (screen or window label) + Screenshot { + /// Device label + device: String, + /// 'screen' for full screen, or a window label + target: String, + }, + + /// Run a shell command on the remote device + Exec { + /// Device label + device: String, + /// Timeout in seconds + #[arg(long)] + timeout: Option, + /// Command (and arguments) to execute + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + parts: Vec, + }, + + /// List all visible windows on the remote device + Windows { + /// Device label + device: String, + }, + + /// Bring a window to the foreground + Focus { + /// Device label + device: String, + /// Window label + window: String, + }, + + /// Maximize and focus a window + Maximize { + /// Device label + device: String, + /// Window label + window: String, + }, + + /// Minimize all windows + MinimizeAll { + /// Device label + device: String, + }, + + /// Show a MessageBox asking the user to do something + Prompt { + /// Device label + device: String, + /// Message to display + message: String, + /// Custom dialog title + #[arg(long)] + title: Option, + }, + + /// Launch a program (fire-and-forget) + Run { + /// Device label + device: String, + /// Program to launch + program: String, + /// Program arguments + #[arg(trailing_var_arg = true)] + args: Vec, + }, + + /// Get clipboard contents + ClipboardGet { + /// Device label + device: String, + }, + + /// Set clipboard contents + ClipboardSet { + /// Device label + device: String, + /// Text to set + text: String, + }, + + /// Upload a local file to the remote device + Upload { + /// Device label + device: String, + /// Local file path + local_path: PathBuf, + /// Remote file path + remote_path: String, + }, + + /// Download a file from the remote device + Download { + /// Device label + device: String, + /// Remote file path + remote_path: String, + /// Local file path + local_path: PathBuf, + }, + + /// Compare relay, CLI, and client commits + Version { + /// Device label + device: String, + }, + + /// Fetch last N lines of client log file + Logs { + /// Device label + device: String, + /// Number of lines + #[arg(long, default_value = "100")] + lines: u32, + }, +} + +fn main() { + let cli = Cli::parse(); + let cfg = load_config(); + + match cli.command { + Commands::Devices => { + let data = req(&cfg, "GET", "/devices", None, 30); + let devices = data["devices"].as_array(); + match devices { + Some(devs) if !devs.is_empty() => { + println!("{:<30}", "Device"); + println!("{}", "-".repeat(30)); + for d in devs { + println!("{}", d["label"].as_str().unwrap_or("?")); + } + } + _ => println!("No devices connected."), + } + } + + Commands::Screenshot { device, target } => { + validate_label(&device); + let out_path = Path::new("/tmp/helios-remote-screenshot.png"); + + let data = if target == "screen" { + req( + &cfg, + "POST", + &format!("/devices/{}/screenshot", device), + None, + 30, + ) + } else { + let wid = resolve_window(&cfg, &device, &target); + req( + &cfg, + "POST", + &format!("/devices/{}/windows/{}/screenshot", device, wid), + None, + 30, + ) + }; + + let b64 = data["image_base64"] + .as_str() + .or(data["screenshot"].as_str()) + .or(data["image"].as_str()) + .or(data["data"].as_str()) + .or(data["png"].as_str()); + + let b64 = match b64 { + Some(s) => s, + None => { + eprintln!("[helios-remote] ERROR: No image in response."); + process::exit(1); + } + }; + + let b64 = if let Some((_, after)) = b64.split_once(',') { + after + } else { + b64 + }; + + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .unwrap_or_else(|e| { + eprintln!("[helios-remote] ERROR: Failed to decode base64: {}", e); + process::exit(1); + }); + + std::fs::write(out_path, &bytes).unwrap_or_else(|e| { + eprintln!("[helios-remote] ERROR: Failed to write screenshot: {}", e); + process::exit(1); + }); + println!("{}", out_path.display()); + } + + Commands::Exec { + device, + timeout, + parts, + } => { + validate_label(&device); + let command = parts.join(" "); + let mut body = json!({"command": command}); + if let Some(t) = timeout { + body["timeout_ms"] = json!(t * 1000); + } + let http_timeout = timeout.unwrap_or(30) + 5; + let data = req( + &cfg, + "POST", + &format!("/devices/{}/exec", device), + Some(body), + http_timeout.max(35), + ); + + let stdout = data["stdout"] + .as_str() + .or(data["output"].as_str()) + .unwrap_or(""); + let stderr = data["stderr"].as_str().unwrap_or(""); + let exit_code = data["exit_code"].as_i64(); + + if !stdout.is_empty() { + if stdout.ends_with('\n') { + print!("{}", stdout); + } else { + println!("{}", stdout); + } + } + if !stderr.is_empty() { + eprintln!("[stderr] {}", stderr); + } + if let Some(code) = exit_code { + if code != 0 { + eprintln!("[helios-remote] Command exited with code {}", code); + process::exit(code as i32); + } + } + } + + Commands::Windows { device } => { + validate_label(&device); + let data = req( + &cfg, + "GET", + &format!("/devices/{}/windows", device), + None, + 30, + ); + let windows = data["windows"].as_array().cloned().unwrap_or_default(); + let visible: Vec<&Value> = windows + .iter() + .filter(|w| w["visible"].as_bool() == Some(true)) + .collect(); + if visible.is_empty() { + println!("No windows returned."); + return; + } + println!("{:<30} Title", "Label"); + println!("{}", "-".repeat(70)); + for w in visible { + println!( + "{:<30} {}", + w["label"].as_str().unwrap_or("?"), + w["title"].as_str().unwrap_or("") + ); + } + } + + Commands::Focus { device, window } => { + validate_label(&device); + let wid = resolve_window(&cfg, &device, &window); + req( + &cfg, + "POST", + &format!("/devices/{}/windows/{}/focus", device, wid), + None, + 30, + ); + println!("Window '{}' focused on {}.", window, device); + } + + Commands::Maximize { device, window } => { + validate_label(&device); + let wid = resolve_window(&cfg, &device, &window); + req( + &cfg, + "POST", + &format!("/devices/{}/windows/{}/maximize", device, wid), + None, + 30, + ); + println!("Window '{}' maximized on {}.", window, device); + } + + Commands::MinimizeAll { device } => { + validate_label(&device); + req( + &cfg, + "POST", + &format!("/devices/{}/windows/minimize-all", device), + None, + 30, + ); + println!("All windows minimized on {}.", device); + } + + Commands::Prompt { + device, + message, + title, + } => { + validate_label(&device); + let mut body = json!({"message": message}); + if let Some(t) = title { + body["title"] = json!(t); + } + let data = req( + &cfg, + "POST", + &format!("/devices/{}/prompt", device), + Some(body), + 30, + ); + let answer = data["answer"].as_str().unwrap_or(""); + if !answer.is_empty() { + println!("{}", answer); + } else { + println!("Prompt confirmed."); + } + } + + Commands::Run { + device, + program, + args, + } => { + validate_label(&device); + req( + &cfg, + "POST", + &format!("/devices/{}/run", device), + Some(json!({"program": program, "args": args})), + 30, + ); + println!("Started {:?} on {}.", program, device); + } + + Commands::ClipboardGet { device } => { + validate_label(&device); + let data = req( + &cfg, + "GET", + &format!("/devices/{}/clipboard", device), + None, + 30, + ); + println!("{}", data["text"].as_str().unwrap_or("")); + } + + Commands::ClipboardSet { device, text } => { + validate_label(&device); + req( + &cfg, + "POST", + &format!("/devices/{}/clipboard", device), + Some(json!({"text": text})), + 30, + ); + println!("Clipboard set ({} chars) on {}.", text.len(), device); + } + + Commands::Upload { + device, + local_path, + remote_path, + } => { + validate_label(&device); + if !local_path.exists() { + eprintln!( + "[helios-remote] ERROR: Local file not found: {}", + local_path.display() + ); + process::exit(1); + } + let bytes = std::fs::read(&local_path).unwrap_or_else(|e| { + eprintln!("[helios-remote] ERROR: Failed to read file: {}", e); + process::exit(1); + }); + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + req( + &cfg, + "POST", + &format!("/devices/{}/upload", device), + Some(json!({"path": remote_path, "content_base64": b64})), + 30, + ); + println!( + "Uploaded {} → {} on {}.", + local_path.display(), + remote_path, + device + ); + } + + Commands::Download { + device, + remote_path, + local_path, + } => { + validate_label(&device); + let encoded = urlencoding::encode(&remote_path); + let data = req( + &cfg, + "GET", + &format!("/devices/{}/download?path={}", device, encoded), + None, + 30, + ); + let b64 = data["content_base64"].as_str().unwrap_or(""); + if b64.is_empty() { + eprintln!("[helios-remote] ERROR: No content in download response."); + process::exit(1); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .unwrap_or_else(|e| { + eprintln!("[helios-remote] ERROR: Failed to decode base64: {}", e); + process::exit(1); + }); + if let Some(parent) = local_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(&local_path, &bytes).unwrap_or_else(|e| { + eprintln!("[helios-remote] ERROR: Failed to write file: {}", e); + process::exit(1); + }); + let size = data["size"].as_u64().unwrap_or(bytes.len() as u64); + println!( + "Downloaded {} → {} ({} bytes).", + remote_path, + local_path.display(), + size + ); + } + + Commands::Version { device } => { + validate_label(&device); + + // Relay version + let relay_commit = match reqwest::blocking::get(&format!("{}/version", cfg.base_url)) { + Ok(r) => r + .json::() + .ok() + .and_then(|v| v["commit"].as_str().map(String::from)) + .unwrap_or_else(|| "?".into()), + Err(e) => format!("{}", e), + }; + + // CLI commit + let cli_commit = GIT_COMMIT; + + // Client version + let client_commit = + match std::panic::catch_unwind(|| { + req(&cfg, "GET", &format!("/devices/{}/version", device), None, 10) + }) { + Ok(data) => data["commit"] + .as_str() + .unwrap_or("?") + .to_string(), + Err(_) => "unreachable".to_string(), + }; + + let all_same = relay_commit == cli_commit && cli_commit == client_commit; + println!(" relay {}", relay_commit); + println!(" cli {}", cli_commit); + println!(" client {}", client_commit); + println!( + " {}", + if all_same { + "✅ all in sync" + } else { + "⚠️ OUT OF SYNC" + } + ); + } + + Commands::Logs { device, lines } => { + validate_label(&device); + let data = req( + &cfg, + "GET", + &format!("/devices/{}/logs?lines={}", device, lines), + None, + 30, + ); + if let Some(err) = data["error"].as_str() { + eprintln!("[helios-remote] {}", err); + process::exit(1); + } + println!( + "# {} (last {} lines)", + data["log_path"].as_str().unwrap_or("?"), + lines + ); + println!("{}", data["content"].as_str().unwrap_or("")); + } + } +}