Compare commits

...

18 Commits

Author SHA1 Message Date
mika kuns
757a095c10 fix(ci): use full GitHub URL for actions/checkout
All checks were successful
CI / build (push) Successful in 12s
Release / release (push) Successful in 7s
Gitea Actions resolves bare action names against the local Gitea
instance by default. Using the full github.com URL makes the runner
pull the action from upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:50:22 +02:00
mika kuns
83afd0ddb3 refactor(ci): use actions/checkout for public repo release flow
Some checks failed
CI / build (push) Failing after 0s
Drops the manual mktemp + git-clone-with-token dance (not needed for a
public repo) in favor of actions/checkout@v4. GITEA_TOKEN is still
required for the release-creation and asset-upload API calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:46:11 +02:00
mika kuns
8cdc7bac16 fix(ci): build test csproj instead of .slnx for .NET 8 runner
.slnx requires .NET 9 SDK / MSBuild 17.8+. The Gitea runner has only
.NET 8, so build the test project directly — its ProjectReference
transitively builds ClaudeMailbox.csproj.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:43:12 +02:00
mika kuns
e3b51122ae fix(service): explain 1073 (service exists) on install-service 2026-04-24 19:40:38 +02:00
mika kuns
e7407b1b3a docs(readme): document install-service verbs and config precedence 2026-04-24 19:36:54 +02:00
mika kuns
c3e5bc2ba2 ci: add tag-triggered Gitea release workflow 2026-04-24 19:35:48 +02:00
mika kuns
202bb692e0 ci: add build+test workflow for main and PRs 2026-04-24 19:34:41 +02:00
mika kuns
dbc6844db6 feat(service): dispatch service verbs from Program entrypoint 2026-04-24 19:33:32 +02:00
mika kuns
ebc0319384 feat(service): implement uninstall-service and status verbs 2026-04-24 19:29:08 +02:00
mika kuns
452dc8514b fix(service): escape bind value and parse port as int in seeded config 2026-04-24 19:27:34 +02:00
mika kuns
f91d3644fb feat(service): implement install-service verb 2026-04-24 19:22:45 +02:00
mika kuns
5c6f4b8b6e feat(service): add ServiceCommands skeleton with platform/admin gates 2026-04-24 19:18:18 +02:00
mika kuns
d8f25dc01b feat(service): enable Windows Service hosting lifetime 2026-04-24 19:14:39 +02:00
mika kuns
870431d0b8 feat(config): load mailbox.json with CLI override in Program 2026-04-24 19:10:54 +02:00
mika kuns
81906e7274 feat(config): add ConfigResolver with CLI>file>default precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 19:06:25 +02:00
mika kuns
f397008ff5 feat(config): add FileConfig model and JSON loader 2026-04-24 19:01:36 +02:00
mika kuns
948c6d4abe docs(plan): implementation plan for gitea release and windows service
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:57:56 +02:00
mika kuns
0586d67a41 docs(spec): gitea release flow and windows service support
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:53:13 +02:00
13 changed files with 2048 additions and 20 deletions

33
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,33 @@
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: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Build
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
dotnet build tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj -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"

View File

@@ -0,0 +1,109 @@
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: Checkout tag
uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- 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: Publish (win-x64, self-contained, single-file)
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
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:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
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:
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd 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."

View File

@@ -33,18 +33,58 @@ Put the resulting `claude-mailbox.exe` on your `PATH`.
## Daemon lifecycle ## Daemon lifecycle
The daemon is a normal console process. Pick whichever level of automation you want: Pick whichever level of automation you want:
1. **Manual.** Open a terminal, run `claude-mailbox serve`, leave it open. Stops when you close the window. 1. **Manual.** `claude-mailbox serve` in a terminal.
2. **Startup shortcut.** Drop a shortcut to `claude-mailbox serve` in `shell:startup` — starts on login. 2. **Startup shortcut.** Shortcut to `claude-mailbox serve` in `shell:startup`.
3. **Windows Service.** `sc.exe create ClaudeMailbox binPath= "C:\path\to\claude-mailbox.exe serve" start= auto` — same pattern ClaudeDo uses. 3. **Windows Service (recommended).** See below.
Defaults: port `47822`, bind `127.0.0.1`, database at `%USERPROFILE%\.claude-mailbox\mailbox.db`. All overridable: ### Windows Service
Install (admin shell):
``` ```
claude-mailbox serve [--port 47822] [--bind 127.0.0.1] [--db-path <path>] claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
``` ```
This:
- Creates `%ProgramData%\ClaudeMailbox\` with ACLs for `LocalService`
- Seeds `mailbox.json` with the defaults (or your flag overrides) — only on first install
- Registers the service via `sc.exe create`, running as `NT AUTHORITY\LocalService` with `start= auto`
Control:
```
claude-mailbox start
claude-mailbox stop
claude-mailbox status # prints Running | Stopped | NotInstalled
claude-mailbox uninstall-service [--purge]
```
`--purge` additionally removes `%ProgramData%\ClaudeMailbox\` (config + database).
### Config precedence
```
CLI flag > mailbox.json > built-in defaults
```
The service is invoked with `serve --config C:\ProgramData\ClaudeMailbox\mailbox.json`, so editing that file and restarting the service is enough to change port/bind/db-path.
Interactive (console) runs without `--config` use `%USERPROFILE%\.claude-mailbox\mailbox.db` (unchanged from v0).
### Manual smoke test
```
claude-mailbox install-service
sc query ClaudeMailbox
claude-mailbox start
Invoke-WebRequest http://127.0.0.1:47822/health
claude-mailbox uninstall-service --purge
```
Defaults: port `47822`, bind `127.0.0.1`, database at `%ProgramData%\ClaudeMailbox\mailbox.db` (service) or `%USERPROFILE%\.claude-mailbox\mailbox.db` (console).
## Use from a Claude session ## Use from a Claude session
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values): Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
# Gitea Release Flow + Windows Service Support
**Date:** 2026-04-24
**Status:** Approved (ready for implementation plan)
## Goal
Two coupled capabilities for ClaudeMailbox:
1. **Gitea Release Flow.** Tag-driven CI that publishes a self-contained `win-x64` single-file binary plus SHA256 checksums to a Gitea release.
2. **Windows Service Support.** The binary self-installs as a Windows Service via new `install-service` / `uninstall-service` / `start` / `stop` verbs, seeded from a `mailbox.json` config file. The ClaudeDo Installer can then provision ClaudeMailbox directly using this stable verb surface.
Both are needed so that a downstream installer (ClaudeDo) can fetch, verify, and register ClaudeMailbox as a long-running service without hand-crafted `sc.exe` calls at the installer side.
## Non-Goals
- Linux / macOS service integration (`systemd`, `launchd`). Single-platform (`win-x64`) only in v1.
- Cross-machine / non-loopback deployment.
- Auto-update of the service binary (ClaudeDo owns update orchestration via its existing `SelfUpdater` pattern).
- Per-user service installation. Service runs as `NT AUTHORITY\LocalService`; DB lives under `%ProgramData%`.
## Architecture Overview
```
tag push ──► .gitea/workflows/release.yml
├─ dotnet publish (win-x64, self-contained, single-file)
├─ sha256 → checksums.txt
└─ POST /api/v1/repos/.../releases
+ upload assets
claude-mailbox-${VERSION}-win-x64.exe
checksums.txt
│ (consumed by ClaudeDo Installer)
claude-mailbox.exe install-service [--port] [--bind] [--db-path]
├─ seed %ProgramData%\ClaudeMailbox\mailbox.json
├─ ACL %ProgramData%\ClaudeMailbox\ for LocalService
└─ sc.exe create ClaudeMailbox
binPath= "<exe> serve --config <mailbox.json>"
start= auto obj= "NT AUTHORITY\LocalService"
ClaudeMailbox Windows Service
(reads mailbox.json, hosts MCP + REST)
```
## Section 1 — Gitea Release Flow
### Trigger
- `push` on tags matching `v*`.
- Separate `ci.yml` runs `dotnet build` + `dotnet test` on every push to `main` (no release).
### Workflow Skeleton
`.gitea/workflows/release.yml` modelled after `C:\Private\ClaudeDo\.gitea\workflows\release.yml`:
- `runs-on: ubuntu-latest`
- `env: DOTNET_ROOT: /home/mika/.dotnet`, `GITEA_API: https://git.kuns.dev/api/v1`, `REPO: releases/ClaudeMailbox` (exact repo slug to confirm at implementation time)
- Steps:
1. **Resolve version** — strip `v` prefix from `github.ref_name``$VERSION`
2. **Prepare workspace**`mktemp -d`
3. **Checkout tag**`git clone --depth 1 --branch $TAG` using `secrets.GITEA_TOKEN`
4. **Publish**`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`
5. **Package assets** — rename produced exe to `claude-mailbox-${VERSION}-win-x64.exe`, write `checksums.txt` via `sha256sum`
6. **Create Gitea Release**`curl POST ${GITEA_API}/repos/${REPO}/releases` with `tag_name`, `name`, `draft:false`, `prerelease:false`, `target_commitish:main`
7. **Upload assets**`curl POST .../releases/${RELEASE_ID}/assets?name=<file>` for each asset
8. **Cleanup**`rm -rf $WORK` with `if: always()`
### Versioning
`Directory.Build.props` gains a MinVer reference so `$VERSION` from the tag is baked into `AssemblyVersion`, `FileVersion`, `InformationalVersion`. Same approach as ClaudeDo (`MinVerVersionOverride` property). Default local dev version: `0.0.0-dev`.
Whether to pull in the `MinVer` NuGet package explicitly or use .NET SDK's built-in `Version` property with `-p:Version=$VERSION` is an implementation choice made during the plan phase — ClaudeDo uses `MinVerVersionOverride` which implies the MinVer package is present.
### Release Assets (stable contract)
- `claude-mailbox-${VERSION}-win-x64.exe` — self-contained single-file binary
- `checksums.txt``sha256sum` output for the exe
No ZIP (single artifact, simpler discovery).
### CI Workflow
`.gitea/workflows/ci.yml`:
- Trigger: push to `main`, PRs to `main`
- Steps: checkout → setup dotnet → `dotnet build``dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj`
- No publish, no release.
## Section 2 — Windows Service Support
### Project Changes
`src/ClaudeMailbox/ClaudeMailbox.csproj` adds:
```xml
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.*" />
```
### Host Wiring
`ServerHost.CreateBuilder` adds (before `Build()`):
```csharp
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
```
No-op when launched as console app; enables Windows Service lifetime when SCM starts the process.
### New CLI Verbs
Dispatch in `Program.cs` extended. New module `src/ClaudeMailbox/Cli/ServiceCommands.cs`:
| Verb | Behavior |
|---|---|
| `install-service [--port] [--bind] [--db-path]` | Admin-only. Seed `mailbox.json` (if missing). ACL ProgramData dir. `sc.exe create`. `sc.exe description`. |
| `uninstall-service [--purge]` | Admin-only. `sc.exe stop` (best-effort), `sc.exe delete`. `--purge` removes `%ProgramData%\ClaudeMailbox`. |
| `start` | `sc.exe start ClaudeMailbox`. |
| `stop` | `sc.exe stop ClaudeMailbox`. |
| `status` | `sc.exe query ClaudeMailbox`, parse `STATE` line, print single-word status. |
Service commands:
- Gate on `OperatingSystem.IsWindows()`. On non-Windows: exit 2, message `"Service commands are Windows-only."`
- Gate on admin privilege (WindowsIdentity + WindowsPrincipal). On missing: exit 5, message `"install-service requires Administrator."`
- Shell out to `sc.exe` via `Process.Start(new ProcessStartInfo { ... RedirectStandardOutput = true })`, capture exit code, surface stderr on failure.
### install-service Flow (concrete)
1. Admin check.
2. `Directory.CreateDirectory(@"C:\ProgramData\ClaudeMailbox")` (idempotent).
3. Apply ACL: add `LocalService` with `Modify` rights on that directory (`DirectorySecurity` + `FileSystemAccessRule`).
4. If `mailbox.json` missing: write seeded JSON with CLI-flag-overridable values:
```json
{ "port": 47822, "bind": "127.0.0.1", "dbPath": "C:\\ProgramData\\ClaudeMailbox\\mailbox.db" }
```
5. Resolve current exe path via `Environment.ProcessPath`.
6. `sc.exe create ClaudeMailbox binPath= "\"<exe>\" serve --config \"C:\ProgramData\ClaudeMailbox\mailbox.json\"" start= auto DisplayName= "Claude Mailbox" obj= "NT AUTHORITY\LocalService"`
7. `sc.exe description ClaudeMailbox "MCP mailbox server for parallel Claude sessions"`
Service name is fixed (`ClaudeMailbox`) in v1 — no multi-instance support.
### uninstall-service Flow
1. Admin check.
2. `sc.exe stop ClaudeMailbox` (ignore failure if not running).
3. `sc.exe delete ClaudeMailbox`.
4. If `--purge`: delete `%ProgramData%\ClaudeMailbox` recursively (only if empty of non-ours files, or unconditionally — default to unconditional with explicit `--purge` opt-in).
## Section 3 — Config File + Precedence
### File
`src/ClaudeMailbox/Config/FileConfig.cs`:
```csharp
public sealed class FileConfig
{
public int? Port { get; set; }
public string? Bind { get; set; }
public string? DbPath { get; set; }
}
```
### Loader
`FileConfig.LoadOrDefault(string? explicitPath)`:
- If `explicitPath` given: must exist, else throw with clear message.
- Else: probe `%ProgramData%\ClaudeMailbox\mailbox.json`. If absent, return empty `FileConfig`.
- Parse via `System.Text.Json.JsonSerializer` with `PropertyNameCaseInsensitive = true`.
### Precedence
In `Program.cs` (before building `DaemonConfig`):
1. CLI flag (`--port`, `--bind`, `--db-path`)
2. Config file (explicit via `--config <path>` OR default `%ProgramData%\ClaudeMailbox\mailbox.json` if present)
3. Hardcoded defaults in `DaemonConfig`
Extract helper `DaemonConfig BuildDaemonConfig(string[] args, FileConfig file)` for testability.
### Backward Compatibility
When `--config` is not passed AND no ProgramData config exists, behavior is unchanged: daemon uses `%USERPROFILE%\.claude-mailbox\mailbox.db` and default port/bind. Existing interactive users are unaffected.
### Service CmdLine
`install-service` always bakes `--config C:\ProgramData\ClaudeMailbox\mailbox.json` into the service's `binPath`, so the service has no dependency on working directory, user profile, or environment.
## Section 4 — Tests
### New Unit Tests
- `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`
- Round-trip JSON with all fields.
- Missing fields deserialize to `null` (optional semantics).
- Malformed JSON throws with actionable message.
- `tests/ClaudeMailbox.Tests/Config/ConfigPrecedenceTests.cs`
- CLI flag wins over file value.
- File value wins over default.
- Default used when neither file nor CLI provides it.
- Mixed: `--port` from CLI, `dbPath` from file, `bind` from default.
### Out of Scope
- Service install/uninstall tests. `sc.exe` requires Administrator, is Windows-only, and not runnable on `ubuntu-latest` CI. A manual smoke-test protocol is documented in README:
```
claude-mailbox.exe install-service
sc query ClaudeMailbox
Invoke-WebRequest http://127.0.0.1:47822/health
claude-mailbox.exe uninstall-service --purge
```
### Cross-Platform Build
Service-related code must compile on Linux (CI runner). Use:
- `Microsoft.Extensions.Hosting.WindowsServices` — NuGet package is cross-platform; `UseWindowsService()` is a no-op on non-Windows.
- `sc.exe` invocations guarded by `OperatingSystem.IsWindows()`.
- `DirectorySecurity` / `FileSystemAccessRule` are in `System.Security.AccessControl` — available on non-Windows but throw on use. Guard all calls.
## Section 5 — ClaudeDo Installer Contract
This section is **informational** — the work is on the ClaudeDo side, not in this repo. Listed here to make the contract explicit.
### Discovery
`GET https://git.kuns.dev/api/v1/repos/releases/ClaudeMailbox/releases/latest` returns `assets[].browser_download_url`. Reuse ClaudeDo's `ReleaseClient` + `ChecksumVerifier` + `VersionComparer`.
### Installer Steps (new in ClaudeDo)
1. Download `claude-mailbox-${VERSION}-win-x64.exe` → `%ProgramFiles%\ClaudeMailbox\claude-mailbox.exe`.
2. Verify SHA256 against `checksums.txt`.
3. Run `claude-mailbox.exe install-service` (elevated). This seeds `mailbox.json`, ACLs ProgramData, creates the service.
4. Run `claude-mailbox.exe start` (or `sc.exe start ClaudeMailbox`).
5. Record entry in ClaudeDo's `InstallManifest` (version + path).
6. On uninstall: `claude-mailbox.exe uninstall-service --purge` → delete `%ProgramFiles%\ClaudeMailbox\`.
### Stable Contract
- Verbs: `install-service`, `uninstall-service`, `start`, `stop`, `status`.
- Flags on `install-service`: `--port`, `--bind`, `--db-path` (all optional).
- Service name: `ClaudeMailbox`.
- Config path: `%ProgramData%\ClaudeMailbox\mailbox.json`.
Any changes to the above require coordinated updates in the ClaudeDo installer.
## Files Touched
- `src/ClaudeMailbox/ClaudeMailbox.csproj` — add `Microsoft.Extensions.Hosting.WindowsServices`.
- `src/ClaudeMailbox/ServerHost.cs``UseWindowsService()` wiring.
- `src/ClaudeMailbox/Program.cs` — dispatch new verbs, integrate `FileConfig` precedence.
- `src/ClaudeMailbox/Cli/ServiceCommands.cs`**new**. Verb handlers.
- `src/ClaudeMailbox/Config/FileConfig.cs`**new**. Config file model + loader.
- `tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs`**new**.
- `tests/ClaudeMailbox.Tests/Config/ConfigPrecedenceTests.cs`**new**.
- `.gitea/workflows/release.yml`**new**. Tag-triggered release.
- `.gitea/workflows/ci.yml`**new**. Build + test on main.
- `Directory.Build.props` — MinVer integration (or `Version` fallback).
- `README.md` — document `install-service` / `uninstall-service`, config file, manual smoke test.
## Open Questions for Implementation Plan
- Exact Gitea repo slug (confirm `releases/ClaudeMailbox` or other).
- MinVer package vs. `-p:Version=$VERSION` — align with ClaudeDo's current convention.
- Whether `status` verb should print parsed `Running | Stopped | NotInstalled` or raw `sc query` output. Recommendation: parsed + exit codes 0/1/2.

View File

@@ -15,6 +15,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" /> <PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" /> <PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,219 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Security.AccessControl;
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;
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 portStr = ClientCommands.GetOption(args, "--port");
var port = int.TryParse(portStr, out var p) ? p : 47822;
var bind = ClientCommands.GetOption(args, "--bind") ?? "127.0.0.1";
var dbPath = ClientCommands.GetOption(args, "--db-path") ?? defaultDbPath;
var json = $$"""
{
"port": {{port}},
"bind": {{System.Text.Json.JsonSerializer.Serialize(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)
{
if (createExit == 1073)
Console.Error.WriteLine($"Service '{ServiceName}' already exists. Run 'claude-mailbox uninstall-service' first.");
else
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);
}
[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;
}
[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;
}
}

View File

@@ -0,0 +1,30 @@
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;
}
}

View File

@@ -0,0 +1,41 @@
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();
}
}

View File

@@ -7,16 +7,20 @@ if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
return await ClientCommands.RunAsync(args); return await ClientCommands.RunAsync(args);
} }
// Strip the optional leading "serve" verb so WebApplication.CreateBuilder if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
// doesn't try to treat it as an unknown flag. {
return await ServiceCommands.RunAsync(args);
}
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args; var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
var cfg = new DaemonConfig var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
{ var defaultConfig = Path.Combine(
Port = ParseInt(serveArgs, "--port", DaemonConfig.DefaultPort), Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
BindAddress = ClientCommands.GetOption(serveArgs, "--bind") ?? DaemonConfig.DefaultBindAddress, "ClaudeMailbox", "mailbox.json");
DbPath = Paths.Expand(ClientCommands.GetOption(serveArgs, "--db-path") ?? Paths.DefaultDbPath()),
}; var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
var builder = ServerHost.CreateBuilder(cfg, serveArgs); var builder = ServerHost.CreateBuilder(cfg, serveArgs);
builder.WebHost.UseUrls(cfg.BaseUrl); builder.WebHost.UseUrls(cfg.BaseUrl);
@@ -38,10 +42,4 @@ catch (IOException ex) when (ex.Message.Contains("address already in use", Strin
return 3; return 3;
} }
static int ParseInt(string[] args, string name, int fallback)
{
var raw = ClientCommands.GetOption(args, name);
return int.TryParse(raw, out var v) ? v : fallback;
}
public partial class Program { } public partial class Program { }

View File

@@ -4,6 +4,7 @@ using ClaudeMailbox.Data.Repositories;
using ClaudeMailbox.Http; using ClaudeMailbox.Http;
using ClaudeMailbox.Mcp; using ClaudeMailbox.Mcp;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting.WindowsServices;
namespace ClaudeMailbox; namespace ClaudeMailbox;
@@ -12,6 +13,7 @@ public static class ServerHost
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null) public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
{ {
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>()); var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
builder.Services.AddSingleton(cfg); builder.Services.AddSingleton(cfg);
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();

View File

@@ -0,0 +1,59 @@
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(Paths.Expand("/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(Paths.Expand("/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);
}
}

View File

@@ -0,0 +1,99 @@
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;
}
}