agentmux_common/cli.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(windows)]
5use crate::win32::NoWindow;
6
7/// Resolved target of a Windows `.cmd` npm shim.
8#[cfg(windows)]
9enum ResolvedShim {
10 /// Shim invokes a Node.js script; run via `node <path>`.
11 NodeScript(String),
12 /// Shim invokes a native executable directly; run via `<path>`.
13 /// Seen on @anthropic-ai/claude-code v2+, which ships a prebuilt
14 /// `claude.exe` instead of a JS entry point.
15 Executable(String),
16}
17
18/// Create a Command for a CLI binary.
19///
20/// On Windows, npm-generated `.cmd` batch wrappers cannot be reliably spawned
21/// via `cmd.exe /C` when stdio is piped — arguments get dropped, output is lost,
22/// and the underlying CLI never executes. Instead, we parse the `.cmd` file to
23/// extract the real entry point and invoke it directly (either `node <script>`
24/// for JS shims or the target `.exe` directly for native-binary shims).
25pub fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
26 let cmd = build_cli_cmd(cli_path);
27 // CREATE_NO_WINDOW: the host + launcher are GUI-subsystem and srv is
28 // launched windowless, so a console-subsystem child (node, claude.exe,
29 // cmd.exe) spawned without this flag forces Windows to allocate a fresh
30 // console window — the "terminal flash" seen during dev, most visibly
31 // from the ambient Haiku calls that fire as the user types
32 // (server/app_api/session.rs) and the per-check `--version`/auth probes.
33 // Applied at this single chokepoint so every `make_cli_cmd` caller is
34 // covered; idempotent for the agent-CLI callers (persistent/subprocess/
35 // acp) that already set it manually.
36 #[cfg(windows)]
37 let cmd = {
38 let mut cmd = cmd;
39 cmd.no_window();
40 cmd
41 };
42 cmd
43}
44
45/// Resolve `cli_path` to a runnable `Command` (the `.cmd`/`.bat` shim parsing
46/// on Windows). `make_cli_cmd` wraps this to apply `CREATE_NO_WINDOW`.
47fn build_cli_cmd(cli_path: &str) -> tokio::process::Command {
48 // `None` means the `.cmd` shim didn't match either known npm shape — fall
49 // back to spawning the raw path directly. For a `tokio::process::Command`
50 // (piped stdio, no PTY) this has the pre-existing, already-documented
51 // failure mode of npm `.cmd` wrappers (args/output can get lost through
52 // `cmd.exe`'s implicit association) rather than the ConPTY indefinite
53 // hang — see `resolve_cli_spawn_target`'s doc for why that hang is
54 // PTY-specific and why this fallback must NOT be `cmd.exe /C` (that's
55 // exactly as broken here as the raw path, so there's nothing to gain by
56 // routing through it explicitly).
57 let (program, args) = resolve_cli_spawn_target(cli_path)
58 .unwrap_or_else(|| (cli_path.to_string(), Vec::new()));
59 let mut cmd = tokio::process::Command::new(&program);
60 cmd.args(&args);
61 cmd
62}
63
64/// Resolve `cli_path` to the actual `(program, args)` spawn target — the
65/// `.cmd`/`.bat` shim parsing on Windows, factored out of `build_cli_cmd` so
66/// non-`tokio::process::Command` spawners (e.g. `portable_pty::CommandBuilder`,
67/// which `run_cli_login_pty` uses) can reuse the same resolution instead of
68/// spawning the raw shim path directly. Spawning a `.cmd` file straight
69/// through `CommandBuilder` forces Windows through `cmd.exe /c`, and that
70/// layer hangs indefinitely under a real ConPTY (confirmed live: 0% CPU, zero
71/// output, target dir never created) — this is the fix for that class of bug,
72/// not just a refactor for its own sake.
73///
74/// Returns `None` when `cli_path` is a `.cmd`/`.bat` shim that doesn't match
75/// either known npm shape (Node-script or native-exe). A previous version
76/// fell back to `("cmd.exe", ["/C", cli_path])` here, but that's the exact
77/// invocation this function exists to avoid — under a ConPTY it hangs just
78/// as indefinitely as the raw shim path did, silently reintroducing the bug
79/// for any unrecognized shim shape (reagent P2 on PR review). Callers must
80/// decide how to handle an unresolvable shim themselves: `build_cli_cmd`
81/// (piped stdio, no ConPTY) falls back to the raw path, which loses some
82/// stdio fidelity but doesn't hang; `run_cli_login_pty` (real ConPTY) must
83/// fail fast instead of attempting a doomed spawn.
84pub fn resolve_cli_spawn_target(cli_path: &str) -> Option<(String, Vec<String>)> {
85 #[cfg(windows)]
86 if cli_path.ends_with(".cmd") || cli_path.ends_with(".bat") {
87 return match parse_cmd_wrapper(cli_path) {
88 Some(ResolvedShim::NodeScript(entry_script)) => {
89 tracing::debug!(cmd = %cli_path, script = %entry_script, "resolved .cmd → node");
90 Some(("node".to_string(), vec![entry_script]))
91 }
92 Some(ResolvedShim::Executable(exe_path)) => {
93 tracing::debug!(cmd = %cli_path, exe = %exe_path, "resolved .cmd → native .exe");
94 Some((exe_path, Vec::new()))
95 }
96 None => {
97 tracing::warn!(cmd = %cli_path, "could not parse .cmd wrapper — no safe spawn target");
98 None
99 }
100 };
101 }
102 Some((cli_path.to_string(), Vec::new()))
103}
104
105/// Parse an npm-generated `.cmd` wrapper to extract the real entry point.
106///
107/// npm `.cmd` wrappers contain a line like one of:
108/// `"%_prog%" "%dp0%\..\@anthropic-ai\claude-code\cli.js" %*` (JS shim)
109/// `"%dp0%\..\@anthropic-ai\claude-code\bin\claude.exe" %*` (native .exe shim)
110/// where `%dp0%` is the directory containing the `.cmd` file itself. We extract
111/// the relative path after `%dp0%\`, resolve it to an absolute path, and tag it
112/// as either a Node script (`.js/.mjs/.cjs`) or a native executable (`.exe`).
113#[cfg(windows)]
114fn parse_cmd_wrapper(cmd_path: &str) -> Option<ResolvedShim> {
115 let content = std::fs::read_to_string(cmd_path).ok()?;
116 let cmd_dir = std::path::Path::new(cmd_path).parent()?;
117
118 for line in content.lines() {
119 let line_trimmed = line.trim();
120 if !line_trimmed.contains("%dp0%") || !line_trimmed.contains("%*") {
121 continue;
122 }
123 if let Some(dp0_idx) = line_trimmed.find("%dp0%\\") {
124 let after_dp0 = &line_trimmed[dp0_idx + 6..]; // skip "%dp0%\"
125 let end = after_dp0.find('"')
126 .or_else(|| after_dp0.find(" %*"))
127 .unwrap_or(after_dp0.len());
128 let relative_path = &after_dp0[..end];
129 let is_node_script = relative_path.ends_with(".js")
130 || relative_path.ends_with(".mjs")
131 || relative_path.ends_with(".cjs");
132 let is_exe = relative_path.ends_with(".exe");
133 if !is_node_script && !is_exe {
134 continue;
135 }
136 let resolved = cmd_dir.join(relative_path);
137 let path_str = match resolved.canonicalize() {
138 Ok(canonical) => {
139 let mut s = canonical.to_string_lossy().to_string();
140 // Windows canonicalize() returns \\?\C:\... (UNC extended path)
141 // which Node.js (and some native binaries) can't handle — strip.
142 if s.starts_with(r"\\?\") {
143 s = s[4..].to_string();
144 }
145 s
146 }
147 Err(_) => resolved.to_string_lossy().to_string(),
148 };
149 return Some(if is_node_script {
150 ResolvedShim::NodeScript(path_str)
151 } else {
152 ResolvedShim::Executable(path_str)
153 });
154 }
155 }
156 None
157}
158
159#[cfg(all(test, windows))]
160mod tests {
161 use super::*;
162
163 /// Real Claude Code v2+ `.cmd` content (`agentmux-instances/.../claude/
164 /// node_modules/.bin/claude.cmd`, verified live) — ships a prebuilt
165 /// native `claude.exe`, no Node entry point. This exact shape is what
166 /// made `run_cli_login_pty` hang under ConPTY when spawned raw.
167 const CLAUDE_EXE_SHIM: &str = concat!(
168 "@ECHO off\r\n",
169 "GOTO start\r\n",
170 ":find_dp0\r\n",
171 "SET dp0=%~dp0\r\n",
172 "EXIT /b\r\n",
173 ":start\r\n",
174 "SETLOCAL\r\n",
175 "CALL :find_dp0\r\n",
176 "\"%dp0%\\..\\@anthropic-ai\\claude-code\\bin\\claude.exe\" %*\r\n",
177 );
178
179 /// Representative JS-entry-point npm shim shape (e.g. OpenClaw), the
180 /// other real shape `resolve_cli_spawn_target` must handle.
181 const NODE_SCRIPT_SHIM: &str = concat!(
182 "@ECHO off\r\n",
183 "GOTO start\r\n",
184 ":find_dp0\r\n",
185 "SET dp0=%~dp0\r\n",
186 "EXIT /b\r\n",
187 ":start\r\n",
188 "SETLOCAL\r\n",
189 "\"%_prog%\" \"%dp0%\\..\\openclaw\\cli.js\" %*\r\n",
190 );
191
192 /// Lay out a `<root>/node_modules/.bin/<name>.cmd` shim pointing at
193 /// `<root>/node_modules/<pkg_rel>` — the real npm install shape
194 /// `parse_cmd_wrapper` expects. Target files are NOT created; canonicalize
195 /// falls back to the non-canonical path (see `parse_cmd_wrapper`'s `Err`
196 /// arm), so a bare `.cmd` fixture is enough — no need to fake the CLI
197 /// binary/script itself.
198 fn write_shim(dir: &std::path::Path, name: &str, content: &str) -> String {
199 let bin_dir = dir.join("node_modules").join(".bin");
200 std::fs::create_dir_all(&bin_dir).unwrap();
201 let cmd_path = bin_dir.join(format!("{name}.cmd"));
202 std::fs::write(&cmd_path, content).unwrap();
203 cmd_path.to_string_lossy().to_string()
204 }
205
206 #[test]
207 fn resolves_native_exe_shim_directly_bypassing_cmd_exe() {
208 let dir = tempfile::tempdir().unwrap();
209 let cmd_path = write_shim(dir.path(), "claude", CLAUDE_EXE_SHIM);
210
211 let (program, args) = resolve_cli_spawn_target(&cmd_path).expect("parseable shim must resolve");
212
213 assert!(
214 program.ends_with("claude.exe") || program.ends_with("claude-code\\bin\\claude.exe"),
215 "expected the resolved native exe, got: {program}"
216 );
217 assert!(args.is_empty(), "exe shim must not prefix extra args, got: {args:?}");
218 assert_ne!(program, "cmd.exe", "must not fall back to spawning cmd.exe /C for a parseable shim");
219 }
220
221 #[test]
222 fn resolves_node_script_shim_to_node_plus_script_arg() {
223 let dir = tempfile::tempdir().unwrap();
224 let cmd_path = write_shim(dir.path(), "openclaw", NODE_SCRIPT_SHIM);
225
226 let (program, args) = resolve_cli_spawn_target(&cmd_path).expect("parseable shim must resolve");
227
228 assert_eq!(program, "node");
229 assert_eq!(args.len(), 1, "expected exactly the script path as the sole arg: {args:?}");
230 assert!(args[0].ends_with("cli.js"), "expected the resolved script path, got: {args:?}");
231 }
232
233 #[test]
234 fn non_cmd_path_passes_through_unchanged() {
235 let (program, args) =
236 resolve_cli_spawn_target(r"C:\some\native\tool.exe").expect("non-.cmd path always resolves");
237 assert_eq!(program, r"C:\some\native\tool.exe");
238 assert!(args.is_empty());
239 }
240
241 /// An unparseable `.cmd` shim must resolve to `None`, not a `cmd.exe /C`
242 /// fallback — that fallback is exactly the invocation this resolver
243 /// exists to avoid, and hangs just as indefinitely under a real ConPTY
244 /// as the raw shim path did (reagent P2 on PR #2422 review). Callers
245 /// decide their own fallback: `build_cli_cmd` uses the raw path (piped
246 /// stdio, no hang risk); `run_cli_login_pty` fails fast instead.
247 #[test]
248 fn unparseable_cmd_resolves_to_none_not_cmd_exe_c() {
249 let dir = tempfile::tempdir().unwrap();
250 let cmd_path = write_shim(dir.path(), "weird", "@ECHO off\r\necho nothing useful here\r\n");
251
252 assert_eq!(
253 resolve_cli_spawn_target(&cmd_path),
254 None,
255 "must not silently fall back to the broken cmd.exe /C invocation"
256 );
257 }
258}