feat: rewrite remote.py as Rust CLI binary (crates/cli)

This commit is contained in:
Helios 2026-03-06 03:02:22 +01:00
parent ba3b365f4e
commit 98b6fabef6
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
6 changed files with 815 additions and 45 deletions

View file

@ -3,5 +3,6 @@ members = [
"crates/common",
"crates/server",
"crates/client",
"crates/cli",
]
resolver = "2"

View file

@ -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 <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
python remote.py screenshot <device> <window_label> # screenshot a specific window
python remote.py exec <device> <command...> # run shell command (PowerShell)
python remote.py exec <device> --timeout 600 <command...> # with custom timeout (seconds)
python remote.py windows <device> # list visible windows
python remote.py focus <device> <window_label> # focus a window
python remote.py maximize <device> <window_label> # maximize and focus a window
python remote.py minimize-all <device> # minimize all windows
python remote.py prompt <device> "Please click Save" # show MessageBox, blocks until user confirms
python remote.py prompt <device> "message" --title "Title" # with custom dialog title
python remote.py run <device> <program> [args...] # launch program (fire-and-forget)
python remote.py clipboard-get <device> # get clipboard text
python remote.py clipboard-set <device> <text> # set clipboard text
python remote.py upload <device> <local> <remote> # upload file to device
python remote.py download <device> <remote> <local> # download file from device
python remote.py version <device> # compare relay/remote.py/client commits
python remote.py logs <device> # fetch last 100 lines of client log
python remote.py logs <device> --lines 200 # custom line count
helios devices # list connected devices
helios screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
helios screenshot <device> <window_label> # screenshot a specific window
helios exec <device> <command...> # run shell command (PowerShell)
helios exec <device> --timeout 600 <command...> # with custom timeout (seconds)
helios windows <device> # list visible windows
helios focus <device> <window_label> # focus a window
helios maximize <device> <window_label> # maximize and focus a window
helios minimize-all <device> # minimize all windows
helios prompt <device> "Please click Save" # show MessageBox, blocks until user confirms
helios prompt <device> "message" --title "Title" # with custom dialog title
helios run <device> <program> [args...] # launch program (fire-and-forget)
helios clipboard-get <device> # get clipboard text
helios clipboard-set <device> <text> # set clipboard text
helios upload <device> <local> <remote> # upload file to device
helios download <device> <remote> <local> # download file from device
helios version <device> # compare relay/helios/client commits
helios logs <device> # fetch last 100 lines of client log
helios logs <device> --lines 200 # custom line count
```
---

View file

@ -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.

16
crates/cli/Cargo.toml Normal file
View file

@ -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"

11
crates/cli/build.rs Normal file
View file

@ -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}");
}

742
crates/cli/src/main.rs Normal file
View file

@ -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<Value>,
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::<Value>().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<u64>,
/// Command (and arguments) to execute
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
parts: Vec<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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::<Value>()
.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(""));
}
}
}