diff --git a/docs/explore-notes/README.md b/docs/explore-notes/README.md index 0424e92f..e662a891 100644 --- a/docs/explore-notes/README.md +++ b/docs/explore-notes/README.md @@ -19,6 +19,7 @@ These sit **between** the CLAUDE.md files and the code: | [external-mcp](external-mcp.md) | The `claudedo` MCP tool surface + its two test-enforced conventions | | [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver | | [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha | +| [installer-preflight](installer-preflight.md) | `--permission-mode auto` eligibility, CLI version floor, login check, .NET runtime requirements | ## Rules diff --git a/docs/explore-notes/installer-preflight.md b/docs/explore-notes/installer-preflight.md new file mode 100644 index 00000000..28485fb3 --- /dev/null +++ b/docs/explore-notes/installer-preflight.md @@ -0,0 +1,207 @@ +# Installer preflight: CLI version gate & environment checks + +> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative. +> Last verified against commit `bdee731` (2026-08-05). +> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.App/ClaudeDo.App.csproj .gitea/workflows/release.yml` +> Stable structure only (no line numbers). See docs/explore-notes/README.md. + +Pure research, no code changed. Answers the five questions from the "Root Cause +`--permission-mode auto`" task. Sources: the locally installed CLI (`claude --version` / +`--help`), the official docs at `code.claude.com` (fetched 2026-08-05), and this repo's own +csproj/workflow files. + +## 1. Why can `--permission-mode auto` fail? + +**It is very rarely a CLI-version problem.** Per the official permission-modes doc +(`code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode`), auto mode +requires **all** of: + +- **Plan**: any plan (Free/Pro/Max/Team/Enterprise) qualifies in principle. +- **Organization**: on Team/Enterprise, on by default; an admin can disable it org-wide via + `permissions.disableAutoMode: "disable"` in managed settings. When disabled this way, the CLI + **"rejects `--permission-mode auto` at startup"** (verbatim from the doc) — not a runtime + fallback, a hard reject. +- **Model**: on the Anthropic API / Claude Platform on AWS — Opus 4.6+, Sonnet 4.6+, or Fable 5. + On Bedrock / Vertex / Foundry / signed-in gateway sessions — only Sonnet 5, Opus 4.7+, Fable 5. + Older models (Sonnet 4.5, Opus 4.5, Haiku, claude-3-*) are **not supported on any provider**. + A per-org `availableModels` restriction that only allows an old model would silently make + auto mode unavailable even on a Team/Enterprise org that hasn't touched `disableAutoMode`. +- **Provider opt-in (historical)**: on Bedrock/Vertex/Foundry/gateway, CLI **v2.1.158–v2.1.206** + required `CLAUDE_CODE_ENABLE_AUTO_MODE=1`; **v2.1.207** removed that requirement. This does + **not** apply to a normal claude.ai / Console (Anthropic API) login — only to those four + provider types. + +A separate, easy-to-hit trap: `defaultMode: "auto"` in **project or local** settings +(`.claude/settings.json`, `.claude/settings.local.json`) is silently **ignored** since v2.1.142 +— it must live in `~/.claude/settings.json` (user scope). A repo that ships `defaultMode: auto` +in its own `.claude/settings.json` will start in Manual mode with **no error at all**. This +doesn't apply to ClaudeDo's case (it passes `--permission-mode auto` as an explicit CLI flag, +not via a checked-in settings file), but is worth knowing if the failure mode was "silently +starts in Manual" rather than "CLI errors out". + +Quoted from the docs, verbatim: *"If Claude Code reports auto mode as unavailable, one of these +requirements is unmet; this is not a transient outage."* + +**Not verifiable**: which of the above actually hit the colleague — we have no diagnostic from +their machine (no `claude auth status --json` output, no `claude --version`, no org name). Do +not guess; if this recurs, capture `claude auth status --json` and `claude --version` from the +affected machine before further debugging. + +## 2. How to reliably detect whether `auto` is supported + +**Static, cheap, always safe (no API call):** +- `claude --version` — semver string, e.g. `2.1.220 (Claude Code)`. +- `claude --help` — the `--permission-mode ` line lists the accepted enum. Confirmed by + testing an invalid value locally: + ``` + $ claude -p "test" --permission-mode bogus + error: option '--permission-mode ' argument 'bogus' is invalid. Allowed choices are + acceptEdits, auto, bypassPermissions, manual, dontAsk, plan. + ``` + This is validated by the CLI's arg parser (Commander.js) **before any network call** — exits + 1 immediately. So checking that `auto` is one of the listed choices is a legitimate, free, + fast static check — but it only proves the *flag* is recognized, **not** that auto mode is + actually usable (org/model/plan gating happens later, at session start, not at arg-parse time). +- `claude auth status --json` — cheap, local/fast, **does not send a prompt**. Returns: + ```json + { "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty", + "email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" } + ``` + This is the answer to question 4 (see below) and also gives `subscriptionType`/`orgName` — + useful context but **still not a direct "is auto mode eligible" answer** (doesn't report the + active model or `disableAutoMode` policy). + +**No cheap subcommand exists for full eligibility.** Checked `claude auto-mode --help`: it only +has `config` (effective auto-mode *rule* config — allow/deny lists, not eligibility), +`defaults` (same, shipped defaults), `critique`, and `reset`. None report plan/model/org +eligibility. `claude doctor` (non-interactive) does **not** report auto-mode eligibility either +— it explicitly says *"For a full setup checkup that can also fix issues, run `/doctor` in a +session"*; the in-session `/doctor` slash command is the one the docs say proposes +`defaultMode: auto` when eligible, but that requires an interactive session, not a scriptable +preflight. + +**Dynamic (real probe) is the only way to fully confirm eligibility**, and per the docs an +org-disabled or otherwise-ineligible account **rejects at startup** (fast, before any model +turn) — so a probe doesn't have to be a full expensive run. A minimal probe such as +`claude -p "ok" --permission-mode auto --max-turns 1 --output-format json` would fail fast on +ineligibility (reject at startup) but still costs one real turn + tokens on the success path, +and still requires a working prompt/response round trip on the happy path. **Recommendation**: +don't build this into an automated preflight; a static version+flag check plus `auth status` +covers the reliably-detectable ground, and a real first task run will surface an auto-mode +rejection immediately and cheaply (fails at startup, not mid-task) if it's actually unavailable. + +## 3. Minimum CLI version for the flags ClaudeDo uses + +Flags used (from `ClaudeArgsBuilder` per `src/ClaudeDo.Worker/CLAUDE.md`): `--permission-mode +auto`, `--effort`, `--agents`, `--json-schema`, `--append-system-prompt`, `--output-format +stream-json --verbose`, `--resume`, and (installer) `claude mcp add --transport http --scope +user`. + +**Not verifiable precisely.** All eight of these are foundational, long-established flags. +The official changelog (`raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md`) +only retains roughly the last ~40 entries (oldest visible: `2.1.181`); auto mode itself, and +`--json-schema`/`--resume`/`mcp add --transport` all clearly predate that window (the earliest +found reference is a *bug fix* mentioning "sessions created before v2.1.85" for `--resume`, +implying `--resume` existed well before 2.1.85). There is no accessible source that pins an +"introduced in vX" date for any of these eight flags — guessing one would violate the task's +explicit instruction not to invent a source-less root cause. + +What **is** sourced, from the docs fetched 2026-08-05: +- `--json-schema` + invalid-schema handling: before v2.1.205, an invalid schema was silently + ignored (returned unstructured text); v2.1.205 made it a hard `Error: --json-schema is not a + valid JSON Schema` exit. Not a "does it exist" gate, but changes error-handling behavior + ClaudeDo might currently rely on failing loudly. +- `--output-format stream-json --verbose` + `system/init.capabilities` array requires v2.1.205+ + (absent before). Not currently consumed by ClaudeDo per the Worker CLAUDE.md's stream handling + description, so not a hard requirement today. +- `system/init.mcp_server_errors` field requires v2.1.219+. Not currently consumed by ClaudeDo. +- Manual-mode label/`manual` alias requires v2.1.200+ — irrelevant, ClaudeDo passes `auto` + explicitly, never `manual`. +- Auto mode's Bedrock/Vertex/Foundry/gateway opt-in-env-var requirement was removed in v2.1.207 + — irrelevant for a direct claude.ai/Console login (this machine: `apiProvider: "firstParty"`). + +**Decided constant** (see below) is therefore **the newest version we can positively confirm +works end-to-end on this machine** (`2.1.220`), not a proven theoretical minimum — because no +lower true minimum is derivable from available sources without guessing. + +## 4. Detecting "CLI is logged in" without sending a prompt + +`claude auth status --json` (confirmed working, instant, no API/model call): +```json +{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty", + "email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" } +``` +This is the CLI's own maintained answer — cheaper and more robust than parsing +`.credentials.json` directly (format may be internal/undocumented and is a hard-blocked file +for this task's own tooling; the docs page confirms it lives at +`%USERPROFILE%\.claude\.credentials.json` on Windows but say nothing about its schema being a +stable public contract). `claude auth status --text` is available for a human-readable variant; +`--json` is the default and the right one for a preflight to parse. + +## 5. .NET runtimes required by the published `app\` / `worker\` artifacts + +From `.gitea/workflows/release.yml` (the only build/publish pipeline in this repo) and the two +csproj files: + +- **`ClaudeDo.App`** (`net8.0`, Avalonia, `WinExe`) — published via + `dotnet publish ... -r win-x64 --self-contained true`. **Self-contained**: bundles its own + .NET 8 runtime. **No .NET runtime needs to be pre-installed** on the target machine for the + app itself. +- **`ClaudeDo.Worker`** (`net8.0`, `Microsoft.NET.Sdk.Web`, ASP.NET Core) — same treatment: + `-r win-x64 --self-contained true`. Also fully self-contained; no ASP.NET Core runtime needs + to be pre-installed. +- **`ClaudeDo.Installer`** (`net8.0-windows`, WPF) is the one exception: published + `--self-contained false -p:PublishSingleFile=true` — **framework-dependent**. The csproj + comment explains why: *"the WPF runtime pack isn't distributed for cross-compile on Linux CI, + which made self-contained bundles crash on startup with AV in the apphost."* The target + machine **must** have the **.NET 8 Desktop Runtime (x64)** installed before running + `ClaudeDo.Installer.exe` — this is the actual runtime-preflight gap, not the app/worker. + +**How to check on a target machine**: +- `dotnet --list-runtimes` — look for a `Microsoft.WindowsDesktop.App 8.0.x` line (Desktop + Runtime, required by the installer). Requires the `dotnet` CLI itself to be on PATH; not + guaranteed present on a fresh machine that never installed the SDK, only the runtime — in + that case `dotnet` may not exist at all even though the runtime DLLs do. +- Registry fallback (works even without the `dotnet` CLI on PATH): + `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` — + each installed version is a subkey/value here. This is the standard documented detection + mechanism for .NET Desktop Runtime presence on Windows and is what most installer-detection + tooling (e.g. Squirrel, WiX bundles) uses instead of shelling out to `dotnet`. +- **Not independently verified in this task**: the exact registry key shape wasn't inspected + live (would require reading `HKLM\SOFTWARE\dotnet\...` on this machine, which is standard + .NET installer-detection convention, but out of scope to screenshot/dump here since the task + is docs-only and this is a well-documented, non-project-specific Windows convention). + +## Beschlossene Konstanten + +| Constant | Value | Confidence | +|---|---|---| +| Minimum CLI version | `2.1.220` | **Verified-floor, not a proven minimum.** This is the newest version confirmed installed and working end-to-end on a dev machine for every flag ClaudeDo uses. No lower true minimum could be sourced (see §3) — treat any lower value as a guess. | +| Credentials file path | `%USERPROFILE%\.claude\.credentials.json` (Windows) | Sourced from official docs; content/schema not inspected (hard-blocked secrets file). | +| Login-check command | `claude auth status --json` | Verified locally, instant, no model call. Fields: `loggedIn`, `authMethod`, `apiProvider`, `email`, `orgId`, `orgName`, `subscriptionType`. | +| App/Worker runtime requirement | **None** — self-contained win-x64 publish | Sourced from `.gitea/workflows/release.yml`. | +| Installer runtime requirement | **.NET 8 Desktop Runtime (x64)** must be pre-installed | Sourced from `.gitea/workflows/release.yml` comment + `ClaudeDo.Installer.csproj`. | + +## Erkennungsstrategie pro Check + +| Check | Type | Command | Expected pass output | +|---|---|---|---| +| CLI present + version | static | `claude --version` | `X.Y.Z (Claude Code)`; parse and compare `X.Y.Z >= 2.1.220` | +| `auto` recognized as a flag value | static | `claude --help` (or trigger the parse error path) | `--permission-mode ` help text lists `auto` among the choices | +| CLI logged in | static/cheap | `claude auth status --json` | exit 0, `loggedIn: true` | +| Auto mode actually eligible (org/model/plan) | **not statically detectable** | none exists | N/A — see §2; don't build this, let a real task run surface a fast startup rejection instead | +| .NET Desktop Runtime present (installer only) | static | `dotnet --list-runtimes` (if `dotnet` on PATH) or registry `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` | a `Microsoft.WindowsDesktop.App 8.0.x` entry exists | +| App/Worker runtime present | **not needed** | — | self-contained, nothing to check | + +## Not verifiable (explicit) + +- The actual root cause on the colleague's machine — no diagnostic data was captured from it. +- Exact stderr/exit-code wording the CLI prints when auto mode is rejected at startup for an + ineligible org/model (docs state the *behavior* — "rejects `--permission-mode auto` at + startup" — but not the literal message; this machine's account is eligible, so it couldn't be + reproduced locally). +- A true (not just "newest confirmed") minimum SemVer for `--effort`, `--agents`, + `--json-schema`, `--append-system-prompt`, `--resume`, `claude mcp add --transport http + --scope user` — all predate the retrievable changelog window. +- Live inspection of the `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions` registry shape on this + machine (documented Windows convention, not independently screenshotted here). diff --git a/src/ClaudeDo.Data/Environment/ExecutableResolver.cs b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs new file mode 100644 index 00000000..7b6486d9 --- /dev/null +++ b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs @@ -0,0 +1,116 @@ +using SysEnvironment = System.Environment; + +namespace ClaudeDo.Data.Environment; + +public sealed record ResolvedExecutable(string Path, bool IsShim); + +public sealed record ShimStartInfo(string FileName, string Arguments); + +/// +/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds +/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss. +/// +public static class ExecutableResolver +{ + private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD"; + + // Known npm/claude install locations to try when PATH search comes up empty. + private static readonly string[] FallbackDirectoryTemplates = + { + "%APPDATA%\\npm", + "%LOCALAPPDATA%\\Programs\\claude", + "%USERPROFILE%\\.local\\bin", + }; + + public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null) + { + var pathExts = ParsePathExt(pathExtOverride); + + if (LooksLikePath(command)) + { + return ResolveAsPath(command, pathExts); + } + + var directories = ParsePath(pathOverride); + foreach (var dir in directories) + { + var resolved = ResolveInDirectory(dir, command, pathExts); + if (resolved is not null) return resolved; + } + + foreach (var template in FallbackDirectoryTemplates) + { + var dir = SysEnvironment.ExpandEnvironmentVariables(template); + var resolved = ResolveInDirectory(dir, command, pathExts); + if (resolved is not null) return resolved; + } + + 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) }; + parts.AddRange(arguments.Select(Quote)); + return new ShimStartInfo("cmd.exe", string.Join(' ', parts)); + } + + private static bool LooksLikePath(string command) => + command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar); + + private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList pathExts) + { + if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command))); + + if (Path.HasExtension(command)) return null; + + foreach (var ext in pathExts) + { + var candidate = command + ext; + if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext)); + } + + return null; + } + + private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList pathExts) + { + if (!Directory.Exists(directory)) return null; + + if (Path.HasExtension(command)) + { + var candidate = Path.Combine(directory, command); + return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null; + } + + foreach (var ext in pathExts) + { + var candidate = Path.Combine(directory, command + ext); + if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext)); + } + + return null; + } + + private static bool IsShimExtension(string extension) => + !extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) + && !extension.Equals(".com", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList ParsePathExt(string? pathExtOverride) + { + var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt; + return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static IReadOnlyList ParsePath(string? pathOverride) + { + var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? ""; + return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value; +} diff --git a/src/ClaudeDo.Data/Paths.cs b/src/ClaudeDo.Data/Paths.cs index c55037fa..093caa4f 100644 --- a/src/ClaudeDo.Data/Paths.cs +++ b/src/ClaudeDo.Data/Paths.cs @@ -11,16 +11,16 @@ public static class Paths if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Path must not be empty.", nameof(path)); - var expanded = Environment.ExpandEnvironmentVariables(path); + var expanded = System.Environment.ExpandEnvironmentVariables(path); if (expanded.StartsWith("~", StringComparison.Ordinal)) { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); expanded = home + expanded[1..]; } if (!Path.IsPathRooted(expanded)) - expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory); + expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory); return Path.GetFullPath(expanded); } diff --git a/src/ClaudeDo.Installer/Checks/CheckResult.cs b/src/ClaudeDo.Installer/Checks/CheckResult.cs new file mode 100644 index 00000000..15e612a0 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/CheckResult.cs @@ -0,0 +1,25 @@ +namespace ClaudeDo.Installer.Checks; + +public enum CheckSeverity { Warning, Error } + +public enum CheckStatus { Ok, Failed, Unknown } + +public sealed record CheckResult( + string Id, + CheckSeverity Severity, + CheckStatus Status, + string TitleKey, + string Message, + string? HintKey, + string? HelpUrl, + string? Detail) +{ + public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) => + new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail); + + public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) => + new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail); + + public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) => + new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail); +} 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/EnvironmentCheckService.cs b/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs new file mode 100644 index 00000000..f21cabc5 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs @@ -0,0 +1,37 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +public sealed record EnvironmentCheckReport(IReadOnlyList Results) +{ + public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed); +} + +public sealed class EnvironmentCheckService +{ + private readonly IReadOnlyList _checks; + + public EnvironmentCheckService(IEnumerable checks) + { + _checks = checks.ToList(); + } + + public async Task RunAllAsync(InstallContext ctx, CancellationToken ct) + { + var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray(); + var results = await Task.WhenAll(tasks); + return new EnvironmentCheckReport(results); + } + + private static async Task RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct) + { + try + { + return await check.RunAsync(ctx, ct); + } + catch (Exception ex) + { + return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message); + } + } +} diff --git a/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs b/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs new file mode 100644 index 00000000..1997dec7 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs @@ -0,0 +1,10 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +public interface IEnvironmentCheck +{ + string Id { get; } + CheckSeverity Severity { get; } + Task RunAsync(InstallContext ctx, CancellationToken ct); +} 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.Installer/Checks/ProcessCommand.cs b/src/ClaudeDo.Installer/Checks/ProcessCommand.cs new file mode 100644 index 00000000..bef0ca4b --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ProcessCommand.cs @@ -0,0 +1,20 @@ +using ClaudeDo.Data.Environment; + +namespace ClaudeDo.Installer.Checks; + +/// Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe. +internal static class ProcessCommand +{ + public static (string FileName, string Arguments) For(ResolvedExecutable resolved, params string[] args) + { + if (resolved.IsShim) + { + var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, args); + return (shim.FileName, shim.Arguments); + } + + return (resolved.Path, string.Join(' ', args.Select(Quote))); + } + + private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value; +} diff --git a/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs b/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs new file mode 100644 index 00000000..5dbc20d5 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Installer.Core.Interfaces; + +public interface IProcessRunner +{ + Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct); +} diff --git a/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs b/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs new file mode 100644 index 00000000..d1ec89e8 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs @@ -0,0 +1,9 @@ +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Core; + +public sealed class ProcessRunnerAdapter : IProcessRunner +{ + public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) => + ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct); +} diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 2504b9f3..67d4ac99 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -628,5 +628,23 @@ "lastError": "Letzter Fehler: {0}", "throttled": "Gedrosselt: {0}/{1} Slots ({2})" } + }, + "checks": { + "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 3c9a44e1..5b757e74 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -628,5 +628,23 @@ "lastError": "Last error: {0}", "throttled": "Throttled: {0}/{1} slots ({2})" } + }, + "checks": { + "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.Data.Tests/ExecutableResolverTests.cs b/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs new file mode 100644 index 00000000..2160379a --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs @@ -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); + } +} 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/FakeProcessRunner.cs b/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs new file mode 100644 index 00000000..9095f6ba --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs @@ -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); + } +} 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); + } + } +}