Merge branch 'claudedo/0c5a46e7b2d84538a54b2e13b44af01e'

This commit is contained in:
mika kuns
2026-08-06 07:56:22 +02:00
13 changed files with 761 additions and 0 deletions
@@ -48,6 +48,10 @@ public static class ExecutableResolver
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)
{
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]);
}
}
+16
View File
@@ -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."
}
}
}
+16
View File
@@ -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."
}
}
}