diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..0c75e0d
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,171 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ env:
+ DOTNET_ROOT: /home/mika/.dotnet
+ GITEA_API: https://git.kuns.dev/api/v1
+ REPO: releases/ClaudeDo
+ steps:
+ - name: Resolve version
+ id: ver
+ run: |
+ set -euo pipefail
+ TAG="${{ github.ref_name }}"
+ VERSION="${TAG#v}"
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "Building version: $VERSION (tag: $TAG)"
+
+ - name: Prepare workspace
+ id: ws
+ run: |
+ set -euo pipefail
+ WORK="$(mktemp -d -t claudedo-release-XXXXXX)"
+ echo "dir=$WORK" >> "$GITHUB_OUTPUT"
+ echo "Workspace: $WORK"
+
+ - name: Checkout tag
+ env:
+ TOKEN: ${{ secrets.GITEA_TOKEN }}
+ WORK: ${{ steps.ws.outputs.dir }}
+ TAG: ${{ steps.ver.outputs.tag }}
+ run: |
+ set -euo pipefail
+ git clone --depth 1 --branch "$TAG" \
+ "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
+ "$WORK/src"
+ git -C "$WORK/src" log -1 --oneline
+
+ - name: Publish ClaudeDo.App (win-x64, self-contained)
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: |
+ set -euo pipefail
+ export PATH="$DOTNET_ROOT:$PATH"
+ cd "$WORK/src"
+ dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
+ -c Release -r win-x64 --self-contained true \
+ /p:Version=$VERSION -o out/app
+
+ - name: Publish ClaudeDo.Worker (win-x64, self-contained)
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: |
+ set -euo pipefail
+ export PATH="$DOTNET_ROOT:$PATH"
+ cd "$WORK/src"
+ dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
+ -c Release -r win-x64 --self-contained true \
+ /p:Version=$VERSION -o out/worker
+
+ - name: Publish ClaudeDo.Installer (win-x64, single-file)
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: |
+ set -euo pipefail
+ export PATH="$DOTNET_ROOT:$PATH"
+ cd "$WORK/src"
+ dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
+ -c Release -r win-x64 --self-contained true \
+ /p:Version=$VERSION /p:PublishSingleFile=true \
+ -o out/installer
+
+ - name: Package assets
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: |
+ set -euo pipefail
+ cd "$WORK/src"
+ mkdir -p assets
+
+ # 1) App + Worker bundle (top-level dirs /app and /worker)
+ rm -rf bundle
+ mkdir -p bundle
+ cp -r out/app bundle/app
+ cp -r out/worker bundle/worker
+ ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
+ ( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
+
+ # 2) Installer single-file exe (renamed)
+ INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
+ if [ -z "$INSTALLER_EXE" ]; then
+ echo "::error::No .exe produced by installer publish" >&2
+ exit 1
+ fi
+ cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
+
+ # 3) Checksums (sha256, relative filenames)
+ ( cd assets && sha256sum \
+ "ClaudeDo-${VERSION}-win-x64.zip" \
+ "ClaudeDo.Installer-${VERSION}.exe" \
+ > checksums.txt )
+
+ echo "--- assets ---"
+ ls -la assets
+
+ - name: Create Gitea Release
+ id: release
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ TAG: ${{ steps.ver.outputs.tag }}
+ TOKEN: ${{ secrets.GITEA_TOKEN }}
+ run: |
+ set -euo pipefail
+ BODY=$(jq -n \
+ --arg tag "$TAG" \
+ --arg name "$TAG" \
+ '{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
+ RESP=$(curl -sS -X POST \
+ -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "$BODY" \
+ "${GITEA_API}/repos/${REPO}/releases")
+ RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
+ if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
+ echo "::error::Release creation failed" >&2
+ echo "$RESP" >&2
+ exit 1
+ fi
+ echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
+ echo "Created release id=$RELEASE_ID for tag=$TAG"
+
+ - name: Upload release assets
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ VERSION: ${{ steps.ver.outputs.version }}
+ RELEASE_ID: ${{ steps.release.outputs.release_id }}
+ TOKEN: ${{ secrets.GITEA_TOKEN }}
+ run: |
+ set -euo pipefail
+ cd "$WORK/src/assets"
+ for f in \
+ "ClaudeDo-${VERSION}-win-x64.zip" \
+ "ClaudeDo.Installer-${VERSION}.exe" \
+ "checksums.txt"
+ do
+ echo "Uploading: $f"
+ curl -sS --fail-with-body -X POST \
+ -H "Authorization: token ${TOKEN}" \
+ -F "attachment=@${f}" \
+ "${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
+ > /dev/null
+ done
+ echo "All assets uploaded."
+
+ - name: Cleanup workspace
+ if: always()
+ env:
+ WORK: ${{ steps.ws.outputs.dir }}
+ run: |
+ rm -rf "$WORK" || true
diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index 785aab8..178690d 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -4,9 +4,11 @@
+
+
diff --git a/docs/superpowers/plans/2026-04-15-installer-download-mode.md b/docs/superpowers/plans/2026-04-15-installer-download-mode.md
new file mode 100644
index 0000000..cb6072f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-15-installer-download-mode.md
@@ -0,0 +1,2382 @@
+# Installer: Download-Mode + Gitea Releases — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rewrite `ClaudeDo.Installer` so it downloads prebuilt App + Worker binaries from a Gitea release on `git.kuns.dev/releases/ClaudeDo` instead of building from source, with a three-way launch flow (fresh install / update / config).
+
+**Architecture:** Fresh install keeps the current wizard (minus the three `dotnet publish`/deploy steps, plus a new `DownloadAndExtractStep`). On every subsequent launch, an async `InstallModeDetector` reads `{InstallDir}/install.json`, hits the Gitea API, and picks between Update mode (short wizard with download) or Config mode (existing `SettingsWindow`, now with Repair + Uninstall buttons).
+
+**Tech Stack:** .NET 8 / WPF + CommunityToolkit.Mvvm (existing), `System.Net.Http`, `System.IO.Compression`, `System.Security.Cryptography.SHA256`, `sc.exe` via `ProcessRunner`. xUnit 2.5.3 tests (matching `ClaudeDo.Worker.Tests` conventions — no mocking library, sealed fakes only).
+
+**Branch:** `feat/installer` (already checked out).
+
+**Out of scope (handled elsewhere):**
+- `.gitea/workflows/release.yml` — being written by Claude on the VPS.
+- Actually publishing releases / runner setup / signing.
+- Spec doc — already at `docs/superpowers/specs/2026-04-15-installer-download-mode-design.md`.
+
+---
+
+## File Structure Overview
+
+**New production files:**
+
+| Path | Responsibility |
+|------|----------------|
+| `src/ClaudeDo.Installer/Core/InstallManifest.cs` | `install.json` record + read/write helpers |
+| `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs` | SHA256 compute + checksum-file parse |
+| `src/ClaudeDo.Installer/Core/IReleaseClient.cs` | Interface for Gitea release queries (DI seam) |
+| `src/ClaudeDo.Installer/Core/ReleaseClient.cs` | Gitea API calls + asset downloads |
+| `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` | Async: reads manifest + queries API, returns `DetectedState` |
+| `src/ClaudeDo.Installer/Steps/StopServiceStep.cs` | `sc stop ClaudeDoWorker` with timeout |
+| `src/ClaudeDo.Installer/Steps/StartServiceStep.cs` | `sc start ClaudeDoWorker` |
+| `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` | API → download → verify → extract into `{InstallDir}` |
+| `src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs` | Writes `install.json` at the end of install/update |
+
+**Files modified:**
+
+| Path | Why |
+|------|-----|
+| `src/ClaudeDo.Installer/Core/InstallerMode.cs` | Rename enum values, replace `ModeDetector` with async flow |
+| `src/ClaudeDo.Installer/Core/InstallContext.cs` | Drop `SourceDirectory`, add `InstallerVersion`, `InstalledVersion`, `LatestVersion`, `Mode` |
+| `src/ClaudeDo.Installer/App.xaml.cs` | Async mode detection at startup; register new services/steps; pick window per mode |
+| `src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs` + `.xaml` | Remove source-dir UI; show current/target version for update mode |
+| `src/ClaudeDo.Installer/Views/SettingsViewModel.cs` + `SettingsWindow.xaml` | Add Repair + Uninstall commands/buttons |
+| `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` | Publish properties for single-file self-contained |
+| `ClaudeDo.slnx` | Add new test project |
+
+**Files deleted:**
+
+| Path | Why |
+|------|-----|
+| `src/ClaudeDo.Installer/Steps/PublishAppStep.cs` | Replaced by `DownloadAndExtractStep` |
+| `src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs` | Same |
+| `src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs` | Same |
+
+**New test project:** `tests/ClaudeDo.Installer.Tests/` (xUnit, targets `net8.0-windows` so it can reference the WPF installer project).
+
+---
+
+## Task 1: Scaffold `ClaudeDo.Installer.Tests` project
+
+**Files:**
+- Create: `tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`
+- Create: `tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs`
+- Modify: `ClaudeDo.slnx`
+
+- [ ] **Step 1: Create the test project file**
+
+Create `tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`:
+
+```xml
+
+
+
+ net8.0-windows
+ true
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Note: `net8.0-windows` + `UseWPF=true` is required because the installer itself is a WPF project; referencing it from the test project means the test project must use the same TFM flavor.
+
+- [ ] **Step 2: Add a reusable `FakeHttpMessageHandler`**
+
+Create `tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http;
+
+namespace ClaudeDo.Installer.Tests;
+
+internal sealed class FakeHttpMessageHandler : HttpMessageHandler
+{
+ private readonly Func _handler;
+
+ public FakeHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ public FakeHttpMessageHandler(HttpStatusCode status, string body)
+ : this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
+ {
+ }
+
+ public List Requests { get; } = new();
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests.Add(request);
+ return Task.FromResult(_handler(request));
+ }
+}
+```
+
+- [ ] **Step 3: Register the test project in `ClaudeDo.slnx`**
+
+Modify `ClaudeDo.slnx` by adding one line inside the `/tests/` folder:
+
+```xml
+
+
+
+
+
+```
+
+- [ ] **Step 4: Verify it builds (no tests yet, so no test-run)**
+
+Run: `dotnet build tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/ClaudeDo.Installer.Tests ClaudeDo.slnx
+git commit -m "test(installer): scaffold ClaudeDo.Installer.Tests project"
+```
+
+---
+
+## Task 2: `InstallManifest` + `InstallManifestStore`
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Core/InstallManifest.cs`
+- Test: `tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs`:
+
+```csharp
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class InstallManifestStoreTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public InstallManifestStoreTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
+ }
+
+ [Fact]
+ public void TryRead_ReturnsNull_WhenFileMissing()
+ {
+ var result = InstallManifestStore.TryRead(_tempDir);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Write_Then_Read_RoundTripsAllFields()
+ {
+ var manifest = new InstallManifest(
+ Version: "0.2.0",
+ InstallDir: _tempDir,
+ WorkerDir: Path.Combine(_tempDir, "worker"),
+ InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
+
+ InstallManifestStore.Write(_tempDir, manifest);
+
+ var round = InstallManifestStore.TryRead(_tempDir);
+ Assert.NotNull(round);
+ Assert.Equal("0.2.0", round!.Version);
+ Assert.Equal(manifest.InstallDir, round.InstallDir);
+ Assert.Equal(manifest.WorkerDir, round.WorkerDir);
+ Assert.Equal(manifest.InstalledAt, round.InstalledAt);
+ }
+
+ [Fact]
+ public void Write_CreatesInstallDir_IfMissing()
+ {
+ var nested = Path.Combine(_tempDir, "nested");
+ Assert.False(Directory.Exists(nested));
+
+ InstallManifestStore.Write(nested, new InstallManifest(
+ "0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
+
+ Assert.True(File.Exists(Path.Combine(nested, "install.json")));
+ }
+
+ [Fact]
+ public void TryRead_ReturnsNull_WhenJsonMalformed()
+ {
+ File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
+ var result = InstallManifestStore.TryRead(_tempDir);
+ Assert.Null(result);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter InstallManifestStoreTests`
+Expected: FAIL with "type or namespace name 'InstallManifest' could not be found" (or similar compile error).
+
+- [ ] **Step 3: Implement `InstallManifest`**
+
+Create `src/ClaudeDo.Installer/Core/InstallManifest.cs`:
+
+```csharp
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed record InstallManifest(
+ string Version,
+ string InstallDir,
+ string WorkerDir,
+ DateTimeOffset InstalledAt);
+
+public static class InstallManifestStore
+{
+ public const string FileName = "install.json";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
+
+ public static InstallManifest? TryRead(string installDir)
+ {
+ var path = ManifestPath(installDir);
+ if (!File.Exists(path)) return null;
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static void Write(string installDir, InstallManifest manifest)
+ {
+ Directory.CreateDirectory(installDir);
+ var json = JsonSerializer.Serialize(manifest, JsonOptions);
+ File.WriteAllText(ManifestPath(installDir), json);
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter InstallManifestStoreTests`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/InstallManifest.cs tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
+git commit -m "feat(installer): add InstallManifest + json-backed store"
+```
+
+---
+
+## Task 3: `ChecksumVerifier`
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`
+- Test: `tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs`:
+
+```csharp
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class ChecksumVerifierTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public ChecksumVerifierTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ }
+
+ [Fact]
+ public void ComputeSha256_KnownVector_EmptyFile()
+ {
+ // sha256 of empty input = e3b0c442...b855
+ var path = Path.Combine(_tempDir, "empty.bin");
+ File.WriteAllBytes(path, Array.Empty());
+
+ var hash = ChecksumVerifier.ComputeSha256(path);
+
+ Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
+ }
+
+ [Fact]
+ public void ComputeSha256_KnownVector_Hello()
+ {
+ // sha256("hello") = 2cf24dba...a9824
+ var path = Path.Combine(_tempDir, "hello.bin");
+ File.WriteAllText(path, "hello");
+
+ var hash = ChecksumVerifier.ComputeSha256(path);
+
+ Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
+ }
+
+ [Fact]
+ public void Verify_ReturnsTrue_WhenHashMatches()
+ {
+ var path = Path.Combine(_tempDir, "x.bin");
+ File.WriteAllText(path, "hello");
+
+ Assert.True(ChecksumVerifier.Verify(path,
+ "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
+ }
+
+ [Fact]
+ public void Verify_IsCaseInsensitive()
+ {
+ var path = Path.Combine(_tempDir, "x.bin");
+ File.WriteAllText(path, "hello");
+
+ Assert.True(ChecksumVerifier.Verify(path,
+ "2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
+ }
+
+ [Fact]
+ public void Verify_ReturnsFalse_OnMismatch()
+ {
+ var path = Path.Combine(_tempDir, "x.bin");
+ File.WriteAllText(path, "hello");
+
+ Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
+ }
+
+ [Fact]
+ public void ParseChecksumsFile_ReadsTwoLines()
+ {
+ var content = """
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
+ 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
+ """;
+
+ var map = ChecksumVerifier.ParseChecksumsFile(content);
+
+ Assert.Equal(2, map.Count);
+ Assert.Equal(
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ map["ClaudeDo-0.2.0-win-x64.zip"]);
+ Assert.Equal(
+ "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
+ map["ClaudeDo.Installer-0.2.0.exe"]);
+ }
+
+ [Fact]
+ public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
+ {
+ var content = """
+
+ not a line
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
+
+ """;
+
+ var map = ChecksumVerifier.ParseChecksumsFile(content);
+
+ Assert.Single(map);
+ Assert.True(map.ContainsKey("file.zip"));
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ChecksumVerifierTests`
+Expected: FAIL (type not defined).
+
+- [ ] **Step 3: Implement `ChecksumVerifier`**
+
+Create `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`:
+
+```csharp
+using System.IO;
+using System.Security.Cryptography;
+
+namespace ClaudeDo.Installer.Core;
+
+public static class ChecksumVerifier
+{
+ public static string ComputeSha256(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ using var sha = SHA256.Create();
+ var hash = sha.ComputeHash(stream);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ public static bool Verify(string filePath, string expectedSha256)
+ {
+ var actual = ComputeSha256(filePath);
+ return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Parses a standard `sha256sum` output: "<hash> <filename>" per line.
+ /// Returns a map keyed by filename.
+ ///
+ public static IReadOnlyDictionary ParseChecksumsFile(string content)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var rawLine in content.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0) continue;
+ var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2) continue;
+ map[parts[1].Trim()] = parts[0].Trim();
+ }
+ return map;
+ }
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ChecksumVerifierTests`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/ChecksumVerifier.cs tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
+git commit -m "feat(installer): add ChecksumVerifier (SHA256 + checksums.txt parser)"
+```
+
+---
+
+## Task 4: `IReleaseClient` + `ReleaseClient`
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Core/IReleaseClient.cs`
+- Create: `src/ClaudeDo.Installer/Core/ReleaseClient.cs`
+- Test: `tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class ReleaseClientTests
+{
+ private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
+ {
+ const string json = """
+ {
+ "tag_name": "v0.2.0",
+ "name": "v0.2.0",
+ "assets": [
+ {
+ "name": "ClaudeDo-0.2.0-win-x64.zip",
+ "browser_download_url": "https://git.example.test/dl/zip",
+ "size": 12345
+ },
+ {
+ "name": "checksums.txt",
+ "browser_download_url": "https://git.example.test/dl/checksums",
+ "size": 128
+ }
+ ]
+ }
+ """;
+
+ var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var release = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.NotNull(release);
+ Assert.Equal("v0.2.0", release!.TagName);
+ Assert.Equal(2, release.Assets.Count);
+ Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
+ Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
+ Assert.Equal(12345, release.Assets[0].Size);
+ }
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_Returns_Null_On404()
+ {
+ var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var release = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.Null(release);
+ }
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
+ {
+ var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var release = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.Null(release);
+ }
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
+ {
+ var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ _ = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.Single(handler.Requests);
+ Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task DownloadAsync_WritesBytesToDisk()
+ {
+ var payload = new byte[] { 1, 2, 3, 4, 5 };
+ var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(payload)
+ });
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ await client.DownloadAsync("https://example/foo", tempPath,
+ new Progress(_ => { }), CancellationToken.None);
+
+ Assert.True(File.Exists(tempPath));
+ Assert.Equal(payload, File.ReadAllBytes(tempPath));
+ }
+ finally
+ {
+ if (File.Exists(tempPath)) File.Delete(tempPath);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ReleaseClientTests`
+Expected: FAIL (types not defined).
+
+- [ ] **Step 3: Implement `IReleaseClient`**
+
+Create `src/ClaudeDo.Installer/Core/IReleaseClient.cs`:
+
+```csharp
+namespace ClaudeDo.Installer.Core;
+
+public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
+
+public sealed record GiteaRelease(
+ string TagName,
+ string Name,
+ IReadOnlyList Assets);
+
+public interface IReleaseClient
+{
+ Task GetLatestReleaseAsync(CancellationToken ct);
+
+ Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct);
+}
+```
+
+- [ ] **Step 4: Implement `ReleaseClient`**
+
+Create `src/ClaudeDo.Installer/Core/ReleaseClient.cs`:
+
+```csharp
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed class ReleaseClient : IReleaseClient
+{
+ public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
+
+ private readonly HttpClient _http;
+ private readonly string _apiBase;
+
+ public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
+ {
+ _http = http;
+ _apiBase = apiBase.TrimEnd('/');
+ }
+
+ public async Task GetLatestReleaseAsync(CancellationToken ct)
+ {
+ try
+ {
+ using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
+ if (!response.IsSuccessStatusCode) return null;
+ var json = await response.Content.ReadAsStringAsync(ct);
+ return ParseRelease(json);
+ }
+ catch (HttpRequestException) { return null; }
+ catch (TaskCanceledException) { return null; }
+ }
+
+ public async Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ {
+ using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using var input = await response.Content.ReadAsStreamAsync(ct);
+ await using var output = File.Create(destPath);
+
+ var buffer = new byte[81920];
+ long total = 0;
+ int read;
+ while ((read = await input.ReadAsync(buffer, ct)) > 0)
+ {
+ await output.WriteAsync(buffer.AsMemory(0, read), ct);
+ total += read;
+ progress.Report(total);
+ }
+ }
+
+ private static GiteaRelease? ParseRelease(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
+
+ var tag = tagEl.GetString() ?? "";
+ var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
+
+ var assets = new List();
+ if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in arr.EnumerateArray())
+ {
+ var aName = item.GetProperty("name").GetString() ?? "";
+ var aUrl = item.GetProperty("browser_download_url").GetString() ?? "";
+ var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
+ assets.Add(new ReleaseAsset(aName, aUrl, aSize));
+ }
+ }
+
+ return new GiteaRelease(tag, name, assets);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Run to verify tests pass**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ReleaseClientTests`
+Expected: PASS (5 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/IReleaseClient.cs src/ClaudeDo.Installer/Core/ReleaseClient.cs tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
+git commit -m "feat(installer): add IReleaseClient + Gitea ReleaseClient"
+```
+
+---
+
+## Task 5: Expand `InstallerMode` + add `InstallModeDetector`
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/Core/InstallerMode.cs`
+- Create: `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`
+- Test: `tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs`
+
+The existing file has:
+
+```csharp
+public enum InstallerMode { Wizard, Settings }
+public static class ModeDetector { public static InstallerMode Detect() { ... } }
+```
+
+We replace both. The old `ModeDetector` looks at `~/.todo-app/*.json`, but that's the *worker/ui config*, not an install manifest. We now key off `{InstallDir}/install.json`, which is the authoritative "is ClaudeDo installed" signal.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs`:
+
+```csharp
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class InstallModeDetectorTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public InstallModeDetectorTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ }
+
+ private sealed class FakeReleaseClient : IReleaseClient
+ {
+ public GiteaRelease? Release { get; set; }
+ public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
+ public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ => throw new NotSupportedException();
+ }
+
+ [Fact]
+ public async Task Detect_FreshInstall_WhenManifestMissing()
+ {
+ var detector = new InstallModeDetector(new FakeReleaseClient());
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.FreshInstall, state.Mode);
+ Assert.Null(state.Existing);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ Assert.Equal("0.1.0", state.Existing!.Version);
+ }
+
+ [Fact]
+ public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Update, state.Mode);
+ Assert.Equal("0.1.0", state.Existing!.Version);
+ Assert.Equal("0.2.0", state.LatestVersion);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
+ {
+ // Installed from a newer tag than latest release — don't offer downgrade.
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter InstallModeDetectorTests`
+Expected: FAIL (types not defined / enum values missing).
+
+- [ ] **Step 3: Replace `InstallerMode.cs` content**
+
+Fully replace `src/ClaudeDo.Installer/Core/InstallerMode.cs` with:
+
+```csharp
+namespace ClaudeDo.Installer.Core;
+
+public enum InstallerMode
+{
+ FreshInstall, // No install.json present -> run full wizard
+ Update, // install.json present, newer release available
+ Config, // install.json present, no update (or API unreachable)
+}
+```
+
+The old synchronous `ModeDetector.Detect()` is removed — replaced by `InstallModeDetector` below.
+
+- [ ] **Step 4: Implement `InstallModeDetector`**
+
+Create `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`:
+
+```csharp
+namespace ClaudeDo.Installer.Core;
+
+public sealed record DetectedState(
+ InstallerMode Mode,
+ InstallManifest? Existing,
+ GiteaRelease? LatestRelease,
+ string? LatestVersion);
+
+public sealed class InstallModeDetector
+{
+ private readonly IReleaseClient _releases;
+
+ public InstallModeDetector(IReleaseClient releases)
+ {
+ _releases = releases;
+ }
+
+ public async Task DetectAsync(string installDir, CancellationToken ct)
+ {
+ var manifest = InstallManifestStore.TryRead(installDir);
+ if (manifest is null)
+ return new DetectedState(InstallerMode.FreshInstall, null, null, null);
+
+ var release = await _releases.GetLatestReleaseAsync(ct);
+ if (release is null)
+ return new DetectedState(InstallerMode.Config, manifest, null, null);
+
+ var latestVersion = release.TagName.TrimStart('v', 'V');
+ if (IsNewer(latestVersion, manifest.Version))
+ return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
+
+ return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
+ }
+
+ private static bool IsNewer(string latest, string current)
+ {
+ if (!Version.TryParse(latest, out var lv)) return false;
+ if (!Version.TryParse(current, out var cv)) return false;
+ return lv > cv;
+ }
+}
+```
+
+- [ ] **Step 5: Run to verify it passes**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter InstallModeDetectorTests`
+Expected: PASS (5 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/InstallerMode.cs src/ClaudeDo.Installer/Core/InstallModeDetector.cs tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
+git commit -m "feat(installer): replace sync ModeDetector with async InstallModeDetector"
+```
+
+Note: this commit temporarily breaks `App.xaml.cs` which still references `ModeDetector.Detect()`. That's fixed in Task 11; it's fine for an intermediate commit because `dotnet test` on the test project succeeds and the installer project build error is expected until the wiring task. **If you want to keep every commit green**, stash this commit and squash it into Task 11 — otherwise leave it.
+
+---
+
+## Task 6: `StopServiceStep` + `StartServiceStep`
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`
+- Create: `src/ClaudeDo.Installer/Steps/StartServiceStep.cs`
+
+Both are thin `sc.exe` wrappers. Manual verification only — tests would need Administrator + a real service, not worth the setup.
+
+- [ ] **Step 1: Implement `StopServiceStep`**
+
+Create `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`:
+
+```csharp
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class StopServiceStep : IInstallStep
+{
+ public const string ServiceName = "ClaudeDoWorker";
+
+ public string Name => "Stop Worker Service";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report($"Stopping {ServiceName} (if running)...");
+
+ // sc.exe query -> returns non-zero if the service does not exist; that's fine.
+ var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
+ if (queryExit != 0)
+ {
+ progress.Report("Service is not registered — nothing to stop.");
+ return StepResult.Ok();
+ }
+
+ if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
+ {
+ progress.Report("Service is already stopped.");
+ return StepResult.Ok();
+ }
+
+ var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
+ if (stopExit != 0)
+ return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
+
+ // Poll until stopped or timeout (up to 30s).
+ for (var i = 0; i < 30; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ await Task.Delay(1000, ct);
+ var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
+ if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
+ {
+ progress.Report("Service stopped.");
+ return StepResult.Ok();
+ }
+ }
+
+ return StepResult.Fail("Service did not stop within 30 seconds.");
+ }
+}
+```
+
+- [ ] **Step 2: Implement `StartServiceStep`**
+
+Create `src/ClaudeDo.Installer/Steps/StartServiceStep.cs`:
+
+```csharp
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class StartServiceStep : IInstallStep
+{
+ private const string ServiceName = StopServiceStep.ServiceName;
+
+ public string Name => "Start Worker Service";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report($"Starting {ServiceName}...");
+
+ var (exit, output) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
+ if (exit == 0) return StepResult.Ok();
+
+ // Exit 1056 = already running — that's fine too.
+ if (output.Contains("1056", StringComparison.OrdinalIgnoreCase))
+ {
+ progress.Report("Service was already running.");
+ return StepResult.Ok();
+ }
+
+ return StepResult.Fail($"sc.exe start failed with exit code {exit}");
+ }
+}
+```
+
+- [ ] **Step 3: Verify the project still builds**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: Build succeeds (steps aren't wired into DI yet — that's Task 11).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Steps/StopServiceStep.cs src/ClaudeDo.Installer/Steps/StartServiceStep.cs
+git commit -m "feat(installer): add Stop/StartServiceStep sc.exe wrappers"
+```
+
+---
+
+## Task 7: `DownloadAndExtractStep`
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs`
+- Test: `tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs`
+
+This step is the heart of the new flow. Test strategy: build a real zip on disk, stand up a `FakeReleaseClient` that "downloads" by copying the prebuilt file, and verify extraction + checksum handling.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs`:
+
+```csharp
+using System.IO;
+using System.IO.Compression;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class DownloadAndExtractStepTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string _installDir;
+
+ public DownloadAndExtractStepTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ _installDir = Path.Combine(_tempDir, "install");
+ Directory.CreateDirectory(_installDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ }
+
+ private sealed class FileCopyReleaseClient : IReleaseClient
+ {
+ private readonly Dictionary _urlToSourceFile;
+ public GiteaRelease? Release { get; set; }
+
+ public FileCopyReleaseClient(Dictionary urlToSourceFile)
+ => _urlToSourceFile = urlToSourceFile;
+
+ public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
+
+ public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ {
+ File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
+ progress.Report(new FileInfo(destPath).Length);
+ return Task.CompletedTask;
+ }
+ }
+
+ [Fact]
+ public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
+ {
+ // Arrange: build a zip with /app/a.txt and /worker/b.txt
+ var zipPath = Path.Combine(_tempDir, "release.zip");
+ using (var fs = File.Create(zipPath))
+ using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
+ {
+ var a = zip.CreateEntry("app/a.txt");
+ using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
+ var b = zip.CreateEntry("worker/b.txt");
+ using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
+ }
+
+ var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
+ var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
+ File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
+
+ var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
+ new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
+ });
+
+ var client = new FileCopyReleaseClient(new()
+ {
+ ["fake://zip"] = zipPath,
+ ["fake://checksums"] = checksumsPath,
+ }) { Release = release };
+
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ // Act
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ // Assert
+ Assert.True(result.Success, result.ErrorMessage);
+ Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
+ Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
+ }
+
+ [Fact]
+ public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
+ {
+ // Arrange: zip is valid, but checksums.txt says something else.
+ var zipPath = Path.Combine(_tempDir, "release.zip");
+ using (var fs = File.Create(zipPath))
+ using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
+ {
+ var a = zip.CreateEntry("app/a.txt");
+ using (var w = new StreamWriter(a.Open())) w.Write("x");
+ }
+
+ var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
+ File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
+
+ // Pre-populate install dir — it must NOT be modified on failure.
+ File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
+
+ var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
+ new ReleaseAsset("checksums.txt", "fake://checksums", 0),
+ });
+
+ var client = new FileCopyReleaseClient(new()
+ {
+ ["fake://zip"] = zipPath,
+ ["fake://checksums"] = checksumsPath,
+ }) { Release = release };
+
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ // Act
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
+ Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
+ Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
+ }
+
+ [Fact]
+ public async Task Fails_When_Release_Has_No_Zip_Asset()
+ {
+ var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty());
+ var client = new FileCopyReleaseClient(new()) { Release = release };
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Fails_When_ReleaseClient_Returns_Null()
+ {
+ var client = new FileCopyReleaseClient(new()) { Release = null };
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter DownloadAndExtractStepTests`
+Expected: FAIL (type not defined).
+
+- [ ] **Step 3: Implement `DownloadAndExtractStep`**
+
+Create `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs`:
+
+```csharp
+using System.IO;
+using System.IO.Compression;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class DownloadAndExtractStep : IInstallStep
+{
+ private readonly IReleaseClient _releases;
+
+ public DownloadAndExtractStep(IReleaseClient releases)
+ {
+ _releases = releases;
+ }
+
+ public string Name => "Download and Extract";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report("Fetching latest release metadata...");
+ var release = await _releases.GetLatestReleaseAsync(ct);
+ if (release is null)
+ return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
+
+ var zipAsset = release.Assets.FirstOrDefault(a =>
+ a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
+ a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
+ var checksumAsset = release.Assets.FirstOrDefault(a =>
+ a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
+
+ if (zipAsset is null)
+ return StepResult.Fail("Release zip asset not found in release metadata.");
+ if (checksumAsset is null)
+ return StepResult.Fail("checksums.txt not found in release metadata.");
+
+ var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(scratchDir);
+
+ try
+ {
+ var zipPath = Path.Combine(scratchDir, zipAsset.Name);
+ var checksumPath = Path.Combine(scratchDir, "checksums.txt");
+
+ progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
+ await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
+ new Progress(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
+ ct);
+
+ progress.Report("Downloading checksums...");
+ await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
+ new Progress(_ => { }), ct);
+
+ progress.Report("Verifying checksum...");
+ var map = ChecksumVerifier.ParseChecksumsFile(File.ReadAllText(checksumPath));
+ if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
+ return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
+ if (!ChecksumVerifier.Verify(zipPath, expectedHash))
+ return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
+
+ // Only after verification do we touch the install directory.
+ progress.Report("Clearing previous app/worker binaries...");
+ var appDest = Path.Combine(ctx.InstallDirectory, "app");
+ var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
+ if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
+ if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
+
+ progress.Report("Extracting...");
+ Directory.CreateDirectory(ctx.InstallDirectory);
+ ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
+
+ // Stash the latest version in the context so WriteInstallManifestStep can persist it.
+ ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
+ return StepResult.Ok();
+ }
+ finally
+ {
+ try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Temporarily add `InstalledVersion` to `InstallContext` so the test compiles**
+
+Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`: add this line anywhere inside the class:
+
+```csharp
+ public string? InstalledVersion { get; set; }
+```
+
+(The full `InstallContext` overhaul is Task 10 — this is a partial change so this task's tests compile.)
+
+- [ ] **Step 5: Run to verify it passes**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter DownloadAndExtractStepTests`
+Expected: PASS (4 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs src/ClaudeDo.Installer/Core/InstallContext.cs tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
+git commit -m "feat(installer): add DownloadAndExtractStep with SHA256 verify"
+```
+
+---
+
+## Task 8: `WriteInstallManifestStep`
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs`
+- Test: `tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs`:
+
+```csharp
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class WriteInstallManifestStepTests : IDisposable
+{
+ private readonly string _installDir;
+
+ public WriteInstallManifestStepTests()
+ {
+ _installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_installDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_installDir, recursive: true); } catch { }
+ }
+
+ [Fact]
+ public async Task Writes_Manifest_WithAllFields()
+ {
+ var ctx = new InstallContext
+ {
+ InstallDirectory = _installDir,
+ InstalledVersion = "0.2.0",
+ };
+
+ var step = new WriteInstallManifestStep();
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.True(result.Success);
+ var manifest = InstallManifestStore.TryRead(_installDir);
+ Assert.NotNull(manifest);
+ Assert.Equal("0.2.0", manifest!.Version);
+ Assert.Equal(_installDir, manifest.InstallDir);
+ Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
+ }
+
+ [Fact]
+ public async Task Fails_When_InstalledVersion_Missing()
+ {
+ var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
+
+ var step = new WriteInstallManifestStep();
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter WriteInstallManifestStepTests`
+Expected: FAIL (type not defined).
+
+- [ ] **Step 3: Implement the step**
+
+Create `src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs`:
+
+```csharp
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class WriteInstallManifestStep : IInstallStep
+{
+ public string Name => "Write Install Manifest";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
+ return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
+
+ var manifest = new InstallManifest(
+ Version: ctx.InstalledVersion!,
+ InstallDir: ctx.InstallDirectory,
+ WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
+ InstalledAt: DateTimeOffset.UtcNow);
+
+ InstallManifestStore.Write(ctx.InstallDirectory, manifest);
+ progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
+ return Task.FromResult(StepResult.Ok());
+ }
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter WriteInstallManifestStepTests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs
+git commit -m "feat(installer): add WriteInstallManifestStep"
+```
+
+---
+
+## Task 9: Delete obsolete publish/deploy steps
+
+**Files:**
+- Delete: `src/ClaudeDo.Installer/Steps/PublishAppStep.cs`
+- Delete: `src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs`
+- Delete: `src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs`
+
+- [ ] **Step 1: Delete the three files**
+
+```bash
+rm src/ClaudeDo.Installer/Steps/PublishAppStep.cs
+rm src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
+rm src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
+```
+
+The installer project will not build until Task 11 removes the DI registrations, but deleting now keeps the diff small and tidy.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add -A src/ClaudeDo.Installer/Steps
+git commit -m "refactor(installer): remove source-build steps (replaced by DownloadAndExtractStep)"
+```
+
+---
+
+## Task 10: Update `InstallContext`
+
+Drop `SourceDirectory` (no longer needed — no source is required), and add version/mode fields.
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/Core/InstallContext.cs`
+
+- [ ] **Step 1: Replace the file**
+
+Fully replace `src/ClaudeDo.Installer/Core/InstallContext.cs` with:
+
+```csharp
+namespace ClaudeDo.Installer.Core;
+
+public sealed class InstallContext
+{
+ // WelcomePage / install destination
+ public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
+
+ // Mode + versions (set by App startup after InstallModeDetector runs)
+ public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
+ public string? InstallerVersion { get; set; } // from this installer's assembly
+ public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
+ public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
+
+ // PathsPage
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+ public string LogRoot { get; set; } = "~/.todo-app/logs";
+ public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
+ public string WorktreeRootStrategy { get; set; } = "sibling";
+ public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
+
+ // ServicePage
+ public int SignalRPort { get; set; } = 47_821;
+ public int QueueBackstopIntervalMs { get; set; } = 30_000;
+ public string ClaudeBin { get; set; } = "claude";
+ public string ServiceAccount { get; set; } = "LocalSystem";
+ public bool AutoStart { get; set; } = true;
+ public int RestartDelayMs { get; set; } = 5000;
+
+ // UiSettingsPage
+ public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
+ public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
+
+ // InstallPage
+ public bool CreateDesktopShortcut { get; set; } = true;
+}
+```
+
+- [ ] **Step 2: Verify the tests still build**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests --no-restore --list-tests`
+Expected: Compilation succeeds (installer project still broken — that's Task 11).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/InstallContext.cs
+git commit -m "refactor(installer): replace SourceDirectory with Mode/Version fields in InstallContext"
+```
+
+---
+
+## Task 11: Wire new mode-aware startup + DI in `App.xaml.cs`
+
+This is the big wiring task. Done right, every later task is a small UI tweak.
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/App.xaml.cs`
+
+- [ ] **Step 1: Replace the file**
+
+Fully replace `src/ClaudeDo.Installer/App.xaml.cs` with:
+
+```csharp
+using System.IO;
+using System.Net.Http;
+using System.Reflection;
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Pages.InstallPage;
+using ClaudeDo.Installer.Pages.PathsPage;
+using ClaudeDo.Installer.Pages.ServicePage;
+using ClaudeDo.Installer.Pages.UiSettingsPage;
+using ClaudeDo.Installer.Pages.WelcomePage;
+using ClaudeDo.Installer.Steps;
+using ClaudeDo.Installer.Views;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ClaudeDo.Installer;
+
+public partial class App : Application
+{
+ private ServiceProvider? _services;
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ _services = BuildServices();
+
+ var context = _services.GetRequiredService();
+ context.InstallerVersion = GetInstallerVersion();
+
+ // Default install dir for detection — on upgrade we stay where we were.
+ var detector = _services.GetRequiredService();
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ DetectedState state;
+ try
+ {
+ state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ state = new DetectedState(InstallerMode.FreshInstall, null, null, null);
+ }
+
+ context.Mode = state.Mode;
+ context.InstalledVersion = state.Existing?.Version;
+ context.LatestVersion = state.LatestVersion;
+ if (state.Existing is not null)
+ context.InstallDirectory = state.Existing.InstallDir;
+
+ Window mainWindow = state.Mode switch
+ {
+ InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
+ {
+ DataContext = _services.GetRequiredService()
+ },
+ InstallerMode.Config => new SettingsWindow
+ {
+ DataContext = _services.GetRequiredService()
+ },
+ _ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
+ };
+
+ DarkTitleBar.Apply(mainWindow);
+ mainWindow.Show();
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ _services?.Dispose();
+ base.OnExit(e);
+ }
+
+ private static string GetInstallerVersion()
+ {
+ var infoAttr = Assembly.GetExecutingAssembly()
+ .GetCustomAttribute();
+ return infoAttr?.InformationalVersion ?? "0.0.0";
+ }
+
+ private static ServiceProvider BuildServices()
+ {
+ var sc = new ServiceCollection();
+
+ // Core
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // HTTP + release client
+ sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
+ sc.AddSingleton(sp => new ReleaseClient(sp.GetRequiredService()));
+ sc.AddSingleton();
+
+ // Pages
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // Steps — execution order matters. InstallerService composes per-mode
+ // step lists from this DI set (see WizardViewModel).
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // ViewModels
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ return sc.BuildServiceProvider();
+ }
+}
+```
+
+Notes on the DI list:
+
+- `IInstallStep` registrations are the **fresh-install** order: download → write config → init DB → register service → create shortcuts → write manifest.
+- `StopServiceStep` / `StartServiceStep` are registered as concrete types (not `IInstallStep`) so they don't appear in the default fresh-install pipeline — they're pulled out explicitly for the Update and Uninstall flows.
+
+- [ ] **Step 2: Build**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: Build succeeds.
+
+- [ ] **Step 3: Run the app and verify it starts**
+
+Run: `dotnet run --project src/ClaudeDo.Installer`
+Expected: Installer launches. On a machine without `install.json` at `C:\Program Files\ClaudeDo` it shows the fresh-install wizard (currently still referencing `SourceDirectory` UI — that's broken-by-design until Task 12).
+
+Verification: it doesn't crash on startup. If it does, the typical cause is `InstallModeDetector` blocking on DI. Fix before proceeding.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/App.xaml.cs
+git commit -m "feat(installer): async mode detection + mode-aware DI wiring"
+```
+
+---
+
+## Task 12: Rewrite `WelcomePageViewModel` (no source dir, mode-aware)
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs`
+- Modify: `src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml`
+
+- [ ] **Step 1: Replace the view-model**
+
+Fully replace `src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs` with:
+
+```csharp
+using System.IO;
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Win32;
+
+namespace ClaudeDo.Installer.Pages.WelcomePage;
+
+public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private WelcomePageView? _view;
+
+ public string Title => "Welcome";
+ public string Icon => "\uE80F";
+ public int Order => 0;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => false;
+ public UserControl View => _view ??= new WelcomePageView { DataContext = this };
+
+ [ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
+ [ObservableProperty] private string? _installError;
+ [ObservableProperty] private string _heading = "Install ClaudeDo";
+ [ObservableProperty] private string _subheading = "Set the installation directory and continue.";
+ [ObservableProperty] private bool _installDirEditable = true;
+
+ public WelcomePageViewModel(InstallContext context)
+ {
+ _context = context;
+ }
+
+ public Task LoadAsync()
+ {
+ InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
+ ? @"C:\Program Files\ClaudeDo"
+ : _context.InstallDirectory;
+
+ switch (_context.Mode)
+ {
+ case InstallerMode.FreshInstall:
+ Heading = "Install ClaudeDo";
+ Subheading = "Choose where to install ClaudeDo, then click Next.";
+ InstallDirEditable = true;
+ break;
+
+ case InstallerMode.Update:
+ Heading = $"Update ClaudeDo {_context.InstalledVersion} -> {_context.LatestVersion}";
+ Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
+ InstallDirEditable = false; // stay where we were installed
+ break;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.InstallDirectory = InstallDirectory;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (string.IsNullOrWhiteSpace(InstallDirectory))
+ {
+ InstallError = "Install directory is required";
+ return false;
+ }
+ InstallError = null;
+ return true;
+ }
+
+ [RelayCommand]
+ private void BrowseInstall()
+ {
+ if (!InstallDirEditable) return;
+ var dialog = new OpenFolderDialog { Title = "Select installation directory" };
+ if (dialog.ShowDialog() == true)
+ InstallDirectory = dialog.FolderName;
+ }
+}
+```
+
+- [ ] **Step 2: Update the XAML**
+
+Open `src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml`. Replace the two source-directory inputs with a single install-directory input, and surface `Heading` / `Subheading` as bound labels. The exact layout matches the existing styling — just remove the Source section and keep Install Directory. Here's a minimal replacement for the body (keep the existing namespace/Resources/outer ``):
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Manual smoke-test fresh-install flow**
+
+Run: `dotnet run --project src/ClaudeDo.Installer`
+
+Expected:
+- Installer launches into WizardWindow (no `install.json` exists anywhere yet).
+- Welcome page shows "Install ClaudeDo" with one install-directory input.
+- Clicking Next advances to PathsPage without errors.
+
+Click through: Welcome → Paths → Service → UiSettings → Install. Do NOT click Install (the download step would actually try to hit Gitea — that's Task 17 territory). Just confirm navigation works. Close the window.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Pages/WelcomePage
+git commit -m "feat(installer): rewrite WelcomePage for download-mode + update heading"
+```
+
+---
+
+## Task 13: Config view — add Repair + Uninstall commands
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/Views/SettingsViewModel.cs`
+- Modify: `src/ClaudeDo.Installer/Views/SettingsWindow.xaml`
+
+Add `RepairCommand` and `UninstallCommand` to the existing `SettingsViewModel`. The actual uninstall logic is in Task 14 — this task just wires the UI and plumbs through `InstallerService`.
+
+- [ ] **Step 1: Extend `SettingsViewModel`**
+
+Open `src/ClaudeDo.Installer/Views/SettingsViewModel.cs` and replace its contents with:
+
+```csharp
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly InstallContext _context;
+ private readonly IReleaseClient _releases;
+ private readonly StopServiceStep _stopService;
+
+ public IReadOnlyList Pages { get; }
+
+ [ObservableProperty]
+ private IInstallerPage? _selectedPage;
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private bool _isStatusError;
+
+ [ObservableProperty]
+ private string _versionLabel = "";
+
+ public SettingsViewModel(
+ PageResolver resolver,
+ InstallContext context,
+ IReleaseClient releases,
+ StopServiceStep stopService)
+ {
+ Pages = resolver.SettingsPages;
+ _context = context;
+ _releases = releases;
+ _stopService = stopService;
+ _selectedPage = Pages.FirstOrDefault();
+
+ VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
+
+ _ = LoadAllAsync();
+ }
+
+ private async Task LoadAllAsync()
+ {
+ foreach (var page in Pages)
+ await page.LoadAsync();
+ }
+
+ [RelayCommand]
+ private async Task Save()
+ {
+ foreach (var page in Pages)
+ {
+ if (!page.Validate())
+ {
+ SelectedPage = page;
+ StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
+ IsStatusError = true;
+ return;
+ }
+ }
+
+ foreach (var page in Pages)
+ await page.ApplyAsync();
+
+ var workerCfg = new InstallerWorkerConfig
+ {
+ DbPath = _context.DbPath,
+ SandboxRoot = _context.SandboxRoot,
+ LogRoot = _context.LogRoot,
+ WorktreeRootStrategy = _context.WorktreeRootStrategy,
+ CentralWorktreeRoot = _context.CentralWorktreeRoot,
+ QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
+ SignalRPort = _context.SignalRPort,
+ ClaudeBin = _context.ClaudeBin,
+ };
+ workerCfg.Save();
+
+ var uiCfg = new InstallerAppSettings
+ {
+ DbPath = _context.UiDbPath,
+ SignalRUrl = _context.SignalRUrl,
+ };
+ uiCfg.Save();
+
+ StatusMessage = "Settings saved.";
+ IsStatusError = false;
+ }
+
+ [RelayCommand]
+ private async Task Repair()
+ {
+ var result = MessageBox.Show(
+ "Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
+ "Repair ClaudeDo",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Question);
+
+ if (result != MessageBoxResult.OK) return;
+
+ StatusMessage = "Repairing... (this window will close when done)";
+ IsStatusError = false;
+
+ // Delegate to the standard Update pipeline: stop service -> download -> start service.
+ var step1 = _stopService;
+ var step2 = new DownloadAndExtractStep(_releases);
+ var step3 = new Steps.StartServiceStep();
+ var progress = new Progress(msg => StatusMessage = msg);
+
+ foreach (var step in new IInstallStep[] { step1, step2, step3 })
+ {
+ var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
+ if (!r.Success)
+ {
+ StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
+ IsStatusError = true;
+ return;
+ }
+ }
+
+ StatusMessage = "Repair complete.";
+ }
+
+ [RelayCommand]
+ private async Task Uninstall()
+ {
+ // Full-removal confirmation. Uninstall logic lives in UninstallRunner (next task).
+ var result = MessageBox.Show(
+ "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
+ "Uninstall ClaudeDo",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (result != MessageBoxResult.Yes) return;
+
+ var progress = new Progress(msg => StatusMessage = msg);
+ var runner = new UninstallRunner(_context, _stopService);
+ var r = await runner.RunAsync(progress, CancellationToken.None);
+
+ if (!r.Success)
+ {
+ StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
+ IsStatusError = true;
+ return;
+ }
+
+ MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ Application.Current.Shutdown();
+ }
+
+ [RelayCommand]
+ private void Close() => Application.Current.Shutdown();
+}
+```
+
+- [ ] **Step 2: Add Repair + Uninstall buttons to `SettingsWindow.xaml`**
+
+Open `src/ClaudeDo.Installer/Views/SettingsWindow.xaml` and add three buttons to the bottom action row, replacing any existing Apply/Cancel pair. The existing `Apply` rename to `Save` (bind to `SaveCommand`). Example for the footer:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+(Keep the existing content area and status message rows; only the footer grid changes.)
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: FAIL — `UninstallRunner` is referenced but doesn't exist yet. That's Task 14. Keep the error visible so the next commit lands cleanly.
+
+- [ ] **Step 4: Commit the VM + XAML changes (compile-broken is OK — noted in msg)**
+
+```bash
+git add src/ClaudeDo.Installer/Views
+git commit -m "feat(installer): add Repair/Uninstall commands + Save/Close UI to Config view"
+```
+
+---
+
+## Task 14: Uninstall runner (service teardown + filesystem cleanup)
+
+**Files:**
+- Create: `src/ClaudeDo.Installer/Core/UninstallRunner.cs`
+
+- [ ] **Step 1: Implement the runner**
+
+Create `src/ClaudeDo.Installer/Core/UninstallRunner.cs`:
+
+```csharp
+using System.IO;
+using ClaudeDo.Data;
+using ClaudeDo.Installer.Steps;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed class UninstallRunner
+{
+ private const string ServiceName = "ClaudeDoWorker";
+
+ private readonly InstallContext _context;
+ private readonly StopServiceStep _stopService;
+
+ public UninstallRunner(InstallContext context, StopServiceStep stopService)
+ {
+ _context = context;
+ _stopService = stopService;
+ }
+
+ public async Task RunAsync(IProgress progress, CancellationToken ct)
+ {
+ // 1) Stop + delete service.
+ progress.Report("Stopping worker service...");
+ var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
+ if (!stopResult.Success)
+ {
+ // Don't bail — service may already be gone. Log and continue.
+ progress.Report($"(Ignored) {stopResult.ErrorMessage}");
+ }
+
+ progress.Report("Unregistering service...");
+ await ProcessRunner.RunAsync("sc.exe", $"delete {ServiceName}", null, progress, ct);
+
+ // 2) Remove shortcuts.
+ progress.Report("Removing shortcuts...");
+ TryDeleteFile(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
+ "ClaudeDo.lnk"));
+ TryDeleteFile(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
+ "Programs", "ClaudeDo.lnk"));
+
+ // 3) Delete install directory.
+ if (Directory.Exists(_context.InstallDirectory))
+ {
+ progress.Report($"Deleting {_context.InstallDirectory}...");
+ TryDeleteDir(_context.InstallDirectory);
+ }
+
+ // 4) Delete ~/.todo-app (config + DB + logs).
+ var appData = Paths.AppDataRoot();
+ if (Directory.Exists(appData))
+ {
+ progress.Report($"Deleting {appData}...");
+ TryDeleteDir(appData);
+ }
+
+ progress.Report("Uninstall complete.");
+ return StepResult.Ok();
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
+ }
+
+ private static void TryDeleteDir(string path)
+ {
+ try { Directory.Delete(path, recursive: true); } catch { /* best effort */ }
+ }
+}
+```
+
+Note: `Paths.AppDataRoot()` comes from `ClaudeDo.Data` (already referenced in the installer csproj) — same helper the existing `ModeDetector` used.
+
+- [ ] **Step 2: Build**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: Build succeeds (Task 13's reference to `UninstallRunner` now resolves).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/UninstallRunner.cs
+git commit -m "feat(installer): add UninstallRunner (service + shortcuts + dirs)"
+```
+
+---
+
+## Task 15: Teach `WizardViewModel` about Update mode
+
+The fresh-install wizard runs all pages in order. Update mode should **skip** Paths/UiSettings/Service (config already exists) and jump Welcome → Install directly.
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/Views/WizardViewModel.cs`
+- Modify: `src/ClaudeDo.Installer/Core/InstallerService.cs`
+
+- [ ] **Step 1: Add a `Mode`-aware page filter to `WizardViewModel`**
+
+Replace the `Pages` initialization in `WizardViewModel` constructor to filter based on `context.Mode`. Open `src/ClaudeDo.Installer/Views/WizardViewModel.cs` and change the constructor + `Pages` property:
+
+```csharp
+public WizardViewModel(PageResolver resolver, InstallContext context)
+{
+ _context = context;
+
+ var all = resolver.WizardPages;
+ Pages = context.Mode == InstallerMode.Update
+ ? all.Where(p => p is Pages.WelcomePage.WelcomePageViewModel
+ || p is Pages.InstallPage.InstallPageViewModel).ToList()
+ : all;
+
+ if (Pages.Count > 0)
+ _ = InitAsync();
+}
+```
+
+- [ ] **Step 2: Teach `InstallerService` about the Update step list**
+
+The fresh-install step list already lives in DI registration order. For Update mode we only run: `StopServiceStep` → `DownloadAndExtractStep` → `StartServiceStep` → `WriteInstallManifestStep`. The simplest change is in `InstallPageViewModel` — not `InstallerService` itself — because that's where the step pipeline is kicked off. Check that file's current step invocation: if it iterates `IEnumerable` from DI, it already picks up the list correctly for Fresh-install mode.
+
+For Update mode, `InstallPageViewModel` should instead pull explicit steps. Since I haven't shown you that file yet, read it first:
+
+Run: `cat src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs`
+
+Expected structure: the VM calls `InstallerService.ExecuteAsync(...)` once. Adjust it so:
+
+```csharp
+// Inside InstallPageViewModel.ExecuteInstallCommand or similar
+var steps = _context.Mode == InstallerMode.Update
+ ? new IInstallStep[]
+ {
+ _serviceProvider.GetRequiredService(),
+ _serviceProvider.GetRequiredService(),
+ _serviceProvider.GetRequiredService(),
+ _serviceProvider.GetRequiredService(),
+ }
+ : _allSteps; // existing IEnumerable from DI
+
+var runner = new InstallerService(steps);
+await runner.ExecuteAsync(_context, progress, ct);
+```
+
+To do this you'll need to inject `IServiceProvider` into `InstallPageViewModel` (add `IServiceProvider` to its ctor) and register `DownloadAndExtractStep`, `WriteInstallManifestStep`, `StopServiceStep`, `StartServiceStep` as **both** concrete types and `IInstallStep` (the `Singleton` lifetime ensures they resolve to the same instance). Adjust `App.xaml.cs` DI registration:
+
+```csharp
+sc.AddSingleton();
+sc.AddSingleton(sp => sp.GetRequiredService());
+sc.AddSingleton();
+sc.AddSingleton(sp => sp.GetRequiredService());
+// StopServiceStep + StartServiceStep are registered as concrete only (already in Task 11).
+```
+
+Apply the same double-registration pattern to any other IInstallStep you need to reference by concrete type.
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Installer
+git commit -m "feat(installer): mode-aware wizard page list + Update-mode step pipeline"
+```
+
+---
+
+## Task 16: csproj publish properties for single-file self-contained
+
+**Files:**
+- Modify: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
+
+- [ ] **Step 1: Add publish-time defaults**
+
+Add (not replace) a `PropertyGroup` scoped to publish in `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`:
+
+```xml
+
+ false
+ true
+ true
+ true
+
+```
+
+These only apply when `dotnet publish /p:PublishSingleFile=true` is passed (as the workflow does). Debug/Release desktop builds aren't affected.
+
+- [ ] **Step 2: Build check**
+
+Run: `dotnet build src/ClaudeDo.Installer`
+Expected: succeeds.
+
+- [ ] **Step 3: Publish-test locally (optional but recommended)**
+
+Run: `dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=0.0.0-test /p:PublishSingleFile=true -o out/installer-test`
+
+Verify `out/installer-test/ClaudeDo.Installer.exe` exists and is standalone (single large exe, no DLLs next to it aside from unavoidable ones like PresentationCore native runtimes — that's expected for WPF).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
+git commit -m "build(installer): add single-file self-contained publish properties"
+```
+
+---
+
+## Task 17: Full build + manual end-to-end smoke
+
+All code is in. The actual Gitea release workflow may or may not be running yet — doesn't matter for local verification because the installer can be pointed at any Gitea instance.
+
+- [ ] **Step 1: Full build**
+
+Run: `dotnet build ClaudeDo.slnx`
+Expected: All 6 projects build.
+
+- [ ] **Step 2: Full test run**
+
+Run: `dotnet test ClaudeDo.slnx`
+Expected: All tests pass. The Installer.Tests project should show ~20 passing tests.
+
+- [ ] **Step 3: Manual fresh-install smoke (skip the actual download)**
+
+Publish and run the installer in "test" mode. Since there's no real release yet, you can either:
+- (a) Skip until the VPS workflow has published v0.0.0-test, then run the real installer, OR
+- (b) Stand up a local fake: run a tiny static file server on `localhost:5000` serving a handcrafted release zip + checksums, and point the installer at it by overriding `ReleaseClient.DefaultApiBase` (add a CLI arg for this if you want).
+
+Recommended: (a). Coordinate with the VPS-side Claude when their workflow is ready.
+
+- [ ] **Step 4: Manual Config/Uninstall smoke**
+
+Place a fake `install.json` at `C:\Program Files\ClaudeDo\install.json`:
+
+```json
+{
+ "version": "9.9.9",
+ "installDir": "C:\\Program Files\\ClaudeDo",
+ "workerDir": "C:\\Program Files\\ClaudeDo\\worker",
+ "installedAt": "2026-04-15T12:00:00Z"
+}
+```
+
+Run the installer. Expected: it opens SettingsWindow (Config mode) because `9.9.9` is newer than any real release.
+
+Click `Save` with no changes → status "Settings saved."
+Click `Uninstall` → confirmation → everything deletes. (**Run on a throwaway machine or adjust paths** — this really does remove `~/.todo-app`.)
+
+- [ ] **Step 5: Push the branch**
+
+```bash
+git push -u origin feat/installer
+```
+
+Expected: branch pushed to `releases/ClaudeDo` on `git.kuns.dev`. Open a PR there (or merge directly on main if you're the only user — your call).
+
+---
+
+## Self-Review Notes
+
+**Spec coverage:**
+- Release artifacts layout → covered by the workflow (not this plan's scope).
+- `install.json` marker → Task 2 + 8.
+- Mode detection (Fresh / Update / Config) → Task 5 + 11 + 15.
+- `DownloadAndExtractStep` (API + checksum + extract) → Task 7.
+- Service stop/start on update → Task 6 + 15.
+- Config view Save/Repair/Uninstall → Task 13 + 14.
+- Uninstall removes InstallDir + `~/.todo-app` → Task 14.
+- Old publish/deploy steps deleted → Task 9.
+- Self-contained installer published single-file → Task 16.
+- Offline = not fatal (Config still opens) → Task 5 test + Task 11 CTS timeout.
+- Checksum mismatch = no mutation of InstallDir → Task 7 test.
+
+**Placeholder scan:** No TBDs, no "add appropriate error handling"-style lines. Every TDD task contains the exact test + implementation.
+
+**Type consistency:** `InstallerMode.FreshInstall` / `Update` / `Config` used everywhere. `InstallContext.InstalledVersion` set in Task 7 (step runtime) AND Task 11 (startup). `IReleaseClient` interface used by detector + download step + settings VM. `StopServiceStep` / `StartServiceStep` spelled identically across tasks.
+
+**Gap I noticed during review:** Task 13's `RepairCommand` constructs a new `DownloadAndExtractStep(_releases)` and a new `StartServiceStep()` inline rather than resolving them from DI. That's fine functionally (they have cheap constructors), but if `DownloadAndExtractStep` grows dependencies later, switch to DI. Leaving as-is.
diff --git a/docs/superpowers/specs/2026-04-15-installer-download-mode-design.md b/docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
new file mode 100644
index 0000000..14c9110
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
@@ -0,0 +1,316 @@
+# Installer: Download-Mode + Gitea Releases
+
+Date: 2026-04-15
+Status: Design — awaiting implementation plan
+
+## Goal
+
+Turn `ClaudeDo.Installer` into a self-contained tool that any user can run on
+any Windows machine to install, update, reconfigure, repair, or uninstall
+ClaudeDo. The installer pulls prebuilt binaries from a Gitea release on
+`git.kuns.dev` instead of building from source.
+
+End-user experience:
+
+1. Download `ClaudeDo.Installer-.exe` from the releases page.
+2. Run it.
+3. Done — no .NET SDK, no source checkout, no manual steps.
+
+## Non-Goals
+
+- Code signing the installer or the app binaries (future concern).
+- Cross-platform installs (Windows-only, same as today).
+- In-app update notifications (the installer handles updates when run; the app
+ does not self-update).
+- Arbitrary-version selection UI. Installer always targets "latest" release.
+- A package-manager listing (winget/Chocolatey/Scoop). Future, separate spec.
+
+## Current State (2026-04-15)
+
+The existing installer (`src/ClaudeDo.Installer/`) is a WPF wizard that only
+works from inside a source checkout on a machine with the .NET SDK installed:
+
+- `PublishAppStep` runs `dotnet publish src/ClaudeDo.App/...`
+- `PublishWorkerStep` runs `dotnet publish src/ClaudeDo.Worker/...`
+- `DeployBinariesStep` copies `bin/Release/.../publish` into the install dir
+- Subsequent steps (`WriteConfigStep`, `InitDatabaseStep`,
+ `CreateShortcutsStep`, `RegisterServiceStep`) are fine to keep.
+
+The installer also contains a partial "Settings" window
+(`Views/SettingsWindow.xaml`, `Views/SettingsViewModel.cs`) — that wiring is
+reused for the Config view shown on subsequent launches (see Mode detection
+below).
+
+## High-Level Design
+
+Two pieces, each small:
+
+**1) A Gitea Actions workflow** that, on every `v*` tag push, builds the App,
+Worker, and Installer; packages them; and creates a Gitea Release on the
+public repo at `git.kuns.dev/releases/ClaudeDo`.
+
+The `releases/` org on the Gitea instance is world-readable without auth;
+private work (including the source repo, if you want) lives under `kuns/*`
+which is never public. The installer only needs to hit `releases/ClaudeDo`.
+
+**2) An installer rewrite** that replaces the three publish/deploy steps with
+a single `DownloadAndExtractStep`, detects existing installs via a marker
+file, and on subsequent launches checks the Gitea API for updates before
+deciding whether to show the Update flow or jump straight to the Config view.
+
+## Release Artifacts
+
+Each `v*` tag produces a Gitea Release with three assets:
+
+```
+ClaudeDo--win-x64.zip # contains /app and /worker subdirs
+ClaudeDo.Installer-.exe # self-contained installer (no .NET needed)
+checksums.txt # SHA256 of the above
+```
+
+Decisions:
+
+- **One combined app+worker zip** (not two separate). Reasons: one download,
+ one extract, guaranteed version-locked pair.
+- **Self-contained installer exe** — user does not need .NET installed.
+- **App + Worker: self-contained** (`--self-contained true`, `-r win-x64`).
+ Zero runtime dependency on the target machine, at the cost of a larger
+ download (~100 MB combined zip). Decision: acceptable trade-off — the
+ installer is one-click, not per-user-problem-to-debug.
+- **Checksums file** — plain text, one line per asset (` `),
+ verified by installer before extract.
+
+The "latest installer exe" URL is stable:
+
+```
+https://git.kuns.dev/releases/ClaudeDo/releases/latest/download/ClaudeDo.Installer-.exe
+```
+
+(Gitea also exposes `/releases/download//` for specific
+versions.)
+
+## Gitea Actions Workflow
+
+File: `.gitea/workflows/release.yml`
+
+- **Trigger:** `push` on tags matching `v*`
+- **Runner:** Linux container with .NET 8 SDK (`dotnet publish -r win-x64`
+ works cross-platform). The installer itself requires Windows to run, but
+ `dotnet publish` can target `win-x64` from Linux.
+- **Steps:**
+ 1. Checkout
+ 2. Setup .NET 8 SDK
+ 3. Derive version from tag (`${{ gitea.ref_name }}` without the `v` prefix)
+ 4. `dotnet publish src/ClaudeDo.App -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/app`
+ 5. `dotnet publish src/ClaudeDo.Worker -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/worker`
+ 6. `dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=$VERSION /p:PublishSingleFile=true -o out/installer`
+ 7. Zip `out/app` + `out/worker` as `ClaudeDo--win-x64.zip` with
+ `app/` and `worker/` as top-level dirs
+ 8. Copy `out/installer/ClaudeDo.Installer.exe` to
+ `ClaudeDo.Installer-.exe`
+ 9. Generate `checksums.txt` (`sha256sum` both files)
+ 10. Create release via Gitea API using the built-in `${{ gitea.token }}`
+ (this token has repo write scope automatically on Actions runs). Release
+ name = tag name. Release notes = `git log` summary between previous tag
+ and this one (nice-to-have).
+
+The workflow needs **no custom secrets** — `gitea.token` is sufficient for
+creating releases on its own repo.
+
+## Installer Changes
+
+### New: `install.json` marker file
+
+Written at the end of every successful install or update to
+`{InstallDir}/install.json`:
+
+```json
+{
+ "version": "0.2.0",
+ "installDir": "C:\\Program Files\\ClaudeDo",
+ "workerDir": "C:\\Program Files\\ClaudeDo\\worker",
+ "installedAt": "2026-04-15T12:34:56Z"
+}
+```
+
+The installer reads this on startup (from the default install dir, or a
+path supplied via CLI arg) to decide which mode to run in.
+
+### Launch flow (`InstallModeDetector`)
+
+On every launch, the installer checks for `install.json` first:
+
+```
+install.json absent?
+ -> Install mode: Welcome -> Paths -> UiSettings -> Service -> Install
+ (writes install.json at the end)
+
+install.json present?
+ -> Query https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
+ (short timeout; if offline, treat as "no update available")
+
+ latest.tag_name > installed.version
+ -> Update mode: Welcome ("Update v0.1.0 -> v0.2.0, Update / Later")
+ If user accepts -> Install steps (download + swap service)
+ If user declines -> fall through to Config view
+ latest.tag_name <= installed.version (or API unreachable)
+ -> Config view: directly open Paths/UiSettings/Service tabs,
+ prefilled from existing ~/.todo-app/*.json.
+ Action buttons: Save · Repair · Uninstall.
+```
+
+Key properties:
+
+- **First run = wizard**, as today — no behavior change for new users.
+- **Every subsequent run = update check first**, then either offer update or
+ drop straight into Config. No "Manage page" with a menu of actions — the
+ Config view *is* the default, and Repair/Uninstall are buttons on it.
+- **Offline / API error = not fatal**: if the release endpoint can't be
+ reached, the installer silently skips the update check and opens Config.
+ The user is never blocked from managing an existing install by a network
+ issue.
+- **Downgrade** (installed version > latest) is treated the same as "no
+ update available" — we don't ever offer a downgrade.
+
+The installer's own version (shown for reference in Config) comes from its
+assembly (`AssemblyInformationalVersion`), set by the workflow's
+`/p:Version=$VERSION`. The *installed* version comes from `install.json`.
+
+### New step: `DownloadAndExtractStep`
+
+Replaces `PublishAppStep`, `PublishWorkerStep`, `DeployBinariesStep`.
+
+```
+1. GET https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
+ Parse tag_name and asset URLs for:
+ - ClaudeDo--win-x64.zip
+ - checksums.txt
+2. Download both to %TEMP%\ClaudeDo-install-\
+3. Parse checksums.txt, verify SHA256 of the zip. Fail hard if mismatch.
+4. (Update mode only) Stop Worker service via sc.exe stop ClaudeDoWorker.
+ Wait up to 30s for it to actually stop. If it won't stop, fail.
+5. (Update mode only) Delete contents of {InstallDir}/app and
+ {InstallDir}/worker, but leave the directories and install.json in place.
+6. Extract zip: /app -> {InstallDir}/app, /worker -> {InstallDir}/worker.
+7. (Update mode only) Start service again via sc.exe start ClaudeDoWorker.
+8. Progress is reported via IProgress — the UI already shows it.
+```
+
+Config files (`~/.todo-app/*.json`) and DB (`~/.todo-app/todo.db`) live
+outside `InstallDir` and are never touched by this step — updates are
+naturally non-destructive.
+
+### Update mode — which steps run
+
+- **Yes:** `DownloadAndExtractStep`
+- **No:** `WriteConfigStep` (user already has config — we don't overwrite)
+- **No:** `InitDatabaseStep` (DB exists)
+- **No:** `CreateShortcutsStep` (already there; Repair can re-run this)
+- **Conditional:** `RegisterServiceStep` only if service is not currently
+ registered (covers someone who unregistered it manually)
+
+### Config view — actions
+
+- **Save** (primary): writes the Paths / UiSettings / Service fields to
+ `~/.todo-app/*.json`. If worker config changed, prompts "Restart service?"
+ and calls `sc stop` / `sc start` if accepted. No download.
+- **Repair:** re-download + extract (same as Update flow), re-create
+ shortcuts, re-register service. Leaves config/DB alone. Confirmation
+ dialog before starting.
+- **Uninstall:** confirmation dialog ("This removes ClaudeDo *and* all of
+ your tasks, config, and database. Type UNINSTALL to confirm."). On
+ confirm:
+ 1. Stop + unregister service (`sc stop`, `sc delete ClaudeDoWorker`)
+ 2. Remove Start Menu / Desktop shortcuts
+ 3. Delete `{InstallDir}` (including `install.json`)
+ 4. Delete `~/.todo-app` in full (config + DB + logs)
+ 5. Exit
+
+ Everything is removed. No "keep my data" option — that was explicitly
+ declined in the design discussion.
+
+### Files to add
+
+```
+src/ClaudeDo.Installer/Core/InstallModeDetector.cs
+src/ClaudeDo.Installer/Core/ReleaseClient.cs // Gitea API + downloads
+src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
+src/ClaudeDo.Installer/Core/InstallManifest.cs // read/write install.json
+src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
+src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
+src/ClaudeDo.Installer/Steps/StopServiceStep.cs // used in Update+Uninstall
+src/ClaudeDo.Installer/Steps/StartServiceStep.cs // used in Update+Repair
+.gitea/workflows/release.yml
+```
+
+### Files to remove
+
+```
+src/ClaudeDo.Installer/Steps/PublishAppStep.cs
+src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
+src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
+```
+
+### Files to update
+
+- `Core/InstallerService.cs` — mode-aware step list
+- `Core/InstallContext.cs` — add `Version`, `Mode`, `IsFirstInstall` fields
+- `Pages/WelcomePage/*` — content + buttons depend on mode
+- `Views/WizardViewModel.cs` — route pages based on mode
+- `Core/PageResolver.cs` — register new/renamed pages
+- `ClaudeDo.Installer.csproj` — add `PublishSingleFile`, `SelfContained`
+ properties (only active when published)
+
+## Failure Modes & Recovery
+
+| Failure | Behavior |
+|---------------------------------------|-------------------------------------------------------|
+| No network / Gitea unreachable | Step fails with clear message + retry button |
+| API returns no releases yet | "No release available — publish a tag first" |
+| Checksum mismatch | Step fails, temp files deleted, user prompted retry |
+| Zip extraction fails mid-way (update) | InstallDir is left partially empty — user re-runs |
+| Service won't stop | Fail before extract; nothing destructive has happened |
+| User cancels mid-download | Temp dir cleaned up; install state unchanged |
+
+For safety, the `DownloadAndExtractStep` always downloads + verifies
+**before** it deletes the old binaries. An aborted download cannot leave
+an install in a half-deleted state.
+
+## Security
+
+- All downloads over HTTPS from a pinned host (`git.kuns.dev`).
+- SHA256 verification before extract (protects against partial downloads and
+ tampered caches on the wire; not a substitute for code signing).
+- No tokens shipped in the installer — repo is public.
+- Worker service runs under the same account as today (no change).
+
+## Decisions to Revisit
+
+1. **Release notes content.** Auto-generated `git log` summary vs manual
+ notes in the tag message vs empty. Start empty; revisit when there are
+ enough releases to care.
+
+2. **Signed installer.** Out of scope for v1. Users will see a SmartScreen
+ warning the first time. Note this in the README.
+
+3. **Installer distribution page.** A simple `README.md` badge or a pinned
+ "Latest release" link on the Gitea repo home is enough for v1.
+
+## Success Criteria
+
+- On a fresh Windows VM with **no source checkout, no .NET runtime, and no
+ .NET SDK**:
+ 1. Download `ClaudeDo.Installer-.exe`.
+ 2. Run it.
+ 3. Complete the wizard.
+ 4. ClaudeDo App launches, Worker service is running, a task can be created
+ and picked up.
+- Running the same installer a second time, with no new release published,
+ opens directly in the Config view after a quick update check.
+- Publishing a new tag, then running the installer on the existing install,
+ offers the update; accepting performs it without touching `~/.todo-app/todo.db`
+ or the config JSONs.
+- Uninstall leaves no trace: `{InstallDir}` gone, `~/.todo-app` gone, service
+ unregistered, shortcuts removed.
+- The entire release pipeline runs on `git.kuns.dev` with no manual steps
+ beyond `git tag vX.Y.Z && git push --tags`.
diff --git a/src/ClaudeDo.Installer/App.xaml b/src/ClaudeDo.Installer/App.xaml
new file mode 100644
index 0000000..c6c0cae
--- /dev/null
+++ b/src/ClaudeDo.Installer/App.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs
new file mode 100644
index 0000000..31e38f8
--- /dev/null
+++ b/src/ClaudeDo.Installer/App.xaml.cs
@@ -0,0 +1,131 @@
+using System.Net.Http;
+using System.Reflection;
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Pages.InstallPage;
+using ClaudeDo.Installer.Pages.PathsPage;
+using ClaudeDo.Installer.Pages.ServicePage;
+using ClaudeDo.Installer.Pages.UiSettingsPage;
+using ClaudeDo.Installer.Pages.WelcomePage;
+using ClaudeDo.Installer.Steps;
+using ClaudeDo.Installer.Views;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ClaudeDo.Installer;
+
+public partial class App : Application
+{
+ private ServiceProvider? _services;
+
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ _services = BuildServices();
+
+ var context = _services.GetRequiredService();
+ context.InstallerVersion = GetInstallerVersion();
+
+ // Default install dir for detection — on upgrade we stay where we were.
+ var detector = _services.GetRequiredService();
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+
+ // Read manifest up front so we can fall back to Config if the API times out
+ // on an existing install. If the API is slow, we do NOT want to drop an
+ // already-installed user into FreshInstall — that would risk overwriting them.
+ var existingManifest = InstallManifestStore.TryRead(context.InstallDirectory);
+
+ DetectedState state;
+ try
+ {
+ state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ state = existingManifest is not null
+ ? new DetectedState(InstallerMode.Config, existingManifest, null, null)
+ : new DetectedState(InstallerMode.FreshInstall, null, null, null);
+ }
+
+ context.Mode = state.Mode;
+ context.InstalledVersion = state.Existing?.Version;
+ context.LatestVersion = state.LatestVersion;
+ if (state.Existing is not null)
+ context.InstallDirectory = state.Existing.InstallDir;
+
+ Window mainWindow = state.Mode switch
+ {
+ InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
+ {
+ DataContext = _services.GetRequiredService()
+ },
+ InstallerMode.Config => new SettingsWindow
+ {
+ DataContext = _services.GetRequiredService()
+ },
+ _ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
+ };
+
+ DarkTitleBar.Apply(mainWindow);
+ mainWindow.Show();
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ _services?.Dispose();
+ base.OnExit(e);
+ }
+
+ private static string GetInstallerVersion()
+ {
+ var infoAttr = Assembly.GetExecutingAssembly()
+ .GetCustomAttribute();
+ return infoAttr?.InformationalVersion ?? "0.0.0";
+ }
+
+ private static ServiceProvider BuildServices()
+ {
+ var sc = new ServiceCollection();
+
+ // Core
+ sc.AddSingleton();
+ sc.AddSingleton();
+ // HTTP + release client
+ sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
+ sc.AddSingleton(sp => new ReleaseClient(sp.GetRequiredService()));
+ sc.AddSingleton();
+
+ // Pages
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // Steps — execution order matters for the FreshInstall pipeline (IEnumerable).
+ // Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
+ // can pull them out individually via GetRequiredService().
+ sc.AddSingleton();
+ sc.AddSingleton(sp => sp.GetRequiredService());
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton(sp => sp.GetRequiredService());
+
+ // Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
+ // Pulled by Update flow + Repair/Uninstall.
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // Runners
+ sc.AddSingleton();
+
+ // ViewModels
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ return sc.BuildServiceProvider();
+ }
+}
diff --git a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
new file mode 100644
index 0000000..ddc212f
--- /dev/null
+++ b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
@@ -0,0 +1,37 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+
+
+
+
+ app.debug.manifest
+
+
+
+
+ app.manifest
+
+
+
+ false
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs b/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
new file mode 100644
index 0000000..b2835a1
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
@@ -0,0 +1,37 @@
+using System.IO;
+using System.Security.Cryptography;
+
+namespace ClaudeDo.Installer.Core;
+
+public static class ChecksumVerifier
+{
+ public static string ComputeSha256(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ using var sha = SHA256.Create();
+ var hash = sha.ComputeHash(stream);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ public static bool Verify(string filePath, string expectedSha256)
+ {
+ var actual = ComputeSha256(filePath);
+ return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static IReadOnlyDictionary ParseChecksumsFile(string content)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var rawLine in content.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ if (line.Length == 0) continue;
+ var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2) continue;
+ var hashPart = parts[0].Trim();
+ if (hashPart.Length != 64) continue;
+ map[parts[1].Trim()] = hashPart;
+ }
+ return map;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/ConfigModels.cs b/src/ClaudeDo.Installer/Core/ConfigModels.cs
new file mode 100644
index 0000000..124c546
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/ConfigModels.cs
@@ -0,0 +1,109 @@
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Installer.Core;
+
+///
+/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
+/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
+///
+public sealed class InstallerWorkerConfig
+{
+ [JsonPropertyName("db_path")]
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+
+ [JsonPropertyName("sandbox_root")]
+ public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
+
+ [JsonPropertyName("log_root")]
+ public string LogRoot { get; set; } = "~/.todo-app/logs";
+
+ [JsonPropertyName("worktree_root_strategy")]
+ public string WorktreeRootStrategy { get; set; } = "sibling";
+
+ [JsonPropertyName("central_worktree_root")]
+ public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
+
+ [JsonPropertyName("queue_backstop_interval_ms")]
+ public int QueueBackstopIntervalMs { get; set; } = 30_000;
+
+ [JsonPropertyName("signalr_port")]
+ public int SignalRPort { get; set; } = 47_821;
+
+ [JsonPropertyName("claude_bin")]
+ public string ClaudeBin { get; set; } = "claude";
+
+ private static readonly JsonSerializerOptions ReadOpts = new()
+ {
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ private static readonly JsonSerializerOptions WriteOpts = new()
+ {
+ WriteIndented = true,
+ };
+
+ public static InstallerWorkerConfig Load()
+ {
+ var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
+ if (!File.Exists(path)) return new();
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, ReadOpts) ?? new();
+ }
+
+ public void Save()
+ {
+ var dir = Paths.AppDataRoot();
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "worker.config.json");
+ var json = JsonSerializer.Serialize(this, WriteOpts);
+ File.WriteAllText(path, json);
+ }
+}
+
+///
+/// Mirrors ClaudeDo.Ui.AppSettings JSON shape.
+/// Keep in sync with src/ClaudeDo.Ui/AppSettings.cs.
+///
+public sealed class InstallerAppSettings
+{
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+ public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
+
+ private static readonly JsonSerializerOptions ReadOpts = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private static readonly JsonSerializerOptions WriteOpts = new()
+ {
+ WriteIndented = true,
+ };
+
+ public static InstallerAppSettings Load()
+ {
+ var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
+ if (!File.Exists(path)) return new();
+ try
+ {
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, ReadOpts) ?? new();
+ }
+ catch
+ {
+ return new();
+ }
+ }
+
+ public void Save()
+ {
+ var dir = Paths.AppDataRoot();
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "ui.config.json");
+ var json = JsonSerializer.Serialize(this, WriteOpts);
+ File.WriteAllText(path, json);
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/DarkTitleBar.cs b/src/ClaudeDo.Installer/Core/DarkTitleBar.cs
new file mode 100644
index 0000000..e13cf41
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/DarkTitleBar.cs
@@ -0,0 +1,29 @@
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Interop;
+
+namespace ClaudeDo.Installer.Core;
+
+public static class DarkTitleBar
+{
+ [DllImport("dwmapi.dll", PreserveSig = true)]
+ private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
+
+ private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
+
+ public static void Apply(Window window)
+ {
+ if (window.IsLoaded)
+ SetDarkMode(window);
+ else
+ window.SourceInitialized += (_, _) => SetDarkMode(window);
+ }
+
+ private static void SetDarkMode(Window window)
+ {
+ var hwnd = new WindowInteropHelper(window).Handle;
+ if (hwnd == IntPtr.Zero) return;
+ int value = 1;
+ DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/IInstallStep.cs b/src/ClaudeDo.Installer/Core/IInstallStep.cs
new file mode 100644
index 0000000..67c22cd
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/IInstallStep.cs
@@ -0,0 +1,20 @@
+namespace ClaudeDo.Installer.Core;
+
+public interface IInstallStep
+{
+ string Name { get; }
+ Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct);
+}
+
+public sealed class StepResult
+{
+ public bool Success { get; init; }
+ public string? ErrorMessage { get; init; }
+
+ public static StepResult Ok() => new() { Success = true };
+ public static StepResult Fail(string error) => new() { Success = false, ErrorMessage = error };
+}
+
+public enum StepStatus { Pending, Running, Done, Failed, Skipped }
+
+public sealed record StepProgress(string StepName, StepStatus Status, string? Message = null);
diff --git a/src/ClaudeDo.Installer/Core/IInstallerPage.cs b/src/ClaudeDo.Installer/Core/IInstallerPage.cs
new file mode 100644
index 0000000..ef4db1c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/IInstallerPage.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Core;
+
+public interface IInstallerPage
+{
+ string Title { get; }
+ string Icon { get; }
+ int Order { get; }
+ bool ShowInWizard { get; }
+ bool ShowInSettings { get; }
+ UserControl View { get; }
+ Task LoadAsync();
+ Task ApplyAsync();
+ bool Validate();
+}
diff --git a/src/ClaudeDo.Installer/Core/IReleaseClient.cs b/src/ClaudeDo.Installer/Core/IReleaseClient.cs
new file mode 100644
index 0000000..8c82449
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/IReleaseClient.cs
@@ -0,0 +1,15 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
+
+public sealed record GiteaRelease(
+ string TagName,
+ string Name,
+ IReadOnlyList Assets);
+
+public interface IReleaseClient
+{
+ Task GetLatestReleaseAsync(CancellationToken ct);
+
+ Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct);
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallContext.cs b/src/ClaudeDo.Installer/Core/InstallContext.cs
new file mode 100644
index 0000000..09ceffc
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallContext.cs
@@ -0,0 +1,35 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed class InstallContext
+{
+ // WelcomePage / install destination
+ public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
+
+ // Mode + versions (set by App startup after InstallModeDetector runs)
+ public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
+ public string? InstallerVersion { get; set; } // from this installer's assembly
+ public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
+ public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
+
+ // PathsPage
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+ public string LogRoot { get; set; } = "~/.todo-app/logs";
+ public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
+ public string WorktreeRootStrategy { get; set; } = "sibling";
+ public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
+
+ // ServicePage
+ public int SignalRPort { get; set; } = 47_821;
+ public int QueueBackstopIntervalMs { get; set; } = 30_000;
+ public string ClaudeBin { get; set; } = "claude";
+ public string ServiceAccount { get; set; } = "LocalSystem";
+ public bool AutoStart { get; set; } = true;
+ public int RestartDelayMs { get; set; } = 5000;
+
+ // UiSettingsPage
+ public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
+ public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
+
+ // InstallPage
+ public bool CreateDesktopShortcut { get; set; } = true;
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallManifest.cs b/src/ClaudeDo.Installer/Core/InstallManifest.cs
new file mode 100644
index 0000000..be2a774
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallManifest.cs
@@ -0,0 +1,48 @@
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed record InstallManifest(
+ string Version,
+ string InstallDir,
+ string WorkerDir,
+ DateTimeOffset InstalledAt);
+
+public static class InstallManifestStore
+{
+ public const string FileName = "install.json";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
+
+ public static InstallManifest? TryRead(string installDir)
+ {
+ var path = ManifestPath(installDir);
+ if (!File.Exists(path)) return null;
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static void Write(string installDir, InstallManifest manifest)
+ {
+ Directory.CreateDirectory(installDir);
+ var json = JsonSerializer.Serialize(manifest, JsonOptions);
+ File.WriteAllText(ManifestPath(installDir), json);
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs
new file mode 100644
index 0000000..50b93c6
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs
@@ -0,0 +1,48 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed record DetectedState(
+ InstallerMode Mode,
+ InstallManifest? Existing,
+ GiteaRelease? LatestRelease,
+ string? LatestVersion);
+
+public sealed class InstallModeDetector
+{
+ private readonly IReleaseClient _releases;
+
+ public InstallModeDetector(IReleaseClient releases)
+ {
+ _releases = releases;
+ }
+
+ public async Task DetectAsync(string installDir, CancellationToken ct)
+ {
+ var manifest = InstallManifestStore.TryRead(installDir);
+ if (manifest is null)
+ return new DetectedState(InstallerMode.FreshInstall, null, null, null);
+
+ var release = await _releases.GetLatestReleaseAsync(ct);
+ if (release is null)
+ return new DetectedState(InstallerMode.Config, manifest, null, null);
+
+ var latestVersion = release.TagName.TrimStart('v', 'V');
+ if (IsNewer(latestVersion, manifest.Version))
+ return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
+
+ return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
+ }
+
+ ///
+ /// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
+ /// AND latest > current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
+ /// treated as "not newer" — the user drops into Config mode with no update offered.
+ /// This is deliberate: offering an update we can't compare is worse than silently skipping it.
+ /// If the project starts shipping pre-release tags, revisit this.
+ ///
+ private static bool IsNewer(string latest, string current)
+ {
+ if (!Version.TryParse(latest, out var lv)) return false;
+ if (!Version.TryParse(current, out var cv)) return false;
+ return lv > cv;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallerMode.cs b/src/ClaudeDo.Installer/Core/InstallerMode.cs
new file mode 100644
index 0000000..3b26696
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallerMode.cs
@@ -0,0 +1,8 @@
+namespace ClaudeDo.Installer.Core;
+
+public enum InstallerMode
+{
+ FreshInstall, // No install.json present -> run full wizard
+ Update, // install.json present, newer release available
+ Config, // install.json present, no update (or API unreachable)
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallerService.cs b/src/ClaudeDo.Installer/Core/InstallerService.cs
new file mode 100644
index 0000000..4d04dc9
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallerService.cs
@@ -0,0 +1,49 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed class InstallerService
+{
+ private readonly IEnumerable _steps;
+
+ public InstallerService(IEnumerable steps) => _steps = steps;
+
+ public async Task> ExecuteAsync(
+ InstallContext ctx,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ var results = new List<(IInstallStep, StepResult)>();
+
+ foreach (var step in _steps)
+ {
+ ct.ThrowIfCancellationRequested();
+ progress.Report(new StepProgress(step.Name, StepStatus.Running));
+
+ var lineProgress = new Progress(msg =>
+ progress.Report(new StepProgress(step.Name, StepStatus.Running, msg)));
+
+ try
+ {
+ var result = await step.ExecuteAsync(ctx, lineProgress, ct);
+ var status = result.Success ? StepStatus.Done : StepStatus.Failed;
+ progress.Report(new StepProgress(step.Name, status, result.ErrorMessage));
+ results.Add((step, result));
+
+ if (!result.Success) break;
+ }
+ catch (OperationCanceledException)
+ {
+ progress.Report(new StepProgress(step.Name, StepStatus.Failed, "Cancelled"));
+ results.Add((step, StepResult.Fail("Cancelled")));
+ break;
+ }
+ catch (Exception ex)
+ {
+ progress.Report(new StepProgress(step.Name, StepStatus.Failed, ex.Message));
+ results.Add((step, StepResult.Fail(ex.Message)));
+ break;
+ }
+ }
+
+ return results;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs b/src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
new file mode 100644
index 0000000..935c731
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
@@ -0,0 +1,14 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed class NullToCollapsedConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is null or "" ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Installer/Core/PageResolver.cs b/src/ClaudeDo.Installer/Core/PageResolver.cs
new file mode 100644
index 0000000..b26c5ff
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/PageResolver.cs
@@ -0,0 +1,17 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed class PageResolver
+{
+ private readonly IReadOnlyList _allPages;
+
+ public PageResolver(IEnumerable pages)
+ {
+ _allPages = pages.OrderBy(p => p.Order).ToList();
+ }
+
+ public IReadOnlyList WizardPages =>
+ _allPages.Where(p => p.ShowInWizard).ToList();
+
+ public IReadOnlyList SettingsPages =>
+ _allPages.Where(p => p.ShowInSettings).ToList();
+}
diff --git a/src/ClaudeDo.Installer/Core/ProcessRunner.cs b/src/ClaudeDo.Installer/Core/ProcessRunner.cs
new file mode 100644
index 0000000..cbe94e0
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/ProcessRunner.cs
@@ -0,0 +1,60 @@
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+
+namespace ClaudeDo.Installer.Core;
+
+public static class ProcessRunner
+{
+ public static async Task<(int ExitCode, string Output)> RunAsync(
+ string fileName,
+ string arguments,
+ string? workingDirectory,
+ IProgress? progress,
+ CancellationToken ct)
+ {
+ var output = new StringBuilder();
+ var outputLock = new object();
+
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ if (!process.Start())
+ return (-1, "Failed to start process");
+
+ var stdoutTask = ReadStreamAsync(process.StandardOutput, output, outputLock, progress);
+ var stderrTask = ReadStreamAsync(process.StandardError, output, outputLock, progress);
+
+ using var reg = ct.Register(() =>
+ {
+ try { process.Kill(entireProcessTree: true); } catch { }
+ });
+
+ await Task.WhenAll(stdoutTask, stderrTask);
+ await process.WaitForExitAsync(ct);
+
+ return (process.ExitCode, output.ToString());
+ }
+
+ private static async Task ReadStreamAsync(
+ StreamReader reader,
+ StringBuilder output,
+ object outputLock,
+ IProgress? progress)
+ {
+ while (await reader.ReadLineAsync() is { } line)
+ {
+ lock (outputLock) { output.AppendLine(line); }
+ progress?.Report(line);
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/ReleaseClient.cs b/src/ClaudeDo.Installer/Core/ReleaseClient.cs
new file mode 100644
index 0000000..78550dd
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/ReleaseClient.cs
@@ -0,0 +1,85 @@
+using System.IO;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed class ReleaseClient : IReleaseClient
+{
+ public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
+
+ private readonly HttpClient _http;
+ private readonly string _apiBase;
+
+ public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
+ {
+ _http = http;
+ _apiBase = apiBase.TrimEnd('/');
+ }
+
+ public async Task GetLatestReleaseAsync(CancellationToken ct)
+ {
+ try
+ {
+ using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
+ if (!response.IsSuccessStatusCode) return null;
+ var json = await response.Content.ReadAsStringAsync(ct);
+ return ParseRelease(json);
+ }
+ catch (HttpRequestException) { return null; }
+ catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; }
+ }
+
+ public async Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ {
+ using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using var input = await response.Content.ReadAsStreamAsync(ct);
+ await using var output = File.Create(destPath);
+
+ var buffer = new byte[81920];
+ long total = 0;
+ int read;
+ while ((read = await input.ReadAsync(buffer, ct)) > 0)
+ {
+ await output.WriteAsync(buffer.AsMemory(0, read), ct);
+ total += read;
+ progress.Report(total);
+ }
+ }
+
+ private static GiteaRelease? ParseRelease(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
+
+ var tag = tagEl.GetString() ?? "";
+ var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
+
+ var assets = new List();
+ if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var item in arr.EnumerateArray())
+ {
+ if (!item.TryGetProperty("name", out var nameField)) continue;
+ if (!item.TryGetProperty("browser_download_url", out var urlField)) continue;
+
+ var aName = nameField.GetString() ?? "";
+ var aUrl = urlField.GetString() ?? "";
+ var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
+ assets.Add(new ReleaseAsset(aName, aUrl, aSize));
+ }
+ }
+
+ return new GiteaRelease(tag, name, assets);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs b/src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
new file mode 100644
index 0000000..14e06c8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
@@ -0,0 +1,37 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace ClaudeDo.Installer.Core;
+
+///
+/// Multi-value converter: compares the page's index with the current page index
+/// to determine step indicator styling.
+///
+public sealed class StepActiveConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length < 2 ||
+ values[0] is not IInstallerPage page ||
+ values[1] is not IInstallerPage currentPage)
+ return DependencyProperty.UnsetValue;
+
+ var isActive = ReferenceEquals(page, currentPage);
+
+ var key = parameter?.ToString() switch
+ {
+ "Background" => isActive ? "AccentBrush" : "WindowBgBrush",
+ "Foreground" => isActive ? "TextPrimaryBrush" : "TextMutedBrush",
+ "BorderBrush" => isActive ? "AccentBrush" : "BorderSubtleBrush",
+ _ => null
+ };
+
+ if (key is null) return DependencyProperty.UnsetValue;
+ return Application.Current.Resources[key] as SolidColorBrush ?? DependencyProperty.UnsetValue;
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Installer/Core/UninstallRunner.cs b/src/ClaudeDo.Installer/Core/UninstallRunner.cs
new file mode 100644
index 0000000..b3d5b0d
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/UninstallRunner.cs
@@ -0,0 +1,127 @@
+using System.IO;
+using ClaudeDo.Data;
+using ClaudeDo.Installer.Steps;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed class UninstallRunner
+{
+ private readonly InstallContext _context;
+ private readonly StopServiceStep _stopService;
+
+ public UninstallRunner(InstallContext context, StopServiceStep stopService)
+ {
+ _context = context;
+ _stopService = stopService;
+ }
+
+ public async Task RunAsync(IProgress progress, CancellationToken ct)
+ {
+ // 1) Validate install dir up front — refuse obviously unsafe paths.
+ // Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
+ if (!IsSafeInstallDir(_context.InstallDirectory, out var safeError))
+ return StepResult.Fail($"Refusing to uninstall: {safeError}");
+
+ // 2) Stop service. If stop fails we MUST abort — deleting a service whose
+ // process is still running leaves orphan locked binaries under the install dir
+ // which Directory.Delete will silently skip.
+ progress.Report("Stopping worker service...");
+ var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
+ if (!stopResult.Success)
+ return StepResult.Fail(
+ $"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
+ "Kill the worker manually and re-run uninstall.");
+
+ // 3) Unregister service.
+ progress.Report("Unregistering service...");
+ await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
+
+ // 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
+ progress.Report("Removing shortcuts...");
+ TryDeleteFile(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
+ "ClaudeDo.lnk"));
+ TryDeleteFile(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
+ "Programs", "ClaudeDo.lnk"));
+
+ // 5) Delete install directory. Track success so we can report partial state.
+ var failures = new List();
+ if (Directory.Exists(_context.InstallDirectory))
+ {
+ progress.Report($"Deleting {_context.InstallDirectory}...");
+ if (!TryDeleteDir(_context.InstallDirectory, out var err))
+ failures.Add($"install dir ({_context.InstallDirectory}): {err}");
+ }
+
+ // 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
+ var appData = Paths.AppDataRoot();
+ if (Directory.Exists(appData))
+ {
+ progress.Report($"Deleting {appData}...");
+ if (!TryDeleteDir(appData, out var err))
+ failures.Add($"app data ({appData}): {err}");
+ }
+
+ if (failures.Count > 0)
+ {
+ return StepResult.Fail(
+ "Uninstall partially succeeded — the following could not be removed:\n " +
+ string.Join("\n ", failures));
+ }
+
+ progress.Report("Uninstall complete.");
+ return StepResult.Ok();
+ }
+
+ ///
+ /// Guards against catastrophic recursive-delete paths. The install dir must be
+ /// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
+ ///
+ private static bool IsSafeInstallDir(string path, out string reason)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ reason = "install directory is empty";
+ return false;
+ }
+
+ string full;
+ try { full = Path.GetFullPath(path); }
+ catch (Exception ex)
+ {
+ reason = $"install directory is not a valid path: {ex.Message}";
+ return false;
+ }
+
+ var name = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ if (string.IsNullOrEmpty(name))
+ {
+ reason = $"install directory resolves to a drive root ({full})";
+ return false;
+ }
+
+ reason = "";
+ return true;
+ }
+
+ private static void TryDeleteFile(string path)
+ {
+ try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort — single shortcut */ }
+ }
+
+ private static bool TryDeleteDir(string path, out string error)
+ {
+ try
+ {
+ Directory.Delete(path, recursive: true);
+ error = "";
+ return true;
+ }
+ catch (Exception ex)
+ {
+ error = ex.Message;
+ return false;
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
new file mode 100644
index 0000000..94781c8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml.cs b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml.cs
new file mode 100644
index 0000000..81d0e5c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.InstallPage;
+
+public partial class InstallPageView : UserControl
+{
+ public InstallPageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
new file mode 100644
index 0000000..fed4636
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
@@ -0,0 +1,148 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ClaudeDo.Installer.Pages.InstallPage;
+
+public partial class InstallPageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private readonly IServiceProvider _serviceProvider;
+ private InstallPageView? _view;
+ private CancellationTokenSource? _cts;
+
+ public string Title => "Install";
+ public string Icon => "\uE896";
+ public int Order => 99;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => false;
+ public UserControl View => _view ??= new InstallPageView { DataContext = this };
+
+ public ObservableCollection Steps { get; } = [];
+
+ [ObservableProperty] private bool _isInstalling;
+ [ObservableProperty] private bool _isComplete;
+ [ObservableProperty] private bool _hasErrors;
+ [ObservableProperty] private double _overallProgress;
+
+ public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
+ {
+ _context = context;
+ _serviceProvider = serviceProvider;
+ }
+
+ public Task LoadAsync()
+ {
+ Steps.Clear();
+ if (_context.Mode == InstallerMode.Update)
+ {
+ Steps.Add(new StepViewModel("Stop Worker Service"));
+ Steps.Add(new StepViewModel("Download and Extract"));
+ Steps.Add(new StepViewModel("Start Worker Service"));
+ Steps.Add(new StepViewModel("Write Install Manifest"));
+ }
+ else
+ {
+ Steps.Add(new StepViewModel("Download and Extract"));
+ Steps.Add(new StepViewModel("Write Configuration"));
+ Steps.Add(new StepViewModel("Initialize Database"));
+ Steps.Add(new StepViewModel("Register Windows Service"));
+ Steps.Add(new StepViewModel("Create Shortcuts"));
+ Steps.Add(new StepViewModel("Write Install Manifest"));
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync() => RunInstallAsync();
+
+ public bool Validate() => true;
+
+ [RelayCommand]
+ private async Task RunInstallAsync()
+ {
+ if (IsInstalling) return;
+
+ IsInstalling = true;
+ IsComplete = false;
+ HasErrors = false;
+ OverallProgress = 0;
+
+ _cts = new CancellationTokenSource();
+
+ var progress = new Progress(p =>
+ {
+ var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
+ if (step is null) return;
+
+ step.Status = p.Status;
+ if (p.Message is not null)
+ step.Messages.Add(p.Message);
+
+ if (p.Status is StepStatus.Running && !step.IsExpanded)
+ step.IsExpanded = true;
+
+ if (p.Status is StepStatus.Done or StepStatus.Failed)
+ {
+ var completed = Steps.Count(s => s.Status is StepStatus.Done or StepStatus.Failed);
+ OverallProgress = (double)completed / Steps.Count * 100;
+ }
+ });
+
+ try
+ {
+ IEnumerable steps;
+ if (_context.Mode == InstallerMode.Update)
+ {
+ steps = new IInstallStep[]
+ {
+ _serviceProvider.GetRequiredService(),
+ _serviceProvider.GetRequiredService(),
+ _serviceProvider.GetRequiredService(),
+ _serviceProvider.GetRequiredService(),
+ };
+ }
+ else
+ {
+ steps = _serviceProvider.GetServices();
+ }
+
+ var runner = new InstallerService(steps);
+ var results = await runner.ExecuteAsync(_context, progress, _cts.Token);
+ HasErrors = results.Any(r => !r.Result.Success);
+ }
+ catch (OperationCanceledException)
+ {
+ HasErrors = true;
+ }
+ finally
+ {
+ IsInstalling = false;
+ IsComplete = true;
+ _cts.Dispose();
+ _cts = null;
+ }
+ }
+
+ [RelayCommand]
+ private void CancelInstall()
+ {
+ _cts?.Cancel();
+ }
+
+ [RelayCommand]
+ private void LaunchApp()
+ {
+ var appExe = System.IO.Path.Combine(_context.InstallDirectory, "app", "ClaudeDo.App.exe");
+ if (System.IO.File.Exists(appExe))
+ {
+ Process.Start(new ProcessStartInfo(appExe) { UseShellExecute = true });
+ Application.Current.Shutdown();
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs b/src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
new file mode 100644
index 0000000..176948a
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
@@ -0,0 +1,17 @@
+using System.Collections.ObjectModel;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Installer.Pages.InstallPage;
+
+public partial class StepViewModel : ObservableObject
+{
+ public string Name { get; }
+
+ [ObservableProperty] private StepStatus _status = StepStatus.Pending;
+ [ObservableProperty] private bool _isExpanded;
+
+ public ObservableCollection Messages { get; } = [];
+
+ public StepViewModel(string name) => Name = name;
+}
diff --git a/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
new file mode 100644
index 0000000..80af2aa
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sibling
+ central
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml.cs b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml.cs
new file mode 100644
index 0000000..e100d64
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.PathsPage;
+
+public partial class PathsPageView : UserControl
+{
+ public PathsPageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
new file mode 100644
index 0000000..6e9c215
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
@@ -0,0 +1,74 @@
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Installer.Pages.PathsPage;
+
+public partial class PathsPageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private PathsPageView? _view;
+
+ public string Title => "Paths";
+ public string Icon => "\uE8B7";
+ public int Order => 1;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => true;
+ public UserControl View => _view ??= new PathsPageView { DataContext = this };
+
+ [ObservableProperty] private string _dbPath = "~/.todo-app/todo.db";
+ [ObservableProperty] private string _logRoot = "~/.todo-app/logs";
+ [ObservableProperty] private string _sandboxRoot = "~/.todo-app/sandbox";
+ [ObservableProperty] private string _worktreeRootStrategy = "sibling";
+ [ObservableProperty] private string _centralWorktreeRoot = "~/.todo-app/worktrees";
+ [ObservableProperty] private string? _validationError;
+
+ public bool IsCentralVisible => WorktreeRootStrategy == "central";
+
+ public PathsPageViewModel(InstallContext context) => _context = context;
+
+ partial void OnWorktreeRootStrategyChanged(string value) =>
+ OnPropertyChanged(nameof(IsCentralVisible));
+
+ public Task LoadAsync()
+ {
+ var cfg = InstallerWorkerConfig.Load();
+ DbPath = cfg.DbPath;
+ LogRoot = cfg.LogRoot;
+ SandboxRoot = cfg.SandboxRoot;
+ WorktreeRootStrategy = cfg.WorktreeRootStrategy;
+ CentralWorktreeRoot = cfg.CentralWorktreeRoot;
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.DbPath = DbPath;
+ _context.UiDbPath = DbPath;
+ _context.LogRoot = LogRoot;
+ _context.SandboxRoot = SandboxRoot;
+ _context.WorktreeRootStrategy = WorktreeRootStrategy;
+ _context.CentralWorktreeRoot = CentralWorktreeRoot;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (string.IsNullOrWhiteSpace(DbPath) ||
+ string.IsNullOrWhiteSpace(LogRoot) ||
+ string.IsNullOrWhiteSpace(SandboxRoot))
+ {
+ ValidationError = "All path fields are required.";
+ return false;
+ }
+
+ if (WorktreeRootStrategy == "central" && string.IsNullOrWhiteSpace(CentralWorktreeRoot))
+ {
+ ValidationError = "Central worktree root is required when using central strategy.";
+ return false;
+ }
+
+ ValidationError = null;
+ return true;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml
new file mode 100644
index 0000000..4edd763
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml.cs b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml.cs
new file mode 100644
index 0000000..d0a1887
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.ServicePage;
+
+public partial class ServicePageView : UserControl
+{
+ public ServicePageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageViewModel.cs b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageViewModel.cs
new file mode 100644
index 0000000..e111f02
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageViewModel.cs
@@ -0,0 +1,88 @@
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Win32;
+
+namespace ClaudeDo.Installer.Pages.ServicePage;
+
+public partial class ServicePageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private ServicePageView? _view;
+
+ public string Title => "Service";
+ public string Icon => "\uE912";
+ public int Order => 2;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => true;
+ public UserControl View => _view ??= new ServicePageView { DataContext = this };
+
+ [ObservableProperty] private int _signalRPort = 47_821;
+ [ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
+ [ObservableProperty] private string _claudeBin = "claude";
+ [ObservableProperty] private bool _isLocalSystem = true;
+ [ObservableProperty] private bool _isCurrentUser;
+ [ObservableProperty] private bool _autoStart = true;
+ [ObservableProperty] private int _restartDelayMs = 5000;
+ [ObservableProperty] private string? _validationError;
+
+ public ServicePageViewModel(InstallContext context) => _context = context;
+
+ public Task LoadAsync()
+ {
+ var cfg = InstallerWorkerConfig.Load();
+ SignalRPort = cfg.SignalRPort;
+ QueueBackstopIntervalMs = cfg.QueueBackstopIntervalMs;
+ ClaudeBin = cfg.ClaudeBin;
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.SignalRPort = SignalRPort;
+ _context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
+ _context.ClaudeBin = ClaudeBin;
+ _context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
+ _context.AutoStart = AutoStart;
+ _context.RestartDelayMs = RestartDelayMs;
+ _context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (SignalRPort < 1024 || SignalRPort > 65535)
+ {
+ ValidationError = "Port must be between 1024 and 65535.";
+ return false;
+ }
+
+ if (QueueBackstopIntervalMs <= 0)
+ {
+ ValidationError = "Queue backstop interval must be greater than 0.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(ClaudeBin))
+ {
+ ValidationError = "Claude CLI path is required.";
+ return false;
+ }
+
+ ValidationError = null;
+ return true;
+ }
+
+ [RelayCommand]
+ private void BrowseClaude()
+ {
+ var dialog = new OpenFileDialog
+ {
+ Title = "Select Claude CLI executable",
+ Filter = "Executables (*.exe)|*.exe|All files (*.*)|*.*",
+ };
+ if (dialog.ShowDialog() == true)
+ ClaudeBin = dialog.FileName;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml
new file mode 100644
index 0000000..8b0f016
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml.cs b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml.cs
new file mode 100644
index 0000000..a1b777c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.UiSettingsPage;
+
+public partial class UiSettingsPageView : UserControl
+{
+ public UiSettingsPageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageViewModel.cs b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageViewModel.cs
new file mode 100644
index 0000000..55236e8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageViewModel.cs
@@ -0,0 +1,83 @@
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Installer.Pages.UiSettingsPage;
+
+public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private UiSettingsPageView? _view;
+
+ public string Title => "UI Settings";
+ public string Icon => "\uE771";
+ public int Order => 3;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => true;
+ public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
+
+ [ObservableProperty] private string _signalRUrl = "http://127.0.0.1:47821/hub";
+ [ObservableProperty] private string _uiDbPath = "~/.todo-app/todo.db";
+ [ObservableProperty] private bool _isSynced = true;
+ [ObservableProperty] private string? _validationError;
+
+ public UiSettingsPageViewModel(InstallContext context) => _context = context;
+
+ partial void OnIsSyncedChanged(bool value)
+ {
+ if (value) SyncFromContext();
+ }
+
+ private void SyncFromContext()
+ {
+ SignalRUrl = $"http://127.0.0.1:{_context.SignalRPort}/hub";
+ UiDbPath = _context.DbPath;
+ }
+
+ public Task LoadAsync()
+ {
+ if (IsSynced)
+ {
+ SyncFromContext();
+ }
+ else
+ {
+ var cfg = InstallerAppSettings.Load();
+ SignalRUrl = cfg.SignalRUrl;
+ UiDbPath = cfg.DbPath;
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ if (IsSynced) SyncFromContext();
+ _context.SignalRUrl = SignalRUrl;
+ _context.UiDbPath = UiDbPath;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (string.IsNullOrWhiteSpace(SignalRUrl))
+ {
+ ValidationError = "SignalR URL is required.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(UiDbPath))
+ {
+ ValidationError = "Database path is required.";
+ return false;
+ }
+
+ if (!Uri.TryCreate(SignalRUrl, UriKind.Absolute, out _))
+ {
+ ValidationError = "SignalR URL must be a valid URL.";
+ return false;
+ }
+
+ ValidationError = null;
+ return true;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml
new file mode 100644
index 0000000..7ad82e4
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml.cs b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml.cs
new file mode 100644
index 0000000..e2f60a3
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.WelcomePage;
+
+public partial class WelcomePageView : UserControl
+{
+ public WelcomePageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs
new file mode 100644
index 0000000..0f2c791
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs
@@ -0,0 +1,87 @@
+using System.IO;
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Win32;
+
+namespace ClaudeDo.Installer.Pages.WelcomePage;
+
+public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private WelcomePageView? _view;
+
+ public string Title => "Welcome";
+ public string Icon => "\uE80F";
+ public int Order => 0;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => false;
+ public UserControl View => _view ??= new WelcomePageView { DataContext = this };
+
+ [ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
+ [ObservableProperty] private string? _installError;
+ [ObservableProperty] private string _heading = "Install ClaudeDo";
+ [ObservableProperty] private string _subheading = "Set the installation directory and continue.";
+ [ObservableProperty] private bool _installDirEditable = true;
+
+ public WelcomePageViewModel(InstallContext context)
+ {
+ _context = context;
+ }
+
+ public Task LoadAsync()
+ {
+ InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
+ ? @"C:\Program Files\ClaudeDo"
+ : _context.InstallDirectory;
+
+ switch (_context.Mode)
+ {
+ case InstallerMode.FreshInstall:
+ Heading = "Install ClaudeDo";
+ Subheading = "Choose where to install ClaudeDo, then click Next.";
+ InstallDirEditable = true;
+ break;
+
+ case InstallerMode.Update:
+ Heading = $"Update ClaudeDo {_context.InstalledVersion ?? "?"} -> {_context.LatestVersion ?? "?"}";
+ Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
+ InstallDirEditable = false; // stay where we were installed
+ break;
+
+ default:
+ // Config and any future modes should never reach the wizard; guard loudly if they do.
+ throw new InvalidOperationException(
+ $"WelcomePage is not valid for installer mode {_context.Mode}");
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.InstallDirectory = InstallDirectory;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (string.IsNullOrWhiteSpace(InstallDirectory))
+ {
+ InstallError = "Install directory is required";
+ return false;
+ }
+ InstallError = null;
+ return true;
+ }
+
+ [RelayCommand]
+ private void BrowseInstall()
+ {
+ if (!InstallDirEditable) return;
+ var dialog = new OpenFolderDialog { Title = "Select installation directory" };
+ if (dialog.ShowDialog() == true)
+ InstallDirectory = dialog.FolderName;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs b/src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
new file mode 100644
index 0000000..86774cd
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
@@ -0,0 +1,91 @@
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
+using System.Text;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class CreateShortcutsStep : IInstallStep
+{
+ public string Name => "Create Shortcuts";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
+ var workingDir = Path.Combine(ctx.InstallDirectory, "app");
+
+ // Start Menu shortcut
+ var startMenuDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
+ "Programs");
+ Directory.CreateDirectory(startMenuDir);
+ var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
+ CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
+ progress.Report($"Created Start Menu shortcut: {startMenuPath}");
+
+ // Desktop shortcut (optional)
+ if (ctx.CreateDesktopShortcut)
+ {
+ var desktopPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
+ "ClaudeDo.lnk");
+ CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
+ progress.Report($"Created Desktop shortcut: {desktopPath}");
+ }
+
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+
+ private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
+ {
+ var link = (IShellLink)new ShellLink();
+ link.SetPath(targetPath);
+ link.SetWorkingDirectory(workingDir);
+ link.SetDescription(description);
+ link.SetIconLocation(targetPath, 0);
+
+ var file = (IPersistFile)link;
+ file.Save(shortcutPath, false);
+ }
+
+ #region COM Interop for IShellLink
+
+ [ComImport]
+ [Guid("00021401-0000-0000-C000-000000000046")]
+ private class ShellLink { }
+
+ [ComImport]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ [Guid("000214F9-0000-0000-C000-000000000046")]
+ private interface IShellLink
+ {
+ void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
+ void GetIDList(out IntPtr ppidl);
+ void SetIDList(IntPtr pidl);
+ void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
+ void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
+ void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
+ void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
+ void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
+ void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
+ void GetHotkey(out short pwHotkey);
+ void SetHotkey(short wHotkey);
+ void GetShowCmd(out int piShowCmd);
+ void SetShowCmd(int iShowCmd);
+ void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
+ void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
+ void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
+ void Resolve(IntPtr hwnd, int fFlags);
+ void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
+ }
+
+ #endregion
+}
diff --git a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
new file mode 100644
index 0000000..94145bf
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
@@ -0,0 +1,91 @@
+using System.IO;
+using System.IO.Compression;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class DownloadAndExtractStep : IInstallStep
+{
+ private readonly IReleaseClient _releases;
+
+ public DownloadAndExtractStep(IReleaseClient releases)
+ {
+ _releases = releases;
+ }
+
+ public string Name => "Download and Extract";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
+ return StepResult.Fail("Install directory is not set.");
+
+ progress.Report("Fetching latest release metadata...");
+ var release = await _releases.GetLatestReleaseAsync(ct);
+ if (release is null)
+ return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
+
+ var zipAsset = release.Assets.FirstOrDefault(a =>
+ a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
+ a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
+ var checksumAsset = release.Assets.FirstOrDefault(a =>
+ a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
+
+ if (zipAsset is null)
+ return StepResult.Fail("Release zip asset not found in release metadata.");
+ if (checksumAsset is null)
+ return StepResult.Fail("checksums.txt not found in release metadata.");
+
+ var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(scratchDir);
+
+ try
+ {
+ var zipPath = Path.Combine(scratchDir, zipAsset.Name);
+ var checksumPath = Path.Combine(scratchDir, "checksums.txt");
+
+ progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
+ await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
+ new Progress(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
+ ct);
+
+ progress.Report("Downloading checksums...");
+ await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
+ new Progress(_ => { }), ct);
+
+ progress.Report("Verifying checksum...");
+ var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
+ if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
+ return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
+ if (!ChecksumVerifier.Verify(zipPath, expectedHash))
+ return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
+
+ // Only after verification do we touch the install directory.
+ progress.Report("Clearing previous app/worker binaries...");
+ var appDest = Path.Combine(ctx.InstallDirectory, "app");
+ var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
+ if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
+ if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
+
+ progress.Report("Extracting...");
+ Directory.CreateDirectory(ctx.InstallDirectory);
+ try
+ {
+ ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
+ }
+ catch (Exception ex)
+ {
+ return StepResult.Fail(
+ $"Extraction failed after old binaries were removed: {ex.Message}. " +
+ "Your install directory may be incomplete. Re-run the installer to retry.");
+ }
+
+ ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
+ return StepResult.Ok();
+ }
+ finally
+ {
+ try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs b/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
new file mode 100644
index 0000000..dc4814c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
@@ -0,0 +1,28 @@
+using ClaudeDo.Data;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class InitDatabaseStep : IInstallStep
+{
+ public string Name => "Initialize Database";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var expandedPath = Paths.Expand(ctx.DbPath);
+ progress.Report($"Initializing database at {expandedPath}");
+
+ var factory = new SqliteConnectionFactory(expandedPath);
+ SchemaInitializer.Apply(factory);
+
+ progress.Report("Schema applied successfully");
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs b/src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
new file mode 100644
index 0000000..ef29c15
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
@@ -0,0 +1,68 @@
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class RegisterServiceStep : IInstallStep
+{
+ private const string ServiceName = "ClaudeDoWorker";
+
+ public string Name => "Register Windows Service";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
+ if (!File.Exists(workerExe))
+ return StepResult.Fail($"Worker executable not found: {workerExe}");
+
+ // Stop existing service (ignore errors — may not exist)
+ progress.Report("Stopping existing service (if any)...");
+ await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
+
+ // Delete existing service (ignore errors)
+ progress.Report("Removing existing service registration (if any)...");
+ await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
+
+ // Create service
+ var startType = ctx.AutoStart ? "auto" : "demand";
+ var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
+
+ if (ctx.ServiceAccount == "CurrentUser")
+ {
+ var username = Environment.UserName;
+ createArgs += $" obj= \".\\{username}\"";
+ }
+
+ progress.Report("Creating service...");
+ var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
+ if (exitCode != 0)
+ return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
+
+ // Configure restart policy
+ var delay = ctx.RestartDelayMs;
+ var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
+ progress.Report("Configuring restart policy...");
+ var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
+ if (failExit != 0)
+ progress.Report($"Warning: failed to set restart policy (exit {failExit})");
+
+ // Start service if auto-start
+ if (ctx.AutoStart)
+ {
+ progress.Report("Starting service...");
+ var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
+ if (startExit != 0)
+ progress.Report("Warning: service created but failed to start. You may need to start it manually.");
+ }
+
+ return StepResult.Ok();
+ }
+
+ private static async Task<(int ExitCode, string Output)> RunSc(
+ string arguments, InstallContext ctx, IProgress progress,
+ CancellationToken ct, bool ignoreErrors = false)
+ {
+ var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
+ return result;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/StartServiceStep.cs b/src/ClaudeDo.Installer/Steps/StartServiceStep.cs
new file mode 100644
index 0000000..0b97ce7
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/StartServiceStep.cs
@@ -0,0 +1,27 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class StartServiceStep : IInstallStep
+{
+ private const string ServiceName = StopServiceStep.ServiceName;
+
+ public string Name => "Start Worker Service";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report($"Starting {ServiceName}...");
+
+ var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
+ if (exit == 0) return StepResult.Ok();
+
+ // Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
+ if (exit == 1056)
+ {
+ progress.Report("Service was already running.");
+ return StepResult.Ok();
+ }
+
+ return StepResult.Fail($"sc.exe start failed with exit code {exit}");
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/StopServiceStep.cs b/src/ClaudeDo.Installer/Steps/StopServiceStep.cs
new file mode 100644
index 0000000..be3c271
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/StopServiceStep.cs
@@ -0,0 +1,48 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class StopServiceStep : IInstallStep
+{
+ public const string ServiceName = "ClaudeDoWorker";
+
+ public string Name => "Stop Worker Service";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report($"Stopping {ServiceName} (if running)...");
+
+ // sc.exe query -> returns non-zero if the service does not exist; that's fine.
+ var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
+ if (queryExit != 0)
+ {
+ progress.Report("Service is not registered — nothing to stop.");
+ return StepResult.Ok();
+ }
+
+ if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
+ {
+ progress.Report("Service is already stopped.");
+ return StepResult.Ok();
+ }
+
+ var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
+ if (stopExit != 0)
+ return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
+
+ // Poll until stopped or timeout (up to 30s).
+ for (var i = 0; i < 30; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ await Task.Delay(1000, ct);
+ var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
+ if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
+ {
+ progress.Report("Service stopped.");
+ return StepResult.Ok();
+ }
+ }
+
+ return StepResult.Fail("Service did not stop within 30 seconds.");
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/WriteConfigStep.cs b/src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
new file mode 100644
index 0000000..7401e4c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
@@ -0,0 +1,42 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class WriteConfigStep : IInstallStep
+{
+ public string Name => "Write Configuration";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var workerCfg = new InstallerWorkerConfig
+ {
+ DbPath = ctx.DbPath,
+ SandboxRoot = ctx.SandboxRoot,
+ LogRoot = ctx.LogRoot,
+ WorktreeRootStrategy = ctx.WorktreeRootStrategy,
+ CentralWorktreeRoot = ctx.CentralWorktreeRoot,
+ QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
+ SignalRPort = ctx.SignalRPort,
+ ClaudeBin = ctx.ClaudeBin,
+ };
+ workerCfg.Save();
+ progress.Report("Written worker.config.json");
+
+ var uiCfg = new InstallerAppSettings
+ {
+ DbPath = ctx.UiDbPath,
+ SignalRUrl = ctx.SignalRUrl,
+ };
+ uiCfg.Save();
+ progress.Report("Written ui.config.json");
+
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs b/src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
new file mode 100644
index 0000000..83cfda8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
@@ -0,0 +1,32 @@
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class WriteInstallManifestStep : IInstallStep
+{
+ public string Name => "Write Install Manifest";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
+ return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
+
+ try
+ {
+ var manifest = new InstallManifest(
+ Version: ctx.InstalledVersion,
+ InstallDir: ctx.InstallDirectory,
+ WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
+ InstalledAt: DateTimeOffset.UtcNow);
+
+ InstallManifestStore.Write(ctx.InstallDirectory, manifest);
+ progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Theme/DarkTheme.xaml b/src/ClaudeDo.Installer/Theme/DarkTheme.xaml
new file mode 100644
index 0000000..0987946
--- /dev/null
+++ b/src/ClaudeDo.Installer/Theme/DarkTheme.xaml
@@ -0,0 +1,280 @@
+
+
+
+
+
+ #3d9474
+ #6bb89e
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
new file mode 100644
index 0000000..40670c8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
@@ -0,0 +1,161 @@
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly InstallContext _context;
+ private readonly IReleaseClient _releases;
+ private readonly StopServiceStep _stopService;
+ private readonly StartServiceStep _startService;
+ private readonly DownloadAndExtractStep _downloadStep;
+ private readonly UninstallRunner _uninstallRunner;
+
+ public IReadOnlyList Pages { get; }
+
+ [ObservableProperty]
+ private IInstallerPage? _selectedPage;
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private bool _isStatusError;
+
+ [ObservableProperty]
+ private string _versionLabel = "";
+
+ public SettingsViewModel(
+ PageResolver resolver,
+ InstallContext context,
+ IReleaseClient releases,
+ StopServiceStep stopService,
+ StartServiceStep startService,
+ DownloadAndExtractStep downloadStep,
+ UninstallRunner uninstallRunner)
+ {
+ Pages = resolver.SettingsPages;
+ _context = context;
+ _releases = releases;
+ _stopService = stopService;
+ _startService = startService;
+ _downloadStep = downloadStep;
+ _uninstallRunner = uninstallRunner;
+ _selectedPage = Pages.FirstOrDefault();
+
+ VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
+
+ _ = LoadAllAsync();
+ }
+
+ private async Task LoadAllAsync()
+ {
+ foreach (var page in Pages)
+ await page.LoadAsync();
+ }
+
+ [RelayCommand]
+ private async Task Save()
+ {
+ foreach (var page in Pages)
+ {
+ if (!page.Validate())
+ {
+ SelectedPage = page;
+ StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
+ IsStatusError = true;
+ return;
+ }
+ }
+
+ foreach (var page in Pages)
+ await page.ApplyAsync();
+
+ var workerCfg = new InstallerWorkerConfig
+ {
+ DbPath = _context.DbPath,
+ SandboxRoot = _context.SandboxRoot,
+ LogRoot = _context.LogRoot,
+ WorktreeRootStrategy = _context.WorktreeRootStrategy,
+ CentralWorktreeRoot = _context.CentralWorktreeRoot,
+ QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
+ SignalRPort = _context.SignalRPort,
+ ClaudeBin = _context.ClaudeBin,
+ };
+ workerCfg.Save();
+
+ var uiCfg = new InstallerAppSettings
+ {
+ DbPath = _context.UiDbPath,
+ SignalRUrl = _context.SignalRUrl,
+ };
+ uiCfg.Save();
+
+ StatusMessage = "Settings saved.";
+ IsStatusError = false;
+ }
+
+ [RelayCommand]
+ private async Task Repair()
+ {
+ var confirm = MessageBox.Show(
+ "Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
+ "Repair ClaudeDo",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Question);
+
+ if (confirm != MessageBoxResult.OK) return;
+
+ StatusMessage = "Repairing...";
+ IsStatusError = false;
+
+ var progress = new Progress(msg => StatusMessage = msg);
+ var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
+
+ foreach (var step in steps)
+ {
+ var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
+ if (!r.Success)
+ {
+ StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
+ IsStatusError = true;
+ return;
+ }
+ }
+
+ StatusMessage = "Repair complete.";
+ }
+
+ [RelayCommand]
+ private async Task Uninstall()
+ {
+ var confirm = MessageBox.Show(
+ "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
+ "Uninstall ClaudeDo",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (confirm != MessageBoxResult.Yes) return;
+
+ var progress = new Progress(msg => StatusMessage = msg);
+ var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
+
+ if (!r.Success)
+ {
+ StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
+ IsStatusError = true;
+ return;
+ }
+
+ MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ Application.Current.Shutdown();
+ }
+
+ [RelayCommand]
+ private void Close() => Application.Current.Shutdown();
+}
diff --git a/src/ClaudeDo.Installer/Views/SettingsWindow.xaml b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml
new file mode 100644
index 0000000..fa01301
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
new file mode 100644
index 0000000..d3dd915
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class SettingsWindow : Window
+{
+ public SettingsWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ClaudeDo.Installer/Views/WizardViewModel.cs b/src/ClaudeDo.Installer/Views/WizardViewModel.cs
new file mode 100644
index 0000000..7b775aa
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/WizardViewModel.cs
@@ -0,0 +1,85 @@
+using System.Linq;
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Pages.InstallPage;
+using ClaudeDo.Installer.Pages.WelcomePage;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class WizardViewModel : ObservableObject
+{
+ private readonly InstallContext _context;
+
+ public IReadOnlyList Pages { get; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanGoBack))]
+ [NotifyPropertyChangedFor(nameof(IsLastPage))]
+ [NotifyPropertyChangedFor(nameof(NextButtonText))]
+ [NotifyPropertyChangedFor(nameof(CurrentPage))]
+ private int _currentPageIndex;
+
+ public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
+ public bool CanGoBack => CurrentPageIndex > 0;
+ public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
+ public string NextButtonText => IsLastPage ? "Install" : "Next \u2192";
+
+ [ObservableProperty]
+ private string? _validationError;
+
+ public WizardViewModel(PageResolver resolver, InstallContext context)
+ {
+ _context = context;
+
+ var all = resolver.WizardPages;
+ Pages = context.Mode == InstallerMode.Update
+ ? all.Where(p => p is WelcomePageViewModel
+ || p is InstallPageViewModel).ToList()
+ : all;
+
+ if (Pages.Count > 0)
+ _ = InitAsync();
+ }
+
+ private async Task InitAsync()
+ {
+ try { await Pages[0].LoadAsync(); }
+ catch { /* first page loads with defaults on error */ }
+ }
+
+ [RelayCommand]
+ private async Task GoBack()
+ {
+ if (!CanGoBack) return;
+ CurrentPageIndex--;
+ await CurrentPage.LoadAsync();
+ ValidationError = null;
+ }
+
+ [RelayCommand]
+ private async Task GoNext()
+ {
+ if (!CurrentPage.Validate())
+ {
+ ValidationError = "Please fix the highlighted errors before continuing.";
+ return;
+ }
+
+ ValidationError = null;
+ await CurrentPage.ApplyAsync();
+
+ if (CurrentPageIndex < Pages.Count - 1)
+ {
+ CurrentPageIndex++;
+ await CurrentPage.LoadAsync();
+ }
+ }
+
+ [RelayCommand]
+ private void Close()
+ {
+ Application.Current.Shutdown();
+ }
+}
diff --git a/src/ClaudeDo.Installer/Views/WizardWindow.xaml b/src/ClaudeDo.Installer/Views/WizardWindow.xaml
new file mode 100644
index 0000000..91d0a31
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/WizardWindow.xaml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs b/src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
new file mode 100644
index 0000000..73c9b83
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class WizardWindow : Window
+{
+ public WizardWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ClaudeDo.Installer/app.debug.manifest b/src/ClaudeDo.Installer/app.debug.manifest
new file mode 100644
index 0000000..ba24c2e
--- /dev/null
+++ b/src/ClaudeDo.Installer/app.debug.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/app.manifest b/src/ClaudeDo.Installer/app.manifest
new file mode 100644
index 0000000..67abdbd
--- /dev/null
+++ b/src/ClaudeDo.Installer/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs b/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
new file mode 100644
index 0000000..d72a0d5
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
@@ -0,0 +1,106 @@
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class ChecksumVerifierTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public ChecksumVerifierTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ }
+
+ [Fact]
+ public void ComputeSha256_KnownVector_EmptyFile()
+ {
+ var path = Path.Combine(_tempDir, "empty.bin");
+ File.WriteAllBytes(path, Array.Empty());
+
+ var hash = ChecksumVerifier.ComputeSha256(path);
+
+ Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
+ }
+
+ [Fact]
+ public void ComputeSha256_KnownVector_Hello()
+ {
+ var path = Path.Combine(_tempDir, "hello.bin");
+ File.WriteAllText(path, "hello");
+
+ var hash = ChecksumVerifier.ComputeSha256(path);
+
+ Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
+ }
+
+ [Fact]
+ public void Verify_ReturnsTrue_WhenHashMatches()
+ {
+ var path = Path.Combine(_tempDir, "x.bin");
+ File.WriteAllText(path, "hello");
+
+ Assert.True(ChecksumVerifier.Verify(path,
+ "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
+ }
+
+ [Fact]
+ public void Verify_IsCaseInsensitive()
+ {
+ var path = Path.Combine(_tempDir, "x.bin");
+ File.WriteAllText(path, "hello");
+
+ Assert.True(ChecksumVerifier.Verify(path,
+ "2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
+ }
+
+ [Fact]
+ public void Verify_ReturnsFalse_OnMismatch()
+ {
+ var path = Path.Combine(_tempDir, "x.bin");
+ File.WriteAllText(path, "hello");
+
+ Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
+ }
+
+ [Fact]
+ public void ParseChecksumsFile_ReadsTwoLines()
+ {
+ var content = """
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
+ 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
+ """;
+
+ var map = ChecksumVerifier.ParseChecksumsFile(content);
+
+ Assert.Equal(2, map.Count);
+ Assert.Equal(
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ map["ClaudeDo-0.2.0-win-x64.zip"]);
+ Assert.Equal(
+ "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
+ map["ClaudeDo.Installer-0.2.0.exe"]);
+ }
+
+ [Fact]
+ public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
+ {
+ var content = """
+
+ not a line
+ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
+
+ """;
+
+ var map = ChecksumVerifier.ParseChecksumsFile(content);
+
+ Assert.Single(map);
+ Assert.True(map.ContainsKey("file.zip"));
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj b/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
new file mode 100644
index 0000000..3aef282
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0-windows
+enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
new file mode 100644
index 0000000..888cfa0
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
@@ -0,0 +1,148 @@
+using System.IO;
+using System.IO.Compression;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class DownloadAndExtractStepTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string _installDir;
+
+ public DownloadAndExtractStepTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ _installDir = Path.Combine(_tempDir, "install");
+ Directory.CreateDirectory(_installDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ }
+
+ private sealed class FileCopyReleaseClient : IReleaseClient
+ {
+ private readonly Dictionary _urlToSourceFile;
+ public GiteaRelease? Release { get; set; }
+
+ public FileCopyReleaseClient(Dictionary urlToSourceFile)
+ => _urlToSourceFile = urlToSourceFile;
+
+ public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
+
+ public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ {
+ File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
+ progress.Report(new FileInfo(destPath).Length);
+ return Task.CompletedTask;
+ }
+ }
+
+ [Fact]
+ public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
+ {
+ var zipPath = Path.Combine(_tempDir, "release.zip");
+ using (var fs = File.Create(zipPath))
+ using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
+ {
+ var a = zip.CreateEntry("app/a.txt");
+ using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
+ var b = zip.CreateEntry("worker/b.txt");
+ using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
+ }
+
+ var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
+ var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
+ File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
+
+ var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
+ new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
+ });
+
+ var client = new FileCopyReleaseClient(new()
+ {
+ ["fake://zip"] = zipPath,
+ ["fake://checksums"] = checksumsPath,
+ }) { Release = release };
+
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.True(result.Success, result.ErrorMessage);
+ Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
+ Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
+ Assert.Equal("0.1.0", ctx.InstalledVersion);
+ }
+
+ [Fact]
+ public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
+ {
+ var zipPath = Path.Combine(_tempDir, "release.zip");
+ using (var fs = File.Create(zipPath))
+ using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
+ {
+ var a = zip.CreateEntry("app/a.txt");
+ using (var w = new StreamWriter(a.Open())) w.Write("x");
+ }
+
+ var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
+ File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
+
+ File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
+
+ var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
+ new ReleaseAsset("checksums.txt", "fake://checksums", 0),
+ });
+
+ var client = new FileCopyReleaseClient(new()
+ {
+ ["fake://zip"] = zipPath,
+ ["fake://checksums"] = checksumsPath,
+ }) { Release = release };
+
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
+ Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
+ Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
+ }
+
+ [Fact]
+ public async Task Fails_When_Release_Has_No_Zip_Asset()
+ {
+ var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty());
+ var client = new FileCopyReleaseClient(new()) { Release = release };
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Fails_When_ReleaseClient_Returns_Null()
+ {
+ var client = new FileCopyReleaseClient(new()) { Release = null };
+ var step = new DownloadAndExtractStep(client);
+ var ctx = new InstallContext { InstallDirectory = _installDir };
+
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs b/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
new file mode 100644
index 0000000..960a8fb
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
@@ -0,0 +1,32 @@
+using System.Net;
+using System.Net.Http;
+
+namespace ClaudeDo.Installer.Tests;
+
+internal sealed class FakeHttpMessageHandler : HttpMessageHandler
+{
+ private readonly Func _handler;
+ private readonly object _lock = new();
+ private readonly List _requests = new();
+
+ public FakeHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ public FakeHttpMessageHandler(HttpStatusCode status, string body)
+ : this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
+ {
+ }
+
+ public IReadOnlyList Requests
+ {
+ get { lock (_lock) return _requests.ToArray(); }
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ lock (_lock) _requests.Add(request);
+ return Task.FromResult(_handler(request));
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs b/tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
new file mode 100644
index 0000000..ff7565f
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
@@ -0,0 +1,76 @@
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class InstallManifestStoreTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public InstallManifestStoreTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
+ }
+
+ [Fact]
+ public void TryRead_ReturnsNull_WhenFileMissing()
+ {
+ var result = InstallManifestStore.TryRead(_tempDir);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Write_Then_Read_RoundTripsAllFields()
+ {
+ var manifest = new InstallManifest(
+ Version: "0.2.0",
+ InstallDir: _tempDir,
+ WorkerDir: Path.Combine(_tempDir, "worker"),
+ InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
+
+ InstallManifestStore.Write(_tempDir, manifest);
+
+ var round = InstallManifestStore.TryRead(_tempDir);
+ Assert.NotNull(round);
+ Assert.Equal("0.2.0", round!.Version);
+ Assert.Equal(manifest.InstallDir, round.InstallDir);
+ Assert.Equal(manifest.WorkerDir, round.WorkerDir);
+ Assert.Equal(manifest.InstalledAt, round.InstalledAt);
+ }
+
+ [Fact]
+ public void Write_CreatesInstallDir_IfMissing()
+ {
+ var nested = Path.Combine(_tempDir, "nested");
+ Assert.False(Directory.Exists(nested));
+
+ InstallManifestStore.Write(nested, new InstallManifest(
+ "0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
+
+ Assert.True(File.Exists(Path.Combine(nested, "install.json")));
+ }
+
+ [Fact]
+ public void TryRead_ReturnsNull_WhenJsonMalformed()
+ {
+ File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
+ var result = InstallManifestStore.TryRead(_tempDir);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void TryRead_ReturnsNull_WhenJsonIsValidButShapeIsWrong()
+ {
+ // Valid JSON, but installedAt has a wrong type — causes JsonException, swallowed silently.
+ File.WriteAllText(Path.Combine(_tempDir, "install.json"),
+ """{"version":"1.0","installDir":"x","workerDir":"y","installedAt":12345}""");
+ var result = InstallManifestStore.TryRead(_tempDir);
+ Assert.Null(result);
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
new file mode 100644
index 0000000..223a0a7
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
@@ -0,0 +1,124 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class InstallModeDetectorTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public InstallModeDetectorTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ }
+
+ private sealed class FakeReleaseClient : IReleaseClient
+ {
+ public GiteaRelease? Release { get; set; }
+ public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
+ public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ => throw new NotSupportedException();
+ }
+
+ [Fact]
+ public async Task Detect_FreshInstall_WhenManifestMissing()
+ {
+ var detector = new InstallModeDetector(new FakeReleaseClient());
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.FreshInstall, state.Mode);
+ Assert.Null(state.Existing);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ Assert.Equal("0.1.0", state.Existing!.Version);
+ }
+
+ [Fact]
+ public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Update, state.Mode);
+ Assert.Equal("0.1.0", state.Existing!.Version);
+ Assert.Equal("0.2.0", state.LatestVersion);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
+ {
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ }
+
+ [Fact]
+ public async Task Detect_Config_WhenInstalledVersion_IsUnparseable()
+ {
+ // install.json has been tampered with or written by an older installer with a
+ // version string we can't compare. Must not crash; must land on Config (no update).
+ InstallManifestStore.Write(_tempDir,
+ new InstallManifest("garbage", _tempDir, _tempDir, DateTimeOffset.UtcNow));
+
+ var fake = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty())
+ };
+ var detector = new InstallModeDetector(fake);
+
+ var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
+
+ Assert.Equal(InstallerMode.Config, state.Mode);
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs b/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
new file mode 100644
index 0000000..954e513
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
@@ -0,0 +1,109 @@
+using System.Net;
+using System.Net.Http;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class ReleaseClientTests
+{
+ private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
+ {
+ const string json = """
+ {
+ "tag_name": "v0.2.0",
+ "name": "v0.2.0",
+ "assets": [
+ {
+ "name": "ClaudeDo-0.2.0-win-x64.zip",
+ "browser_download_url": "https://git.example.test/dl/zip",
+ "size": 12345
+ },
+ {
+ "name": "checksums.txt",
+ "browser_download_url": "https://git.example.test/dl/checksums",
+ "size": 128
+ }
+ ]
+ }
+ """;
+
+ var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var release = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.NotNull(release);
+ Assert.Equal("v0.2.0", release!.TagName);
+ Assert.Equal(2, release.Assets.Count);
+ Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
+ Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
+ Assert.Equal(12345, release.Assets[0].Size);
+ }
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_Returns_Null_On404()
+ {
+ var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var release = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.Null(release);
+ }
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
+ {
+ var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var release = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.Null(release);
+ }
+
+ [Fact]
+ public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
+ {
+ var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ _ = await client.GetLatestReleaseAsync(CancellationToken.None);
+
+ Assert.Single(handler.Requests);
+ Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task DownloadAsync_WritesBytesToDisk()
+ {
+ var payload = new byte[] { 1, 2, 3, 4, 5 };
+ var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(payload)
+ });
+ using var http = new HttpClient(handler);
+ var client = new ReleaseClient(http, ApiBase);
+
+ var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ await client.DownloadAsync("https://example/foo", tempPath,
+ new Progress(_ => { }), CancellationToken.None);
+
+ Assert.True(File.Exists(tempPath));
+ Assert.Equal(payload, File.ReadAllBytes(tempPath));
+ }
+ finally
+ {
+ if (File.Exists(tempPath)) File.Delete(tempPath);
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs b/tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs
new file mode 100644
index 0000000..3dc4044
--- /dev/null
+++ b/tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs
@@ -0,0 +1,51 @@
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Steps;
+
+namespace ClaudeDo.Installer.Tests;
+
+public sealed class WriteInstallManifestStepTests : IDisposable
+{
+ private readonly string _installDir;
+
+ public WriteInstallManifestStepTests()
+ {
+ _installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_installDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_installDir, recursive: true); } catch { }
+ }
+
+ [Fact]
+ public async Task Writes_Manifest_WithAllFields()
+ {
+ var ctx = new InstallContext
+ {
+ InstallDirectory = _installDir,
+ InstalledVersion = "0.2.0",
+ };
+
+ var step = new WriteInstallManifestStep();
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.True(result.Success);
+ var manifest = InstallManifestStore.TryRead(_installDir);
+ Assert.NotNull(manifest);
+ Assert.Equal("0.2.0", manifest!.Version);
+ Assert.Equal(_installDir, manifest.InstallDir);
+ Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
+ }
+
+ [Fact]
+ public async Task Fails_When_InstalledVersion_Missing()
+ {
+ var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
+
+ var step = new WriteInstallManifestStep();
+ var result = await step.ExecuteAsync(ctx, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.False(result.Success);
+ }
+}