fix(#2305): run_cmd returns Result, nix fmt, collapse nested if

- run_cmd now returns Result<String, String> — callers pattern-match
  instead of comparing against an "ok" sentinel string
- Add cmd_result() helper to format run_cmd results as tool strings
- mouse_click: collapse nested if-let into let-chain (clippy collapsible_if)
- nix fmt: reformat screen.nix package list
This commit is contained in:
iris 2026-07-20 20:30:02 +02:00 committed by mara
commit 584dfed0c9
2 changed files with 49 additions and 37 deletions

View file

@ -26,24 +26,35 @@ use rmcp::{
use serde::Deserialize; use serde::Deserialize;
use tokio::process::Command; use tokio::process::Command;
/// Run a command and return a human-readable result string. /// Run a command and return its result.
/// On success returns stdout (trimmed) or `"ok"` when stdout is empty. /// `Ok(stdout_trimmed)` on success (may be empty string when the tool
/// On failure returns a `"command failed (exit N): <stderr>"` string. /// produces no output). `Err(human_readable_message)` on failure
async fn run_cmd(program: &str, args: &[&str]) -> String { /// (non-zero exit or failed spawn).
async fn run_cmd(program: &str, args: &[&str]) -> Result<String, String> {
match Command::new(program).args(args).output().await { match Command::new(program).args(args).output().await {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
let s = String::from_utf8_lossy(&out.stdout).trim().to_owned(); Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
if s.is_empty() { "ok".to_owned() } else { s }
} }
Ok(out) => { Ok(out) => {
let msg = String::from_utf8_lossy(&out.stderr).trim().to_owned(); let msg = String::from_utf8_lossy(&out.stderr).trim().to_owned();
format!( Err(format!(
"command failed (exit {}): {}", "command failed (exit {}): {}",
out.status.code().unwrap_or(-1), out.status.code().unwrap_or(-1),
if msg.is_empty() { "(no stderr)" } else { &msg } if msg.is_empty() { "(no stderr)" } else { &msg }
) ))
} }
Err(e) => format!("failed to launch `{program}`: {e}"), Err(e) => Err(format!("failed to launch `{program}`: {e}")),
}
}
/// Format a `run_cmd` result as a tool-response string. `Ok("")` →
/// `"ok"` (most input tools produce no stdout); `Ok(s)` → `s`;
/// `Err(e)` → `e`.
fn cmd_result(r: Result<String, String>) -> String {
match r {
Ok(s) if s.is_empty() => "ok".to_owned(),
Ok(s) => s,
Err(e) => e,
} }
} }
@ -65,11 +76,9 @@ impl ScreenMcp {
.map_or(0, |d| d.as_millis()); .map_or(0, |d| d.as_millis());
format!("/tmp/hive-screenshot-{ms}.png") format!("/tmp/hive-screenshot-{ms}.png")
}); });
let result = run_cmd("grim", &["-t", "png", &path]).await; match run_cmd("grim", &["-t", "png", &path]).await {
if result == "ok" { Ok(_) => format!("screenshot saved to `{path}` — use the Read tool to view it"),
format!("screenshot saved to `{path}` — use the Read tool to view it") Err(e) => e,
} else {
result
} }
} }
@ -80,7 +89,7 @@ impl ScreenMcp {
`WAYLAND_DISPLAY` (provided automatically when `hyperhive.gui.enable = true`)." `WAYLAND_DISPLAY` (provided automatically when `hyperhive.gui.enable = true`)."
)] )]
async fn type_text(&self, Parameters(args): Parameters<TypeTextArgs>) -> String { async fn type_text(&self, Parameters(args): Parameters<TypeTextArgs>) -> String {
run_cmd("wtype", &[&args.text]).await cmd_result(run_cmd("wtype", &[&args.text]).await)
} }
#[tool( #[tool(
@ -92,7 +101,7 @@ impl ScreenMcp {
`hyperhive.gui.screenInput = true` in the agent config to enable the daemon." `hyperhive.gui.screenInput = true` in the agent config to enable the daemon."
)] )]
async fn key_press(&self, Parameters(args): Parameters<KeyPressArgs>) -> String { async fn key_press(&self, Parameters(args): Parameters<KeyPressArgs>) -> String {
run_cmd("ydotool", &["key", &args.keys]).await cmd_result(run_cmd("ydotool", &["key", &args.keys]).await)
} }
#[tool( #[tool(
@ -102,18 +111,20 @@ impl ScreenMcp {
in the agent config to enable the daemon." in the agent config to enable the daemon."
)] )]
async fn mouse_move(&self, Parameters(args): Parameters<MouseMoveArgs>) -> String { async fn mouse_move(&self, Parameters(args): Parameters<MouseMoveArgs>) -> String {
run_cmd( cmd_result(
"ydotool", run_cmd(
&[ "ydotool",
"mousemove", &[
"--absolute", "mousemove",
"-x", "--absolute",
&args.x.to_string(), "-x",
"-y", &args.x.to_string(),
&args.y.to_string(), "-y",
], &args.y.to_string(),
],
)
.await,
) )
.await
} }
#[tool( #[tool(
@ -125,8 +136,8 @@ impl ScreenMcp {
)] )]
async fn mouse_click(&self, Parameters(args): Parameters<MouseClickArgs>) -> String { async fn mouse_click(&self, Parameters(args): Parameters<MouseClickArgs>) -> String {
// Move first when coordinates are supplied. // Move first when coordinates are supplied.
if let (Some(x), Some(y)) = (args.x, args.y) { if let (Some(x), Some(y)) = (args.x, args.y)
let mv = run_cmd( && let Err(e) = run_cmd(
"ydotool", "ydotool",
&[ &[
"mousemove", "mousemove",
@ -137,10 +148,9 @@ impl ScreenMcp {
&y.to_string(), &y.to_string(),
], ],
) )
.await; .await
if mv != "ok" { {
return mv; return e;
}
} }
// Map the button name to ydotool's button code. // Map the button name to ydotool's button code.
let button = match args.button.as_deref().unwrap_or("left") { let button = match args.button.as_deref().unwrap_or("left") {
@ -148,7 +158,7 @@ impl ScreenMcp {
"middle" => "0xC2", "middle" => "0xC2",
_ => "0xC0", // left _ => "0xC0", // left
}; };
run_cmd("ydotool", &["click", button]).await cmd_result(run_cmd("ydotool", &["click", button]).await)
} }
} }

View file

@ -40,9 +40,11 @@
# grim: Wayland screenshot; wtype: text/key input (no daemon). # grim: Wayland screenshot; wtype: text/key input (no daemon).
# ydotool: mouse + key injection via uinput (needs screenInput). # ydotool: mouse + key injection via uinput (needs screenInput).
environment.systemPackages = environment.systemPackages = [
[ pkgs.grim pkgs.wtype ] pkgs.grim
++ lib.optional config.hyperhive.gui.screenInput pkgs.ydotool; pkgs.wtype
]
++ lib.optional config.hyperhive.gui.screenInput pkgs.ydotool;
# ydotoold — uinput event injection daemon. The socket lands at # ydotoold — uinput event injection daemon. The socket lands at
# /tmp/.ydotool_socket by default; ydotool picks it up # /tmp/.ydotool_socket by default; ydotool picks it up