using System.ComponentModel; using System.Diagnostics; namespace StellaOps.Interop; public sealed class ToolManager { private readonly string _workDir; private readonly IReadOnlyDictionary _toolPaths; public ToolManager(string workDir, IReadOnlyDictionary? toolPaths = null) { _workDir = workDir; _toolPaths = toolPaths ?? new Dictionary(StringComparer.OrdinalIgnoreCase); } public async Task VerifyToolAsync(string tool, string args, CancellationToken ct = default) { var result = await RunAsync(tool, args, ct).ConfigureAwait(false); if (!result.Success) { throw new ToolExecutionException( $"Tool '{tool}' not available or failed to run.", result); } } public async Task RunAsync(string tool, string args, CancellationToken ct = default) { var toolPath = ResolveToolPath(tool); if (toolPath is null) { return ToolResult.Failed($"Tool not found: {tool}"); } var startInfo = new ProcessStartInfo { FileName = toolPath, Arguments = args, WorkingDirectory = _workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; try { using var process = Process.Start(startInfo); if (process is null) { return ToolResult.Failed($"Failed to start tool: {tool}"); } var stdOutTask = process.StandardOutput.ReadToEndAsync(ct); var stdErrTask = process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct).ConfigureAwait(false); var stdout = await stdOutTask.ConfigureAwait(false); var stderr = await stdErrTask.ConfigureAwait(false); return process.ExitCode == 0 ? ToolResult.Ok(stdout, stderr, process.ExitCode) : ToolResult.Failed(stderr, stdout, process.ExitCode); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) { return ToolResult.Failed(ex.Message); } } public bool IsToolAvailable(string tool) => ResolveToolPath(tool) is not null; public string? ResolveToolPath(string tool) { if (_toolPaths.TryGetValue(tool, out var configured) && File.Exists(configured)) { return configured; } return FindOnPath(tool); } public static string? FindOnPath(string tool) { if (File.Exists(tool)) { return Path.GetFullPath(tool); } var path = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrWhiteSpace(path)) { return null; } foreach (var dir in path.Split(Path.PathSeparator)) { if (string.IsNullOrWhiteSpace(dir)) { continue; } var candidate = Path.Combine(dir, tool); if (File.Exists(candidate)) { return candidate; } if (OperatingSystem.IsWindows()) { var exeCandidate = candidate + ".exe"; if (File.Exists(exeCandidate)) { return exeCandidate; } } } return null; } } public sealed record ToolResult( bool Success, int ExitCode, string StdOut, string StdErr, string? Error) { public static ToolResult Ok(string stdout, string stderr, int exitCode) => new(true, exitCode, stdout, stderr, null); public static ToolResult Failed(string error) => new(false, -1, string.Empty, string.Empty, error); public static ToolResult Failed(string error, string stdout, int exitCode) => new(false, exitCode, stdout, error, error); } public sealed class ToolExecutionException : Exception { public ToolExecutionException(string message, ToolResult result) : base(message) { Result = result; } public ToolResult Result { get; } }