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/GitCheck.cs b/src/ClaudeDo.Installer/Checks/GitCheck.cs
new file mode 100644
index 00000000..71aa3994
--- /dev/null
+++ b/src/ClaudeDo.Installer/Checks/GitCheck.cs
@@ -0,0 +1,49 @@
+using System.Text.RegularExpressions;
+using ClaudeDo.Data.Environment;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Core.Interfaces;
+
+namespace ClaudeDo.Installer.Checks;
+
+/// Without git, no task can run (worktrees) — blocking.
+public sealed class GitCheck : IEnvironmentCheck
+{
+ public const string CheckId = "git";
+ private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
+
+ private readonly IProcessRunner _processRunner;
+ private readonly string? _pathOverride;
+ private readonly string? _pathExtOverride;
+
+ public GitCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
+ {
+ _processRunner = processRunner;
+ _pathOverride = pathOverride;
+ _pathExtOverride = pathExtOverride;
+ }
+
+ public string Id => CheckId;
+ public CheckSeverity Severity => CheckSeverity.Error;
+
+ public async Task RunAsync(InstallContext ctx, CancellationToken ct)
+ {
+ var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
+ if (resolved is null)
+ {
+ return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
+ "checks.git.hint", "https://git-scm.com/downloads");
+ }
+
+ var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
+ var (exitCode, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
+
+ if (exitCode != 0)
+ {
+ return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
+ "checks.git.hint", "https://git-scm.com/downloads", output);
+ }
+
+ var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
+ return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
+ }
+}
diff --git a/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs b/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs
new file mode 100644
index 00000000..8a4a5a07
--- /dev/null
+++ b/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs
@@ -0,0 +1,60 @@
+using ClaudeDo.Data.Environment;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Core.Interfaces;
+
+namespace ClaudeDo.Installer.Checks;
+
+/// Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker.
+public sealed class GitIdentityCheck : IEnvironmentCheck
+{
+ public const string CheckId = "git-identity";
+
+ private readonly IProcessRunner _processRunner;
+ private readonly string? _pathOverride;
+ private readonly string? _pathExtOverride;
+
+ public GitIdentityCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
+ {
+ _processRunner = processRunner;
+ _pathOverride = pathOverride;
+ _pathExtOverride = pathExtOverride;
+ }
+
+ public string Id => CheckId;
+ public CheckSeverity Severity => CheckSeverity.Warning;
+
+ public async Task RunAsync(InstallContext ctx, CancellationToken ct)
+ {
+ var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
+ if (resolved is null)
+ {
+ return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked.");
+ }
+
+ var name = await ReadConfigAsync(resolved, "user.name", ct);
+ var email = await ReadConfigAsync(resolved, "user.email", ct);
+ var hasName = !string.IsNullOrWhiteSpace(name);
+ var hasEmail = !string.IsNullOrWhiteSpace(email);
+
+ if (hasName && hasEmail)
+ {
+ return CheckResult.Ok(Id, Severity, "checks.gitIdentity.title", $"{name} <{email}>");
+ }
+
+ var missing = (hasName, hasEmail) switch
+ {
+ (false, false) => "user.name, user.email",
+ (false, true) => "user.name",
+ _ => "user.email",
+ };
+
+ return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
+ }
+
+ private async Task ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
+ {
+ var (fileName, arguments) = ProcessCommand.For(resolved, "config", "--get", key);
+ var (_, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
+ return output.Trim();
+ }
+}
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/Interfaces/IPortOwnerResolver.cs b/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs
new file mode 100644
index 00000000..39e2a4e6
--- /dev/null
+++ b/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs
@@ -0,0 +1,6 @@
+namespace ClaudeDo.Installer.Checks.Interfaces;
+
+public interface IPortOwnerResolver
+{
+ Task FindOwningProcessNameAsync(int port, CancellationToken ct);
+}
diff --git a/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs b/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs
new file mode 100644
index 00000000..0a4c3e26
--- /dev/null
+++ b/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs
@@ -0,0 +1,33 @@
+using System.Diagnostics;
+using ClaudeDo.Installer.Checks.Interfaces;
+using ClaudeDo.Installer.Core.Interfaces;
+
+namespace ClaudeDo.Installer.Checks;
+
+public sealed class NetstatPortOwnerResolver : IPortOwnerResolver
+{
+ private readonly IProcessRunner _processRunner;
+
+ public NetstatPortOwnerResolver(IProcessRunner processRunner) => _processRunner = processRunner;
+
+ public async Task FindOwningProcessNameAsync(int port, CancellationToken ct)
+ {
+ var (exitCode, output) = await _processRunner.RunAsync("netstat.exe", "-ano -p TCP", null, ct);
+ if (exitCode != 0) return null;
+
+ var suffix = $":{port}";
+ foreach (var line in output.Split('\n'))
+ {
+ var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 5) continue;
+ if (!parts[0].Equals("TCP", StringComparison.OrdinalIgnoreCase)) continue;
+ if (!parts[1].EndsWith(suffix, StringComparison.Ordinal)) continue;
+ if (!int.TryParse(parts[^1], out var pid)) continue;
+
+ try { return Process.GetProcessById(pid).ProcessName; }
+ catch { return null; }
+ }
+
+ return null;
+ }
+}
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/PortCheck.cs b/src/ClaudeDo.Installer/Checks/PortCheck.cs
new file mode 100644
index 00000000..07f1ade1
--- /dev/null
+++ b/src/ClaudeDo.Installer/Checks/PortCheck.cs
@@ -0,0 +1,71 @@
+using System.Net;
+using System.Net.Sockets;
+using ClaudeDo.Installer.Checks.Interfaces;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Checks;
+
+/// Both ports are configurable in the installer settings, so a conflict is only a warning.
+public sealed class PortCheck : IEnvironmentCheck
+{
+ public const string CheckId = "ports";
+
+ private readonly IPortOwnerResolver _portOwnerResolver;
+
+ public PortCheck(IPortOwnerResolver portOwnerResolver) => _portOwnerResolver = portOwnerResolver;
+
+ public string Id => CheckId;
+ public CheckSeverity Severity => CheckSeverity.Warning;
+
+ public async Task RunAsync(InstallContext ctx, CancellationToken ct)
+ {
+ var ports = new (string Label, int Port)[]
+ {
+ ("SignalR", ctx.SignalRPort),
+ ("External MCP", ctx.ExternalMcpPort),
+ };
+
+ var blocked = new List();
+ var ownWorker = new List();
+
+ foreach (var (label, port) in ports)
+ {
+ if (IsFree(port)) continue;
+
+ var owner = await _portOwnerResolver.FindOwningProcessNameAsync(port, ct);
+ if (owner is not null && owner.Contains("ClaudeDo.Worker", StringComparison.OrdinalIgnoreCase))
+ {
+ ownWorker.Add($"{label} ({port})");
+ continue;
+ }
+
+ blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'.");
+ }
+
+ if (blocked.Count > 0)
+ {
+ return CheckResult.Fail(Id, Severity, "checks.ports.title", string.Join(" ", blocked), "checks.ports.hint");
+ }
+
+ if (ownWorker.Count > 0)
+ {
+ return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}.");
+ }
+
+ return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available.");
+ }
+
+ private static bool IsFree(int port)
+ {
+ try
+ {
+ using var listener = new TcpListener(IPAddress.Loopback, port);
+ listener.Start();
+ return true;
+ }
+ catch (SocketException)
+ {
+ return false;
+ }
+ }
+}
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/Checks/WriteAccessCheck.cs b/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs
new file mode 100644
index 00000000..fe396c2e
--- /dev/null
+++ b/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs
@@ -0,0 +1,62 @@
+using System.IO;
+using ClaudeDo.Data;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Checks;
+
+/// Without write access nothing can be installed — blocking.
+public sealed class WriteAccessCheck : IEnvironmentCheck
+{
+ public const string CheckId = "write-access";
+
+ public string Id => CheckId;
+ public CheckSeverity Severity => CheckSeverity.Error;
+
+ public Task RunAsync(InstallContext ctx, CancellationToken ct)
+ {
+ foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
+ {
+ var error = TryWrite(target);
+ if (error is not null)
+ {
+ return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
+ $"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
+ }
+ }
+
+ return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable."));
+ }
+
+ private static string? TryWrite(string path)
+ {
+ var probeDir = FirstExistingParent(path);
+ var probeFile = Path.Combine(probeDir, $".claudedo-write-check-{Guid.NewGuid():N}.tmp");
+
+ try
+ {
+ File.WriteAllText(probeFile, string.Empty);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return ex.Message;
+ }
+ finally
+ {
+ try { File.Delete(probeFile); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ private static string FirstExistingParent(string path)
+ {
+ var current = Path.GetFullPath(path);
+ while (!Directory.Exists(current))
+ {
+ var parent = Path.GetDirectoryName(current);
+ if (string.IsNullOrEmpty(parent) || parent == current) break;
+ current = parent;
+ }
+
+ return current;
+ }
+}
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/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/GitCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs
new file mode 100644
index 00000000..aae7bd12
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs
@@ -0,0 +1,66 @@
+using ClaudeDo.Installer.Checks;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests.Checks;
+
+public sealed class GitCheckTests : IDisposable
+{
+ private readonly string _dir;
+
+ public GitCheckTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), $"cdgitcheck_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch { }
+ }
+
+ private string MakeGitExe()
+ {
+ var path = Path.Combine(_dir, "git.exe");
+ File.WriteAllText(path, "");
+ return path;
+ }
+
+ [Fact]
+ public async Task Git_present_with_parseable_version_reports_ok()
+ {
+ MakeGitExe();
+ var runner = new FakeProcessRunner((0, "git version 2.43.0.windows.1\n"));
+ var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Ok, result.Status);
+ Assert.Contains("2.43.0", result.Message);
+ Assert.Contains(_dir, result.Message);
+ }
+
+ [Fact]
+ public async Task Git_not_found_on_path_reports_failed()
+ {
+ var runner = new FakeProcessRunner();
+ var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Failed, result.Status);
+ Assert.Equal(CheckSeverity.Error, result.Severity);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task Git_nonzero_exit_reports_failed()
+ {
+ MakeGitExe();
+ var runner = new FakeProcessRunner((1, "some error"));
+ var check = new GitCheck(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/GitIdentityCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs
new file mode 100644
index 00000000..84b431a3
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs
@@ -0,0 +1,88 @@
+using ClaudeDo.Installer.Checks;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests.Checks;
+
+public sealed class GitIdentityCheckTests : IDisposable
+{
+ private readonly string _dir;
+
+ public GitIdentityCheckTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), $"cdgitid_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); } catch { }
+ }
+
+ private void MakeGitExe() => File.WriteAllText(Path.Combine(_dir, "git.exe"), "");
+
+ [Fact]
+ public async Task Both_set_reports_ok()
+ {
+ MakeGitExe();
+ var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (0, "mika@example.com\n"));
+ var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Ok, result.Status);
+ }
+
+ [Fact]
+ public async Task Only_name_set_reports_failed_naming_email()
+ {
+ MakeGitExe();
+ var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (1, ""));
+ var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Failed, result.Status);
+ Assert.Contains("user.email", result.Message);
+ Assert.DoesNotContain("user.name", result.Message);
+ }
+
+ [Fact]
+ public async Task Only_email_set_reports_failed_naming_name()
+ {
+ MakeGitExe();
+ var runner = new FakeProcessRunner((1, ""), (0, "mika@example.com\n"));
+ var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Failed, result.Status);
+ Assert.Contains("user.name", result.Message);
+ Assert.DoesNotContain("user.email", result.Message);
+ }
+
+ [Fact]
+ public async Task Neither_set_reports_failed_naming_both()
+ {
+ MakeGitExe();
+ var runner = new FakeProcessRunner((1, ""), (1, ""));
+ var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Failed, result.Status);
+ Assert.Contains("user.name", result.Message);
+ Assert.Contains("user.email", result.Message);
+ }
+
+ [Fact]
+ public async Task Git_missing_reports_unknown_not_failed()
+ {
+ var runner = new FakeProcessRunner();
+ var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
+
+ var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Unknown, result.Status);
+ Assert.Empty(runner.Calls);
+ }
+}
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);
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs
new file mode 100644
index 00000000..2a256f0c
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs
@@ -0,0 +1,68 @@
+using System.Net;
+using System.Net.Sockets;
+using ClaudeDo.Installer.Checks;
+using ClaudeDo.Installer.Checks.Interfaces;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests.Checks;
+
+public sealed class PortCheckTests
+{
+ private sealed class FakeOwnerResolver : IPortOwnerResolver
+ {
+ private readonly string? _owner;
+ public FakeOwnerResolver(string? owner) => _owner = owner;
+ public Task FindOwningProcessNameAsync(int port, CancellationToken ct) => Task.FromResult(_owner);
+ }
+
+ private static int GetFreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ [Fact]
+ public async Task Both_ports_free_reports_ok()
+ {
+ var ctx = new InstallContext { SignalRPort = GetFreePort(), ExternalMcpPort = GetFreePort() };
+ var check = new PortCheck(new FakeOwnerResolver(null));
+
+ var result = await check.RunAsync(ctx, CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Ok, result.Status);
+ }
+
+ [Fact]
+ public async Task Port_in_use_by_other_process_reports_failed()
+ {
+ var port = GetFreePort();
+ using var occupying = new TcpListener(IPAddress.Loopback, port);
+ occupying.Start();
+ var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() };
+ var check = new PortCheck(new FakeOwnerResolver("SomeOtherApp"));
+
+ var result = await check.RunAsync(ctx, CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Failed, result.Status);
+ Assert.Equal(CheckSeverity.Warning, result.Severity);
+ Assert.Contains("SomeOtherApp", result.Message);
+ }
+
+ [Fact]
+ public async Task Port_in_use_by_own_worker_reports_ok()
+ {
+ var port = GetFreePort();
+ using var occupying = new TcpListener(IPAddress.Loopback, port);
+ occupying.Start();
+ var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() };
+ var check = new PortCheck(new FakeOwnerResolver("ClaudeDo.Worker"));
+
+ var result = await check.RunAsync(ctx, CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Ok, result.Status);
+ Assert.Contains("ClaudeDo Worker", result.Message);
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs
new file mode 100644
index 00000000..e0734afa
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs
@@ -0,0 +1,80 @@
+using System.Security.AccessControl;
+using System.Security.Principal;
+using ClaudeDo.Installer.Checks;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests.Checks;
+
+public sealed class WriteAccessCheckTests : IDisposable
+{
+ private readonly List _cleanupDirs = new();
+
+ private string TempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"cdwriteaccess_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ _cleanupDirs.Add(dir);
+ return dir;
+ }
+
+ public void Dispose()
+ {
+ foreach (var dir in _cleanupDirs)
+ {
+ try { Directory.Delete(dir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public async Task Writable_directory_reports_ok()
+ {
+ var dir = TempDir();
+ var check = new WriteAccessCheck();
+
+ var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Ok, result.Status);
+ }
+
+ [Fact]
+ public async Task NonExistent_path_under_writable_parent_reports_ok()
+ {
+ var parent = TempDir();
+ var target = Path.Combine(parent, "does", "not", "exist");
+ var check = new WriteAccessCheck();
+
+ var result = await check.RunAsync(new InstallContext { InstallDirectory = target }, CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Ok, result.Status);
+ }
+
+ [Fact]
+ public async Task Unwritable_directory_reports_failed_with_no_leftover_file()
+ {
+ var dir = TempDir();
+ var dirInfo = new DirectoryInfo(dir);
+ var identity = WindowsIdentity.GetCurrent().User!;
+ var denyRule = new FileSystemAccessRule(identity, FileSystemRights.CreateFiles | FileSystemRights.WriteData, AccessControlType.Deny);
+
+ var acl = dirInfo.GetAccessControl();
+ acl.AddAccessRule(denyRule);
+ dirInfo.SetAccessControl(acl);
+
+ try
+ {
+ var check = new WriteAccessCheck();
+
+ var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None);
+
+ Assert.Equal(CheckStatus.Failed, result.Status);
+ Assert.Equal(CheckSeverity.Error, result.Severity);
+ Assert.Contains(dir, result.Message);
+ Assert.Empty(Directory.GetFiles(dir));
+ }
+ finally
+ {
+ acl.RemoveAccessRule(denyRule);
+ dirInfo.SetAccessControl(acl);
+ }
+ }
+}