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

35 KiB
Raw Permalink Blame History

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:

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:

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
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:

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:

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
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:

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
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:

<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:

builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");

Add the using at the top:

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
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:

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
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:

[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):

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
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:

[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
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:

if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
{
    return await ClientCommands.RunAsync(args);
}

with:

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
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:

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
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:

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

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:

## 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
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 statusRunning, 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.