Merge subtask

This commit is contained in:
mika kuns
2026-08-06 07:54:06 +02:00
5 changed files with 290 additions and 13 deletions
@@ -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;
}
+3 -3
View File
@@ -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);
}
@@ -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<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
{
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,
};
}
}
+18 -1
View File
@@ -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<string, Task> 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);