fix(worker): resolve claude CLI shims (.cmd/.bat) not just .exe on PATH
UseShellExecute=false only appends .exe when searching PATH, so an npm-installed claude.cmd was never found even though it works from a shell. Adds a shared ExecutableResolver in ClaudeDo.Data (PATH/PATHEXT aware, with known npm/claude install-dir fallbacks) and wires it into ClaudeCliPreflight and ClaudeProcess; shims are launched via cmd.exe /c.
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string> arguments)
|
||||||
|
{
|
||||||
|
var parts = new List<string> { "/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<string> 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<string> 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<string> ParsePathExt(string? pathExtOverride)
|
||||||
|
{
|
||||||
|
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
|
||||||
|
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> 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;
|
||||||
|
}
|
||||||
@@ -11,16 +11,16 @@ public static class Paths
|
|||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
throw new ArgumentException("Path must not be empty.", nameof(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))
|
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..];
|
expanded = home + expanded[1..];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Path.IsPathRooted(expanded))
|
if (!Path.IsPathRooted(expanded))
|
||||||
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
|
expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory);
|
||||||
|
|
||||||
return Path.GetFullPath(expanded);
|
return Path.GetFullPath(expanded);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using ClaudeDo.Data.Environment;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Lifecycle;
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
@@ -8,11 +9,17 @@ public static class ClaudeCliPreflight
|
|||||||
|
|
||||||
public static async Task<Result> CheckAsync(string claudeBin, CancellationToken ct = default)
|
public static async Task<Result> 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
|
try
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo
|
var psi = resolved.IsShim
|
||||||
|
? BuildShimPsi(resolved.Path)
|
||||||
|
: new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = claudeBin,
|
FileName = resolved.Path,
|
||||||
Arguments = "--version",
|
Arguments = "--version",
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
@@ -35,4 +42,18 @@ public static class ClaudeCliPreflight
|
|||||||
return new Result(false, "", ex.Message, -1);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using ClaudeDo.Data.Environment;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Runner;
|
namespace ClaudeDo.Worker.Runner;
|
||||||
@@ -24,9 +25,11 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
Func<string, Task> onStdoutLine,
|
Func<string, Task> onStdoutLine,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var resolved = ExecutableResolver.Resolve(_cfg.ClaudeBin)
|
||||||
|
?? throw new InvalidOperationException($"'{_cfg.ClaudeBin}' not found on PATH.");
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = _cfg.ClaudeBin,
|
|
||||||
WorkingDirectory = workingDirectory,
|
WorkingDirectory = workingDirectory,
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
@@ -36,6 +39,20 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
StandardOutputEncoding = Encoding.UTF8,
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
StandardErrorEncoding = 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)
|
foreach (var arg in arguments)
|
||||||
psi.ArgumentList.Add(arg);
|
psi.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user