Merge branch 'claudedo/0c5a46e7b2d84538a54b2e13b44af01e'
This commit is contained in:
@@ -48,6 +48,10 @@ public static class ExecutableResolver
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Expanded fallback directories tried when PATH search comes up empty (for diagnostics).</summary>
|
||||||
|
public static IReadOnlyList<string> FallbackDirectories() =>
|
||||||
|
FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList();
|
||||||
|
|
||||||
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
|
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
|
||||||
{
|
{
|
||||||
var parts = new List<string> { "/c", Quote(shimPath) };
|
var parts = new List<string> { "/c", Quote(shimPath) };
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<CheckResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.IO;
|
||||||
|
using ClaudeDo.Data.Environment;
|
||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
/// <summary>Without the claude CLI, no task can ever run — blocking.</summary>
|
||||||
|
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<CheckResult> 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<string> SearchedLocations()
|
||||||
|
{
|
||||||
|
var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return pathEntries.Concat(ExecutableResolver.FallbackDirectories());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the claude CLI and runs `--version` at most once per install run, shared across
|
||||||
|
/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck.
|
||||||
|
/// </summary>
|
||||||
|
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<ClaudeCliLookupResult> 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<ClaudeCliLookupResult> 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
/// <summary>Gates the CLI flags ClaudeArgsBuilder relies on — blocking.</summary>
|
||||||
|
public sealed class ClaudeVersionCheck : IEnvironmentCheck
|
||||||
|
{
|
||||||
|
public const string CheckId = "claude-version";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<CheckResult> 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}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Checks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<CheckResult> 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -653,6 +653,22 @@
|
|||||||
"writeAccess": {
|
"writeAccess": {
|
||||||
"title": "Schreibzugriff",
|
"title": "Schreibzugriff",
|
||||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,6 +653,22 @@
|
|||||||
"writeAccess": {
|
"writeAccess": {
|
||||||
"title": "Write access",
|
"title": "Write access",
|
||||||
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user