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/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/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/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index 5d0e58ea..c64d31d5 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -636,5 +636,23 @@
"lastError": "Letzter Fehler: {0}",
"throttled": "Gedrosselt: {0}/{1} Slots ({2})"
}
+ },
+ "checks": {
+ "git": {
+ "title": "Git",
+ "hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
+ },
+ "gitIdentity": {
+ "title": "Git-Identität",
+ "hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
+ },
+ "ports": {
+ "title": "Ports",
+ "hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
+ },
+ "writeAccess": {
+ "title": "Schreibzugriff",
+ "hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
+ }
}
}
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 558d0d6d..f7cd8b65 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -636,5 +636,23 @@
"lastError": "Last error: {0}",
"throttled": "Throttled: {0}/{1} slots ({2})"
}
+ },
+ "checks": {
+ "git": {
+ "title": "Git",
+ "hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
+ },
+ "gitIdentity": {
+ "title": "Git identity",
+ "hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
+ },
+ "ports": {
+ "title": "Ports",
+ "hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
+ },
+ "writeAccess": {
+ "title": "Write access",
+ "hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
+ }
}
}
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/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);
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj b/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
index 9a9b4eb2..aeb3d207 100644
--- a/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
+++ b/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
@@ -14,6 +14,7 @@
+