19 Commits

Author SHA1 Message Date
mika kuns
05d87d2aa7 feat(node): add TypeScript sibling project for npm-based install
Some checks failed
CI (Node) / build-test (push) Successful in 6s
CI (.NET) / build (push) Successful in 10s
Release / release (push) Successful in 8s
Release (Node) / release (push) Failing after 10s
Introduces @kuns/claude-mailbox under node/, a wire-compatible TypeScript
port of the .NET daemon that distributes via the public Gitea npm registry.
The .NET project stays in src/ClaudeMailbox/ untouched; users pick whichever
flavor they prefer.

- node/ project: fastify + @modelcontextprotocol/sdk StreamableHTTPServerTransport
  + better-sqlite3, schema and wire surface match the C# version (port 47822,
  X-Mailbox header, MCP tool names, snake_case SQLite columns)
- Cross-platform autostart: Scheduled Task (Win, no admin) / Windows Service
  (Win, --service) / launchd (mac) / systemd --user (linux)
- 9/9 vitest tests pass; end-to-end /health + send/check round-trip verified
- CI split: existing ci.yml/release.yml renamed to *-dotnet.yml with path
  filters, new ci-node.yml and release-node.yml publish to Gitea npm registry
- install.ps1 / install.sh bootstrap one-liners at repo root; homebrew/
  contains a tap formula template
- README install section reordered: npm path primary, dotnet publish secondary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:06:46 +02:00
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
36 changed files with 7649 additions and 23 deletions

View File

@@ -0,0 +1,45 @@
name: CI (.NET)
on:
push:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "ClaudeMailbox.slnx"
- "global.json"
- ".gitea/workflows/ci-dotnet.yml"
pull_request:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "ClaudeMailbox.slnx"
- "global.json"
- ".gitea/workflows/ci-dotnet.yml"
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,34 @@
name: CI (Node)
on:
push:
branches: [main]
paths:
- "node/**"
- ".gitea/workflows/ci-node.yml"
pull_request:
paths:
- "node/**"
- ".gitea/workflows/ci-node.yml"
jobs:
build-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: node
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Node version
run: node --version && npm --version
- name: Install
run: npm ci
- name: Test
run: npm test
- name: Build
run: npm run build

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

@@ -0,0 +1,116 @@
name: Release (Node)
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
env:
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeMailbox
NPM_REGISTRY_HOST: git.kuns.dev/api/packages/releases/npm/
defaults:
run:
working-directory: node
steps:
- name: Checkout tag
uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Node version
run: node --version && npm --version
- 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"
- name: Set package version
env:
VERSION: ${{ steps.ver.outputs.version }}
run: npm version --no-git-tag-version "$VERSION"
- name: Install
run: npm ci
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Pack
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
npm pack
mv "kuns-claude-mailbox-${VERSION}.tgz" "claude-mailbox-${VERSION}.tgz"
( sha256sum "claude-mailbox-${VERSION}.tgz" > checksums.txt )
- name: Configure npm auth for Gitea
env:
NPM_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
echo "@kuns:registry=https://${NPM_REGISTRY_HOST}" > .npmrc
echo "//${NPM_REGISTRY_HOST}:_authToken=${NPM_TOKEN}" >> .npmrc
- name: Publish to Gitea npm registry
run: npm publish --access public
- name: Find or create Gitea release
id: release
env:
TAG: ${{ steps.ver.outputs.tag }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Try to find an existing release for this tag (the .NET workflow may have created it).
EXISTING=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
RELEASE_ID=$(echo "$EXISTING" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
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')
fi
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Could not resolve release id" >&2
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
- name: Upload tarball + checksums
env:
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
for f in \
"claude-mailbox-${VERSION}.tgz" \
"checksums.txt"
do
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

View File

@@ -25,26 +25,85 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
## Install ## Install
The recommended path is the npm package — it works on Windows, macOS, and Linux.
```sh
# one-time per machine: point the @kuns scope at the public Gitea npm registry
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
# install
npm install -g @kuns/claude-mailbox
```
Or use the bootstrap one-liner:
```powershell ```powershell
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true # Windows
irm https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.ps1 | iex
``` ```
Put the resulting `claude-mailbox.exe` on your `PATH`. ```sh
# macOS / Linux
curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
```
## Daemon lifecycle macOS users can also install via Homebrew once the tap is published:
The daemon is a normal console process. Pick whichever level of automation you want: ```sh
brew install kuns/tap/claude-mailbox
```
1. **Manual.** Open a terminal, run `claude-mailbox serve`, leave it open. Stops when you close the window. ### Autostart
2. **Startup shortcut.** Drop a shortcut to `claude-mailbox serve` in `shell:startup` — starts on login.
3. **Windows Service.** `sc.exe create ClaudeMailbox binPath= "C:\path\to\claude-mailbox.exe serve" start= auto` — same pattern ClaudeDo uses.
Defaults: port `47822`, bind `127.0.0.1`, database at `%USERPROFILE%\.claude-mailbox\mailbox.db`. All overridable: ```sh
claude-mailbox install-autostart # per-user, no admin
claude-mailbox install-autostart --service # Windows only: register as a Windows Service (admin)
claude-mailbox status # Running | Stopped | NotInstalled
claude-mailbox uninstall-autostart [--purge]
```
| Platform | Default mechanism | `--service` mechanism |
|---|---|---|
| Windows | Scheduled Task at logon (no admin) | Windows Service (admin, via `node-windows`) |
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
### Config precedence
``` ```
claude-mailbox serve [--port 47822] [--bind 127.0.0.1] [--db-path <path>] CLI flag > mailbox.json > built-in defaults
``` ```
`mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Pass `--config <path>` to override.
Defaults: port `47822`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
### Smoke test
```sh
claude-mailbox install-autostart
claude-mailbox status
curl http://127.0.0.1:47822/health
claude-mailbox uninstall-autostart --purge
```
### Build the .NET binary (alternative)
The original .NET 8 implementation still lives in `src/ClaudeMailbox/`. Build a self-contained Windows exe with:
```powershell
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
Put the resulting `claude-mailbox.exe` on your `PATH` and use the legacy `install-service` verbs (Windows-only, admin shell):
```
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
claude-mailbox uninstall-service [--purge]
```
The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema), so a `.mcp.json` configured against one works against the other.
## 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

@@ -0,0 +1,29 @@
# Homebrew formula for ClaudeMailbox.
#
# Publish this file to your tap repo (e.g. kuns/homebrew-tap as
# Formula/claude-mailbox.rb), then on a Mac:
#
# brew tap kuns/tap https://git.kuns.dev/kuns/homebrew-tap.git
# brew install kuns/tap/claude-mailbox
#
# The formula thin-wraps the @kuns/claude-mailbox npm package: it relies on
# Homebrew's `node` formula and runs `npm install -g` into a private libexec,
# then symlinks the bin into Homebrew's prefix so the binary lands on PATH.
class ClaudeMailbox < Formula
desc "Standalone MCP mail server for parallel Claude session coordination"
homepage "https://git.kuns.dev/releases/ClaudeMailbox"
url "https://git.kuns.dev/api/packages/releases/npm/@kuns/claude-mailbox/-/@kuns/claude-mailbox-VERSION.tgz"
sha256 "REPLACE_WITH_SHA256_OF_THE_TARBALL"
license "MIT"
depends_on "node"
def install
system "npm", "install", *Language::Node.std_npm_install_args(libexec)
bin.install_symlink Dir["#{libexec}/bin/*"]
end
test do
assert_match "claude-mailbox", shell_output("#{bin}/claude-mailbox --version")
end
end

46
install.ps1 Normal file
View File

@@ -0,0 +1,46 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Bootstrap installer for ClaudeMailbox on Windows.
.DESCRIPTION
Configures the @kuns scoped npm registry to point at the public Gitea
package registry, installs @kuns/claude-mailbox globally, and optionally
registers per-user autostart via Scheduled Task.
.PARAMETER NoAutostart
Skip the install-autostart step.
.PARAMETER Service
Install as a Windows Service (admin shell required) instead of a Scheduled Task.
.EXAMPLE
irm https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.ps1 | iex
#>
[CmdletBinding()]
param(
[switch] $NoAutostart,
[switch] $Service
)
$ErrorActionPreference = "Stop"
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
throw "Node.js / npm not found on PATH. Install Node 20+ from https://nodejs.org and retry."
}
Write-Host "Configuring @kuns scoped npm registry..." -ForegroundColor Cyan
npm config set "@kuns:registry" "https://git.kuns.dev/api/packages/releases/npm/"
Write-Host "Installing @kuns/claude-mailbox globally..." -ForegroundColor Cyan
npm install -g "@kuns/claude-mailbox"
if (-not $NoAutostart) {
$args = @("install-autostart")
if ($Service) { $args += "--service" }
Write-Host "Registering autostart..." -ForegroundColor Cyan
& claude-mailbox @args
}
Write-Host ""
Write-Host "ClaudeMailbox installed. Run 'claude-mailbox status' to verify." -ForegroundColor Green

28
install.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env sh
# Bootstrap installer for ClaudeMailbox on macOS / Linux.
#
# Usage:
# curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
#
# Env vars:
# NO_AUTOSTART=1 skip install-autostart
set -eu
if ! command -v npm >/dev/null 2>&1; then
echo "error: Node.js / npm not found on PATH. Install Node 20+ from https://nodejs.org and retry." >&2
exit 1
fi
echo "Configuring @kuns scoped npm registry..."
npm config set "@kuns:registry" "https://git.kuns.dev/api/packages/releases/npm/"
echo "Installing @kuns/claude-mailbox globally..."
npm install -g "@kuns/claude-mailbox"
if [ -z "${NO_AUTOSTART:-}" ]; then
echo "Registering autostart..."
claude-mailbox install-autostart
fi
echo ""
echo "ClaudeMailbox installed. Run 'claude-mailbox status' to verify."

5
node/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.log
.DS_Store
coverage

20
node/README.md Normal file
View File

@@ -0,0 +1,20 @@
# @kuns/claude-mailbox
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other. TypeScript / Node port of the .NET `claude-mailbox` daemon — wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
## Install
One-time per machine:
```sh
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
npm install -g @kuns/claude-mailbox
```
Then:
```sh
claude-mailbox install-autostart # registers per-OS autostart, no admin needed by default
```
See the repository [README](../README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.

3756
node/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
node/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@kuns/claude-mailbox",
"version": "0.0.0",
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
"type": "module",
"bin": {
"claude-mailbox": "dist/cli.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"start": "node dist/cli.js serve",
"prepack": "npm run build"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"better-sqlite3": "^11.3.0",
"commander": "^12.1.0",
"fastify": "^5.0.0",
"zod": "^3.25.0"
},
"optionalDependencies": {
"node-windows": "^1.0.0-beta.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.7.4",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"keywords": [
"mcp",
"model-context-protocol",
"claude",
"mailbox",
"ipc"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.kuns.dev/releases/ClaudeMailbox.git"
},
"publishConfig": {
"registry": "https://git.kuns.dev/api/packages/releases/npm/"
}
}

View File

@@ -0,0 +1,121 @@
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
const LABEL = "dev.kuns.claude-mailbox";
function plistPath(): string {
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
}
function logDir(): string {
return join(homedir(), "Library", "Logs", "ClaudeMailbox");
}
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(dirname(path), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildPlist(node: string, script: string, configPath: string): string {
mkdirSync(logDir(), { recursive: true });
const argv = [node, script, "serve", "--config", configPath];
const argsXml = argv
.map((a) => ` <string>${escapeXml(a)}</string>`)
.join("\n");
const stdout = join(logDir(), "stdout.log");
const stderr = join(logDir(), "stderr.log");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
${argsXml}
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${escapeXml(stdout)}</string>
<key>StandardErrorPath</key>
<string>${escapeXml(stderr)}</string>
</dict>
</plist>
`;
}
export function darwinManager(): AutostartManager {
return {
mode: "default",
async install(opts) {
const configPath = ensureConfigSeeded(opts);
const { node, script } = cliEntry();
const plist = buildPlist(node, script, configPath);
const path = plistPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, plist, "utf8");
run("launchctl", ["unload", path]);
const r = run("launchctl", ["load", "-w", path]);
if (r.status !== 0) {
throw new Error(`launchctl load failed: ${r.stderr || r.stdout}`);
}
},
async uninstall(purge) {
const path = plistPath();
if (existsSync(path)) {
run("launchctl", ["unload", path]);
unlinkSync(path);
}
if (purge) {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
if (parsed.dbPath && existsSync(parsed.dbPath)) {
rmSync(parsed.dbPath, { force: true });
}
} catch {
// ignore
}
unlinkSync(cfg);
}
}
},
async start() {
const r = run("launchctl", ["start", LABEL]);
if (r.status !== 0) throw new Error(`launchctl start failed: ${r.stderr || r.stdout}`);
},
async stop() {
run("launchctl", ["stop", LABEL]);
},
async status() {
if (!existsSync(plistPath())) return "NotInstalled";
const r = run("launchctl", ["list", LABEL]);
if (r.status !== 0) return "Stopped";
const pidMatch = r.stdout.match(/"PID"\s*=\s*(\d+)/);
return pidMatch ? "Running" : "Stopped";
},
};
}

View File

@@ -0,0 +1,59 @@
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
export interface AutostartInstallOpts {
port?: number;
bind?: string;
dbPath?: string;
}
export interface AutostartManager {
readonly mode: "default" | "service";
install(opts: AutostartInstallOpts): Promise<void>;
uninstall(purge: boolean): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
status(): Promise<"Running" | "Stopped" | "NotInstalled">;
}
export interface RunResult {
status: number;
stdout: string;
stderr: string;
}
export function run(file: string, args: string[]): RunResult {
const opts: SpawnSyncOptionsWithStringEncoding = {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
shell: false,
};
const r = spawnSync(file, args, opts);
return {
status: r.status ?? -1,
stdout: typeof r.stdout === "string" ? r.stdout : "",
stderr: typeof r.stderr === "string" ? r.stderr : "",
};
}
export function cliEntry(): { node: string; script: string } {
const here = dirname(fileURLToPath(import.meta.url));
return {
node: process.execPath,
script: resolve(here, "..", "cli.js"),
};
}
export async function autostartManager(mode: "default" | "service" = "default"): Promise<AutostartManager> {
if (process.platform === "win32") {
const mod = await import("./windows.js");
return mod.windowsManager(mode);
}
if (process.platform === "darwin") {
const mod = await import("./darwin.js");
return mod.darwinManager();
}
const mod = await import("./linux.js");
return mod.linuxManager();
}

104
node/src/autostart/linux.ts Normal file
View File

@@ -0,0 +1,104 @@
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
const UNIT_NAME = "claude-mailbox.service";
function unitPath(): string {
const xdg = process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config");
return join(xdg, "systemd", "user", UNIT_NAME);
}
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(dirname(path), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function buildUnit(node: string, script: string, configPath: string): string {
const exec = `${shellQuote(node)} ${shellQuote(script)} serve --config ${shellQuote(configPath)}`;
return `[Unit]
Description=ClaudeMailbox MCP mail daemon
After=network.target
[Service]
Type=simple
ExecStart=${exec}
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
`;
}
function systemctl(args: string[]): { status: number; stdout: string; stderr: string } {
return run("systemctl", ["--user", ...args]);
}
export function linuxManager(): AutostartManager {
return {
mode: "default",
async install(opts) {
const configPath = ensureConfigSeeded(opts);
const { node, script } = cliEntry();
const path = unitPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, buildUnit(node, script, configPath), "utf8");
const reload = systemctl(["daemon-reload"]);
if (reload.status !== 0) {
throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`);
}
const enable = systemctl(["enable", "--now", UNIT_NAME]);
if (enable.status !== 0) {
throw new Error(`systemctl enable --now failed: ${enable.stderr || enable.stdout}`);
}
},
async uninstall(purge) {
systemctl(["disable", "--now", UNIT_NAME]);
const path = unitPath();
if (existsSync(path)) unlinkSync(path);
systemctl(["daemon-reload"]);
if (purge) {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
if (parsed.dbPath && existsSync(parsed.dbPath)) {
rmSync(parsed.dbPath, { force: true });
}
} catch {
// ignore
}
unlinkSync(cfg);
}
}
},
async start() {
const r = systemctl(["start", UNIT_NAME]);
if (r.status !== 0) throw new Error(`systemctl start failed: ${r.stderr || r.stdout}`);
},
async stop() {
systemctl(["stop", UNIT_NAME]);
},
async status() {
if (!existsSync(unitPath())) return "NotInstalled";
const r = systemctl(["is-active", UNIT_NAME]);
if (r.stdout.trim() === "active") return "Running";
return "Stopped";
},
};
}

View File

@@ -0,0 +1,226 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
const TASK_NAME = "ClaudeMailbox";
const SERVICE_NAME = "ClaudeMailbox";
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(join(path, ".."), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function buildServeCommand(): { node: string; script: string; configPath: string } {
const { node, script } = cliEntry();
return { node, script, configPath: userConfigPath() };
}
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
const configPath = ensureConfigSeeded(opts);
const { node, script } = buildServeCommand();
const tr = `"${node}" "${script}" serve --config "${configPath}"`;
const r = run("schtasks.exe", [
"/Create",
"/SC",
"ONLOGON",
"/TN",
TASK_NAME,
"/TR",
tr,
"/RL",
"LIMITED",
"/F",
]);
if (r.status !== 0) {
throw new Error(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout}`);
}
const start = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
if (start.status !== 0) {
console.warn(`Task created but /Run returned ${start.status}: ${start.stderr.trim()}`);
}
}
function scheduledTaskUninstall(purge: boolean): void {
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
if (r.status !== 0 && !/cannot find/i.test(r.stderr)) {
throw new Error(`schtasks /Delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
}
if (purge) purgeData();
}
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
if (r.status !== 0) {
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) return "NotInstalled";
return "Stopped";
}
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
return "Stopped";
}
function scheduledTaskRun(): void {
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
}
function scheduledTaskEnd(): void {
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
}
interface NodeWindowsService {
on(event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop", cb: () => void): void;
install(): void;
uninstall(): void;
start(): void;
stop(): void;
exists?: boolean;
}
interface NodeWindowsModule {
Service: new (opts: {
name: string;
description?: string;
script: string;
nodeOptions?: string[];
workingDirectory?: string;
}) => NodeWindowsService;
}
async function loadNodeWindows(): Promise<NodeWindowsModule> {
try {
return (await import("node-windows")) as unknown as NodeWindowsModule;
} catch (err) {
throw new Error(
"node-windows is not installed. Install it with `npm i -g node-windows` or use the default Scheduled Task autostart instead.",
);
}
}
function isAdministrator(): boolean {
const r = run("net.exe", ["session"]);
return r.status === 0;
}
async function serviceInstall(opts: AutostartInstallOpts): Promise<void> {
if (!isAdministrator()) {
throw new Error("install-autostart --service requires an Administrator shell.");
}
ensureConfigSeeded(opts);
const { script, configPath } = buildServeCommand();
const nw = await loadNodeWindows();
await new Promise<void>((resolveFn, rejectFn) => {
const svc = new nw.Service({
name: SERVICE_NAME,
description: "ClaudeMailbox MCP mail daemon for parallel Claude session coordination.",
script,
nodeOptions: [],
});
svc.on("install", () => {
svc.start();
resolveFn();
});
svc.on("alreadyinstalled", () => resolveFn());
try {
svc.install();
} catch (e) {
rejectFn(e);
}
void configPath;
});
}
async function serviceUninstall(purge: boolean): Promise<void> {
if (!isAdministrator()) {
throw new Error("uninstall-autostart --service requires an Administrator shell.");
}
const { script } = buildServeCommand();
const nw = await loadNodeWindows();
await new Promise<void>((resolveFn, rejectFn) => {
const svc = new nw.Service({ name: SERVICE_NAME, script });
svc.on("uninstall", () => resolveFn());
try {
svc.uninstall();
} catch (e) {
rejectFn(e);
}
});
if (purge) purgeData();
}
function serviceStatus(): "Running" | "Stopped" | "NotInstalled" {
const r = run("sc.exe", ["query", SERVICE_NAME]);
if (r.status !== 0) return "NotInstalled";
if (/STATE\s*:\s*\d+\s+RUNNING/i.test(r.stdout)) return "Running";
return "Stopped";
}
function serviceStart(): void {
const r = run("sc.exe", ["start", SERVICE_NAME]);
if (r.status !== 0) throw new Error(`sc start failed: ${r.stderr || r.stdout}`);
}
function serviceStop(): void {
const r = run("sc.exe", ["stop", SERVICE_NAME]);
if (r.status !== 0 && !/has not been started/i.test(r.stdout)) {
throw new Error(`sc stop failed: ${r.stderr || r.stdout}`);
}
}
function purgeData(): void {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
void parsed;
} catch {
// ignore
}
}
}
export function windowsManager(mode: "default" | "service"): AutostartManager {
if (mode === "service") {
return {
mode,
install: serviceInstall,
uninstall: serviceUninstall,
async start() {
serviceStart();
},
async stop() {
serviceStop();
},
async status() {
return serviceStatus();
},
};
}
return {
mode,
async install(opts) {
scheduledTaskInstall(opts);
},
async uninstall(purge) {
scheduledTaskUninstall(purge);
},
async start() {
scheduledTaskRun();
},
async stop() {
scheduledTaskEnd();
},
async status() {
return scheduledTaskStatus();
},
};
}

204
node/src/cli.ts Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env node
import { Command } from "commander";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
import { startServer } from "./server.js";
import { autostartManager } from "./autostart/index.js";
function readVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
version?: string;
};
return pkg.version ?? "unknown";
} catch {
return "unknown";
}
}
const DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
async function callJson(
method: string,
url: string,
init: { headers?: Record<string, string>; body?: unknown } = {},
): Promise<unknown> {
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
let body: string | undefined;
if (init.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(init.body);
}
const res = await fetch(url, { method, headers, body });
const text = await res.text();
if (!res.ok) {
throw new Error(`${method} ${url}${res.status}: ${text}`);
}
return text.length ? JSON.parse(text) : null;
}
function reportClientError(err: unknown, url: string): never {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Could not reach daemon at ${url}: ${msg}`);
console.error("Is 'claude-mailbox serve' running?");
process.exit(2);
}
const program = new Command();
program
.name("claude-mailbox")
.description("MCP mail server that lets parallel Claude sessions coordinate.")
.version(readVersion(), "-V, --version");
program
.command("serve")
.description("Run the daemon in the foreground.")
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
.option("--bind <address>", "Bind address")
.option("--db-path <path>", "SQLite database path")
.option("--config <path>", "Path to mailbox.json")
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
const cfg = resolveConfig(opts);
try {
const { app } = await startServer(cfg);
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/EADDRINUSE|already in use/i.test(msg)) {
console.error(
`Port ${cfg.port} is already in use. Another claude-mailbox instance may be running.`,
);
process.exit(3);
}
console.error(msg);
process.exit(1);
}
});
program
.command("send")
.description("Send a message via REST.")
.requiredOption("--to <name>", "Recipient mailbox")
.requiredOption("--from <name>", "Sender mailbox (X-Mailbox header)")
.requiredOption("--body <text>", "Message body")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { to: string; from: string; body: string; url: string }) => {
try {
const out = await callJson("POST", `${opts.url}/v1/send`, {
headers: { "X-Mailbox": opts.from },
body: { to: opts.to, body: opts.body },
});
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("peek")
.description("Non-consuming inbox status.")
.requiredOption("--name <name>", "Mailbox name")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { name: string; url: string }) => {
try {
const out = await callJson(
"GET",
`${opts.url}/v1/peek?name=${encodeURIComponent(opts.name)}`,
);
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("check")
.description("Pull pending messages and mark delivered.")
.requiredOption("--name <name>", "Mailbox name (also sent as X-Mailbox)")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { name: string; url: string }) => {
try {
const out = await callJson(
"POST",
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(opts.name)}`,
{ headers: { "X-Mailbox": opts.name } },
);
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("list")
.description("List known mailboxes.")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { url: string }) => {
try {
const out = await callJson("GET", `${opts.url}/v1/list`);
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("install-autostart")
.description(
"Register autostart for the current OS (Scheduled Task / launchd / systemd-user). Use --service on Windows for a Windows Service (admin).",
)
.option("--service", "Windows: install as a Windows Service (requires admin) instead of a Scheduled Task")
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
.option("--bind <address>", "Bind address")
.option("--db-path <path>", "SQLite database path")
.action(async (opts: { service?: boolean; port?: number; bind?: string; dbPath?: string }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
console.log("Autostart installed.");
});
program
.command("uninstall-autostart")
.description("Remove autostart for the current OS.")
.option("--service", "Windows: uninstall the Windows Service variant")
.option("--purge", "Also delete database and config")
.action(async (opts: { service?: boolean; purge?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.uninstall(!!opts.purge);
console.log("Autostart removed.");
});
program
.command("start")
.description("Start the autostart-managed daemon.")
.option("--service", "Windows: target the Windows Service variant")
.action(async (opts: { service?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.start();
});
program
.command("stop")
.description("Stop the autostart-managed daemon.")
.option("--service", "Windows: target the Windows Service variant")
.action(async (opts: { service?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.stop();
});
program
.command("status")
.description("Print autostart status (Running | Stopped | NotInstalled).")
.option("--service", "Windows: target the Windows Service variant")
.action(async (opts: { service?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
console.log(await mgr.status());
});
program.parseAsync(process.argv).catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});

91
node/src/config.ts Normal file
View File

@@ -0,0 +1,91 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export const DEFAULT_PORT = 47822;
export const DEFAULT_BIND = "127.0.0.1";
export interface FileConfig {
port?: number;
bind?: string;
dbPath?: string;
}
export interface DaemonConfig {
port: number;
bind: string;
dbPath: string;
}
export function defaultDbPath(): string {
return join(homedir(), ".claude-mailbox", "mailbox.db");
}
export function userConfigPath(): string {
return join(homedir(), ".claude-mailbox", "mailbox.json");
}
export function machineConfigPath(): string | null {
if (process.platform === "win32") {
const programData = process.env["ProgramData"] ?? "C:\\ProgramData";
return join(programData, "ClaudeMailbox", "mailbox.json");
}
if (process.platform === "darwin") {
return "/Library/Application Support/ClaudeMailbox/mailbox.json";
}
return "/etc/claude-mailbox/mailbox.json";
}
function expandPath(p: string): string {
let out = p;
if (out.startsWith("~")) out = join(homedir(), out.slice(1));
out = out.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
out = out.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => process.env[name] ?? "");
return resolve(out);
}
export function loadFileConfig(explicitPath?: string): FileConfig {
const candidates: string[] = [];
if (explicitPath) {
if (!existsSync(explicitPath)) {
throw new Error(`Config file not found: ${explicitPath}`);
}
candidates.push(explicitPath);
} else {
candidates.push(userConfigPath());
const machine = machineConfigPath();
if (machine) candidates.push(machine);
}
for (const path of candidates) {
if (existsSync(path)) {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as FileConfig;
return {
port: typeof parsed.port === "number" ? parsed.port : undefined,
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
};
}
}
return {};
}
export interface ServeOverrides {
port?: number;
bind?: string;
dbPath?: string;
config?: string;
}
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
const file = loadFileConfig(overrides.config);
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
return { port, bind, dbPath: expandPath(dbPathRaw) };
}
export function baseUrl(cfg: { port: number; bind: string }): string {
return `http://${cfg.bind}:${cfg.port}`;
}

182
node/src/db.ts Normal file
View File

@@ -0,0 +1,182 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface MailboxRow {
name: string;
created_at: string;
last_seen_at: string;
}
export interface MessageRow {
id: number;
to_mailbox: string;
from_mailbox: string;
body: string;
created_at: string;
delivered_at: string | null;
}
export interface InboxStatus {
pending: number;
oldestAt: Date | null;
}
export interface MailboxInfo {
name: string;
lastSeenAt: Date;
pendingForYou: number;
}
const DDL_STATEMENTS: string[] = [
`CREATE TABLE IF NOT EXISTS mailboxes (
name TEXT NOT NULL PRIMARY KEY,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
to_mailbox TEXT NOT NULL REFERENCES mailboxes(name) ON DELETE RESTRICT,
from_mailbox TEXT NOT NULL REFERENCES mailboxes(name) ON DELETE RESTRICT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
delivered_at TEXT NULL
)`,
`CREATE INDEX IF NOT EXISTS ix_messages_to_delivered
ON messages (to_mailbox, delivered_at)`,
];
function nowIso(): string {
return new Date().toISOString();
}
function parseDate(s: string | null | undefined): Date | null {
if (!s) return null;
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
const d = new Date(normalized);
return isNaN(d.getTime()) ? null : d;
}
export class MailboxStore {
private readonly db: Database.Database;
private readonly stmts: {
findMailbox: Database.Statement;
insertMailbox: Database.Statement;
touchMailbox: Database.Statement;
listMailboxes: Database.Statement;
insertMessage: Database.Statement;
countPending: Database.Statement;
oldestPending: Database.Statement;
selectPending: Database.Statement;
markDelivered: Database.Statement;
pendingByRecipient: Database.Statement;
};
constructor(public readonly dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma("journal_mode = WAL");
this.db.pragma("foreign_keys = ON");
for (const sql of DDL_STATEMENTS) this.db.prepare(sql).run();
this.stmts = {
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
insertMailbox: this.db.prepare(
"INSERT INTO mailboxes (name, created_at, last_seen_at) VALUES (?, ?, ?)",
),
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
insertMessage: this.db.prepare(
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
),
countPending: this.db.prepare(
"SELECT COUNT(*) AS n FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL",
),
oldestPending: this.db.prepare(
"SELECT created_at FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id LIMIT 1",
),
selectPending: this.db.prepare(
"SELECT * FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id",
),
markDelivered: this.db.prepare(
"UPDATE messages SET delivered_at = ? WHERE id IN (SELECT value FROM json_each(?))",
),
pendingByRecipient: this.db.prepare(
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
),
};
}
close(): void {
this.db.close();
}
upsertMailbox(name: string): void {
const now = nowIso();
const existing = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
if (existing) {
this.stmts.touchMailbox.run(now, name);
} else {
this.stmts.insertMailbox.run(name, now, now);
}
}
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
const tx = this.db.transaction(() => {
this.upsertMailbox(from);
this.upsertMailbox(to);
const createdAt = nowIso();
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
});
return tx();
}
peek(name: string): InboxStatus {
const row = this.stmts.countPending.get(name) as { n: number };
if (row.n === 0) return { pending: 0, oldestAt: null };
const oldest = this.stmts.oldestPending.get(name) as { created_at: string } | undefined;
return { pending: row.n, oldestAt: parseDate(oldest?.created_at) };
}
checkInbox(name: string): MessageRow[] {
const tx = this.db.transaction(() => {
const pending = this.stmts.selectPending.all(name) as MessageRow[];
if (pending.length > 0) {
const ids = pending.map((m) => m.id);
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
}
return pending;
});
return tx();
}
listMailboxes(forName?: string): MailboxInfo[] {
const rows = this.stmts.listMailboxes.all() as MailboxRow[];
const pendingMap = new Map<string, number>();
if (forName) {
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
for (const c of counts) pendingMap.set(c.to_mailbox, c.n);
}
return rows.map((r) => ({
name: r.name,
lastSeenAt: parseDate(r.last_seen_at) ?? new Date(0),
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
}));
}
}
export function rowToMessage(r: MessageRow): {
id: number;
from: string;
body: string;
sentAt: Date;
} {
return {
id: r.id,
from: r.from_mailbox,
body: r.body,
sentAt: parseDate(r.created_at) ?? new Date(0),
};
}

121
node/src/mcp.ts Normal file
View File

@@ -0,0 +1,121 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import type { FastifyInstance } from "fastify";
import { MailboxStore, rowToMessage } from "./db.js";
import { HEADER_NAME } from "./server.js";
function buildMcpServer(store: MailboxStore): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
const requireSender = (extra: unknown): string => {
const headers =
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
?.requestInfo?.headers ?? {};
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
if (!value) {
throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
}
return value;
};
server.registerTool(
"send",
{
title: "Send mail",
description:
"Send a message to another mailbox. The sender is the current session's X-Mailbox name.",
inputSchema: {
to: z.string().describe("Name of the recipient mailbox."),
body: z.string().describe("Message body (plain text or markdown)."),
},
},
async ({ to, body }, extra) => {
const from = requireSender(extra);
const r = store.send(from, to, body);
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
structuredContent: out,
};
},
);
server.registerTool(
"check_inbox",
{
title: "Check inbox",
description:
"Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.",
inputSchema: {},
},
async (_args, extra) => {
const name = requireSender(extra);
const messages = store.checkInbox(name).map((m) => {
const x = rowToMessage(m);
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() };
});
return {
content: [{ type: "text", text: JSON.stringify(messages) }],
structuredContent: { messages },
};
},
);
server.registerTool(
"peek_inbox",
{
title: "Peek inbox",
description:
"Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.",
inputSchema: {},
},
async (_args, extra) => {
const name = requireSender(extra);
const status = store.peek(name);
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
structuredContent: out,
};
},
);
server.registerTool(
"list_mailboxes",
{
title: "List mailboxes",
description: "Discover known mailboxes and how many messages each has waiting for you.",
inputSchema: {},
},
async (_args, extra) => {
const name = requireSender(extra);
const list = store.listMailboxes(name).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
}));
return {
content: [{ type: "text", text: JSON.stringify(list) }],
structuredContent: { mailboxes: list },
};
},
);
return server;
}
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> {
const mcpServer = buildMcpServer(store);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await mcpServer.connect(transport);
const handle = async (req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) => {
await transport.handleRequest(req.raw, reply.raw, req.body);
};
app.post("/mcp", handle);
app.get("/mcp", handle);
app.delete("/mcp", handle);
}

113
node/src/server.ts Normal file
View File

@@ -0,0 +1,113 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { MailboxStore, rowToMessage } from "./db.js";
import type { DaemonConfig } from "./config.js";
import { registerMcp } from "./mcp.js";
export const HEADER_NAME = "x-mailbox";
declare module "fastify" {
interface FastifyRequest {
mailboxName?: string;
}
}
function readVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
version?: string;
};
return pkg.version ?? "unknown";
} catch {
return "unknown";
}
}
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
const app = Fastify({ logger: true });
const version = readVersion();
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
const url = req.url.split("?")[0] ?? "/";
if (url === "/health" || url === "/mcp" || url.startsWith("/mcp/")) return;
const headerValue = req.headers[HEADER_NAME];
const name = (Array.isArray(headerValue) ? headerValue[0] : headerValue ?? "").trim();
if (!name) {
if (ANONYMOUS_PATHS.has(url)) return;
reply.code(400).send({ error: `Missing ${HEADER_NAME} header.` });
return reply;
}
req.mailboxName = name;
store.upsertMailbox(name);
});
app.get("/health", async () => ({
status: "ok",
version,
dbPath: cfg.dbPath,
}));
app.post<{ Body: { to?: string; body?: string } }>("/v1/send", async (req, reply) => {
const { to, body } = req.body ?? {};
if (!to || !body) {
reply.code(400);
return { error: "to and body are required" };
}
const from = req.mailboxName!;
const result = store.send(from, to, body);
return { id: result.id, queuedAt: result.queuedAt.toISOString() };
});
app.get<{ Querystring: { name?: string } }>("/v1/peek", async (req, reply) => {
const name = (req.query.name ?? "").trim();
if (!name) {
reply.code(400);
return { error: "name is required" };
}
const status = store.peek(name);
return {
pending: status.pending,
oldestAt: status.oldestAt?.toISOString() ?? null,
};
});
app.post<{ Querystring: { name?: string } }>("/v1/check-inbox", async (req, reply) => {
const name = (req.query.name ?? "").trim();
if (name !== req.mailboxName) {
reply.code(403);
return { error: "X-Mailbox header must match name." };
}
return store.checkInbox(name).map((m) => {
const msg = rowToMessage(m);
return { ...msg, sentAt: msg.sentAt.toISOString() };
});
});
app.get("/v1/list", async (req) => {
const name = req.mailboxName;
return store.listMailboxes(name).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
}));
});
await registerMcp(app, store);
return app;
}
export async function startServer(cfg: DaemonConfig): Promise<{ app: FastifyInstance; store: MailboxStore }> {
const store = new MailboxStore(cfg.dbPath);
const app = await buildServer(cfg, store);
await app.listen({ host: cfg.bind, port: cfg.port });
return { app, store };
}

21
node/src/types/node-windows.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "node-windows" {
export interface ServiceOpts {
name: string;
description?: string;
script: string;
nodeOptions?: string[];
workingDirectory?: string;
}
export class Service {
constructor(opts: ServiceOpts);
on(
event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop" | "error",
cb: (err?: unknown) => void,
): void;
install(): void;
uninstall(): void;
start(): void;
stop(): void;
}
}

94
node/tests/db.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MailboxStore } from "../src/db.js";
let dir: string;
let dbPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-test-"));
dbPath = join(dir, "test.db");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("schema", () => {
it("creates fresh tables and is idempotent on re-open", () => {
const a = new MailboxStore(dbPath);
a.upsertMailbox("alice");
a.close();
const b = new MailboxStore(dbPath);
const list = b.listMailboxes();
expect(list.map((m) => m.name)).toEqual(["alice"]);
b.close();
});
});
describe("send / peek / check round-trip", () => {
it("delivers a message exactly once", () => {
const store = new MailboxStore(dbPath);
try {
const result = store.send("alice", "bob", "hello bob");
expect(result.id).toBeGreaterThan(0);
const peek1 = store.peek("bob");
expect(peek1.pending).toBe(1);
expect(peek1.oldestAt).toBeInstanceOf(Date);
const pulled = store.checkInbox("bob");
expect(pulled).toHaveLength(1);
expect(pulled[0]!.from_mailbox).toBe("alice");
expect(pulled[0]!.body).toBe("hello bob");
const peek2 = store.peek("bob");
expect(peek2.pending).toBe(0);
expect(peek2.oldestAt).toBeNull();
const empty = store.checkInbox("bob");
expect(empty).toEqual([]);
} finally {
store.close();
}
});
it("checkInbox returns all pending in order and marks them delivered atomically", () => {
const store = new MailboxStore(dbPath);
try {
for (let i = 0; i < 10; i++) {
store.send("alice", "bob", `msg ${i}`);
}
const first = store.checkInbox("bob");
expect(first).toHaveLength(10);
expect(first.map((m) => m.body)).toEqual(
Array.from({ length: 10 }, (_, i) => `msg ${i}`),
);
const second = store.checkInbox("bob");
expect(second).toEqual([]);
} finally {
store.close();
}
});
});
describe("listMailboxes", () => {
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
const store = new MailboxStore(dbPath);
try {
store.send("alice", "bob", "x");
store.send("alice", "bob", "y");
store.send("carol", "bob", "z");
const fromBob = store.listMailboxes("bob");
expect(fromBob.map((m) => m.name)).toEqual(["alice", "bob", "carol"]);
const bobRow = fromBob.find((m) => m.name === "bob");
expect(bobRow?.pendingForYou).toBe(3);
} finally {
store.close();
}
});
});

109
node/tests/server.test.ts Normal file
View File

@@ -0,0 +1,109 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MailboxStore } from "../src/db.js";
import { buildServer } from "../src/server.js";
import type { FastifyInstance } from "fastify";
let dir: string;
let dbPath: string;
let store: MailboxStore;
let app: FastifyInstance;
let baseUrl: string;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
dbPath = join(dir, "test.db");
store = new MailboxStore(dbPath);
app = await buildServer({ port: 0, bind: "127.0.0.1", dbPath }, store);
await app.listen({ host: "127.0.0.1", port: 0 });
const addr = app.server.address();
if (!addr || typeof addr === "string") throw new Error("no address");
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await app.close();
store.close();
rmSync(dir, { recursive: true, force: true });
});
async function call(
method: string,
path: string,
init: { headers?: Record<string, string>; body?: unknown } = {},
): Promise<{ status: number; body: unknown }> {
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
let body: string | undefined;
if (init.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(init.body);
}
const res = await fetch(`${baseUrl}${path}`, { method, headers, body });
const text = await res.text();
return { status: res.status, body: text.length ? JSON.parse(text) : null };
}
describe("REST surface", () => {
it("/health is anonymous", async () => {
const r = await call("GET", "/health");
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ status: "ok", dbPath });
});
it("POST /v1/send requires X-Mailbox", async () => {
const r = await call("POST", "/v1/send", { body: { to: "bob", body: "hi" } });
expect(r.status).toBe(400);
});
it("POST /v1/send → /v1/check-inbox round-trip", async () => {
const send = await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "hi bob" },
});
expect(send.status).toBe(200);
expect(send.body).toMatchObject({ id: expect.any(Number), queuedAt: expect.any(String) });
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.status).toBe(200);
expect(peek.body).toMatchObject({ pending: 1 });
const check = await call("POST", "/v1/check-inbox?name=bob", {
headers: { "X-Mailbox": "bob" },
});
expect(check.status).toBe(200);
expect(Array.isArray(check.body)).toBe(true);
const arr = check.body as Array<{ from: string; body: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.from).toBe("alice");
expect(arr[0]!.body).toBe("hi bob");
const peekAfter = await call("GET", "/v1/peek?name=bob");
expect(peekAfter.body).toMatchObject({ pending: 0, oldestAt: null });
});
it("POST /v1/check-inbox rejects mismatched X-Mailbox", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
const wrong = await call("POST", "/v1/check-inbox?name=bob", {
headers: { "X-Mailbox": "alice" },
});
expect(wrong.status).toBe(403);
});
it("/v1/list and /v1/peek are anonymous", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
const list = await call("GET", "/v1/list");
expect(list.status).toBe(200);
expect(Array.isArray(list.body)).toBe(true);
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.status).toBe(200);
});
});

24
node/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist", "tests"]
}

9
node/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 15_000,
pool: "forks",
},
});

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;
}
}