agentmux_srv\server/cli_handlers.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::rpc_types::{
8 CheckCliAuthResult, CommandCheckCliAuthData, CommandResolveCliData, CommandRunCliLoginData,
9 ResolveCliResult, RunCliLoginResult, COMMAND_CHECK_CLI_AUTH, COMMAND_RESOLVE_CLI,
10};
11
12use super::AppState;
13
14/// Register CLI-related RPC handlers (resolvecli, checkcliauth, runclilogin).
15pub fn register_cli_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
16 // resolvecli → detect or install a CLI tool for an agent provider.
17 // Each AgentMux version gets its own isolated CLI install at:
18 // <agentmux_home>/instances/v<AGENTMUX_VERSION>/cli/<provider>/
19 // (shared with `install.start` / `install.check` and the frontend
20 // launch path; resolved via `DataPaths::from_env()`).
21 // Never falls back to system PATH for npm-backed providers.
22 let broker_resolve = state.broker.clone();
23 engine.register_handler(
24 COMMAND_RESOLVE_CLI,
25 Box::new(move |data, _ctx| {
26 let broker = broker_resolve.clone();
27 Box::pin(async move {
28 const AGENTMUX_VERSION: &str = env!("CARGO_PKG_VERSION");
29
30 let cmd: CommandResolveCliData = serde_json::from_value(data)
31 .map_err(|e| format!("resolvecli: {e}"))?;
32 tracing::info!(
33 provider = %cmd.provider_id,
34 cli = %cmd.cli_command,
35 block_id = %cmd.block_id,
36 agentmux_version = AGENTMUX_VERSION,
37 "ResolveCli"
38 );
39
40 // Canonical install directory — shared with
41 // `install.start` / `install.check` and the frontend's
42 // `agent-model.ts::resolveCliDir`. Resolves to
43 // `<agentmux_home>/instances/v<version>/cli/<provider>/`
44 // via `DataPaths::from_env()` so portable, installed,
45 // and `AGENTMUX_HOME_OVERRIDE` modes all agree.
46 let paths = agentmux_common::DataPaths::from_env()
47 .ok_or_else(|| "DataPaths::from_env() failed".to_string())?;
48 let provider_dir = paths
49 .home_dir
50 .join("instances")
51 .join(format!("v{AGENTMUX_VERSION}"))
52 .join("cli")
53 .join(&cmd.provider_id)
54 .to_string_lossy()
55 .to_string();
56 // npm binary path — the only valid location for installed CLIs.
57 let npm_bin = if cfg!(windows) {
58 format!("{}/node_modules/.bin/{}.cmd", provider_dir, cmd.cli_command)
59 } else {
60 format!("{}/node_modules/.bin/{}", provider_dir, cmd.cli_command)
61 };
62
63 // Step 1: Check if already installed in versioned directory
64 if std::path::Path::new(&npm_bin).exists() {
65 let version = get_cli_version(&npm_bin).await;
66 tracing::info!(
67 path = %npm_bin, version = %version,
68 "CLI found in versioned install"
69 );
70 return Ok(Some(serde_json::to_value(&ResolveCliResult {
71 cli_path: npm_bin,
72 version,
73 source: "local_install".to_string(),
74 }).unwrap()));
75 }
76
77 // Step 2: Not in versioned dir — check system PATH for non-npm CLIs.
78 if cmd.npm_package.is_empty() {
79 if let Some(path) = resolve_cli_on_path(&cmd.cli_command).await {
80 let version = get_cli_version(&path).await;
81 tracing::info!(
82 path = %path, version = %version,
83 "CLI found on system PATH"
84 );
85 return Ok(Some(serde_json::to_value(&ResolveCliResult {
86 cli_path: path,
87 version,
88 source: "system_path".to_string(),
89 }).unwrap()));
90 }
91 // This branch fires for PATH-only providers
92 // (`npm_package` empty) whose CLI isn't on the
93 // system PATH. AgentMux can't auto-install these
94 // — emit AMX-CLI-004 with a manual install hint
95 // instead of AMX-CLI-001 ("Click Install now"),
96 // which would point the user at an install
97 // affordance that doesn't apply.
98 let install_hint = if cfg!(target_os = "windows") {
99 cmd.windows_install_command.clone()
100 } else {
101 cmd.unix_install_command.clone()
102 };
103 return Err(agentmux_common::AgentMuxError::CliMissingOnPath {
104 provider: cmd.provider_id.clone(),
105 cli: cmd.cli_command.clone(),
106 install_hint,
107 }
108 .to_wire()
109 .to_string());
110 }
111
112 tracing::info!(
113 provider = %cmd.provider_id,
114 npm_package = %cmd.npm_package,
115 pinned_version = %cmd.pinned_version,
116 target_dir = %provider_dir,
117 "CLI not found locally, installing via npm"
118 );
119
120 {
121 // Verify npm is available before attempting install.
122 let npm_available = if cfg!(windows) {
123 // CREATE_NO_WINDOW (0x08000000) suppresses cmd flash —
124 // see broader fix in this file's other spawns.
125 let mut probe = tokio::process::Command::new("where");
126 probe.arg("npm");
127 #[cfg(windows)]
128 {
129 use std::os::windows::process::CommandExt;
130 probe.creation_flags(0x08000000);
131 }
132 probe.output().await.map(|o| o.status.success()).unwrap_or(false)
133 } else {
134 tokio::process::Command::new("which").arg("npm").output().await
135 .map(|o| o.status.success()).unwrap_or(false)
136 };
137 if !npm_available {
138 return Err(format!(
139 "{} requires Node.js/npm to install. \
140 Install Node.js from https://nodejs.org then restart AgentMux.",
141 cmd.cli_command
142 ));
143 }
144
145 // Use `npm install --prefix <dir> <pkg>@<ver>` to avoid cd+chaining issues.
146 // On Windows, normalize the prefix path to backslashes so npm handles it correctly.
147 // npm.cmd must be invoked via cmd /C on Windows — it's a batch script, not an exe.
148 let prefix_dir = if cfg!(windows) {
149 provider_dir.replace('/', "\\")
150 } else {
151 provider_dir.clone()
152 };
153 let package_arg = format!("{}@{}", cmd.npm_package, cmd.pinned_version);
154 tracing::info!(package = %package_arg, prefix = %prefix_dir, "running npm install");
155
156 // Collect all npm output after completion via .output().
157 // Pipe-based streaming (both async IOCP and sync blocking) does not receive
158 // data from cmd.exe /C batch script children on Windows — output only becomes
159 // available after the process exits. We run in spawn_blocking and publish all
160 // lines at once when done; users see the full install log after it completes.
161 let block_id_install = cmd.block_id.clone();
162 tracing::info!(block_id = %block_id_install, package = %package_arg, prefix = %prefix_dir, "running npm install");
163
164 let broker_npm = broker.clone();
165 let exit_status = tokio::task::spawn_blocking(move || {
166 let result = {
167 #[cfg(windows)]
168 {
169 // npm on Windows is a .cmd batch script — must be invoked via cmd.exe /C.
170 // Use raw_arg to pass the command string WITHOUT Rust's CreateProcess
171 // quoting. With .args(["/C", str]), Rust wraps str in outer quotes and
172 // escapes inner quotes as \", which cmd.exe treats as literal backslash+quote,
173 // corrupting paths: CWD + \"C:\path\" → ENOENT.
174 // raw_arg passes the string verbatim; cmd.exe sees:
175 // cmd /C npm install ... --prefix "C:\path with spaces\..." pkg
176 // and tokenizes "..." as a quoted path correctly.
177 use std::os::windows::process::CommandExt;
178 // CREATE_NO_WINDOW (0x08000000): suppress the
179 // brief cmd.exe console flash that Windows
180 // shows by default when CreateProcess is
181 // called from a GUI process. Without this
182 // flag the user sees a black console
183 // window pop and disappear during npm
184 // install — observed during workspace
185 // setup paths (e.g. tear-off triggering
186 // CLI install on first agent block).
187 const CREATE_NO_WINDOW: u32 = 0x08000000;
188 let npm_cmd_str = format!(
189 "npm install --loglevel=http --no-audit --no-fund --no-progress --prefix \"{}\" {}",
190 prefix_dir, package_arg
191 );
192 std::process::Command::new("cmd")
193 .arg("/C")
194 .raw_arg(&npm_cmd_str)
195 .creation_flags(CREATE_NO_WINDOW)
196 .env("CI", "true")
197 .env("FORCE_COLOR", "0")
198 .output()
199 }
200 #[cfg(not(windows))]
201 {
202 std::process::Command::new("npm")
203 .args(["install", "--loglevel=http", "--no-audit", "--no-fund", "--no-progress", "--prefix", &prefix_dir, &package_arg])
204 .env("CI", "true")
205 .env("FORCE_COLOR", "0")
206 .output()
207 }
208 };
209 match result {
210 Ok(out) => {
211 tracing::info!(exit_code = out.status.code().unwrap_or(-1), stdout_bytes = out.stdout.len(), stderr_bytes = out.stderr.len(), "npm install output collected");
212 // Publish stderr first (npm writes progress/errors there), then stdout
213 for line in String::from_utf8_lossy(&out.stderr).lines() {
214 if !line.trim().is_empty() {
215 tracing::info!(line = %line, "npm stderr");
216 if !block_id_install.is_empty() {
217 crate::backend::wps::publish_install_progress(&broker_npm, &block_id_install, line);
218 }
219 }
220 }
221 for line in String::from_utf8_lossy(&out.stdout).lines() {
222 if !line.trim().is_empty() {
223 tracing::info!(line = %line, "npm stdout");
224 if !block_id_install.is_empty() {
225 crate::backend::wps::publish_install_progress(&broker_npm, &block_id_install, line);
226 }
227 }
228 }
229 Ok(out.status)
230 }
231 Err(e) => Err(format!("failed to run npm install: {e}")),
232 }
233 }).await
234 .map_err(|e| format!("npm spawn_blocking panicked: {e}"))?
235 .map_err(|e| e)?;
236 tracing::info!(exit_code = exit_status.code().unwrap_or(-1), "npm install completed");
237
238 if !exit_status.success() {
239 return Err(agentmux_common::AgentMuxError::NpmInstallFailed {
240 package: format!("{}@{}", cmd.npm_package, cmd.pinned_version),
241 message: format!(
242 "exit {}; check the output above",
243 exit_status.code().unwrap_or(-1)
244 ),
245 }
246 .to_wire()
247 .to_string());
248 }
249
250 // Verify npm binary exists
251 if std::path::Path::new(&npm_bin).exists() {
252 let version = get_cli_version(&npm_bin).await;
253 tracing::info!(path = %npm_bin, version = %version, "CLI installed (npm)");
254 return Ok(Some(serde_json::to_value(&ResolveCliResult {
255 cli_path: npm_bin,
256 version,
257 source: "installed".to_string(),
258 }).unwrap()));
259 }
260
261 Err(agentmux_common::AgentMuxError::CliShimMissing {
262 provider: cmd.provider_id.clone(),
263 expected_path: npm_bin.clone(),
264 }
265 .to_wire()
266 .to_string())
267 }
268 })
269 }),
270 );
271
272 // checkcliauth → check if a CLI tool is authenticated
273 // For Claude: reads ~/.claude/.credentials.json directly (instant, no subprocess).
274 // For other providers: falls back to running the CLI auth check command.
275 engine.register_handler(
276 COMMAND_CHECK_CLI_AUTH,
277 Box::new(|data, _ctx| {
278 Box::pin(async move {
279 let cmd: CommandCheckCliAuthData = serde_json::from_value(data)
280 .map_err(|e| format!("checkcliauth: {e}"))?;
281 tracing::info!(cli = %cmd.cli_path, "CheckCliAuth");
282
283 // Two-phase auth check for Claude; single-phase for other providers.
284 //
285 // Phase 1 (fast, <1 ms): read the credentials file to determine whether
286 // tokens exist at all. If no file / no tokens → return unauthenticated
287 // immediately without spawning the CLI. This avoids a 10+ second cold-start
288 // stall when the user is definitely not logged in.
289 //
290 // Phase 2 (CLI, 10 s timeout): only when tokens ARE present, run
291 // `claude auth status --json` to validate them and obtain the real email.
292 // This catches expired/revoked tokens — the false-positive that the old
293 // file-only fast path missed.
294 //
295 // Other providers skip Phase 1 and go straight to the CLI (they don't have
296 // a predictable credentials file layout).
297 //
298 // See SPEC_AUTH_CHECK_FALSE_POSITIVE_2026_04_15.md.
299 if cmd.cli_path.to_lowercase().contains("claude") {
300 let home = std::env::var("HOME")
301 .or_else(|_| std::env::var("USERPROFILE"))
302 .unwrap_or_default();
303
304 // First-run bootstrap of the SHARED provider auth dir
305 // (~/.agentmux/shared/providers/claude). It is account-wide, so the
306 // user's existing global ~/.claude login is imported into it ONCE,
307 // gated on a sentinel so a later `claude auth logout` in this provider
308 // space sticks. This is a one-time bootstrap of the single shared
309 // auth, NOT per-instance reseeding. Retro:
310 // docs/retro/retro-provider-auth-isolation-regression-2026-06-05.md
311 if let Some(config_dir) = cmd.auth_env.get("CLAUDE_CONFIG_DIR") {
312 let iso = format!("{}/.credentials.json", config_dir);
313 let seeded = format!("{}/.agentmux-cred-seeded", config_dir);
314 let global = format!("{}/.claude/.credentials.json", home);
315 if !std::path::Path::new(&iso).exists()
316 && !std::path::Path::new(&seeded).exists()
317 && std::path::Path::new(&global).exists()
318 {
319 match std::fs::create_dir_all(config_dir)
320 .and_then(|_| std::fs::copy(&global, &iso))
321 {
322 Ok(_) => {
323 let _ = std::fs::write(
324 &seeded,
325 b"imported from global ~/.claude on first run\n",
326 );
327 tracing::info!(
328 "claude auth: imported global ~/.claude into shared provider dir (first run)"
329 );
330 }
331 Err(e) => tracing::warn!(
332 "claude auth: failed to import global creds into shared provider dir: {e}"
333 ),
334 }
335 }
336 }
337
338 // §4 INVARIANT (provider-auth-isolation.md): validate the SAME dir
339 // the agent runs in — the isolated CLAUDE_CONFIG_DIR if set, else
340 // global ~/.claude. NEVER "isolated OR global": that "check global /
341 // run isolated" split is the validate-spin regression's root cause
342 // (phase 2 below runs `claude auth status --json` against this exact
343 // dir, so phase 1 must check the same one).
344 let creds_path = cmd
345 .auth_env
346 .get("CLAUDE_CONFIG_DIR")
347 .map(|d| format!("{}/.credentials.json", d))
348 .unwrap_or_else(|| format!("{}/.claude/.credentials.json", home));
349
350 let tokens_exist = match std::fs::read_to_string(&creds_path) {
351 Ok(content) => serde_json::from_str::<serde_json::Value>(&content)
352 .ok()
353 .map(|json| {
354 let oauth = json.get("claudeAiOauth");
355 let has_token = oauth
356 .and_then(|o| o.get("accessToken"))
357 .and_then(|v| v.as_str())
358 .map(|s| !s.is_empty())
359 .unwrap_or(false);
360 let has_refresh = oauth
361 .and_then(|o| o.get("refreshToken"))
362 .and_then(|v| v.as_str())
363 .map(|s| !s.is_empty())
364 .unwrap_or(false);
365 has_token || has_refresh
366 })
367 .unwrap_or(false),
368 Err(_) => false,
369 };
370
371 if !tokens_exist {
372 // On macOS the Claude CLI stores credentials in the
373 // Keychain ("Claude Safe Storage"), NOT in
374 // .credentials.json — so a missing file does NOT mean
375 // logged out. Fall through to `claude auth status`, which
376 // reads the Keychain (and does so without a prompt — the
377 // CLI owns that Keychain item). On Windows/Linux the file
378 // IS the credential store, so the fast "definitely not
379 // authenticated" short-circuit stays correct there.
380 #[cfg(not(target_os = "macos"))]
381 {
382 tracing::info!("claude auth check: no credentials in provider dir, skipping CLI");
383 let result = CheckCliAuthResult {
384 authenticated: false,
385 email: None,
386 auth_method: None,
387 raw_output: "no credentials found".to_string(),
388 };
389 return Ok(Some(serde_json::to_value(&result).unwrap()));
390 }
391 #[cfg(target_os = "macos")]
392 tracing::info!(
393 "claude auth check: no credentials file — checking Keychain via CLI (macOS)"
394 );
395 } else {
396 // Tokens exist in the provider dir — validate with the CLI (10 s timeout).
397 tracing::info!("claude auth check: credentials found, validating via CLI");
398 }
399 }
400
401 let output = tokio::time::timeout(
402 std::time::Duration::from_secs(10),
403 {
404 let mut check_cmd = make_cli_cmd(&cmd.cli_path);
405 check_cmd.args(&cmd.auth_check_args);
406 for (k, v) in &cmd.auth_env {
407 check_cmd.env(k, v);
408 }
409 // Null stdin: prevents the CLI from blocking on interactive
410 // first-run prompts (onboarding, theme selection, etc.) that
411 // only appear when stdin is a TTY or non-null pipe.
412 check_cmd.stdin(std::process::Stdio::null());
413 check_cmd.output()
414 },
415 ).await
416 .map_err(|_| "auth check timed out (10s)".to_string())?
417 .map_err(|e| format!("failed to run auth check: {e}"))?;
418
419 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
420 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
421
422 let mut email = None;
423 let mut auth_method = None;
424
425 let authenticated = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&stdout) {
426 // Claude outputs `emailAddress`; other CLIs use `email`. Check both.
427 email = json.get("emailAddress")
428 .or_else(|| json.get("email"))
429 .and_then(|v| v.as_str())
430 .map(|s| s.to_string());
431 auth_method = json.get("authMethod")
432 .and_then(|v| v.as_str())
433 .map(|s| s.to_string());
434 json.get("loggedIn")
435 .and_then(|v| v.as_bool())
436 .unwrap_or(false)
437 } else {
438 output.status.success()
439 };
440
441 let raw_output = if !stdout.is_empty() { stdout } else { stderr };
442
443 let result = CheckCliAuthResult {
444 authenticated,
445 email,
446 auth_method,
447 raw_output,
448 };
449 Ok(Some(serde_json::to_value(&result).unwrap()))
450 })
451 }),
452 );
453
454 // runclilogin → spawn CLI login flow, extract OAuth URL from output, return immediately
455 engine.register_handler(
456 "runclilogin",
457 Box::new(|data, _ctx| {
458 Box::pin(async move {
459 let cmd: CommandRunCliLoginData = serde_json::from_value(data)
460 .map_err(|e| format!("runclilogin: {e}"))?;
461 tracing::info!(cli = %cmd.cli_path, args = ?cmd.login_args, "RunCliLogin");
462
463 // Dead path: the active login flow is the CEF host IPC
464 // `run_cli_login`, which owns the child's lifecycle (supersede-kill
465 // + timeout reaper). This srv-side variant previously spawned a
466 // DETACHED, unkillable `auth login` child here — a process leak if
467 // ever invoked. It has no live caller; do NOT spawn.
468 let result = RunCliLoginResult { auth_url: None, raw_output: String::new() };
469 Ok(Some(serde_json::to_value(&result).unwrap()))
470 })
471 }),
472 );
473
474 // toolchain.env — report the environment the srv resolves tools in: the
475 // effective PATH, how it was derived (set by the host/srv PATH enricher,
476 // see SPEC_TOOLCHAIN_MANAGER §3), and OS/arch. Powers the Toolchain
477 // modal's Environment section so PATH problems are diagnosable.
478 engine.register_handler(
479 "toolchain.env",
480 Box::new(|_data, _ctx| {
481 Box::pin(async move {
482 let path = std::env::var("PATH").unwrap_or_default();
483 let path_source =
484 std::env::var("AGENTMUX_PATH_SOURCE").unwrap_or_else(|_| "inherited".to_string());
485 Ok(Some(serde_json::json!({
486 "path": path,
487 "pathSource": path_source,
488 "os": std::env::consts::OS,
489 "arch": std::env::consts::ARCH,
490 })))
491 })
492 }),
493 );
494
495 // widget.health — HTTP liveness probe for an external widget server running
496 // on localhost. The frontend passes { port, health_check_path,
497 // health_check_body_contains? } and gets back { healthy, status_code }.
498 // Connection-refused or timeout → { healthy: false } (not an RPC error).
499 // health_check_body_contains lets callers distinguish services that share
500 // a default port (e.g. Flowise and Grafana both default to 3000).
501 engine.register_handler(
502 "widget.health",
503 Box::new(|data, _ctx| {
504 Box::pin(async move {
505 let port_raw = data.get("port").and_then(|v| v.as_u64()).unwrap_or(0);
506 if port_raw == 0 || port_raw > 65535 {
507 return Ok(Some(serde_json::json!({ "healthy": false, "status_code": null })));
508 }
509 let port = port_raw as u16;
510 let path = data
511 .get("health_check_path")
512 .and_then(|v| v.as_str())
513 .unwrap_or("/")
514 .to_string();
515 let body_contains = data
516 .get("health_check_body_contains")
517 .and_then(|v| v.as_str())
518 .map(|s| s.to_string());
519 let url = format!("http://127.0.0.1:{}{}", port, path);
520 let client = reqwest::Client::builder()
521 .timeout(std::time::Duration::from_secs(3))
522 .build()
523 .map_err(|e| e.to_string())?;
524 match client.get(&url).send().await {
525 Ok(resp) => {
526 let status = resp.status().as_u16();
527 let ok_status = resp.status().is_success();
528 if !ok_status {
529 return Ok(Some(serde_json::json!({ "healthy": false, "status_code": status })));
530 }
531 // Optionally verify response body for service identity.
532 let healthy = if let Some(needle) = body_contains {
533 let body = resp.text().await.unwrap_or_default();
534 body.contains(&needle)
535 } else {
536 true
537 };
538 Ok(Some(serde_json::json!({ "healthy": healthy, "status_code": status })))
539 }
540 Err(_) => Ok(Some(serde_json::json!({ "healthy": false, "status_code": null }))),
541 }
542 })
543 }),
544 );
545
546 // widget.api — HTTP proxy to a widget's local server. Bypasses browser CORS
547 // restrictions: the frontend sends { port, path, method?, headers?, body? }
548 // and gets back { ok, status_code, body, error? }. Agents use this to call
549 // ComfyUI /prompt, Grafana /api/query, etc. without needing a CORS header.
550 // 30-second timeout accommodates generative tasks (image synthesis, etc.).
551 engine.register_handler(
552 "widget.api",
553 Box::new(|data, _ctx| {
554 Box::pin(async move {
555 let port_raw = data.get("port").and_then(|v| v.as_u64()).unwrap_or(0);
556 if port_raw == 0 || port_raw > 65535 {
557 return Ok(Some(serde_json::json!({
558 "ok": false, "status_code": null, "body": null,
559 "error": "invalid port"
560 })));
561 }
562 let port = port_raw as u16;
563 let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string();
564 // Reject paths that could escape localhost: must start with '/',
565 // no '@' (user-info injection: 127.0.0.1@evil.com), no backslash,
566 // no protocol-relative '//' prefix.
567 if !path.starts_with('/') || path.contains('@') || path.contains('\\') || path.starts_with("//") {
568 return Ok(Some(serde_json::json!({
569 "ok": false, "status_code": null, "body": null,
570 "error": "invalid path"
571 })));
572 }
573 let method = data
574 .get("method")
575 .and_then(|v| v.as_str())
576 .unwrap_or("GET")
577 .to_uppercase();
578 let body_str = data.get("body").and_then(|v| v.as_str()).map(|s| s.to_string());
579 let headers_obj = data.get("headers").and_then(|v| v.as_object()).cloned();
580
581 let url = format!("http://127.0.0.1:{}{}", port, path);
582 let client = reqwest::Client::builder()
583 .timeout(std::time::Duration::from_secs(30))
584 .redirect(reqwest::redirect::Policy::none())
585 .build()
586 .map_err(|e| e.to_string())?;
587
588 let mut req = match method.as_str() {
589 "POST" => client.post(&url),
590 "PUT" => client.put(&url),
591 "DELETE" => client.delete(&url),
592 "PATCH" => client.patch(&url),
593 _ => client.get(&url),
594 };
595
596 if let Some(headers) = headers_obj {
597 for (k, v) in &headers {
598 if let Some(vs) = v.as_str() {
599 if let (Ok(hn), Ok(hv)) = (
600 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
601 reqwest::header::HeaderValue::from_str(vs),
602 ) {
603 req = req.header(hn, hv);
604 }
605 }
606 }
607 }
608
609 if let Some(body) = body_str {
610 req = req
611 .header(reqwest::header::CONTENT_TYPE, "application/json")
612 .body(body);
613 }
614
615 match req.send().await {
616 Ok(resp) => {
617 let status = resp.status().as_u16();
618 let body = resp.text().await.unwrap_or_default();
619 Ok(Some(serde_json::json!({ "ok": true, "status_code": status, "body": body })))
620 }
621 Err(e) => Ok(Some(serde_json::json!({
622 "ok": false, "status_code": null, "body": null,
623 "error": e.to_string()
624 }))),
625 }
626 })
627 }),
628 );
629}
630
631/// Re-export from shared crate for internal use.
632pub(crate) fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
633 agentmux_common::make_cli_cmd(cli_path)
634}
635
636/// Resolve a CLI command on the system PATH.
637///
638/// Uses `where` on Windows and `which` on Unix. Returns the absolute path
639/// if the command is found and exists, otherwise `None`.
640///
641/// On Windows we pass the bare command name (no `.cmd` suffix) so that
642/// `where` resolves the correct extension via PATHEXT. This correctly finds
643/// `docker.exe`, `git.exe`, `node.exe` AND `npm.cmd` — previously the
644/// hard-coded `.cmd` suffix caused all `.exe`-based tools (docker, git,
645/// node) to report as not installed even when present on PATH.
646///
647/// `where` can return multiple lines (e.g. Node.js ships both an extensionless
648/// `npm` Unix shell script and `npm.cmd` on Windows). We filter to the first
649/// line whose extension `make_cli_cmd` can actually spawn (.exe / .cmd / .bat).
650/// Taking the raw first line would yield the extensionless entry, which
651/// `Command::new` cannot run on Windows without a shell.
652pub(crate) async fn resolve_cli_on_path(cli_command: &str) -> Option<String> {
653 let which_result = if cfg!(windows) {
654 let mut probe = tokio::process::Command::new("where");
655 probe.arg(cli_command);
656 #[cfg(windows)]
657 {
658 use std::os::windows::process::CommandExt;
659 probe.creation_flags(0x08000000);
660 }
661 probe.output().await
662 } else {
663 tokio::process::Command::new("which").arg(cli_command).output().await
664 };
665 if let Ok(out) = which_result {
666 if out.status.success() {
667 let stdout_str = String::from_utf8_lossy(&out.stdout);
668 #[cfg(windows)]
669 let path: &str = stdout_str
670 .lines()
671 .map(str::trim)
672 .find(|l| {
673 let lo = l.to_lowercase();
674 lo.ends_with(".exe") || lo.ends_with(".cmd") || lo.ends_with(".bat")
675 })
676 .unwrap_or("");
677 #[cfg(not(windows))]
678 let path: &str = stdout_str.lines().next().unwrap_or("").trim();
679 if !path.is_empty() && std::path::Path::new(path).exists() {
680 return Some(path.to_string());
681 }
682 }
683 }
684 None
685}
686
687async fn get_cli_version(cli_path: &str) -> String {
688 let result = tokio::time::timeout(
689 std::time::Duration::from_secs(5),
690 {
691 let mut c = make_cli_cmd(cli_path);
692 c.arg("--version").stdin(std::process::Stdio::null());
693 c.output()
694 },
695 ).await;
696 match result {
697 Ok(Ok(output)) if output.status.success() => {
698 String::from_utf8_lossy(&output.stdout).trim().to_string()
699 }
700 Ok(_) => "unknown".to_string(),
701 Err(_) => {
702 tracing::warn!(cli_path = %cli_path, "get_cli_version timed out after 5s");
703 "unknown".to_string()
704 }
705 }
706}