Files
ClaudeMailbox/docs/superpowers/plans/2026-04-24-gitea-release-and-windows-service.md
2026-04-24 18:57:56 +02:00

1151 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Gitea Release Flow + Windows Service 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:** Ship a tag-triggered Gitea release flow for ClaudeMailbox and make the binary self-install as a Windows Service via `install-service` / `uninstall-service` verbs seeded from `%ProgramData%\ClaudeMailbox\mailbox.json`.
**Architecture:** Config precedence (CLI flag > JSON file > default) feeds the existing `DaemonConfig`. `UseWindowsService()` is a no-op in console mode and enables SCM lifetime when the service starts. Service install shells out to `sc.exe` with admin + Windows guards. Release workflow mirrors the ClaudeDo pattern (ubuntu-latest, `dotnet publish -r win-x64 --self-contained`, MinVer override, Gitea REST API).
**Tech Stack:** .NET 8, ASP.NET Core, xUnit, `Microsoft.Extensions.Hosting.WindowsServices`, MinVer (already present), Gitea Actions, `sc.exe`.
**Spec:** `docs/superpowers/specs/2026-04-24-gitea-release-and-windows-service-design.md`
---
## File Structure
| Path | Action | Responsibility |
|---|---|---|
| `src/ClaudeMailbox/Config/FileConfig.cs` | create | JSON model + loader for `mailbox.json` |
| `src/ClaudeMailbox/Config/ConfigResolver.cs` | create | Pure helper: merge CLI args + FileConfig → DaemonConfig |
| `src/ClaudeMailbox/Cli/ServiceCommands.cs` | create | `install-service` / `uninstall-service` / `start` / `stop` / `status` verbs |
| `src/ClaudeMailbox/Program.cs` | modify | Dispatch new verbs, apply config precedence |
| `src/ClaudeMailbox/ServerHost.cs` | modify | `UseWindowsService()` call |
| `src/ClaudeMailbox/ClaudeMailbox.csproj` | modify | Add `Microsoft.Extensions.Hosting.WindowsServices` |
| `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs` | create | Unit tests for JSON loader |
| `tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs` | create | Unit tests for precedence logic |
| `.gitea/workflows/ci.yml` | create | Build + test on main |
| `.gitea/workflows/release.yml` | create | Tag-triggered publish to Gitea release |
| `README.md` | modify | Document service verbs, config file, manual smoke test |
---
## Task 1: FileConfig model + loader
**Files:**
- Create: `src/ClaudeMailbox/Config/FileConfig.cs`
- Test: `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`
- [ ] **Step 1: Write failing tests**
Create `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`:
```csharp
using ClaudeMailbox.Config;
namespace ClaudeMailbox.Tests.Config;
public sealed class FileConfigTests
{
[Fact]
public void Load_ReturnsEmpty_WhenPathIsNullAndDefaultMissing()
{
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
var cfg = FileConfig.Load(explicitPath: null, defaultPath: missing);
Assert.Null(cfg.Port);
Assert.Null(cfg.Bind);
Assert.Null(cfg.DbPath);
}
[Fact]
public void Load_ReadsDefaultPath_WhenExplicitPathNull()
{
var path = WriteTemp("""{"port":9000,"bind":"0.0.0.0","dbPath":"C:\\tmp\\a.db"}""");
try
{
var cfg = FileConfig.Load(explicitPath: null, defaultPath: path);
Assert.Equal(9000, cfg.Port);
Assert.Equal("0.0.0.0", cfg.Bind);
Assert.Equal(@"C:\tmp\a.db", cfg.DbPath);
}
finally { File.Delete(path); }
}
[Fact]
public void Load_ExplicitPath_WinsOverDefault()
{
var defaultPath = WriteTemp("""{"port":1111}""");
var explicitPath = WriteTemp("""{"port":2222}""");
try
{
var cfg = FileConfig.Load(explicitPath: explicitPath, defaultPath: defaultPath);
Assert.Equal(2222, cfg.Port);
}
finally { File.Delete(defaultPath); File.Delete(explicitPath); }
}
[Fact]
public void Load_ExplicitPathMissing_Throws()
{
var missing = Path.Combine(Path.GetTempPath(), $"nope-{Guid.NewGuid():N}.json");
var ex = Assert.Throws<FileNotFoundException>(() =>
FileConfig.Load(explicitPath: missing, defaultPath: null));
Assert.Contains(missing, ex.Message);
}
[Fact]
public void Load_MissingFields_AreNull()
{
var path = WriteTemp("""{"port":1234}""");
try
{
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
Assert.Equal(1234, cfg.Port);
Assert.Null(cfg.Bind);
Assert.Null(cfg.DbPath);
}
finally { File.Delete(path); }
}
[Fact]
public void Load_CaseInsensitive_PropertyNames()
{
var path = WriteTemp("""{"Port":1,"BIND":"x","DBPATH":"y"}""");
try
{
var cfg = FileConfig.Load(explicitPath: path, defaultPath: null);
Assert.Equal(1, cfg.Port);
Assert.Equal("x", cfg.Bind);
Assert.Equal("y", cfg.DbPath);
}
finally { File.Delete(path); }
}
[Fact]
public void Load_MalformedJson_Throws()
{
var path = WriteTemp("not json");
try
{
Assert.ThrowsAny<Exception>(() => FileConfig.Load(explicitPath: path, defaultPath: null));
}
finally { File.Delete(path); }
}
private static string WriteTemp(string content)
{
var p = Path.Combine(Path.GetTempPath(), $"mailbox-{Guid.NewGuid():N}.json");
File.WriteAllText(p, content);
return p;
}
}
```
- [ ] **Step 2: Run tests to confirm they fail**
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~FileConfigTests"`
Expected: compile error — `FileConfig` does not exist.
- [ ] **Step 3: Implement `FileConfig`**
Create `src/ClaudeMailbox/Config/FileConfig.cs`:
```csharp
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClaudeMailbox.Config;
public sealed class FileConfig
{
[JsonPropertyName("port")]
public int? Port { get; set; }
[JsonPropertyName("bind")]
public string? Bind { get; set; }
[JsonPropertyName("dbPath")]
public string? DbPath { get; set; }
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
};
public static FileConfig Load(string? explicitPath, string? defaultPath)
{
if (!string.IsNullOrEmpty(explicitPath))
{
if (!File.Exists(explicitPath))
throw new FileNotFoundException($"Config file not found: {explicitPath}", explicitPath);
return Parse(File.ReadAllText(explicitPath));
}
if (!string.IsNullOrEmpty(defaultPath) && File.Exists(defaultPath))
return Parse(File.ReadAllText(defaultPath));
return new FileConfig();
}
private static FileConfig Parse(string json)
{
return JsonSerializer.Deserialize<FileConfig>(json, Options) ?? new FileConfig();
}
}
```
- [ ] **Step 4: Run tests to confirm pass**
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~FileConfigTests"`
Expected: 7 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeMailbox/Config/FileConfig.cs tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs
git commit -m "feat(config): add FileConfig model and JSON loader"
```
---
## Task 2: ConfigResolver (CLI > File > Default precedence)
**Files:**
- Create: `src/ClaudeMailbox/Config/ConfigResolver.cs`
- Test: `tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs`
- [ ] **Step 1: Write failing tests**
Create `tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs`:
```csharp
using ClaudeMailbox.Config;
namespace ClaudeMailbox.Tests.Config;
public sealed class ConfigResolverTests
{
[Fact]
public void CliFlag_WinsOverFile()
{
var file = new FileConfig { Port = 1000 };
var cfg = ConfigResolver.Build(new[] { "--port", "9999" }, file);
Assert.Equal(9999, cfg.Port);
}
[Fact]
public void File_WinsOverDefault()
{
var file = new FileConfig { Port = 1000, Bind = "0.0.0.0", DbPath = "/tmp/x.db" };
var cfg = ConfigResolver.Build(Array.Empty<string>(), file);
Assert.Equal(1000, cfg.Port);
Assert.Equal("0.0.0.0", cfg.BindAddress);
Assert.Equal("/tmp/x.db", cfg.DbPath);
}
[Fact]
public void Default_UsedWhenNeitherCliNorFile()
{
var cfg = ConfigResolver.Build(Array.Empty<string>(), new FileConfig());
Assert.Equal(DaemonConfig.DefaultPort, cfg.Port);
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
Assert.Equal(Paths.DefaultDbPath(), cfg.DbPath);
}
[Fact]
public void Mixed_CliPort_FileDbPath_DefaultBind()
{
var file = new FileConfig { DbPath = "/tmp/mixed.db" };
var cfg = ConfigResolver.Build(new[] { "--port", "7000" }, file);
Assert.Equal(7000, cfg.Port);
Assert.Equal(DaemonConfig.DefaultBindAddress, cfg.BindAddress);
Assert.Equal("/tmp/mixed.db", cfg.DbPath);
}
[Fact]
public void CliDbPath_ExpandsEnvVars()
{
var file = new FileConfig();
var cfg = ConfigResolver.Build(new[] { "--db-path", "~/foo.db" }, file);
Assert.DoesNotContain("~", cfg.DbPath);
}
[Fact]
public void InvalidPortFlag_FallsBackToFileOrDefault()
{
var file = new FileConfig { Port = 4242 };
var cfg = ConfigResolver.Build(new[] { "--port", "not-a-number" }, file);
Assert.Equal(4242, cfg.Port);
}
}
```
- [ ] **Step 2: Run tests, confirm they fail**
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~ConfigResolverTests"`
Expected: compile error — `ConfigResolver` does not exist.
- [ ] **Step 3: Implement `ConfigResolver`**
Create `src/ClaudeMailbox/Config/ConfigResolver.cs`:
```csharp
using ClaudeMailbox.Cli;
namespace ClaudeMailbox.Config;
public static class ConfigResolver
{
public static DaemonConfig Build(string[] serveArgs, FileConfig file)
{
var cliPort = ParseIntOption(serveArgs, "--port");
var cliBind = ClientCommands.GetOption(serveArgs, "--bind");
var cliDbPath = ClientCommands.GetOption(serveArgs, "--db-path");
var port = cliPort ?? file.Port ?? DaemonConfig.DefaultPort;
var bind = cliBind ?? file.Bind ?? DaemonConfig.DefaultBindAddress;
var dbPathRaw = cliDbPath ?? file.DbPath ?? Paths.DefaultDbPath();
return new DaemonConfig
{
Port = port,
BindAddress = bind,
DbPath = Paths.Expand(dbPathRaw),
};
}
private static int? ParseIntOption(string[] args, string name)
{
var raw = ClientCommands.GetOption(args, name);
return int.TryParse(raw, out var v) ? v : null;
}
}
```
- [ ] **Step 4: Run tests, confirm pass**
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj --filter "FullyQualifiedName~ConfigResolverTests"`
Expected: 6 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeMailbox/Config/ConfigResolver.cs tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs
git commit -m "feat(config): add ConfigResolver with CLI>file>default precedence"
```
---
## Task 3: Wire FileConfig + ConfigResolver into Program.cs
**Files:**
- Modify: `src/ClaudeMailbox/Program.cs`
- [ ] **Step 1: Replace config construction in `Program.cs`**
Full new content of `src/ClaudeMailbox/Program.cs`:
```csharp
using ClaudeMailbox;
using ClaudeMailbox.Cli;
using ClaudeMailbox.Config;
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
{
return await ClientCommands.RunAsync(args);
}
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
var defaultConfig = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ClaudeMailbox", "mailbox.json");
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
builder.WebHost.UseUrls(cfg.BaseUrl);
var app = builder.Build();
ServerHost.ConfigurePipeline(app);
app.Logger.LogInformation("ClaudeMailbox listening on {Url} (db: {Db})", cfg.BaseUrl, cfg.DbPath);
try
{
await app.RunAsync();
return 0;
}
catch (IOException ex) when (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("Only one usage", StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine($"Port {cfg.Port} is already in use. Another claude-mailbox instance may be running.");
return 3;
}
public partial class Program { }
```
Note: removed the local `ParseInt` helper (now in `ConfigResolver`).
- [ ] **Step 2: Run full test suite**
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
Expected: all existing tests still pass. No new failures.
- [ ] **Step 3: Smoke-test console mode**
Run: `dotnet run --project src/ClaudeMailbox -- serve --port 47999`
Expected: `ClaudeMailbox listening on http://127.0.0.1:47999` in log output. Ctrl+C to stop.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeMailbox/Program.cs
git commit -m "feat(config): load mailbox.json with CLI override in Program"
```
---
## Task 4: Add WindowsServices package + UseWindowsService
**Files:**
- Modify: `src/ClaudeMailbox/ClaudeMailbox.csproj`
- Modify: `src/ClaudeMailbox/ServerHost.cs`
- [ ] **Step 1: Add package reference**
In `src/ClaudeMailbox/ClaudeMailbox.csproj`, add to the `<ItemGroup>` with package references:
```xml
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
```
- [ ] **Step 2: Wire `UseWindowsService()` in `ServerHost.CreateBuilder`**
In `src/ClaudeMailbox/ServerHost.cs`, in `CreateBuilder`, after `var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());` add:
```csharp
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
```
Add the using at the top:
```csharp
using Microsoft.Extensions.Hosting.WindowsServices;
```
- [ ] **Step 3: Build and run existing tests**
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
Expected: success, no warnings about missing package.
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeMailbox/ClaudeMailbox.csproj src/ClaudeMailbox/ServerHost.cs
git commit -m "feat(service): enable Windows Service hosting lifetime"
```
---
## Task 5: ServiceCommands skeleton (platform + admin gates)
**Files:**
- Create: `src/ClaudeMailbox/Cli/ServiceCommands.cs`
This task adds the module with platform/admin guards but no verb bodies yet (verbs added in Tasks 67). No dispatch wired yet — that's Task 8. This lets us build incrementally and verify the gates work on Linux CI.
- [ ] **Step 1: Create the module**
Create `src/ClaudeMailbox/Cli/ServiceCommands.cs`:
```csharp
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Security.Principal;
namespace ClaudeMailbox.Cli;
public static class ServiceCommands
{
public const string ServiceName = "ClaudeMailbox";
public static Task<int> RunAsync(string[] args)
{
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("Service commands are Windows-only.");
return Task.FromResult(2);
}
var verb = args[0];
return verb switch
{
"install-service" => Task.FromResult(InstallService(args)),
"uninstall-service" => Task.FromResult(UninstallService(args)),
"start" => Task.FromResult(RunSc("start", ServiceName)),
"stop" => Task.FromResult(RunSc("stop", ServiceName)),
"status" => Task.FromResult(Status()),
_ => Task.FromResult(PrintError($"Unknown service command: {verb}")),
};
}
[SupportedOSPlatform("windows")]
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
[SupportedOSPlatform("windows")]
private static int RequireAdmin()
{
if (IsAdministrator()) return 0;
Console.Error.WriteLine("This command requires Administrator privileges.");
return 5;
}
[SupportedOSPlatform("windows")]
private static int InstallService(string[] args)
{
var admin = RequireAdmin();
if (admin != 0) return admin;
Console.Error.WriteLine("install-service: not yet implemented.");
return 1;
}
[SupportedOSPlatform("windows")]
private static int UninstallService(string[] args)
{
var admin = RequireAdmin();
if (admin != 0) return admin;
Console.Error.WriteLine("uninstall-service: not yet implemented.");
return 1;
}
[SupportedOSPlatform("windows")]
private static int Status()
{
Console.Error.WriteLine("status: not yet implemented.");
return 1;
}
[SupportedOSPlatform("windows")]
internal static int RunSc(params string[] scArgs)
{
var psi = new ProcessStartInfo("sc.exe")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in scArgs) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi)!;
var stdout = proc.StandardOutput.ReadToEnd();
var stderr = proc.StandardError.ReadToEnd();
proc.WaitForExit();
if (!string.IsNullOrWhiteSpace(stdout)) Console.Write(stdout);
if (!string.IsNullOrWhiteSpace(stderr)) Console.Error.Write(stderr);
return proc.ExitCode;
}
private static int PrintError(string msg)
{
Console.Error.WriteLine(msg);
return 1;
}
}
```
- [ ] **Step 2: Build and test**
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
Expected: success on both Linux and Windows (the `SupportedOSPlatform` attribute plus `OperatingSystem.IsWindows()` short-circuit keeps the analyzer happy).
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
Expected: existing tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeMailbox/Cli/ServiceCommands.cs
git commit -m "feat(service): add ServiceCommands skeleton with platform/admin gates"
```
---
## Task 6: install-service verb
**Files:**
- Modify: `src/ClaudeMailbox/Cli/ServiceCommands.cs`
- [ ] **Step 1: Replace `InstallService` body**
In `src/ClaudeMailbox/Cli/ServiceCommands.cs`, replace the `InstallService` method with:
```csharp
[SupportedOSPlatform("windows")]
private static int InstallService(string[] args)
{
var admin = RequireAdmin();
if (admin != 0) return admin;
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
var dataDir = Path.Combine(programData, "ClaudeMailbox");
var configPath = Path.Combine(dataDir, "mailbox.json");
var defaultDbPath = Path.Combine(dataDir, "mailbox.db");
Directory.CreateDirectory(dataDir);
ApplyLocalServiceAcl(dataDir);
if (!File.Exists(configPath))
{
var port = ClientCommands.GetOption(args, "--port") ?? "47822";
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
var json = $$"""
{
"port": {{port}},
"bind": "{{bind}}",
"dbPath": {{System.Text.Json.JsonSerializer.Serialize(dbPath)}}
}
""";
File.WriteAllText(configPath, json);
Console.WriteLine($"Seeded config: {configPath}");
}
else
{
Console.WriteLine($"Config already exists, leaving untouched: {configPath}");
}
var exe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve current executable path.");
var binPath = $"\"{exe}\" serve --config \"{configPath}\"";
var createExit = RunSc("create", ServiceName,
"binPath=", binPath,
"start=", "auto",
"DisplayName=", "Claude Mailbox",
"obj=", "NT AUTHORITY\\LocalService");
if (createExit != 0)
{
Console.Error.WriteLine($"sc create failed (exit {createExit}).");
return createExit;
}
RunSc("description", ServiceName, "MCP mailbox server for parallel Claude sessions");
Console.WriteLine($"Service '{ServiceName}' installed. Start with: claude-mailbox start");
return 0;
}
[SupportedOSPlatform("windows")]
private static void ApplyLocalServiceAcl(string path)
{
var info = new DirectoryInfo(path);
var security = info.GetAccessControl();
var localService = new SecurityIdentifier(WellKnownSidType.LocalServiceSid, null);
security.AddAccessRule(new FileSystemAccessRule(
localService,
FileSystemRights.Modify | FileSystemRights.Synchronize,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
PropagationFlags.None,
AccessControlType.Allow));
info.SetAccessControl(security);
}
```
Add these using directives at the top of the file (with the existing usings):
```csharp
using System.Security.AccessControl;
```
- [ ] **Step 2: Build on Linux (CI runner will do this too)**
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
Expected: success. `SupportedOSPlatform("windows")` suppresses CA1416 warnings for the ACL + SID types.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeMailbox/Cli/ServiceCommands.cs
git commit -m "feat(service): implement install-service verb"
```
---
## Task 7: uninstall-service + status verbs
**Files:**
- Modify: `src/ClaudeMailbox/Cli/ServiceCommands.cs`
- [ ] **Step 1: Replace `UninstallService` and `Status` bodies**
In `src/ClaudeMailbox/Cli/ServiceCommands.cs`, replace both methods:
```csharp
[SupportedOSPlatform("windows")]
private static int UninstallService(string[] args)
{
var admin = RequireAdmin();
if (admin != 0) return admin;
var purge = Array.IndexOf(args, "--purge") >= 0;
// Best-effort stop; ignore failure if not running.
RunSc("stop", ServiceName);
var deleteExit = RunSc("delete", ServiceName);
if (deleteExit != 0)
{
Console.Error.WriteLine($"sc delete failed (exit {deleteExit}).");
return deleteExit;
}
if (purge)
{
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
var dataDir = Path.Combine(programData, "ClaudeMailbox");
if (Directory.Exists(dataDir))
{
Directory.Delete(dataDir, recursive: true);
Console.WriteLine($"Purged: {dataDir}");
}
}
Console.WriteLine($"Service '{ServiceName}' uninstalled.");
return 0;
}
[SupportedOSPlatform("windows")]
private static int Status()
{
var psi = new ProcessStartInfo("sc.exe")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
psi.ArgumentList.Add("query");
psi.ArgumentList.Add(ServiceName);
using var proc = Process.Start(psi)!;
var stdout = proc.StandardOutput.ReadToEnd();
proc.WaitForExit();
if (proc.ExitCode != 0)
{
Console.WriteLine("NotInstalled");
return 2;
}
var state = stdout.Split('\n')
.Select(l => l.Trim())
.FirstOrDefault(l => l.StartsWith("STATE", StringComparison.Ordinal))
?? "";
if (state.Contains("RUNNING", StringComparison.Ordinal))
{
Console.WriteLine("Running");
return 0;
}
Console.WriteLine("Stopped");
return 1;
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeMailbox/Cli/ServiceCommands.cs
git commit -m "feat(service): implement uninstall-service and status verbs"
```
---
## Task 8: Dispatch service verbs in Program.cs
**Files:**
- Modify: `src/ClaudeMailbox/Program.cs`
- [ ] **Step 1: Extend the early dispatch**
In `src/ClaudeMailbox/Program.cs`, replace the first dispatch block:
```csharp
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
{
return await ClientCommands.RunAsync(args);
}
```
with:
```csharp
if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
{
return await ClientCommands.RunAsync(args);
}
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
{
return await ServiceCommands.RunAsync(args);
}
```
- [ ] **Step 2: Build and run tests**
Run: `dotnet build src/ClaudeMailbox/ClaudeMailbox.csproj`
Expected: success.
Run: `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
Expected: all tests pass.
- [ ] **Step 3: Smoke test on Linux (should refuse cleanly)**
Run: `dotnet run --project src/ClaudeMailbox -- status`
Expected: exits with code 2, stderr `Service commands are Windows-only.`
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeMailbox/Program.cs
git commit -m "feat(service): dispatch service verbs from Program entrypoint"
```
---
## Task 9: CI workflow (build + test on main)
**Files:**
- Create: `.gitea/workflows/ci.yml`
- [ ] **Step 1: Write workflow file**
Create `.gitea/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
dotnet build ClaudeMailbox.slnx -c Release
- name: Test
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj \
-c Release --no-build --logger "console;verbosity=normal"
```
Note: `fetch-depth: 0` is required because MinVer walks the commit history to compute versions from tags.
- [ ] **Step 2: Commit**
```bash
git add .gitea/workflows/ci.yml
git commit -m "ci: add build+test workflow for main and PRs"
```
---
## Task 10: Release workflow (tag-triggered publish)
**Files:**
- Create: `.gitea/workflows/release.yml`
- [ ] **Step 1: Write workflow file**
Create `.gitea/workflows/release.yml`:
```yaml
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeMailbox
steps:
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building version: $VERSION (tag: $TAG)"
- name: Prepare workspace
id: ws
run: |
set -euo pipefail
WORK="$(mktemp -d -t claude-mailbox-release-XXXXXX)"
echo "dir=$WORK" >> "$GITHUB_OUTPUT"
echo "Workspace: $WORK"
- name: Checkout tag
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git clone --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src"
git -C "$WORK/src" log -1 --oneline
- name: Publish (win-x64, self-contained, single-file)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeMailbox/ClaudeMailbox.csproj \
-c Release -r win-x64 --self-contained true \
/p:MinVerVersionOverride=$VERSION \
/p:PublishSingleFile=true \
/p:IncludeNativeLibrariesForSelfExtract=true \
-o out/app
- name: Package assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
cd "$WORK/src"
mkdir -p assets
EXE_SRC=$(ls out/app/*.exe | head -n 1)
if [ -z "$EXE_SRC" ]; then
echo "::error::No .exe produced by publish" >&2
exit 1
fi
EXE_NAME="claude-mailbox-${VERSION}-win-x64.exe"
cp "$EXE_SRC" "assets/${EXE_NAME}"
( cd assets && sha256sum "${EXE_NAME}" > checksums.txt )
echo "--- assets ---"
ls -la assets
- name: Create Gitea Release
id: release
env:
TAG: ${{ steps.ver.outputs.tag }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITEA_API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Release creation failed" >&2
echo "$RESP" >&2
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
echo "Created release id=$RELEASE_ID for tag=$TAG"
- name: Upload release assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd "$WORK/src/assets"
for f in \
"claude-mailbox-${VERSION}-win-x64.exe" \
"checksums.txt"
do
echo "Uploading: $f"
curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${f}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
> /dev/null
done
echo "All assets uploaded."
- name: Cleanup workspace
if: always()
env:
WORK: ${{ steps.ws.outputs.dir }}
run: |
rm -rf "$WORK" || true
```
**Assumptions to verify before first tag push** (from the spec's Open Questions):
- `REPO` slug is `releases/ClaudeMailbox` — if the actual Gitea slug differs, edit this file and `ci.yml`.
- `secrets.GITEA_TOKEN` exists in the Gitea repo settings with `write:repository` scope.
- [ ] **Step 2: Commit**
```bash
git add .gitea/workflows/release.yml
git commit -m "ci: add tag-triggered Gitea release workflow"
```
---
## Task 11: README updates
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update the "Daemon lifecycle" and add service subsections**
Replace the `## Daemon lifecycle` section in `README.md` with:
```markdown
## Daemon lifecycle
Pick whichever level of automation you want:
1. **Manual.** `claude-mailbox serve` in a terminal.
2. **Startup shortcut.** Shortcut to `claude-mailbox serve` in `shell:startup`.
3. **Windows Service (recommended).** See below.
### Windows Service
Install (admin shell):
```
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
```
This:
- Creates `%ProgramData%\ClaudeMailbox\` with ACLs for `LocalService`
- Seeds `mailbox.json` with the defaults (or your flag overrides) — only on first install
- Registers the service via `sc.exe create`, running as `NT AUTHORITY\LocalService` with `start= auto`
Control:
```
claude-mailbox start
claude-mailbox stop
claude-mailbox status # prints Running | Stopped | NotInstalled
claude-mailbox uninstall-service [--purge]
```
`--purge` additionally removes `%ProgramData%\ClaudeMailbox\` (config + database).
### Config precedence
```
CLI flag > mailbox.json > built-in defaults
```
The service is invoked with `serve --config C:\ProgramData\ClaudeMailbox\mailbox.json`, so editing that file and restarting the service is enough to change port/bind/db-path.
Interactive (console) runs without `--config` use `%USERPROFILE%\.claude-mailbox\mailbox.db` (unchanged from v0).
### Manual smoke test
```
claude-mailbox install-service
sc query ClaudeMailbox
claude-mailbox start
Invoke-WebRequest http://127.0.0.1:47822/health
claude-mailbox uninstall-service --purge
```
Defaults: port `47822`, bind `127.0.0.1`, database at `%ProgramData%\ClaudeMailbox\mailbox.db` (service) or `%USERPROFILE%\.claude-mailbox\mailbox.db` (console).
```
Leave everything else in the README intact. In particular, the existing `sc.exe create ...` snippet in the original "Daemon lifecycle" section is superseded by `install-service` and must be removed (it's part of the replacement above).
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs(readme): document install-service verbs and config precedence"
```
---
## Final verification checklist
Run after all tasks:
- [ ] `dotnet build ClaudeMailbox.slnx -c Release` — succeeds on local and CI (Linux).
- [ ] `dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj` — all tests (existing + 13 new) pass.
- [ ] `dotnet run --project src/ClaudeMailbox -- status` on Linux → exit 2, `Service commands are Windows-only.`
- [ ] On Windows admin shell (manual):
- [ ] `claude-mailbox install-service --port 47823` → service created, `mailbox.json` seeded.
- [ ] `claude-mailbox start` → exit 0.
- [ ] `Invoke-WebRequest http://127.0.0.1:47823/health` → 200.
- [ ] `claude-mailbox status``Running`, exit 0.
- [ ] `claude-mailbox uninstall-service --purge` → service deleted, `%ProgramData%\ClaudeMailbox` gone.
- [ ] Push tag `v0.2.0`, watch `.gitea/workflows/release.yml` succeed, verify release at `https://git.kuns.dev/releases/ClaudeMailbox/releases` has `claude-mailbox-0.2.0-win-x64.exe` + `checksums.txt`.
## Open questions (carry-over from spec)
- **Gitea repo slug:** plan assumes `releases/ClaudeMailbox`. Confirm before first tag; if different, edit `REPO:` in both workflow files.
- **GITEA_TOKEN scope:** must include `write:repository` for release creation and asset upload.
- **Service name collisions:** if the machine already has a `ClaudeMailbox` service from manual `sc.exe create` experiments, `install-service` will fail with exit 1073 (service already exists). Run `sc.exe delete ClaudeMailbox` first.