diff --git a/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs b/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs new file mode 100644 index 0000000..1839e19 --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; + +namespace ClaudeDo.Worker.Lifecycle; + +public static class ClaudeCliPreflight +{ + public sealed record Result(bool Ok, string Version, string Error, int ExitCode); + + public static async Task CheckAsync(string claudeBin, CancellationToken ct = default) + { + try + { + var psi = new ProcessStartInfo + { + FileName = claudeBin, + 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); + + var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); + var stderrTask = proc.StandardError.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + + var stdout = (await stdoutTask).Trim(); + var stderr = (await stderrTask).Trim(); + return new Result(proc.ExitCode == 0, stdout, stderr, proc.ExitCode); + } + catch (Exception ex) + { + return new Result(false, "", ex.Message, -1); + } + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 54a9a0e..d89ab79 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -141,6 +141,22 @@ app.UseMiddleware(); app.MapHub("/hub"); app.MapMcp("/mcp"); +// Claude CLI preflight: fail fast if the configured binary is unreachable or non-zero. +// Skippable via CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (e.g. tests). +if (Environment.GetEnvironmentVariable("CLAUDEDO_SKIP_CLI_PREFLIGHT") != "1") +{ + var preflight = await ClaudeCliPreflight.CheckAsync(cfg.ClaudeBin); + if (!preflight.Ok) + { + app.Logger.LogCritical( + "Claude CLI preflight failed (bin: '{Bin}', exit: {Exit}): {Error}. " + + "Fix `claude_bin` in worker.config.json or set CLAUDEDO_SKIP_CLI_PREFLIGHT=1 to bypass.", + cfg.ClaudeBin, preflight.ExitCode, preflight.Error); + Environment.Exit(1); + } + app.Logger.LogInformation("Claude CLI preflight OK: {Version}", preflight.Version); +} + app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", cfg.SignalRPort, cfg.DbPath); diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/ClaudeCliPreflightTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/ClaudeCliPreflightTests.cs new file mode 100644 index 0000000..2334853 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/ClaudeCliPreflightTests.cs @@ -0,0 +1,29 @@ +using ClaudeDo.Worker.Lifecycle; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public sealed class ClaudeCliPreflightTests +{ + [Fact] + public async Task NonExistentBinary_ReturnsNotOk_WithError() + { + var result = await ClaudeCliPreflight.CheckAsync("definitely-not-a-real-bin-xyz123"); + + Assert.False(result.Ok); + Assert.NotEqual(0, result.ExitCode); + Assert.NotEmpty(result.Error); + } + + [Fact] + public async Task BinaryAcceptingVersionFlag_ReturnsOk_WithVersionOutput() + { + // `dotnet --version` is always available in this build environment, exits 0, + // and prints the SDK version on stdout — same shape as `claude --version`. + var result = await ClaudeCliPreflight.CheckAsync("dotnet"); + + Assert.True(result.Ok, $"expected ok, got exit={result.ExitCode} err='{result.Error}'"); + Assert.Equal(0, result.ExitCode); + Assert.NotEmpty(result.Version); + } +}