35 KiB
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()inServerHost.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 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:
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
InstallServicebody
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
UninstallServiceandStatusbodies
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):
-
REPOslug isreleases/ClaudeMailbox— if the actual Gitea slug differs, edit this file andci.yml. -
secrets.GITEA_TOKENexists in the Gitea repo settings withwrite:repositoryscope. -
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 -- statuson Linux → exit 2,Service commands are Windows-only.- On Windows admin shell (manual):
claude-mailbox install-service --port 47823→ service created,mailbox.jsonseeded.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%\ClaudeMailboxgone.
- Push tag
v0.2.0, watch.gitea/workflows/release.ymlsucceed, verify release athttps://git.kuns.dev/releases/ClaudeMailbox/releaseshasclaude-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, editREPO:in both workflow files. - GITEA_TOKEN scope: must include
write:repositoryfor release creation and asset upload. - Service name collisions: if the machine already has a
ClaudeMailboxservice from manualsc.exe createexperiments,install-servicewill fail with exit 1073 (service already exists). Runsc.exe delete ClaudeMailboxfirst.