agentmux_common/
cli.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4/// Resolved target of a Windows `.cmd` npm shim.
5#[cfg(windows)]
6enum ResolvedShim {
7    /// Shim invokes a Node.js script; run via `node <path>`.
8    NodeScript(String),
9    /// Shim invokes a native executable directly; run via `<path>`.
10    /// Seen on @anthropic-ai/claude-code v2+, which ships a prebuilt
11    /// `claude.exe` instead of a JS entry point.
12    Executable(String),
13}
14
15/// Create a Command for a CLI binary.
16///
17/// On Windows, npm-generated `.cmd` batch wrappers cannot be reliably spawned
18/// via `cmd.exe /C` when stdio is piped — arguments get dropped, output is lost,
19/// and the underlying CLI never executes. Instead, we parse the `.cmd` file to
20/// extract the real entry point and invoke it directly (either `node <script>`
21/// for JS shims or the target `.exe` directly for native-binary shims).
22pub fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
23    #[cfg(windows)]
24    if cli_path.ends_with(".cmd") || cli_path.ends_with(".bat") {
25        match parse_cmd_wrapper(cli_path) {
26            Some(ResolvedShim::NodeScript(entry_script)) => {
27                tracing::debug!(cmd = %cli_path, script = %entry_script, "resolved .cmd → node");
28                let mut c = tokio::process::Command::new("node");
29                c.arg(&entry_script);
30                return c;
31            }
32            Some(ResolvedShim::Executable(exe_path)) => {
33                tracing::debug!(cmd = %cli_path, exe = %exe_path, "resolved .cmd → native .exe");
34                return tokio::process::Command::new(&exe_path);
35            }
36            None => {
37                tracing::warn!(cmd = %cli_path, "could not parse .cmd wrapper, falling back to cmd.exe /C");
38                let mut c = tokio::process::Command::new("cmd.exe");
39                c.args(["/C", cli_path]);
40                return c;
41            }
42        }
43    }
44    tokio::process::Command::new(cli_path)
45}
46
47/// Parse an npm-generated `.cmd` wrapper to extract the real entry point.
48///
49/// npm `.cmd` wrappers contain a line like one of:
50///   `"%_prog%"  "%dp0%\..\@anthropic-ai\claude-code\cli.js" %*`   (JS shim)
51///   `"%dp0%\..\@anthropic-ai\claude-code\bin\claude.exe"   %*`    (native .exe shim)
52/// where `%dp0%` is the directory containing the `.cmd` file itself. We extract
53/// the relative path after `%dp0%\`, resolve it to an absolute path, and tag it
54/// as either a Node script (`.js/.mjs/.cjs`) or a native executable (`.exe`).
55#[cfg(windows)]
56fn parse_cmd_wrapper(cmd_path: &str) -> Option<ResolvedShim> {
57    let content = std::fs::read_to_string(cmd_path).ok()?;
58    let cmd_dir = std::path::Path::new(cmd_path).parent()?;
59
60    for line in content.lines() {
61        let line_trimmed = line.trim();
62        if !line_trimmed.contains("%dp0%") || !line_trimmed.contains("%*") {
63            continue;
64        }
65        if let Some(dp0_idx) = line_trimmed.find("%dp0%\\") {
66            let after_dp0 = &line_trimmed[dp0_idx + 6..]; // skip "%dp0%\"
67            let end = after_dp0.find('"')
68                .or_else(|| after_dp0.find(" %*"))
69                .unwrap_or(after_dp0.len());
70            let relative_path = &after_dp0[..end];
71            let is_node_script = relative_path.ends_with(".js")
72                || relative_path.ends_with(".mjs")
73                || relative_path.ends_with(".cjs");
74            let is_exe = relative_path.ends_with(".exe");
75            if !is_node_script && !is_exe {
76                continue;
77            }
78            let resolved = cmd_dir.join(relative_path);
79            let path_str = match resolved.canonicalize() {
80                Ok(canonical) => {
81                    let mut s = canonical.to_string_lossy().to_string();
82                    // Windows canonicalize() returns \\?\C:\... (UNC extended path)
83                    // which Node.js (and some native binaries) can't handle — strip.
84                    if s.starts_with(r"\\?\") {
85                        s = s[4..].to_string();
86                    }
87                    s
88                }
89                Err(_) => resolved.to_string_lossy().to_string(),
90            };
91            return Some(if is_node_script {
92                ResolvedShim::NodeScript(path_str)
93            } else {
94                ResolvedShim::Executable(path_str)
95            });
96        }
97    }
98    None
99}