From 948c6d4abe29ae25e075111b0c13e9604d761450 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 24 Apr 2026 18:57:56 +0200 Subject: [PATCH] docs(plan): implementation plan for gitea release and windows service Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04-24-gitea-release-and-windows-service.md | 1150 +++++++++++++++++ 1 file changed, 1150 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-gitea-release-and-windows-service.md diff --git a/docs/superpowers/plans/2026-04-24-gitea-release-and-windows-service.md b/docs/superpowers/plans/2026-04-24-gitea-release-and-windows-service.md new file mode 100644 index 0000000..953e93a --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-gitea-release-and-windows-service.md @@ -0,0 +1,1150 @@ +# 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(() => + 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(() => 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(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(), 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(), 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 `` with package references: + +```xml + +``` + +- [ ] **Step 2: Wire `UseWindowsService()` in `ServerHost.CreateBuilder`** + +In `src/ClaudeMailbox/ServerHost.cs`, in `CreateBuilder`, after `var builder = WebApplication.CreateBuilder(args ?? Array.Empty());` 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 6–7). 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 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 ] +``` + +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.