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