# 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
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
</Project>
```

- [ ] **Step 2: Add to solution**

Edit `ClaudeDo.slnx` — add the new `<Project>` line under the `/src/` folder:

```xml
    <Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
```

- [ ] **Step 3: Add reference from Installer**

Edit `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add inside the existing `<ItemGroup>` that holds `<ProjectReference>` (or create one):

```xml
    <ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
```

- [ ] **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<ReleaseAsset> Assets);

public interface IReleaseClient
{
    Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);

    Task DownloadAsync(string url, string destPath, IProgress<long> 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
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>
  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../../src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
  </ItemGroup>
</Project>
```

- [ ] **Step 2: Add to solution**

Edit `ClaudeDo.slnx` — add under `/tests/` folder:

```xml
    <Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
```

- [ ] **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/<filename>`.
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
{
    /// <summary>
    /// 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.
    /// </summary>
    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-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
    private static partial Regex InstallerAssetRegex();

    public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> 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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
        {
            if (Throw) throw new HttpRequestException("boom");
            return Task.FromResult(Release);
        }

        public Task DownloadAsync(string url, string destPath, IProgress<long> 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<SelfUpdateDecision> 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 "<old-path>"`, 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
    /// <summary>
    /// Called when launched with `--replace-self "&lt;old-path&gt;"`. Waits for the old process
    /// to release its file lock, deletes the old file, copies the current exe to the old path,
    /// then calls <paramref name="launchProcess"/> with that path. Returns true on success.
    /// </summary>
    public static async Task<bool> HandleReplaceSelfAsync(
        string oldPath,
        string currentExePath,
        Func<string, bool> 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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null);

        public async Task DownloadAsync(string url, string destPath, IProgress<long> 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<long>(_ => { }), 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<long>(_ => { }), 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
    /// <summary>
    /// Downloads the installer asset + checksums file to <paramref name="tempDir"/>, 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.
    /// </summary>
    public static async Task<string?> DownloadAndVerifyAsync(
        IReleaseClient releases,
        ReleaseAsset installerAsset,
        ReleaseAsset checksumsAsset,
        string tempDir,
        IProgress<long> 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<long>(_ => { }), 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
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="ClaudeDo Installer Update"
        Width="460" Height="180"
        WindowStartupLocation="CenterScreen"
        ResizeMode="NoResize"
        Background="#1a1a1a" Foreground="#f0f0f0">
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/>
        <TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
        <TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
        <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
            <Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
            <Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
            <Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
        </StackPanel>
    </Grid>
</Window>
```

- [ ] **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 "<old-path>"
        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<long>(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 `<ItemGroup>` holding `<ProjectReference>` items (or create one), add:

```xml
    <ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
```

- [ ] **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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
        {
            if (Throw) throw new HttpRequestException();
            return Task.FromResult(Release);
        }

        public Task DownloadAsync(string url, string destPath, IProgress<long> 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";

    /// <summary>
    /// Convenience entry point for production callers: starts from AppContext.BaseDirectory
    /// and falls back to registry if the walk fails.
    /// </summary>
    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
    <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
```

(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<System.Net.Http.HttpClient>();
        sc.AddSingleton<ClaudeDo.Releases.IReleaseClient>(sp =>
            new ClaudeDo.Releases.ReleaseClient(sp.GetRequiredService<System.Net.Http.HttpClient>()));

        sc.AddSingleton<ClaudeDo.Ui.Services.InstallerLocator>();
        sc.AddSingleton(sp =>
        {
            var releases = sp.GetRequiredService<ClaudeDo.Releases.IReleaseClient>();
            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<IslandsShellViewModel>()` 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
  <Grid RowDefinitions="36,*,22">
```

to:

```xml
  <Grid RowDefinitions="36,Auto,*,22">
```

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
    <Border Grid.Row="1"
            Background="{DynamicResource AccentSoftBrush}"
            BorderBrush="{DynamicResource LineBrush}"
            BorderThickness="0,0,0,1"
            Padding="12,6"
            IsVisible="{Binding IsUpdateBannerVisible}">
      <Grid ColumnDefinitions="*,Auto,Auto">
        <TextBlock Grid.Column="0"
                   VerticalAlignment="Center"
                   Foreground="{DynamicResource TextDimBrush}">
          <Run Text="Update available: v"/>
          <Run Text="{Binding UpdateCheck.CurrentVersion}"/>
          <Run Text=" → v"/>
          <Run Text="{Binding UpdateBannerLatestVersion}"/>
        </TextBlock>
        <Button Grid.Column="1" Margin="0,0,8,0"
                Content="Update now"
                Command="{Binding UpdateNowCommand}"/>
        <Button Grid.Column="2"
                Content="Dismiss"
                Command="{Binding DismissBannerCommand}"/>
      </Grid>
    </Border>
    <TextBlock Grid.Row="1" HorizontalAlignment="Right" VerticalAlignment="Center"
               Margin="0,0,12,0"
               Foreground="{DynamicResource TextFaintBrush}"
               Text="{Binding InlineUpdateStatus}"
               IsVisible="{Binding InlineUpdateStatus, Converter={StaticResource StringNotEmptyConverter}}"/>
```

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
    <Menu Grid.Column="0" Margin="12,0,0,0" VerticalAlignment="Center"
          Background="Transparent">
      <MenuItem Header="Help">
        <MenuItem Header="Check for updates" Command="{Binding CheckForUpdatesCommand}"/>
      </MenuItem>
    </Menu>
```

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-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.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.
