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.
113 lines
4.1 KiB
C#
113 lines
4.1 KiB
C#
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;
|
|
}
|