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);
+ }
+}