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}