diff --git a/docs/superpowers/plans/2026-04-23-self-update.md b/docs/superpowers/plans/2026-04-23-self-update.md
new file mode 100644
index 0000000..72a6478
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-23-self-update.md
@@ -0,0 +1,1831 @@
+# Self-Update 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:** Add a "newer version available" check to both the Avalonia app (banner + Help menu manual trigger) and the WPF installer (self-replacement before running the wizard).
+
+**Architecture:** A new `ClaudeDo.Releases` library consolidates Gitea API access, checksum verification, version comparison, and installer self-update logic. The installer runs `SelfUpdater` before showing its wizard. The app runs `UpdateCheckService` on startup; a banner in `MainWindow` and a `Help → Check for updates` menu item expose status; clicking **Update now** launches the installed `ClaudeDo.Installer.exe` and closes the app.
+
+**Tech Stack:** .NET 8, Avalonia 12 (UI), WPF (Installer), CommunityToolkit.Mvvm, xUnit, `Microsoft.Extensions.DependencyInjection`.
+
+**Spec:** `docs/superpowers/specs/2026-04-23-self-update-design.md`
+
+**Build note:** The root solution `ClaudeDo.slnx` does not build on .NET 8 (known issue — needs .NET 9). Always build and test against individual `.csproj` files, not the solution.
+
+---
+
+## File Structure
+
+**New:**
+
+- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
+- `src/ClaudeDo.Releases/IReleaseClient.cs` — moved from `src/ClaudeDo.Installer/Core/IReleaseClient.cs`
+- `src/ClaudeDo.Releases/ReleaseClient.cs` — moved from `src/ClaudeDo.Installer/Core/ReleaseClient.cs`
+- `src/ClaudeDo.Releases/ChecksumVerifier.cs` — moved from `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`
+- `src/ClaudeDo.Releases/VersionComparer.cs`
+- `src/ClaudeDo.Releases/SelfUpdater.cs`
+- `src/ClaudeDo.Releases/SelfUpdateResult.cs`
+- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
+- `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`
+- `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
+- `tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs` — port existing from `tests/ClaudeDo.Installer.Tests`
+- `tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs` — port existing from `tests/ClaudeDo.Installer.Tests`
+- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
+- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
+- `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`
+- `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`
+
+**Modified:**
+
+- `ClaudeDo.slnx` — add `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj` and `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`.
+- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add `ProjectReference` to `ClaudeDo.Releases`.
+- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use `VersionComparer` from `ClaudeDo.Releases`; update namespace imports after the type moves.
+- `src/ClaudeDo.Installer/App.xaml.cs` — run `SelfUpdater` before the wizard; handle `--replace-self` argument.
+- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — add `ProjectReference` to `ClaudeDo.Releases`.
+- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner above content; Help menu in the titlebar.
+- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — wire `UpdateCheckService`, expose `IsBannerVisible`, `LatestVersion`, `InlineStatusText`, `CheckForUpdatesCommand`, `UpdateNowCommand`, `DismissBannerCommand`.
+- `src/ClaudeDo.App/Program.cs` — register `IReleaseClient`, `HttpClient` (via `IHttpClientFactory` or a single-instance singleton), `UpdateCheckService`, `InstallerLocator`.
+- `docs/open.md` — manual verification checklist entries.
+
+---
+
+## Task 1: Create `ClaudeDo.Releases` project skeleton
+
+**Files:**
+
+- Create: `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
+- Modify: `ClaudeDo.slnx`
+- Modify: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
+
+- [ ] **Step 1: Create the csproj**
+
+Write `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`:
+
+```xml
+
+
+ net8.0
+ enable
+ enable
+ latest
+
+
+```
+
+- [ ] **Step 2: Add to solution**
+
+Edit `ClaudeDo.slnx` — add the new `` line under the `/src/` folder:
+
+```xml
+
+```
+
+- [ ] **Step 3: Add reference from Installer**
+
+Edit `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add inside the existing `` that holds `` (or create one):
+
+```xml
+
+```
+
+- [ ] **Step 4: Build to confirm skeleton compiles**
+
+Run: `dotnet build src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
+Expected: `Build succeeded.`
+
+Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
+Expected: `Build succeeded.` (still consumes types from its own `Core/` folder — nothing moved yet)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/ClaudeDo.Releases.csproj src/ClaudeDo.Installer/ClaudeDo.Installer.csproj ClaudeDo.slnx
+git commit -m "feat(releases): add empty ClaudeDo.Releases library"
+```
+
+---
+
+## Task 2: Move release-API + checksum types into `ClaudeDo.Releases`
+
+**Files:**
+
+- Create: `src/ClaudeDo.Releases/IReleaseClient.cs`
+- Create: `src/ClaudeDo.Releases/ReleaseClient.cs`
+- Create: `src/ClaudeDo.Releases/ChecksumVerifier.cs`
+- Delete: `src/ClaudeDo.Installer/Core/IReleaseClient.cs`
+- Delete: `src/ClaudeDo.Installer/Core/ReleaseClient.cs`
+- Delete: `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`
+
+- [ ] **Step 1: Move `IReleaseClient.cs` to Releases**
+
+Create `src/ClaudeDo.Releases/IReleaseClient.cs` with the exact content of `src/ClaudeDo.Installer/Core/IReleaseClient.cs` but change the namespace from `ClaudeDo.Installer.Core` to `ClaudeDo.Releases`:
+
+```csharp
+namespace ClaudeDo.Releases;
+
+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 2: Move `ReleaseClient.cs` to Releases**
+
+Create `src/ClaudeDo.Releases/ReleaseClient.cs` with the content of `src/ClaudeDo.Installer/Core/ReleaseClient.cs`, changing the namespace to `ClaudeDo.Releases`. The rest of the file is unchanged (same HTTP logic, same `DefaultApiBase`).
+
+- [ ] **Step 3: Move `ChecksumVerifier.cs` to Releases**
+
+Create `src/ClaudeDo.Releases/ChecksumVerifier.cs` with the content of `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`, changing the namespace to `ClaudeDo.Releases`. No other changes.
+
+- [ ] **Step 4: Delete the originals**
+
+```bash
+rm src/ClaudeDo.Installer/Core/IReleaseClient.cs \
+ src/ClaudeDo.Installer/Core/ReleaseClient.cs \
+ src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
+```
+
+- [ ] **Step 5: Add `using ClaudeDo.Releases;` to every installer file that referenced these types**
+
+Run `grep -l "ClaudeDo.Installer.Core" src/ClaudeDo.Installer --include="*.cs" -r` to find files that use types in `ClaudeDo.Installer.Core`. Inspect each and, wherever the file used `IReleaseClient`, `ReleaseClient`, `GiteaRelease`, `ReleaseAsset`, `ChecksumVerifier`, add `using ClaudeDo.Releases;`.
+
+Known call sites (from the current code) — confirm and update each:
+
+- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — uses `IReleaseClient`, `GiteaRelease`
+- `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` — uses `IReleaseClient`, `ChecksumVerifier`
+- `src/ClaudeDo.Installer/App.xaml.cs` — registers `ReleaseClient` in DI
+
+In `InstallModeDetector.cs`, the `DetectedState` record currently returns `GiteaRelease?`. That type is now in `ClaudeDo.Releases`. Keep the record signature identical, just add `using ClaudeDo.Releases;`.
+
+- [ ] **Step 6: Build Installer to verify moves**
+
+Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
+Expected: `Build succeeded.`
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/ src/ClaudeDo.Installer/
+git commit -m "refactor(releases): move release-API + checksum types to ClaudeDo.Releases"
+```
+
+---
+
+## Task 3: Create `ClaudeDo.Releases.Tests` and port existing tests
+
+**Files:**
+
+- Create: `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
+- Create: `tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs` (port)
+- Create: `tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs` (port)
+- Modify: `ClaudeDo.slnx`
+- Modify (delete ported tests): `tests/ClaudeDo.Installer.Tests/...`
+
+- [ ] **Step 1: Create the test csproj**
+
+Write `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`:
+
+```xml
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add to solution**
+
+Edit `ClaudeDo.slnx` — add under `/tests/` folder:
+
+```xml
+
+```
+
+- [ ] **Step 3: Identify ported tests**
+
+Run: `grep -l -E "ReleaseClient|ChecksumVerifier" tests/ClaudeDo.Installer.Tests/ -r --include="*.cs"`
+Expected: a list of files (e.g. `ReleaseClientTests.cs`, `ChecksumVerifierTests.cs`). If no match, there are none to port and this task still proceeds — just skip the file copies.
+
+- [ ] **Step 4: Copy each matched test file to `tests/ClaudeDo.Releases.Tests/`**
+
+For each file from Step 3:
+1. Copy to `tests/ClaudeDo.Releases.Tests/`.
+2. Change namespace from `ClaudeDo.Installer.Tests...` to `ClaudeDo.Releases.Tests`.
+3. Update `using ClaudeDo.Installer.Core;` → `using ClaudeDo.Releases;`.
+4. Delete the original file in `tests/ClaudeDo.Installer.Tests/`.
+
+- [ ] **Step 5: Build + run the new test project**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
+Expected: all ported tests pass (or `No tests were found` if none were ported — in which case the project still builds and we'll add tests in later tasks).
+
+- [ ] **Step 6: Build + run the Installer tests (confirm no broken refs)**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`
+Expected: `Build succeeded.` and all remaining tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add tests/ClaudeDo.Releases.Tests/ tests/ClaudeDo.Installer.Tests/ ClaudeDo.slnx
+git commit -m "test(releases): port ReleaseClient + ChecksumVerifier tests to new project"
+```
+
+---
+
+## Task 4: Add `VersionComparer` (TDD)
+
+**Files:**
+
+- Create: `src/ClaudeDo.Releases/VersionComparer.cs`
+- Create: `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Write `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`:
+
+```csharp
+namespace ClaudeDo.Releases.Tests;
+
+public class VersionComparerTests
+{
+ [Theory]
+ [InlineData("0.2.0", "0.1.0", true, false)]
+ [InlineData("0.2.0", "0.2.0", false, false)]
+ [InlineData("0.1.0", "0.2.0", false, false)]
+ [InlineData("v0.2.0", "0.1.0", true, false)]
+ [InlineData("0.2.0", "v0.1.0", true, false)]
+ [InlineData("1.0.0.0", "0.99.99.99", true, false)]
+ public void Compare_ParseableVersions(string latest, string current, bool expectedNewer, bool expectedUnparseable)
+ {
+ var result = VersionComparer.Compare(latest, current);
+ Assert.Equal(expectedNewer, result.IsNewer);
+ Assert.Equal(expectedUnparseable, result.Unparseable);
+ }
+
+ [Theory]
+ [InlineData("0.2.0-beta", "0.1.0")]
+ [InlineData("0.2.0", "0.1.0-alpha")]
+ [InlineData("garbage", "0.1.0")]
+ [InlineData("", "0.1.0")]
+ public void Compare_UnparseableReturnsNotNewer(string latest, string current)
+ {
+ var result = VersionComparer.Compare(latest, current);
+ Assert.False(result.IsNewer);
+ Assert.True(result.Unparseable);
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter VersionComparerTests`
+Expected: FAIL — `VersionComparer` does not exist.
+
+- [ ] **Step 3: Implement**
+
+Write `src/ClaudeDo.Releases/VersionComparer.cs`:
+
+```csharp
+namespace ClaudeDo.Releases;
+
+public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable);
+
+public static class VersionComparer
+{
+ ///
+ /// Returns IsNewer=true only when both versions parse as System.Version AND latest > current.
+ /// Pre-release tags like "0.2.0-beta" fail to parse and are treated as not newer with
+ /// Unparseable=true so callers can surface a hint.
+ ///
+ public static VersionCompareResult Compare(string latest, string current)
+ {
+ var latestTrimmed = (latest ?? "").TrimStart('v', 'V');
+ var currentTrimmed = (current ?? "").TrimStart('v', 'V');
+
+ var unparseable = !Version.TryParse(latestTrimmed, out var lv)
+ | !Version.TryParse(currentTrimmed, out var cv);
+
+ if (unparseable) return new VersionCompareResult(false, true);
+ return new VersionCompareResult(lv > cv, false);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify pass**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter VersionComparerTests`
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/VersionComparer.cs tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
+git commit -m "feat(releases): add VersionComparer"
+```
+
+---
+
+## Task 5: Refactor `InstallModeDetector` to use `VersionComparer`
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`
+
+- [ ] **Step 1: Replace the private `IsNewer` method with a call to `VersionComparer.Compare`**
+
+Edit `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`. The existing `DetectAsync` currently calls a private `IsNewer(latestVersion, manifest.Version, out var unparseable)`. Replace the method and its call site:
+
+Inside `DetectAsync`, change:
+
+```csharp
+var newer = IsNewer(latestVersion, manifest.Version, out var unparseable);
+```
+
+to:
+
+```csharp
+var cmp = VersionComparer.Compare(latestVersion, manifest.Version);
+var newer = cmp.IsNewer;
+var unparseable = cmp.Unparseable;
+```
+
+Delete the entire private `IsNewer` method at the bottom of the file.
+
+Ensure `using ClaudeDo.Releases;` is present at the top.
+
+- [ ] **Step 2: Build + run existing tests**
+
+Run: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`
+Expected: all existing `InstallModeDetector` tests still pass — `VersionComparer` preserves identical semantics.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/Core/InstallModeDetector.cs
+git commit -m "refactor(installer): use shared VersionComparer in InstallModeDetector"
+```
+
+---
+
+## Task 6: Add `SelfUpdater` — installer-asset matching (TDD)
+
+**Files:**
+
+- Create: `src/ClaudeDo.Releases/SelfUpdater.cs`
+- Create: `src/ClaudeDo.Releases/SelfUpdateResult.cs`
+- Create: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
+
+- [ ] **Step 1: Write failing tests for asset matching**
+
+Write `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
+
+```csharp
+namespace ClaudeDo.Releases.Tests;
+
+public class SelfUpdaterAssetMatchingTests
+{
+ [Fact]
+ public void FindInstallerAsset_PicksInstallerExeByPattern()
+ {
+ var assets = new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10),
+ new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst.exe", 20),
+ new ReleaseAsset("checksums.txt", "https://x/checks", 1),
+ };
+
+ var result = SelfUpdater.FindInstallerAsset(assets);
+
+ Assert.NotNull(result);
+ Assert.Equal("ClaudeDo.Installer-0.3.0.exe", result!.Asset.Name);
+ Assert.Equal("0.3.0", result.Version);
+ }
+
+ [Fact]
+ public void FindInstallerAsset_ReturnsNullWhenAbsent()
+ {
+ var assets = new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10),
+ };
+
+ Assert.Null(SelfUpdater.FindInstallerAsset(assets));
+ }
+
+ [Fact]
+ public void FindInstallerAsset_IgnoresAppZipThatContainsInstaller()
+ {
+ var assets = new[]
+ {
+ new ReleaseAsset("ClaudeDo.Installer.Portable-0.3.0.zip", "https://x/1", 1),
+ new ReleaseAsset("not-the-installer.exe", "https://x/2", 1),
+ };
+
+ Assert.Null(SelfUpdater.FindInstallerAsset(assets));
+ }
+}
+```
+
+- [ ] **Step 2: Write the minimum stub to make the compiler happy**
+
+Write `src/ClaudeDo.Releases/SelfUpdateResult.cs`:
+
+```csharp
+namespace ClaudeDo.Releases;
+
+public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
+```
+
+Write `src/ClaudeDo.Releases/SelfUpdater.cs`:
+
+```csharp
+using System.Text.RegularExpressions;
+
+namespace ClaudeDo.Releases;
+
+public static partial class SelfUpdater
+{
+ [GeneratedRegex(@"^ClaudeDo\.Installer-(?[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
+ private static partial Regex InstallerAssetRegex();
+
+ public static InstallerAssetMatch? FindInstallerAsset(IEnumerable assets)
+ {
+ foreach (var asset in assets)
+ {
+ var m = InstallerAssetRegex().Match(asset.Name);
+ if (m.Success)
+ {
+ return new InstallerAssetMatch(asset, m.Groups["version"].Value);
+ }
+ }
+ return null;
+ }
+}
+```
+
+- [ ] **Step 3: Run tests to verify pass**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterAssetMatchingTests`
+Expected: all 3 tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/SelfUpdater.cs src/ClaudeDo.Releases/SelfUpdateResult.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
+git commit -m "feat(releases): add SelfUpdater installer-asset matching"
+```
+
+---
+
+## Task 7: Add `SelfUpdater.DecideUpdateAsync` (TDD)
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Releases/SelfUpdater.cs`
+- Modify: `src/ClaudeDo.Releases/SelfUpdateResult.cs`
+- Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
+
+- [ ] **Step 1: Add failing decision-logic tests**
+
+Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
+
+```csharp
+public class SelfUpdaterDecisionTests
+{
+ private sealed class FakeReleaseClient : IReleaseClient
+ {
+ public GiteaRelease? Release { get; set; }
+ public bool Throw { get; set; }
+
+ public Task GetLatestReleaseAsync(CancellationToken ct)
+ {
+ if (Throw) throw new HttpRequestException("boom");
+ return Task.FromResult(Release);
+ }
+
+ public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ => throw new NotSupportedException("not used in decision tests");
+ }
+
+ [Fact]
+ public async Task Decide_NoRelease_NoUpdate()
+ {
+ var client = new FakeReleaseClient { Release = null };
+ var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None);
+ Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
+ }
+
+ [Fact]
+ public async Task Decide_NetworkError_NoUpdate()
+ {
+ var client = new FakeReleaseClient { Throw = true };
+ var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None);
+ Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
+ }
+
+ [Fact]
+ public async Task Decide_OlderLatest_NoUpdate()
+ {
+ var client = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.1.0", "rel", new[]
+ {
+ new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1),
+ }),
+ };
+ var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
+ Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
+ }
+
+ [Fact]
+ public async Task Decide_NewerLatestWithAsset_UpdateAvailable()
+ {
+ var client = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.3.0", "rel", new[]
+ {
+ new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20),
+ new ReleaseAsset("checksums.txt", "https://checks", 1),
+ }),
+ };
+ var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
+ Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind);
+ Assert.Equal("0.3.0", d.LatestVersion);
+ Assert.NotNull(d.InstallerAsset);
+ Assert.NotNull(d.ChecksumsAsset);
+ }
+
+ [Fact]
+ public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate()
+ {
+ var client = new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.3.0", "rel", new[]
+ {
+ new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20),
+ }),
+ };
+ var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None);
+ Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind);
+ }
+}
+```
+
+- [ ] **Step 2: Add return types to `SelfUpdateResult.cs`**
+
+Extend `src/ClaudeDo.Releases/SelfUpdateResult.cs`:
+
+```csharp
+namespace ClaudeDo.Releases;
+
+public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
+
+public enum SelfUpdateDecisionKind
+{
+ NoUpdate,
+ UpdateAvailable,
+}
+
+public sealed record SelfUpdateDecision(
+ SelfUpdateDecisionKind Kind,
+ string? LatestVersion = null,
+ ReleaseAsset? InstallerAsset = null,
+ ReleaseAsset? ChecksumsAsset = null);
+```
+
+- [ ] **Step 3: Implement `DecideUpdateAsync`**
+
+Add to `src/ClaudeDo.Releases/SelfUpdater.cs`:
+
+```csharp
+ public static async Task DecideUpdateAsync(
+ IReleaseClient releases,
+ string currentVersion,
+ CancellationToken ct)
+ {
+ GiteaRelease? release;
+ try
+ {
+ release = await releases.GetLatestReleaseAsync(ct);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
+ {
+ return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
+ }
+
+ if (release is null)
+ return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
+
+ var match = FindInstallerAsset(release.Assets);
+ if (match is null)
+ return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
+
+ var cmp = VersionComparer.Compare(match.Version, currentVersion);
+ if (!cmp.IsNewer)
+ return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
+
+ var checksums = release.Assets.FirstOrDefault(
+ a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
+
+ return new SelfUpdateDecision(
+ SelfUpdateDecisionKind.UpdateAvailable,
+ LatestVersion: match.Version,
+ InstallerAsset: match.Asset,
+ ChecksumsAsset: checksums);
+ }
+```
+
+Add `using System.Net.Http;` at the top.
+
+- [ ] **Step 4: Run tests**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdater`
+Expected: all SelfUpdater tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/SelfUpdater.cs src/ClaudeDo.Releases/SelfUpdateResult.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
+git commit -m "feat(releases): add SelfUpdater.DecideUpdateAsync"
+```
+
+---
+
+## Task 8: Add `SelfUpdater.HandleReplaceSelfAsync` (TDD)
+
+**Purpose:** When the installer is launched with `--replace-self ""`, it waits for the old process to release its file lock, deletes the old exe, copies itself to the old path, relaunches from the old path, and exits.
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Releases/SelfUpdater.cs`
+- Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
+
+```csharp
+public class SelfUpdaterReplaceSelfTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public SelfUpdaterReplaceSelfTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } }
+
+ [Fact]
+ public async Task Replace_DeletesOldAndCopiesCurrent()
+ {
+ var oldPath = Path.Combine(_tempDir, "old.exe");
+ var currentPath = Path.Combine(_tempDir, "current.exe");
+ await File.WriteAllTextAsync(oldPath, "OLD");
+ await File.WriteAllTextAsync(currentPath, "NEW");
+
+ var relaunchedWith = "";
+ var result = await SelfUpdater.HandleReplaceSelfAsync(
+ oldPath: oldPath,
+ currentExePath: currentPath,
+ launchProcess: path => { relaunchedWith = path; return true; },
+ maxWaitMs: 500);
+
+ Assert.True(result);
+ Assert.Equal(oldPath, relaunchedWith);
+ Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath));
+ }
+
+ [Fact]
+ public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse()
+ {
+ var oldPath = Path.Combine(_tempDir, "locked.exe");
+ var currentPath = Path.Combine(_tempDir, "current.exe");
+ await File.WriteAllTextAsync(oldPath, "OLD");
+ await File.WriteAllTextAsync(currentPath, "NEW");
+
+ // Hold an exclusive lock across the wait window.
+ using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+
+ var result = await SelfUpdater.HandleReplaceSelfAsync(
+ oldPath: oldPath,
+ currentExePath: currentPath,
+ launchProcess: _ => true,
+ maxWaitMs: 200);
+
+ Assert.False(result);
+ }
+}
+```
+
+- [ ] **Step 2: Implement**
+
+Add to `src/ClaudeDo.Releases/SelfUpdater.cs`:
+
+```csharp
+ ///
+ /// Called when launched with `--replace-self "<old-path>"`. Waits for the old process
+ /// to release its file lock, deletes the old file, copies the current exe to the old path,
+ /// then calls with that path. Returns true on success.
+ ///
+ public static async Task HandleReplaceSelfAsync(
+ string oldPath,
+ string currentExePath,
+ Func launchProcess,
+ int maxWaitMs = 5000)
+ {
+ var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ if (File.Exists(oldPath))
+ {
+ File.Delete(oldPath);
+ }
+ break;
+ }
+ catch (IOException)
+ {
+ await Task.Delay(100);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ await Task.Delay(100);
+ }
+ }
+
+ if (File.Exists(oldPath))
+ {
+ return false;
+ }
+
+ File.Copy(currentExePath, oldPath, overwrite: false);
+ return launchProcess(oldPath);
+ }
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterReplaceSelfTests`
+Expected: both tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/SelfUpdater.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
+git commit -m "feat(releases): add SelfUpdater.HandleReplaceSelfAsync"
+```
+
+---
+
+## Task 9: Add `SelfUpdater.DownloadAndVerifyAsync` (TDD)
+
+**Purpose:** Download the new installer to `%TEMP%` and verify it against `checksums.txt`.
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Releases/SelfUpdater.cs`
+- Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
+
+```csharp
+public class SelfUpdaterDownloadTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public SelfUpdaterDownloadTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } }
+
+ private sealed class StubReleaseClient : IReleaseClient
+ {
+ public string FileContent { get; set; } = "";
+ public string ChecksumsBody { get; set; } = "";
+
+ public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(null);
+
+ public async Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ {
+ if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase))
+ {
+ await File.WriteAllTextAsync(destPath, ChecksumsBody, ct);
+ }
+ else
+ {
+ await File.WriteAllTextAsync(destPath, FileContent, ct);
+ }
+ progress.Report(FileContent.Length);
+ }
+ }
+
+ [Fact]
+ public async Task Download_MatchingChecksum_ReturnsPath()
+ {
+ var content = "FAKE-INSTALLER-BINARY";
+ var hash = Sha256Hex(content);
+ var client = new StubReleaseClient
+ {
+ FileContent = content,
+ ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n",
+ };
+ var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length);
+ var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100);
+
+ var path = await SelfUpdater.DownloadAndVerifyAsync(
+ client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.NotNull(path);
+ Assert.Equal(content, await File.ReadAllTextAsync(path!));
+ }
+
+ [Fact]
+ public async Task Download_ChecksumMismatch_ReturnsNull()
+ {
+ var client = new StubReleaseClient
+ {
+ FileContent = "real",
+ ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n",
+ };
+ var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4);
+ var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100);
+
+ var path = await SelfUpdater.DownloadAndVerifyAsync(
+ client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None);
+
+ Assert.Null(path);
+ }
+
+ private static string Sha256Hex(string s)
+ {
+ using var sha = System.Security.Cryptography.SHA256.Create();
+ return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant();
+ }
+}
+```
+
+- [ ] **Step 2: Implement**
+
+Add to `src/ClaudeDo.Releases/SelfUpdater.cs`:
+
+```csharp
+ ///
+ /// Downloads the installer asset + checksums file to , verifies the
+ /// installer's sha256 against the entry in checksums.txt keyed by asset name, and returns the
+ /// path to the verified installer on success. Returns null on download or verification failure.
+ ///
+ public static async Task DownloadAndVerifyAsync(
+ IReleaseClient releases,
+ ReleaseAsset installerAsset,
+ ReleaseAsset checksumsAsset,
+ string tempDir,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ Directory.CreateDirectory(tempDir);
+ var installerPath = Path.Combine(tempDir, installerAsset.Name);
+ var checksumsPath = Path.Combine(tempDir, "checksums.txt");
+
+ try
+ {
+ await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
+ await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress(_ => { }), ct);
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
+ {
+ return null;
+ }
+
+ var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
+ var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
+ if (!map.TryGetValue(installerAsset.Name, out var expected))
+ return null;
+
+ return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
+ }
+```
+
+Add `using System.Net.Http;` and `using System.IO;` at the top if not present.
+
+- [ ] **Step 3: Run tests**
+
+Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterDownloadTests`
+Expected: both pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Releases/SelfUpdater.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
+git commit -m "feat(releases): add SelfUpdater.DownloadAndVerifyAsync"
+```
+
+---
+
+## Task 10: Wire `SelfUpdater` into Installer `App.xaml.cs`
+
+**Purpose:** Before the wizard window shows, (1) handle `--replace-self` if present, otherwise (2) check for a newer installer and prompt the user.
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Installer/App.xaml.cs`
+- Create: `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml`
+- Create: `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs`
+
+- [ ] **Step 1: Create the prompt window XAML**
+
+Write `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml`:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Create the prompt window code-behind**
+
+Write `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs`:
+
+```csharp
+using System.Windows;
+
+namespace ClaudeDo.Installer.Views;
+
+public enum SelfUpdateChoice { Update, Continue, Cancel }
+
+public partial class SelfUpdatePromptWindow : Window
+{
+ public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
+
+ public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
+ {
+ InitializeComponent();
+ DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
+ }
+
+ public void ShowProgress(string text)
+ {
+ ProgressText.Text = text;
+ ProgressText.Visibility = Visibility.Visible;
+ UpdateBtn.IsEnabled = false;
+ ContinueBtn.IsEnabled = false;
+ }
+
+ private void UpdateBtn_Click(object sender, RoutedEventArgs e)
+ {
+ Choice = SelfUpdateChoice.Update;
+ DialogResult = true;
+ }
+
+ private void ContinueBtn_Click(object sender, RoutedEventArgs e)
+ {
+ Choice = SelfUpdateChoice.Continue;
+ DialogResult = true;
+ }
+
+ private void CancelBtn_Click(object sender, RoutedEventArgs e)
+ {
+ Choice = SelfUpdateChoice.Cancel;
+ DialogResult = false;
+ }
+}
+```
+
+- [ ] **Step 3: Modify `App.xaml.cs` OnStartup to run self-update first**
+
+Edit `src/ClaudeDo.Installer/App.xaml.cs`. Locate `OnStartup`; immediately after `base.OnStartup(e);` (but before service construction and main window show), insert the self-update logic. Replace the relevant top of `OnStartup` with:
+
+```csharp
+ protected override async void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ // --- Self-update pre-flight ---
+ var currentExePath = System.Reflection.Assembly.GetEntryAssembly()!.Location;
+ if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ {
+ currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
+ }
+
+ // Argument form: --replace-self ""
+ var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
+ if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
+ {
+ var oldPath = e.Args[replaceSelfIndex + 1];
+ var relaunched = await ClaudeDo.Releases.SelfUpdater.HandleReplaceSelfAsync(
+ oldPath: oldPath,
+ currentExePath: currentExePath,
+ launchProcess: path =>
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
+ return true;
+ }
+ catch { return false; }
+ });
+ if (relaunched)
+ {
+ Shutdown(0);
+ return;
+ }
+ // If replacement failed, fall through to normal wizard from the temp location.
+ }
+ else
+ {
+ // Normal launch: check for a newer installer.
+ using var http = new System.Net.Http.HttpClient();
+ var releases = new ClaudeDo.Releases.ReleaseClient(http);
+ var currentVersion = InstallContext.GetInstallerVersion();
+
+ var decision = await ClaudeDo.Releases.SelfUpdater.DecideUpdateAsync(releases, currentVersion, CancellationToken.None);
+ if (decision.Kind == ClaudeDo.Releases.SelfUpdateDecisionKind.UpdateAvailable)
+ {
+ var prompt = new Views.SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
+ var ok = prompt.ShowDialog() == true;
+ if (!ok)
+ {
+ Shutdown(0);
+ return;
+ }
+ if (prompt.Choice == Views.SelfUpdateChoice.Update)
+ {
+ prompt.ShowProgress("Downloading...");
+ var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
+ var verifiedPath = await ClaudeDo.Releases.SelfUpdater.DownloadAndVerifyAsync(
+ releases,
+ decision.InstallerAsset!,
+ decision.ChecksumsAsset!,
+ tempDir,
+ new Progress(bytes => { /* could update prompt.ProgressText */ }),
+ CancellationToken.None);
+
+ if (verifiedPath is null)
+ {
+ MessageBox.Show(prompt, "Update download or verification failed. Continuing with current installer.",
+ "ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ else
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(verifiedPath)
+ {
+ ArgumentList = { "--replace-self", currentExePath },
+ UseShellExecute = true,
+ });
+ Shutdown(0);
+ return;
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(prompt, "Failed to launch updated installer: " + ex.Message
+ + "\nContinuing with current installer.", "ClaudeDo Installer",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+ }
+ // SelfUpdateChoice.Continue falls through to normal wizard.
+ }
+ // Failure / no-update also falls through to normal wizard.
+ }
+
+ // --- Existing wizard start-up unchanged below this line ---
+ // (Preserve the rest of the original OnStartup body as-is.)
+```
+
+> **Note:** If `InstallContext.GetInstallerVersion()` does not yet return a `System.Version`-parseable string, see Step 4.
+
+- [ ] **Step 4: Ensure `InstallContext.GetInstallerVersion()` returns a parseable version**
+
+Open `src/ClaudeDo.Installer/Core/InstallContext.cs` and inspect the existing `GetInstallerVersion()`. If it returns something like `"0.0.0-dev"` or an informational string, add a variant that returns the raw assembly `Version.ToString()` (e.g. `"0.0.0.0"`). If it already returns a numeric `Major.Minor.Build` string, no change is needed.
+
+If a change is required, add (or replace) with:
+
+```csharp
+ public static string GetInstallerVersion()
+ {
+ var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0);
+ return v.ToString();
+ }
+```
+
+Only change this method if needed — preserve existing behavior if it already returns a parseable version.
+
+- [ ] **Step 5: Build + smoke-test**
+
+Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
+Expected: `Build succeeded.`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Installer/App.xaml.cs src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs src/ClaudeDo.Installer/Core/InstallContext.cs
+git commit -m "feat(installer): self-update pre-flight before wizard"
+```
+
+---
+
+## Task 11: Add `UpdateCheckService` in `ClaudeDo.Ui.Services`
+
+**Files:**
+
+- Create: `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
+- Create: `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`
+- Modify: `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
+
+- [ ] **Step 1: Add project reference from Ui**
+
+Edit `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — inside the existing `` holding `` items (or create one), add:
+
+```xml
+
+```
+
+- [ ] **Step 2: Write failing tests**
+
+Write `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`:
+
+```csharp
+using ClaudeDo.Releases;
+using ClaudeDo.Ui.Services;
+
+namespace ClaudeDo.Ui.Tests.Services;
+
+public class UpdateCheckServiceTests
+{
+ private sealed class FakeReleaseClient : IReleaseClient
+ {
+ public GiteaRelease? Release { get; set; }
+ public bool Throw { get; set; }
+
+ public Task GetLatestReleaseAsync(CancellationToken ct)
+ {
+ if (Throw) throw new HttpRequestException();
+ return Task.FromResult(Release);
+ }
+
+ public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct)
+ => throw new NotSupportedException();
+ }
+
+ [Fact]
+ public async Task Check_NewerRelease_SetsUpdateAvailable()
+ {
+ var svc = new UpdateCheckService(new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.3.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 1) }),
+ },
+ currentVersion: "0.1.0");
+
+ await svc.CheckNowAsync(CancellationToken.None);
+
+ Assert.Equal(UpdateCheckStatus.UpdateAvailable, svc.LastCheckStatus);
+ Assert.True(svc.IsUpdateAvailable);
+ Assert.Equal("0.3.0", svc.LatestVersion);
+ }
+
+ [Fact]
+ public async Task Check_SameRelease_SetsUpToDate()
+ {
+ var svc = new UpdateCheckService(new FakeReleaseClient
+ {
+ Release = new GiteaRelease("v0.1.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "u", 1) }),
+ },
+ currentVersion: "0.1.0");
+
+ await svc.CheckNowAsync(CancellationToken.None);
+
+ Assert.Equal(UpdateCheckStatus.UpToDate, svc.LastCheckStatus);
+ Assert.False(svc.IsUpdateAvailable);
+ }
+
+ [Fact]
+ public async Task Check_NetworkError_SetsCheckFailedButDoesNotThrow()
+ {
+ var svc = new UpdateCheckService(new FakeReleaseClient { Throw = true }, "0.1.0");
+ await svc.CheckNowAsync(CancellationToken.None);
+ Assert.Equal(UpdateCheckStatus.CheckFailed, svc.LastCheckStatus);
+ }
+}
+```
+
+- [ ] **Step 3: Implement `UpdateCheckService`**
+
+Write `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`:
+
+```csharp
+using ClaudeDo.Releases;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Ui.Services;
+
+public enum UpdateCheckStatus
+{
+ NeverChecked,
+ CheckFailed,
+ UpToDate,
+ UpdateAvailable,
+}
+
+public sealed partial class UpdateCheckService : ObservableObject
+{
+ private readonly IReleaseClient _releases;
+
+ [ObservableProperty] private bool _isUpdateAvailable;
+ [ObservableProperty] private string? _latestVersion;
+ [ObservableProperty] private string _currentVersion;
+ [ObservableProperty] private bool _isChecking;
+ [ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
+
+ public UpdateCheckService(IReleaseClient releases, string currentVersion)
+ {
+ _releases = releases;
+ _currentVersion = currentVersion;
+ }
+
+ public async Task CheckNowAsync(CancellationToken ct)
+ {
+ IsChecking = true;
+ try
+ {
+ GiteaRelease? rel;
+ try
+ {
+ rel = await _releases.GetLatestReleaseAsync(ct);
+ }
+ catch
+ {
+ LastCheckStatus = UpdateCheckStatus.CheckFailed;
+ IsUpdateAvailable = false;
+ return;
+ }
+
+ if (rel is null)
+ {
+ LastCheckStatus = UpdateCheckStatus.CheckFailed;
+ IsUpdateAvailable = false;
+ return;
+ }
+
+ var latest = (rel.TagName ?? "").TrimStart('v', 'V');
+ var cmp = VersionComparer.Compare(latest, CurrentVersion);
+ if (cmp.IsNewer)
+ {
+ LatestVersion = latest;
+ IsUpdateAvailable = true;
+ LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
+ }
+ else
+ {
+ IsUpdateAvailable = false;
+ LastCheckStatus = UpdateCheckStatus.UpToDate;
+ }
+ }
+ finally
+ {
+ IsChecking = false;
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter UpdateCheckServiceTests`
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Services/UpdateCheckService.cs src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/
+git commit -m "feat(ui): add UpdateCheckService"
+```
+
+---
+
+## Task 12: Add `InstallerLocator` in `ClaudeDo.Ui.Services`
+
+**Files:**
+
+- Create: `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
+- Create: `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+Write `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`:
+
+```csharp
+using ClaudeDo.Ui.Services;
+
+namespace ClaudeDo.Ui.Tests.Services;
+
+public class InstallerLocatorTests : IDisposable
+{
+ private readonly string _root;
+
+ public InstallerLocatorTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ public void Dispose() { try { Directory.Delete(_root, true); } catch { } }
+
+ [Fact]
+ public void Find_WalkUpFromAppDir_ToInstallJsonSibling()
+ {
+ var installDir = Path.Combine(_root, "ClaudeDo");
+ var appDir = Path.Combine(installDir, "app");
+ var uninstallerDir = Path.Combine(installDir, "uninstaller");
+ Directory.CreateDirectory(appDir);
+ Directory.CreateDirectory(uninstallerDir);
+
+ File.WriteAllText(Path.Combine(installDir, "install.json"), "{}");
+ var installerPath = Path.Combine(uninstallerDir, "ClaudeDo.Installer.exe");
+ File.WriteAllText(installerPath, "x");
+
+ var locator = new InstallerLocator();
+ var found = locator.FindByWalkingUp(appDir);
+
+ Assert.Equal(installerPath, found);
+ }
+
+ [Fact]
+ public void Find_ReturnsNullWhenNoInstallJson()
+ {
+ var appDir = Path.Combine(_root, "somewhere", "app");
+ Directory.CreateDirectory(appDir);
+
+ var locator = new InstallerLocator();
+ Assert.Null(locator.FindByWalkingUp(appDir));
+ }
+
+ [Fact]
+ public void Find_ReturnsNullWhenInstallerMissingFromUninstallerDir()
+ {
+ var installDir = Path.Combine(_root, "ClaudeDo");
+ var appDir = Path.Combine(installDir, "app");
+ Directory.CreateDirectory(appDir);
+ Directory.CreateDirectory(Path.Combine(installDir, "uninstaller"));
+ File.WriteAllText(Path.Combine(installDir, "install.json"), "{}");
+
+ var locator = new InstallerLocator();
+ Assert.Null(locator.FindByWalkingUp(appDir));
+ }
+}
+```
+
+- [ ] **Step 2: Implement**
+
+Write `src/ClaudeDo.Ui/Services/InstallerLocator.cs`:
+
+```csharp
+namespace ClaudeDo.Ui.Services;
+
+public sealed class InstallerLocator
+{
+ private const string InstallJson = "install.json";
+ private const string InstallerExe = "ClaudeDo.Installer.exe";
+ private const string UninstallerSubdir = "uninstaller";
+
+ ///
+ /// Convenience entry point for production callers: starts from AppContext.BaseDirectory
+ /// and falls back to registry if the walk fails.
+ ///
+ public string? Find()
+ => FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry();
+
+ public string? FindByWalkingUp(string startDir)
+ {
+ var dir = new DirectoryInfo(startDir);
+ while (dir is not null)
+ {
+ var manifest = Path.Combine(dir.FullName, InstallJson);
+ if (File.Exists(manifest))
+ {
+ var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
+ return File.Exists(candidate) ? candidate : null;
+ }
+ dir = dir.Parent;
+ }
+ return null;
+ }
+
+ public string? FindByRegistry()
+ {
+ if (!OperatingSystem.IsWindows()) return null;
+
+ try
+ {
+ using var key = Microsoft.Win32.Registry.LocalMachine
+ .OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
+ var location = key?.GetValue("InstallLocation") as string;
+ if (string.IsNullOrEmpty(location)) return null;
+ var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
+ return File.Exists(candidate) ? candidate : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
+```
+
+If the Ui csproj does not already pull in `Microsoft.Win32.Registry`, add:
+
+```xml
+
+```
+
+(Windows-only runtime reference; Avalonia can ship cross-platform so gate usage with `OperatingSystem.IsWindows()` as shown.)
+
+- [ ] **Step 3: Run tests**
+
+Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter InstallerLocatorTests`
+Expected: all pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Services/InstallerLocator.cs src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
+git commit -m "feat(ui): add InstallerLocator"
+```
+
+---
+
+## Task 13: Wire new services into `ClaudeDo.App/Program.cs` DI
+
+**Files:**
+
+- Modify: `src/ClaudeDo.App/Program.cs`
+
+- [ ] **Step 1: Add new service registrations**
+
+Edit `src/ClaudeDo.App/Program.cs`. Inside `BuildServices()` (or whatever method builds the `ServiceCollection`), register:
+
+```csharp
+ sc.AddSingleton();
+ sc.AddSingleton(sp =>
+ new ClaudeDo.Releases.ReleaseClient(sp.GetRequiredService()));
+
+ sc.AddSingleton();
+ sc.AddSingleton(sp =>
+ {
+ var releases = sp.GetRequiredService();
+ var version = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0";
+ return new ClaudeDo.Ui.Services.UpdateCheckService(releases, version);
+ });
+```
+
+Place the registrations alongside existing singleton registrations. Keep the order logical: `HttpClient` → `IReleaseClient` → services that depend on them.
+
+- [ ] **Step 2: Inject `UpdateCheckService` and `InstallerLocator` into `IslandsShellViewModel`**
+
+Locate `IslandsShellViewModel`'s constructor. Add parameters for both services and forward them to fields. Also update the `IslandsShellViewModel` DI registration in `Program.cs` so that these are resolved through the container (normally Microsoft DI does this automatically as long as the ViewModel is registered with `AddSingleton()` or similar — verify by inspecting the existing registration).
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
+Expected: `Build succeeded.`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+git commit -m "feat(app): register UpdateCheckService and InstallerLocator in DI"
+```
+
+---
+
+## Task 14: Extend `IslandsShellViewModel` with update-check state + commands
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
+
+- [ ] **Step 1: Add fields, properties, and commands**
+
+At the top of `IslandsShellViewModel`, add (or extend) fields:
+
+```csharp
+ private readonly UpdateCheckService _updateCheck;
+ private readonly InstallerLocator _installerLocator;
+
+ [ObservableProperty] private bool _isUpdateBannerVisible;
+ [ObservableProperty] private string? _updateBannerLatestVersion;
+ [ObservableProperty] private string? _inlineUpdateStatus; // short message shown briefly
+ private bool _bannerDismissedThisSession;
+```
+
+Add `using ClaudeDo.Ui.Services;` and `using CommunityToolkit.Mvvm.ComponentModel;` if not already present.
+
+- [ ] **Step 2: Subscribe to update service**
+
+In the constructor, after assigning fields:
+
+```csharp
+ _updateCheck.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
+ {
+ RefreshBannerFromStatus();
+ }
+ };
+ // Fire-and-forget startup check — never block UI on this.
+ _ = Task.Run(async () =>
+ {
+ try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
+ });
+```
+
+And the helper:
+
+```csharp
+ private void RefreshBannerFromStatus()
+ {
+ switch (_updateCheck.LastCheckStatus)
+ {
+ case UpdateCheckStatus.UpdateAvailable:
+ if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
+ UpdateBannerLatestVersion = _updateCheck.LatestVersion;
+ IsUpdateBannerVisible = true;
+ InlineUpdateStatus = null;
+ break;
+ case UpdateCheckStatus.UpToDate:
+ IsUpdateBannerVisible = false;
+ ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
+ break;
+ case UpdateCheckStatus.CheckFailed:
+ ShowInlineStatus("Could not check for updates");
+ break;
+ }
+ }
+
+ private async void ShowInlineStatus(string text)
+ {
+ InlineUpdateStatus = text;
+ await Task.Delay(3000);
+ if (InlineUpdateStatus == text) InlineUpdateStatus = null;
+ }
+```
+
+- [ ] **Step 3: Add commands**
+
+Add three commands:
+
+```csharp
+ [RelayCommand]
+ private async Task CheckForUpdatesAsync()
+ {
+ await _updateCheck.CheckNowAsync(CancellationToken.None);
+ }
+
+ [RelayCommand]
+ private void DismissBanner()
+ {
+ _bannerDismissedThisSession = true;
+ IsUpdateBannerVisible = false;
+ }
+
+ [RelayCommand]
+ private void UpdateNow()
+ {
+ var path = _installerLocator.Find();
+ if (path is null) return;
+
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
+ Environment.Exit(0); // close the app so the installer can replace its files
+ }
+ catch
+ {
+ // Intentionally silent — if this fails there's not much we can do from UI.
+ }
+ }
+```
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
+Expected: `Build succeeded.`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+git commit -m "feat(ui): wire update-check state and commands into shell VM"
+```
+
+---
+
+## Task 15: Add banner + Help menu to `MainWindow.axaml`
+
+**Files:**
+
+- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
+
+- [ ] **Step 1: Adjust the Grid RowDefinitions to leave room for a banner**
+
+In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, change:
+
+```xml
+
+```
+
+to:
+
+```xml
+
+```
+
+Then shift every child element in Row 1 (content) and Row 2 (status bar) down by one: `Grid.Row="1"` → `Grid.Row="2"`, `Grid.Row="2"` → `Grid.Row="3"`. Be systematic — search the file for `Grid.Row="1"` and `Grid.Row="2"` and update every occurrence.
+
+- [ ] **Step 2: Insert the banner at `Grid.Row="1"`**
+
+Add (at the appropriate place, sibling to the titlebar and content rows):
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+If `StringNotEmptyConverter` does not exist, use a simple `TextBlock` bound to `InlineUpdateStatus` and rely on empty text being invisible-ish (set `IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"` — Avalonia provides `ObjectConverters.IsNotNull` in `Avalonia.Data.Converters`).
+
+The VM field for the banner's "current version" text exposes `UpdateCheck.CurrentVersion` — add an exposed property on `IslandsShellViewModel`: `public UpdateCheckService UpdateCheck => _updateCheck;` so the XAML can bind to nested properties.
+
+- [ ] **Step 3: Add Help menu to the titlebar**
+
+Locate the titlebar row (`Grid.Row="0"`). The current titlebar has `ColumnDefinitions="Auto,*,Auto"` — add a menu in the middle (column 1) or as a new element in column 0 after the existing app-title content. A safe insertion: in the leftmost column next to the app icon/title, add:
+
+```xml
+
+```
+
+Placement is flexible — pick a spot that matches the existing titlebar layout (you may need to read the current titlebar `StackPanel`/`Grid` around line 30-60 of the file). Prefer adding the menu to whichever column already holds titlebar controls.
+
+- [ ] **Step 4: Add a property on `IslandsShellViewModel` exposing the service**
+
+In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, add:
+
+```csharp
+ public UpdateCheckService UpdateCheck => _updateCheck;
+```
+
+- [ ] **Step 5: Build + run the app manually**
+
+Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj src/ClaudeDo.App/ClaudeDo.App.csproj`
+Expected: `Build succeeded.`
+
+Run: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
+Expected: the app launches; Help menu is visible in the titlebar; no banner shown unless a newer release actually exists in Gitea.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+git commit -m "feat(ui): add update banner and Help menu to MainWindow"
+```
+
+---
+
+## Task 16: Add manual verification checklist to `docs/open.md`
+
+**Files:**
+
+- Modify: `docs/open.md`
+
+- [ ] **Step 1: Append a new section**
+
+Add to the end of `docs/open.md`:
+
+```markdown
+## Self-Update — Manual Verification
+
+Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with two asset names the code expects — `ClaudeDo--win-x64.zip`, `ClaudeDo.Installer-.exe` — and `checksums.txt` listing both.
+
+1. Install current version (e.g. `0.2.x`) normally.
+2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
+3. Launch the app — confirm banner appears with "Update available: v0.2.x → v0.3.0".
+4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
+5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date".
+6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** — confirm the running exe is replaced and the wizard opens on the new version.
+7. Repeat step 6 with **Continue anyway** — confirm wizard opens without self-update.
+8. Kill network during startup in both the app and the installer — confirm silent fallback (no errors, no banner, wizard opens normally).
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/open.md
+git commit -m "docs(open): add self-update manual verification checklist"
+```
+
+---
+
+## Self-Review
+
+Before handing off: walk through `docs/superpowers/specs/2026-04-23-self-update-design.md` one more time and confirm each section maps to a task above.
+
+- Shared module (Spec §Architecture) → Tasks 1, 2, 3
+- `VersionComparer` (Spec §Version Comparison) → Tasks 4, 5
+- `SelfUpdater` asset matching (Spec §B, step 3) → Task 6
+- `SelfUpdater` decision (Spec §B, step 2) → Task 7
+- `--replace-self` handler (Spec §B, step 1 + relaunch) → Task 8
+- Download + verify (Spec §B, step 4) → Task 9
+- Installer entry integration + prompt (Spec §B, step 3) → Task 10
+- `UpdateCheckService` (Spec §A) → Task 11
+- `InstallerLocator` (Spec §A) → Task 12
+- DI wiring (Spec §Architecture Overview) → Task 13
+- Shell VM state + commands (Spec §A UI changes + Update action flow) → Task 14
+- Banner + Help menu (Spec §A UI changes) → Task 15
+- Manual verification (Spec §Testing) → Task 16
+
+No gaps.