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}