diff --git a/src/ClaudeDo.Data/Environment/ExecutableResolver.cs b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs new file mode 100644 index 00000000..a859dc14 --- /dev/null +++ b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs @@ -0,0 +1,112 @@ +using SysEnvironment = System.Environment; + +namespace ClaudeDo.Data.Environment; + +public sealed record ResolvedExecutable(string Path, bool IsShim); + +public sealed record ShimStartInfo(string FileName, string Arguments); + +/// +/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds +/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss. +/// +public static class ExecutableResolver +{ + private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD"; + + // Known npm/claude install locations to try when PATH search comes up empty. + private static readonly string[] FallbackDirectoryTemplates = + { + "%APPDATA%\\npm", + "%LOCALAPPDATA%\\Programs\\claude", + "%USERPROFILE%\\.local\\bin", + }; + + public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null) + { + var pathExts = ParsePathExt(pathExtOverride); + + if (LooksLikePath(command)) + { + return ResolveAsPath(command, pathExts); + } + + var directories = ParsePath(pathOverride); + foreach (var dir in directories) + { + var resolved = ResolveInDirectory(dir, command, pathExts); + if (resolved is not null) return resolved; + } + + foreach (var template in FallbackDirectoryTemplates) + { + var dir = SysEnvironment.ExpandEnvironmentVariables(template); + var resolved = ResolveInDirectory(dir, command, pathExts); + if (resolved is not null) return resolved; + } + + return null; + } + + public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList arguments) + { + var parts = new List { "/c", Quote(shimPath) }; + parts.AddRange(arguments.Select(Quote)); + return new ShimStartInfo("cmd.exe", string.Join(' ', parts)); + } + + private static bool LooksLikePath(string command) => + command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar); + + private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList pathExts) + { + if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command))); + + if (Path.HasExtension(command)) return null; + + foreach (var ext in pathExts) + { + var candidate = command + ext; + if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext)); + } + + return null; + } + + private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList pathExts) + { + if (!Directory.Exists(directory)) return null; + + if (Path.HasExtension(command)) + { + var candidate = Path.Combine(directory, command); + return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null; + } + + foreach (var ext in pathExts) + { + var candidate = Path.Combine(directory, command + ext); + if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext)); + } + + return null; + } + + private static bool IsShimExtension(string extension) => + !extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".com", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList ParsePathExt(string? pathExtOverride) + { + var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt; + return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static IReadOnlyList ParsePath(string? pathOverride) + { + var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? ""; + return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value; +} diff --git a/src/ClaudeDo.Data/Paths.cs b/src/ClaudeDo.Data/Paths.cs index c55037fa..093caa4f 100644 --- a/src/ClaudeDo.Data/Paths.cs +++ b/src/ClaudeDo.Data/Paths.cs @@ -11,16 +11,16 @@ public static class Paths if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Path must not be empty.", nameof(path)); - var expanded = Environment.ExpandEnvironmentVariables(path); + var expanded = System.Environment.ExpandEnvironmentVariables(path); if (expanded.StartsWith("~", StringComparison.Ordinal)) { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); expanded = home + expanded[1..]; } if (!Path.IsPathRooted(expanded)) - expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory); + expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory); return Path.GetFullPath(expanded); } diff --git a/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs b/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs index 1839e19d..26ceeda2 100644 --- a/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs +++ b/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using ClaudeDo.Data.Environment; namespace ClaudeDo.Worker.Lifecycle; @@ -8,17 +9,23 @@ public static class ClaudeCliPreflight public static async Task CheckAsync(string claudeBin, CancellationToken ct = default) { + var resolved = ExecutableResolver.Resolve(claudeBin); + if (resolved is null) + return new Result(false, "", $"'{claudeBin}' not found on PATH.", -1); + try { - var psi = new ProcessStartInfo - { - FileName = claudeBin, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; + var psi = resolved.IsShim + ? BuildShimPsi(resolved.Path) + : new ProcessStartInfo + { + FileName = resolved.Path, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; using var proc = Process.Start(psi); if (proc is null) return new Result(false, "", "Process.Start returned null", -1); @@ -35,4 +42,18 @@ public static class ClaudeCliPreflight return new Result(false, "", ex.Message, -1); } } + + private static ProcessStartInfo BuildShimPsi(string shimPath) + { + var shim = ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "--version" }); + return new ProcessStartInfo + { + FileName = shim.FileName, + Arguments = shim.Arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + } } diff --git a/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs b/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs index d94b10ac..0ecd94b5 100644 --- a/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs +++ b/src/ClaudeDo.Worker/Runner/ClaudeProcess.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using ClaudeDo.Data.Environment; using ClaudeDo.Worker.Config; namespace ClaudeDo.Worker.Runner; @@ -24,9 +25,11 @@ public sealed class ClaudeProcess : IClaudeProcess Func onStdoutLine, CancellationToken ct) { + var resolved = ExecutableResolver.Resolve(_cfg.ClaudeBin) + ?? throw new InvalidOperationException($"'{_cfg.ClaudeBin}' not found on PATH."); + var psi = new ProcessStartInfo { - FileName = _cfg.ClaudeBin, WorkingDirectory = workingDirectory, RedirectStandardInput = true, RedirectStandardOutput = true, @@ -36,6 +39,20 @@ public sealed class ClaudeProcess : IClaudeProcess StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; + + // A shim (.cmd/.bat) can't be launched directly with UseShellExecute=false; + // route it through cmd.exe /c so the real interpreter starts the target. + if (resolved.IsShim) + { + psi.FileName = "cmd.exe"; + psi.ArgumentList.Add("/c"); + psi.ArgumentList.Add(resolved.Path); + } + else + { + psi.FileName = resolved.Path; + } + foreach (var arg in arguments) psi.ArgumentList.Add(arg); diff --git a/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs b/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs new file mode 100644 index 00000000..2160379a --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs @@ -0,0 +1,127 @@ +using ClaudeDo.Data.Environment; + +namespace ClaudeDo.Data.Tests; + +public sealed class ExecutableResolverTests : IDisposable +{ + private readonly string _root; + + public ExecutableResolverTests() + { + _root = Path.Combine(Path.GetTempPath(), $"claudedo_exeresolve_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private string MakeDir(string name) + { + var dir = Path.Combine(_root, name); + Directory.CreateDirectory(dir); + return dir; + } + + private static void Touch(string path) => File.WriteAllText(path, ""); + + [Fact] + public void Command_with_directory_separator_is_treated_as_path() + { + var dir = MakeDir("bin1"); + var exePath = Path.Combine(dir, "myclaude.exe"); + Touch(exePath); + + var result = ExecutableResolver.Resolve(exePath, pathOverride: "C:\\does\\not\\matter"); + + Assert.NotNull(result); + Assert.Equal(exePath, result!.Path); + Assert.False(result.IsShim); + } + + [Fact] + public void Path_like_command_without_extension_resolves_via_pathext() + { + var dir = MakeDir("bin2"); + var cmdPath = Path.Combine(dir, "myclaude.cmd"); + Touch(cmdPath); + var commandWithoutExt = Path.Combine(dir, "myclaude"); + + var result = ExecutableResolver.Resolve(commandWithoutExt, pathExtOverride: ".com;.exe;.bat;.cmd"); + + Assert.NotNull(result); + Assert.Equal(cmdPath, result!.Path); + Assert.True(result.IsShim); + } + + [Fact] + public void Cmd_file_in_path_is_found_when_no_exe_exists() + { + var dir = MakeDir("bin3"); + Touch(Path.Combine(dir, "claude.cmd")); + + var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd"); + + Assert.NotNull(result); + Assert.Equal(Path.Combine(dir, "claude.cmd"), result!.Path); + Assert.True(result.IsShim); + } + + [Fact] + public void Exe_wins_against_cmd_in_same_directory() + { + var dir = MakeDir("bin4"); + Touch(Path.Combine(dir, "claude.cmd")); + Touch(Path.Combine(dir, "claude.exe")); + + var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd"); + + Assert.NotNull(result); + Assert.Equal(Path.Combine(dir, "claude.exe"), result!.Path); + Assert.False(result.IsShim); + } + + [Fact] + public void Directory_order_in_path_is_respected() + { + var dir1 = MakeDir("bin5a"); + var dir2 = MakeDir("bin5b"); + Touch(Path.Combine(dir1, "claude.cmd")); + Touch(Path.Combine(dir2, "claude.exe")); + + var pathOverride = $"{dir1}{Path.PathSeparator}{dir2}"; + var result = ExecutableResolver.Resolve("claude", pathOverride: pathOverride, pathExtOverride: ".com;.exe;.bat;.cmd"); + + Assert.NotNull(result); + Assert.Equal(Path.Combine(dir1, "claude.cmd"), result!.Path); + Assert.True(result.IsShim); + } + + [Fact] + public void Unresolvable_command_returns_null() + { + var dir = MakeDir("bin6"); + + var result = ExecutableResolver.Resolve( + "claudedo_totally_missing_cmd_9f3a1", + pathOverride: dir, + pathExtOverride: ".com;.exe;.bat;.cmd"); + + Assert.Null(result); + } + + [Fact] + public void BuildShimStartInfo_uses_cmd_exe_and_quotes_paths_with_spaces() + { + const string shimPath = @"C:\Program Files\claude\claude.cmd"; + + var startInfo = ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "--version", "arg2" }); + + Assert.Equal("cmd.exe", startInfo.FileName); + Assert.Contains("/c", startInfo.Arguments); + Assert.Contains($"\"{shimPath}\"", startInfo.Arguments); + Assert.Contains("--version", startInfo.Arguments); + Assert.Contains("arg2", startInfo.Arguments); + } +}