feat(installer): add Claude CLI preflight checks (found, version, login, auto-mode)
Four IEnvironmentCheck implementations in src/ClaudeDo.Installer/Checks/: - ClaudeCliCheck (Error) — resolves ctx.ClaudeBin via ExecutableResolver, runs --version; failure message lists searched PATH entries + fallback dirs, flags shim resolution (.cmd/.ps1) in Detail. - ClaudeVersionCheck (Error) — tolerant version parsing (ignores surrounding text), numeric System.Version comparison against a named floor constant (2.1.220, see docs/explore-notes/installer-preflight.md §3). Unparseable or missing CLI -> Unknown, never Failed. - ClaudeAuthCheck (Error) — `claude auth status --json`, never sends a prompt. - PermissionModeAutoCheck (Warning) — per the note's §2 conclusion, real auto-mode eligibility (org/model/plan) has no cheap static signal, so this only confirms `claude --help` still lists "auto" as a --permission-mode choice. Kept as its own check rather than folded into ClaudeVersionCheck, since the note explicitly separates "flag recognized" from "mode eligible" and only the former is checkable at all. All four share a new ClaudeCliLookup that resolves the CLI and runs --version exactly once per install run (memoized, semaphore-guarded), so none of the three version-dependent checks repeats that call. Foundation prerequisites this task depends on (docs/explore-notes/ installer-preflight.md, ExecutableResolver, the IEnvironmentCheck/CheckResult/ EnvironmentCheckService abstraction, ProcessCommand, IProcessRunner) existed only on an unmerged sibling branch, not on main. Rather than merging that whole unreviewed branch, the specific prerequisite files were copied in as-is (git history shows their origin). GitCheck/GitIdentityCheck/PortCheck/ WriteAccessCheck from that branch were intentionally NOT brought in — out of scope for this task. Deviations/decisions worth flagging: - Added ExecutableResolver.FallbackDirectories() (public) so ClaudeCliCheck can name the checked fallback locations in its failure message; the prior branch kept that list private. - Paths.cs now qualifies System.Environment explicitly. Adding the ClaudeDo.Data.Environment namespace makes bare `Environment.X` inside any ClaudeDo.Data.* namespace resolve to the sibling namespace instead of System.Environment (C# prefers nested/enclosing namespace members over usings) — this broke the build until qualified. Not done (explicitly out of scope): no DI wiring into the wizard UI, no XAML — matches how the prerequisite Git/Port/WriteAccess checks were also left unwired.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <mode>` line lists the accepted enum. Confirmed by
|
||||
testing an invalid value locally:
|
||||
```
|
||||
$ claude -p "test" --permission-mode bogus
|
||||
error: option '--permission-mode <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 <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).
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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) };
|
||||
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<string> 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<string> 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<string> ParsePathExt(string? pathExtOverride)
|
||||
{
|
||||
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
|
||||
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,37 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed record EnvironmentCheckReport(IReadOnlyList<CheckResult> Results)
|
||||
{
|
||||
public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed);
|
||||
}
|
||||
|
||||
public sealed class EnvironmentCheckService
|
||||
{
|
||||
private readonly IReadOnlyList<IEnvironmentCheck> _checks;
|
||||
|
||||
public EnvironmentCheckService(IEnumerable<IEnvironmentCheck> checks)
|
||||
{
|
||||
_checks = checks.ToList();
|
||||
}
|
||||
|
||||
public async Task<EnvironmentCheckReport> 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<CheckResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public interface IEnvironmentCheck
|
||||
{
|
||||
string Id { get; }
|
||||
CheckSeverity Severity { get; }
|
||||
Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class ExecutableResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public ExecutableResolverTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"claudedo_exeresolve_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private string MakeDir(string name)
|
||||
{
|
||||
var dir = Path.Combine(_root, name);
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static void Touch(string path) => File.WriteAllText(path, "");
|
||||
|
||||
[Fact]
|
||||
public void Command_with_directory_separator_is_treated_as_path()
|
||||
{
|
||||
var dir = MakeDir("bin1");
|
||||
var exePath = Path.Combine(dir, "myclaude.exe");
|
||||
Touch(exePath);
|
||||
|
||||
var result = ExecutableResolver.Resolve(exePath, pathOverride: "C:\\does\\not\\matter");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(exePath, result!.Path);
|
||||
Assert.False(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_like_command_without_extension_resolves_via_pathext()
|
||||
{
|
||||
var dir = MakeDir("bin2");
|
||||
var cmdPath = Path.Combine(dir, "myclaude.cmd");
|
||||
Touch(cmdPath);
|
||||
var commandWithoutExt = Path.Combine(dir, "myclaude");
|
||||
|
||||
var result = ExecutableResolver.Resolve(commandWithoutExt, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(cmdPath, result!.Path);
|
||||
Assert.True(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmd_file_in_path_is_found_when_no_exe_exists()
|
||||
{
|
||||
var dir = MakeDir("bin3");
|
||||
Touch(Path.Combine(dir, "claude.cmd"));
|
||||
|
||||
var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.Combine(dir, "claude.cmd"), result!.Path);
|
||||
Assert.True(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exe_wins_against_cmd_in_same_directory()
|
||||
{
|
||||
var dir = MakeDir("bin4");
|
||||
Touch(Path.Combine(dir, "claude.cmd"));
|
||||
Touch(Path.Combine(dir, "claude.exe"));
|
||||
|
||||
var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.Combine(dir, "claude.exe"), result!.Path);
|
||||
Assert.False(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directory_order_in_path_is_respected()
|
||||
{
|
||||
var dir1 = MakeDir("bin5a");
|
||||
var dir2 = MakeDir("bin5b");
|
||||
Touch(Path.Combine(dir1, "claude.cmd"));
|
||||
Touch(Path.Combine(dir2, "claude.exe"));
|
||||
|
||||
var pathOverride = $"{dir1}{Path.PathSeparator}{dir2}";
|
||||
var result = ExecutableResolver.Resolve("claude", pathOverride: pathOverride, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.Combine(dir1, "claude.cmd"), result!.Path);
|
||||
Assert.True(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unresolvable_command_returns_null()
|
||||
{
|
||||
var dir = MakeDir("bin6");
|
||||
|
||||
var result = ExecutableResolver.Resolve(
|
||||
"claudedo_totally_missing_cmd_9f3a1",
|
||||
pathOverride: dir,
|
||||
pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildShimStartInfo_uses_cmd_exe_and_quotes_paths_with_spaces()
|
||||
{
|
||||
const string shimPath = @"C:\Program Files\claude\claude.cmd";
|
||||
|
||||
var startInfo = ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "--version", "arg2" });
|
||||
|
||||
Assert.Equal("cmd.exe", startInfo.FileName);
|
||||
Assert.Contains("/c", startInfo.Arguments);
|
||||
Assert.Contains($"\"{shimPath}\"", startInfo.Arguments);
|
||||
Assert.Contains("--version", startInfo.Arguments);
|
||||
Assert.Contains("arg2", startInfo.Arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeAuthCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeAuthCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_in_reports_ok_without_sending_a_prompt()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.All(runner.Calls, call => Assert.DoesNotContain("-p ", call.Arguments));
|
||||
Assert.Contains(runner.Calls, call => call.Arguments.Contains("auth") && call.Arguments.Contains("status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_out_reports_failed()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "{\"loggedIn\":false}"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.NotNull(result.HintKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_output_reports_unknown_not_failed()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "not json"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_missing_reports_unknown()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeAuthCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeCliCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeCliCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudecli_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Found_as_exe_reports_ok_with_path_and_version_and_no_shim_detail()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.Contains(_dir, result.Message);
|
||||
Assert.Contains("2.1.230", result.Message);
|
||||
Assert.Null(result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Found_as_shim_flags_it_in_detail()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.cmd"), "");
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".cmd"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Not_found_reports_failed_naming_searched_locations()
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
// A distinctive, definitely-missing name — a plain "claude" would still resolve via
|
||||
// ExecutableResolver's fixed fallback dirs (e.g. %USERPROFILE%\.local\bin) on a machine
|
||||
// that has the real CLI installed, regardless of pathOverride.
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.Equal(CheckSeverity.Error, result.Severity);
|
||||
Assert.Contains(_dir, result.Message);
|
||||
Assert.NotNull(result.HintKey);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nonzero_exit_reports_failed()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
var runner = new FakeProcessRunner((1, "some error"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeCliLookupTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeCliLookupTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Version_is_executed_only_once_across_repeated_calls()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Single(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_calls_still_run_version_only_once()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8)
|
||||
.Select(_ => lookup.ResolveAsync(new InstallContext(), CancellationToken.None)));
|
||||
|
||||
Assert.Single(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Version_is_executed_only_once_when_shared_across_all_four_checks()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
var ctx = new InstallContext();
|
||||
|
||||
await new ClaudeCliCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new ClaudeVersionCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new ClaudeAuthCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new PermissionModeAutoCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
var versionCalls = runner.Calls.Count(c => c.Arguments.Contains("--version"));
|
||||
Assert.Equal(1, versionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_not_found_returns_null_resolved_without_running_a_process()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe");
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await lookup.ResolveAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Resolved);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeVersionCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeVersionCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Theory]
|
||||
[InlineData("2.1.220 (Claude Code)", CheckStatus.Ok)] // exactly at the floor
|
||||
[InlineData("2.1.219 (Claude Code)", CheckStatus.Failed)] // one patch below the floor
|
||||
[InlineData("2.1.191", CheckStatus.Failed)]
|
||||
[InlineData("v2.1.191", CheckStatus.Failed)]
|
||||
[InlineData("2.10.0", CheckStatus.Ok)] // minor 10 > minor 1, not a lexical trap
|
||||
[InlineData("garbage output, no version here", CheckStatus.Unknown)]
|
||||
public async Task Version_parsing_and_gate(string versionOutput, CheckStatus expected)
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, versionOutput));
|
||||
var check = new ClaudeVersionCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(expected, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Minor_version_compares_numerically_not_lexically()
|
||||
{
|
||||
Assert.True(new Version(2, 10, 0) > new Version(2, 9, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_not_found_reports_unknown_not_failed()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeVersionCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
Assert.Empty(runner.Calls);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
internal sealed class FakeProcessRunner : IProcessRunner
|
||||
{
|
||||
private readonly Queue<(int ExitCode, string Output)> _responses;
|
||||
|
||||
public FakeProcessRunner(params (int ExitCode, string Output)[] responses)
|
||||
{
|
||||
_responses = new Queue<(int, string)>(responses);
|
||||
}
|
||||
|
||||
public List<(string FileName, string Arguments)> Calls { get; } = new();
|
||||
|
||||
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
|
||||
{
|
||||
Calls.Add((fileName, arguments));
|
||||
var response = _responses.Count > 0 ? _responses.Dequeue() : (0, "");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class PermissionModeAutoCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
private const string HelpWithAuto =
|
||||
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")\n";
|
||||
|
||||
private const string HelpWithoutAuto =
|
||||
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"bypassPermissions\", \"manual\")\n";
|
||||
|
||||
public PermissionModeAutoCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdpermmode_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_listed_in_help_reports_ok()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithAuto));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_missing_from_help_reports_failed_with_hint()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithoutAuto));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
||||
Assert.NotNull(result.HintKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Help_not_determinable_reports_unknown()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (1, ""));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_missing_reports_unknown()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdpermmode_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new PermissionModeAutoCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user