feat(installer): add Claude CLI preflight checks (found, version, login, auto-mode)

Four IEnvironmentCheck implementations in src/ClaudeDo.Installer/Checks/:
- ClaudeCliCheck (Error) — resolves ctx.ClaudeBin via ExecutableResolver, runs
  --version; failure message lists searched PATH entries + fallback dirs, flags
  shim resolution (.cmd/.ps1) in Detail.
- ClaudeVersionCheck (Error) — tolerant version parsing (ignores surrounding
  text), numeric System.Version comparison against a named floor constant
  (2.1.220, see docs/explore-notes/installer-preflight.md §3). Unparseable or
  missing CLI -> Unknown, never Failed.
- ClaudeAuthCheck (Error) — `claude auth status --json`, never sends a prompt.
- PermissionModeAutoCheck (Warning) — per the note's §2 conclusion, real
  auto-mode eligibility (org/model/plan) has no cheap static signal, so this
  only confirms `claude --help` still lists "auto" as a --permission-mode
  choice. Kept as its own check rather than folded into ClaudeVersionCheck,
  since the note explicitly separates "flag recognized" from "mode eligible"
  and only the former is checkable at all.

All four share a new ClaudeCliLookup that resolves the CLI and runs
--version exactly once per install run (memoized, semaphore-guarded), so
none of the three version-dependent checks repeats that call.

Foundation prerequisites this task depends on (docs/explore-notes/
installer-preflight.md, ExecutableResolver, the IEnvironmentCheck/CheckResult/
EnvironmentCheckService abstraction, ProcessCommand, IProcessRunner) existed
only on an unmerged sibling branch, not on main. Rather than merging that
whole unreviewed branch, the specific prerequisite files were copied in
as-is (git history shows their origin). GitCheck/GitIdentityCheck/PortCheck/
WriteAccessCheck from that branch were intentionally NOT brought in — out of
scope for this task.

Deviations/decisions worth flagging:
- Added ExecutableResolver.FallbackDirectories() (public) so ClaudeCliCheck
  can name the checked fallback locations in its failure message; the prior
  branch kept that list private.
- Paths.cs now qualifies System.Environment explicitly. Adding the
  ClaudeDo.Data.Environment namespace makes bare `Environment.X` inside any
  ClaudeDo.Data.* namespace resolve to the sibling namespace instead of
  System.Environment (C# prefers nested/enclosing namespace members over
  usings) — this broke the build until qualified.

Not done (explicitly out of scope): no DI wiring into the wizard UI, no
XAML — matches how the prerequisite Git/Port/WriteAccess checks were also
left unwired.
This commit is contained in:
mika kuns
2026-08-05 19:43:34 +02:00
parent bdee731376
commit d743a9d0e9
24 changed files with 1344 additions and 3 deletions
@@ -0,0 +1,127 @@
using ClaudeDo.Data.Environment;
namespace ClaudeDo.Data.Tests;
public sealed class ExecutableResolverTests : IDisposable
{
private readonly string _root;
public ExecutableResolverTests()
{
_root = Path.Combine(Path.GetTempPath(), $"claudedo_exeresolve_{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string MakeDir(string name)
{
var dir = Path.Combine(_root, name);
Directory.CreateDirectory(dir);
return dir;
}
private static void Touch(string path) => File.WriteAllText(path, "");
[Fact]
public void Command_with_directory_separator_is_treated_as_path()
{
var dir = MakeDir("bin1");
var exePath = Path.Combine(dir, "myclaude.exe");
Touch(exePath);
var result = ExecutableResolver.Resolve(exePath, pathOverride: "C:\\does\\not\\matter");
Assert.NotNull(result);
Assert.Equal(exePath, result!.Path);
Assert.False(result.IsShim);
}
[Fact]
public void Path_like_command_without_extension_resolves_via_pathext()
{
var dir = MakeDir("bin2");
var cmdPath = Path.Combine(dir, "myclaude.cmd");
Touch(cmdPath);
var commandWithoutExt = Path.Combine(dir, "myclaude");
var result = ExecutableResolver.Resolve(commandWithoutExt, pathExtOverride: ".com;.exe;.bat;.cmd");
Assert.NotNull(result);
Assert.Equal(cmdPath, result!.Path);
Assert.True(result.IsShim);
}
[Fact]
public void Cmd_file_in_path_is_found_when_no_exe_exists()
{
var dir = MakeDir("bin3");
Touch(Path.Combine(dir, "claude.cmd"));
var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd");
Assert.NotNull(result);
Assert.Equal(Path.Combine(dir, "claude.cmd"), result!.Path);
Assert.True(result.IsShim);
}
[Fact]
public void Exe_wins_against_cmd_in_same_directory()
{
var dir = MakeDir("bin4");
Touch(Path.Combine(dir, "claude.cmd"));
Touch(Path.Combine(dir, "claude.exe"));
var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd");
Assert.NotNull(result);
Assert.Equal(Path.Combine(dir, "claude.exe"), result!.Path);
Assert.False(result.IsShim);
}
[Fact]
public void Directory_order_in_path_is_respected()
{
var dir1 = MakeDir("bin5a");
var dir2 = MakeDir("bin5b");
Touch(Path.Combine(dir1, "claude.cmd"));
Touch(Path.Combine(dir2, "claude.exe"));
var pathOverride = $"{dir1}{Path.PathSeparator}{dir2}";
var result = ExecutableResolver.Resolve("claude", pathOverride: pathOverride, pathExtOverride: ".com;.exe;.bat;.cmd");
Assert.NotNull(result);
Assert.Equal(Path.Combine(dir1, "claude.cmd"), result!.Path);
Assert.True(result.IsShim);
}
[Fact]
public void Unresolvable_command_returns_null()
{
var dir = MakeDir("bin6");
var result = ExecutableResolver.Resolve(
"claudedo_totally_missing_cmd_9f3a1",
pathOverride: dir,
pathExtOverride: ".com;.exe;.bat;.cmd");
Assert.Null(result);
}
[Fact]
public void BuildShimStartInfo_uses_cmd_exe_and_quotes_paths_with_spaces()
{
const string shimPath = @"C:\Program Files\claude\claude.cmd";
var startInfo = ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "--version", "arg2" });
Assert.Equal("cmd.exe", startInfo.FileName);
Assert.Contains("/c", startInfo.Arguments);
Assert.Contains($"\"{shimPath}\"", startInfo.Arguments);
Assert.Contains("--version", startInfo.Arguments);
Assert.Contains("arg2", startInfo.Arguments);
}
}
@@ -0,0 +1,88 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class ClaudeAuthCheckTests : IDisposable
{
private readonly string _dir;
public ClaudeAuthCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
[Fact]
public async Task Logged_in_reports_ok_without_sending_a_prompt()
{
var runner = new FakeProcessRunner(
(0, "2.1.230 (Claude Code)"),
(0, "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}"));
var check = new ClaudeAuthCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.All(runner.Calls, call => Assert.DoesNotContain("-p ", call.Arguments));
Assert.Contains(runner.Calls, call => call.Arguments.Contains("auth") && call.Arguments.Contains("status"));
}
[Fact]
public async Task Logged_out_reports_failed()
{
var runner = new FakeProcessRunner(
(0, "2.1.230 (Claude Code)"),
(0, "{\"loggedIn\":false}"));
var check = new ClaudeAuthCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.NotNull(result.HintKey);
}
[Fact]
public async Task Unparseable_output_reports_unknown_not_failed()
{
var runner = new FakeProcessRunner(
(0, "2.1.230 (Claude Code)"),
(0, "not json"));
var check = new ClaudeAuthCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Unknown, result.Status);
}
[Fact]
public async Task Cli_missing_reports_unknown()
{
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_empty_{Guid.NewGuid():N}");
Directory.CreateDirectory(emptyDir);
try
{
var runner = new FakeProcessRunner();
var check = new ClaudeAuthCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Equal(CheckStatus.Unknown, result.Status);
Assert.Empty(runner.Calls);
}
finally
{
Directory.Delete(emptyDir, recursive: true);
}
}
}
@@ -0,0 +1,79 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class ClaudeCliCheckTests : IDisposable
{
private readonly string _dir;
public ClaudeCliCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudecli_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
[Fact]
public async Task Found_as_exe_reports_ok_with_path_and_version_and_no_shim_detail()
{
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.Contains(_dir, result.Message);
Assert.Contains("2.1.230", result.Message);
Assert.Null(result.Detail);
}
[Fact]
public async Task Found_as_shim_flags_it_in_detail()
{
File.WriteAllText(Path.Combine(_dir, "claude.cmd"), "");
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".cmd"));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.NotNull(result.Detail);
}
[Fact]
public async Task Not_found_reports_failed_naming_searched_locations()
{
var runner = new FakeProcessRunner();
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
// A distinctive, definitely-missing name — a plain "claude" would still resolve via
// ExecutableResolver's fixed fallback dirs (e.g. %USERPROFILE%\.local\bin) on a machine
// that has the real CLI installed, regardless of pathOverride.
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Error, result.Severity);
Assert.Contains(_dir, result.Message);
Assert.NotNull(result.HintKey);
Assert.Empty(runner.Calls);
}
[Fact]
public async Task Nonzero_exit_reports_failed()
{
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
var runner = new FakeProcessRunner((1, "some error"));
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
}
}
@@ -0,0 +1,84 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class ClaudeCliLookupTests : IDisposable
{
private readonly string _dir;
public ClaudeCliLookupTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
[Fact]
public async Task Version_is_executed_only_once_across_repeated_calls()
{
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
Assert.Single(runner.Calls);
}
[Fact]
public async Task Concurrent_calls_still_run_version_only_once()
{
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
await Task.WhenAll(Enumerable.Range(0, 8)
.Select(_ => lookup.ResolveAsync(new InstallContext(), CancellationToken.None)));
Assert.Single(runner.Calls);
}
[Fact]
public async Task Version_is_executed_only_once_when_shared_across_all_four_checks()
{
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
var ctx = new InstallContext();
await new ClaudeCliCheck(lookup).RunAsync(ctx, CancellationToken.None);
await new ClaudeVersionCheck(lookup).RunAsync(ctx, CancellationToken.None);
await new ClaudeAuthCheck(lookup).RunAsync(ctx, CancellationToken.None);
await new PermissionModeAutoCheck(lookup).RunAsync(ctx, CancellationToken.None);
var versionCalls = runner.Calls.Count(c => c.Arguments.Contains("--version"));
Assert.Equal(1, versionCalls);
}
[Fact]
public async Task Cli_not_found_returns_null_resolved_without_running_a_process()
{
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_empty_{Guid.NewGuid():N}");
Directory.CreateDirectory(emptyDir);
try
{
var runner = new FakeProcessRunner();
var lookup = new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe");
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var result = await lookup.ResolveAsync(ctx, CancellationToken.None);
Assert.Null(result.Resolved);
Assert.Empty(runner.Calls);
}
finally
{
Directory.Delete(emptyDir, recursive: true);
}
}
}
@@ -0,0 +1,69 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class ClaudeVersionCheckTests : IDisposable
{
private readonly string _dir;
public ClaudeVersionCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
[Theory]
[InlineData("2.1.220 (Claude Code)", CheckStatus.Ok)] // exactly at the floor
[InlineData("2.1.219 (Claude Code)", CheckStatus.Failed)] // one patch below the floor
[InlineData("2.1.191", CheckStatus.Failed)]
[InlineData("v2.1.191", CheckStatus.Failed)]
[InlineData("2.10.0", CheckStatus.Ok)] // minor 10 > minor 1, not a lexical trap
[InlineData("garbage output, no version here", CheckStatus.Unknown)]
public async Task Version_parsing_and_gate(string versionOutput, CheckStatus expected)
{
var runner = new FakeProcessRunner((0, versionOutput));
var check = new ClaudeVersionCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(expected, result.Status);
}
[Fact]
public void Minor_version_compares_numerically_not_lexically()
{
Assert.True(new Version(2, 10, 0) > new Version(2, 9, 0));
}
[Fact]
public async Task Cli_not_found_reports_unknown_not_failed()
{
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_empty_{Guid.NewGuid():N}");
Directory.CreateDirectory(emptyDir);
try
{
var runner = new FakeProcessRunner();
var check = new ClaudeVersionCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Empty(runner.Calls);
Assert.Equal(CheckStatus.Unknown, result.Status);
}
finally
{
Directory.Delete(emptyDir, recursive: true);
}
}
}
@@ -0,0 +1,22 @@
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Tests.Checks;
internal sealed class FakeProcessRunner : IProcessRunner
{
private readonly Queue<(int ExitCode, string Output)> _responses;
public FakeProcessRunner(params (int ExitCode, string Output)[] responses)
{
_responses = new Queue<(int, string)>(responses);
}
public List<(string FileName, string Arguments)> Calls { get; } = new();
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
{
Calls.Add((fileName, arguments));
var response = _responses.Count > 0 ? _responses.Dequeue() : (0, "");
return Task.FromResult(response);
}
}
@@ -0,0 +1,88 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class PermissionModeAutoCheckTests : IDisposable
{
private readonly string _dir;
private const string HelpWithAuto =
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")\n";
private const string HelpWithoutAuto =
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"bypassPermissions\", \"manual\")\n";
public PermissionModeAutoCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdpermmode_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
[Fact]
public async Task Auto_listed_in_help_reports_ok()
{
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithAuto));
var check = new PermissionModeAutoCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.Equal(CheckSeverity.Warning, result.Severity);
}
[Fact]
public async Task Auto_missing_from_help_reports_failed_with_hint()
{
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithoutAuto));
var check = new PermissionModeAutoCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Warning, result.Severity);
Assert.NotNull(result.HintKey);
}
[Fact]
public async Task Help_not_determinable_reports_unknown()
{
var runner = new FakeProcessRunner((0, "2.1.230"), (1, ""));
var check = new PermissionModeAutoCheck(MakeLookup(runner));
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Unknown, result.Status);
}
[Fact]
public async Task Cli_missing_reports_unknown()
{
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdpermmode_empty_{Guid.NewGuid():N}");
Directory.CreateDirectory(emptyDir);
try
{
var runner = new FakeProcessRunner();
var check = new PermissionModeAutoCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Equal(CheckStatus.Unknown, result.Status);
Assert.Empty(runner.Calls);
}
finally
{
Directory.Delete(emptyDir, recursive: true);
}
}
}