diff --git a/src/ClaudeDo.Data/Environment/ExecutableResolver.cs b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs index a859dc14..7b6486d9 100644 --- a/src/ClaudeDo.Data/Environment/ExecutableResolver.cs +++ b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs @@ -48,6 +48,10 @@ public static class ExecutableResolver return null; } + /// Expanded fallback directories tried when PATH search comes up empty (for diagnostics). + public static IReadOnlyList FallbackDirectories() => + FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList(); + public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList arguments) { var parts = new List { "/c", Quote(shimPath) }; diff --git a/src/ClaudeDo.Installer/Checks/ClaudeAuthCheck.cs b/src/ClaudeDo.Installer/Checks/ClaudeAuthCheck.cs new file mode 100644 index 00000000..9206f920 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeAuthCheck.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// +/// Checks login via `claude auth status --json` — never sends a prompt (costs tokens, can hang +/// on a usage-limited account). See docs/explore-notes/installer-preflight.md §4. +/// +public sealed class ClaudeAuthCheck : IEnvironmentCheck +{ + public const string CheckId = "claude-auth"; + + private readonly ClaudeCliLookup _lookup; + + public ClaudeAuthCheck(ClaudeCliLookup lookup) + { + _lookup = lookup; + } + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Error; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var lookup = await _lookup.ResolveAsync(ctx, ct); + if (lookup.Resolved is null) + { + return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title", + "The claude CLI was not found — login could not be checked."); + } + + var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json"); + var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct); + + if (exitCode != 0) + { + return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title", + "Login status could not be determined.", output); + } + + var loggedIn = TryParseLoggedIn(output); + if (loggedIn is null) + { + return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title", + "Login status could not be determined.", output); + } + + return loggedIn.Value + ? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.") + : CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint"); + } + + private static bool? TryParseLoggedIn(string output) + { + try + { + using var doc = JsonDocument.Parse(output); + if (doc.RootElement.TryGetProperty("loggedIn", out var prop) && + prop.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return prop.GetBoolean(); + } + } + catch (JsonException) + { + // fall through to null — treated as Unknown + } + + return null; + } +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeCliCheck.cs b/src/ClaudeDo.Installer/Checks/ClaudeCliCheck.cs new file mode 100644 index 00000000..9ba8f5a8 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeCliCheck.cs @@ -0,0 +1,54 @@ +using System.IO; +using ClaudeDo.Data.Environment; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Without the claude CLI, no task can ever run — blocking. +public sealed class ClaudeCliCheck : IEnvironmentCheck +{ + public const string CheckId = "claude-cli"; + + private readonly ClaudeCliLookup _lookup; + + public ClaudeCliCheck(ClaudeCliLookup lookup) + { + _lookup = lookup; + } + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Error; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var lookup = await _lookup.ResolveAsync(ctx, ct); + + if (lookup.Resolved is null) + { + var searched = string.Join(", ", SearchedLocations()); + return CheckResult.Fail(Id, Severity, "checks.claudeCli.title", + $"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.", + "checks.claudeCli.hint"); + } + + if (lookup.ExitCode != 0) + { + return CheckResult.Fail(Id, Severity, "checks.claudeCli.title", + $"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.", + "checks.claudeCli.hint", detail: lookup.Output); + } + + var version = lookup.ParsedVersion?.ToString() ?? lookup.Output.Trim(); + var detail = lookup.Resolved.IsShim + ? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe." + : null; + return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail); + } + + private IEnumerable SearchedLocations() + { + var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? ""; + var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return pathEntries.Concat(ExecutableResolver.FallbackDirectories()); + } +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeCliLookup.cs b/src/ClaudeDo.Installer/Checks/ClaudeCliLookup.cs new file mode 100644 index 00000000..801027f8 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeCliLookup.cs @@ -0,0 +1,71 @@ +using System.Text.RegularExpressions; +using ClaudeDo.Data.Environment; +using ClaudeDo.Installer.Core; +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Checks; + +public sealed record ClaudeCliLookupResult( + ResolvedExecutable? Resolved, + int ExitCode, + string Output, + Version? ParsedVersion); + +/// +/// Resolves the claude CLI and runs `--version` at most once per install run, shared across +/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck. +/// +public sealed class ClaudeCliLookup +{ + private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled); + + private readonly SemaphoreSlim _gate = new(1, 1); + private ClaudeCliLookupResult? _cached; + + public ClaudeCliLookup(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null) + { + ProcessRunner = processRunner; + PathOverride = pathOverride; + PathExtOverride = pathExtOverride; + } + + public IProcessRunner ProcessRunner { get; } + public string? PathOverride { get; } + public string? PathExtOverride { get; } + + public async Task ResolveAsync(InstallContext ctx, CancellationToken ct) + { + if (_cached is not null) return _cached; + + await _gate.WaitAsync(ct); + try + { + _cached ??= await ResolveCoreAsync(ctx, ct); + return _cached; + } + finally + { + _gate.Release(); + } + } + + private async Task ResolveCoreAsync(InstallContext ctx, CancellationToken ct) + { + var resolved = ExecutableResolver.Resolve(ctx.ClaudeBin, PathOverride, PathExtOverride); + if (resolved is null) + { + return new ClaudeCliLookupResult(null, ExitCode: -1, Output: string.Empty, ParsedVersion: null); + } + + var (fileName, arguments) = ProcessCommand.For(resolved, "--version"); + var (exitCode, output) = await ProcessRunner.RunAsync(fileName, arguments, null, ct); + var parsedVersion = exitCode == 0 ? ParseVersion(output) : null; + + return new ClaudeCliLookupResult(resolved, exitCode, output, parsedVersion); + } + + private static Version? ParseVersion(string output) => + VersionPattern.Match(output) is { Success: true } match && Version.TryParse(match.Value, out var version) + ? version + : null; +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeVersionCheck.cs b/src/ClaudeDo.Installer/Checks/ClaudeVersionCheck.cs new file mode 100644 index 00000000..1d595dc8 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeVersionCheck.cs @@ -0,0 +1,54 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Gates the CLI flags ClaudeArgsBuilder relies on — blocking. +public sealed class ClaudeVersionCheck : IEnvironmentCheck +{ + public const string CheckId = "claude-version"; + + /// + /// Newest version confirmed to work end-to-end with every flag ClaudeDo uses (--permission-mode + /// auto, --effort, --agents, --json-schema, --append-system-prompt, --output-format stream-json + /// --verbose, --resume, mcp add --transport http --scope user). A verified floor, not a proven + /// theoretical minimum — see docs/explore-notes/installer-preflight.md §3. + /// + public static readonly Version MinimumVersion = new(2, 1, 220); + + private readonly ClaudeCliLookup _lookup; + + public ClaudeVersionCheck(ClaudeCliLookup lookup) + { + _lookup = lookup; + } + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Error; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var lookup = await _lookup.ResolveAsync(ctx, ct); + + if (lookup.Resolved is null) + { + return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title", + "The claude CLI was not found — version could not be checked."); + } + + if (lookup.ExitCode != 0 || lookup.ParsedVersion is null) + { + return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title", + $"Could not parse a version from '{lookup.Output.Trim()}'."); + } + + if (lookup.ParsedVersion < MinimumVersion) + { + return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title", + $"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.", + "checks.claudeVersion.hint"); + } + + return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title", + $"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion})."); + } +} diff --git a/src/ClaudeDo.Installer/Checks/PermissionModeAutoCheck.cs b/src/ClaudeDo.Installer/Checks/PermissionModeAutoCheck.cs new file mode 100644 index 00000000..72f2d5e7 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/PermissionModeAutoCheck.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// +/// Static-only check per docs/explore-notes/installer-preflight.md §2: real auto-mode eligibility +/// (org/model/plan) has no cheap detectable signal, so this only confirms the CLI's argument +/// parser still recognizes "auto" as a --permission-mode choice. A warning, not a blocker — a +/// too-old CLI just degrades to acceptEdits/default with more prompts. +/// +public sealed class PermissionModeAutoCheck : IEnvironmentCheck +{ + public const string CheckId = "claude-permission-mode-auto"; + + private static readonly Regex AutoWord = new(@"\bauto\b", RegexOptions.Compiled); + private const int SearchWindow = 300; + + private readonly ClaudeCliLookup _lookup; + + public PermissionModeAutoCheck(ClaudeCliLookup lookup) + { + _lookup = lookup; + } + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Warning; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var lookup = await _lookup.ResolveAsync(ctx, ct); + if (lookup.Resolved is null) + { + return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title", + "The claude CLI was not found — permission-mode support could not be checked."); + } + + var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help"); + var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(output)) + { + return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title", + "Could not determine whether --permission-mode auto is supported."); + } + + if (AutoIsListed(output)) + { + return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title", + "--permission-mode auto is recognized by this CLI."); + } + + return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title", + "This claude CLI version does not list 'auto' as a --permission-mode choice.", + "checks.permissionModeAuto.hint"); + } + + private static bool AutoIsListed(string helpOutput) + { + var index = helpOutput.IndexOf("--permission-mode", StringComparison.Ordinal); + if (index < 0) return false; + + var windowEnd = Math.Min(helpOutput.Length, index + SearchWindow); + return AutoWord.IsMatch(helpOutput[index..windowEnd]); + } +} diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index c64d31d5..8d4942c7 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -653,6 +653,22 @@ "writeAccess": { "title": "Schreibzugriff", "hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)." + }, + "claudeCli": { + "title": "Claude CLI", + "hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift." + }, + "claudeVersion": { + "title": "Claude-CLI-Version", + "hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code" + }, + "claudeAuth": { + "title": "Claude-CLI-Login", + "hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab." + }, + "permissionModeAuto": { + "title": "Unterstützung für --permission-mode auto", + "hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen." } } } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index f7cd8b65..1814b8cc 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -653,6 +653,22 @@ "writeAccess": { "title": "Write access", "hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)." + }, + "claudeCli": { + "title": "Claude CLI", + "hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect." + }, + "claudeVersion": { + "title": "Claude CLI version", + "hint": "Update the CLI: npm update -g @anthropic-ai/claude-code" + }, + "claudeAuth": { + "title": "Claude CLI login", + "hint": "Run 'claude' once and complete the login flow." + }, + "permissionModeAuto": { + "title": "--permission-mode auto support", + "hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts." } } } diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeAuthCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeAuthCheckTests.cs new file mode 100644 index 00000000..6061388e --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeAuthCheckTests.cs @@ -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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliCheckTests.cs new file mode 100644 index 00000000..da89313e --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliCheckTests.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliLookupTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliLookupTests.cs new file mode 100644 index 00000000..a062dd9f --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliLookupTests.cs @@ -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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeVersionCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeVersionCheckTests.cs new file mode 100644 index 00000000..c21d7fcd --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeVersionCheckTests.cs @@ -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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/PermissionModeAutoCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/PermissionModeAutoCheckTests.cs new file mode 100644 index 00000000..7894f955 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/PermissionModeAutoCheckTests.cs @@ -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 Permission mode (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")\n"; + + private const string HelpWithoutAuto = + "Options:\n --permission-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); + } + } +}