Compare commits
19 Commits
ec42e8e4bd
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d87d2aa7 | ||
|
|
757a095c10 | ||
|
|
83afd0ddb3 | ||
|
|
8cdc7bac16 | ||
|
|
e3b51122ae | ||
|
|
e7407b1b3a | ||
|
|
c3e5bc2ba2 | ||
|
|
202bb692e0 | ||
|
|
dbc6844db6 | ||
|
|
ebc0319384 | ||
|
|
452dc8514b | ||
|
|
f91d3644fb | ||
|
|
5c6f4b8b6e | ||
|
|
d8f25dc01b | ||
|
|
870431d0b8 | ||
|
|
81906e7274 | ||
|
|
f397008ff5 | ||
|
|
948c6d4abe | ||
|
|
0586d67a41 |
45
.gitea/workflows/ci-dotnet.yml
Normal file
45
.gitea/workflows/ci-dotnet.yml
Normal 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"
|
||||
34
.gitea/workflows/ci-node.yml
Normal file
34
.gitea/workflows/ci-node.yml
Normal 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
|
||||
109
.gitea/workflows/release-dotnet.yml
Normal file
109
.gitea/workflows/release-dotnet.yml
Normal 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."
|
||||
116
.gitea/workflows/release-node.yml
Normal file
116
.gitea/workflows/release-node.yml
Normal 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
|
||||
77
README.md
77
README.md
@@ -25,26 +25,85 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
|
||||
|
||||
## 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
|
||||
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.
|
||||
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.
|
||||
### Autostart
|
||||
|
||||
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
|
||||
|
||||
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
@@ -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.
|
||||
29
homebrew/claude-mailbox.rb
Normal file
29
homebrew/claude-mailbox.rb
Normal 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
46
install.ps1
Normal 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
28
install.sh
Normal 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
5
node/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
20
node/README.md
Normal file
20
node/README.md
Normal 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
3756
node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
node/package.json
Normal file
55
node/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
121
node/src/autostart/darwin.ts
Normal file
121
node/src/autostart/darwin.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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";
|
||||
},
|
||||
};
|
||||
}
|
||||
59
node/src/autostart/index.ts
Normal file
59
node/src/autostart/index.ts
Normal 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
104
node/src/autostart/linux.ts
Normal 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";
|
||||
},
|
||||
};
|
||||
}
|
||||
226
node/src/autostart/windows.ts
Normal file
226
node/src/autostart/windows.ts
Normal 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
204
node/src/cli.ts
Normal 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
91
node/src/config.ts
Normal 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
182
node/src/db.ts
Normal 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
121
node/src/mcp.ts
Normal 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
113
node/src/server.ts
Normal 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
21
node/src/types/node-windows.d.ts
vendored
Normal 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
94
node/tests/db.test.ts
Normal 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
109
node/tests/server.test.ts
Normal 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
24
node/tsconfig.json
Normal 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
9
node/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 15_000,
|
||||
pool: "forks",
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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.AspNetCore" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
219
src/ClaudeMailbox/Cli/ServiceCommands.cs
Normal file
219
src/ClaudeMailbox/Cli/ServiceCommands.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
30
src/ClaudeMailbox/Config/ConfigResolver.cs
Normal file
30
src/ClaudeMailbox/Config/ConfigResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/ClaudeMailbox/Config/FileConfig.cs
Normal file
41
src/ClaudeMailbox/Config/FileConfig.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,20 @@ if (args.Length > 0 && args[0] is "send" or "peek" or "check" or "list")
|
||||
return await ClientCommands.RunAsync(args);
|
||||
}
|
||||
|
||||
// Strip the optional leading "serve" verb so WebApplication.CreateBuilder
|
||||
// doesn't try to treat it as an unknown flag.
|
||||
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
|
||||
{
|
||||
return await ServiceCommands.RunAsync(args);
|
||||
}
|
||||
|
||||
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
||||
|
||||
var cfg = new DaemonConfig
|
||||
{
|
||||
Port = ParseInt(serveArgs, "--port", DaemonConfig.DefaultPort),
|
||||
BindAddress = ClientCommands.GetOption(serveArgs, "--bind") ?? DaemonConfig.DefaultBindAddress,
|
||||
DbPath = Paths.Expand(ClientCommands.GetOption(serveArgs, "--db-path") ?? Paths.DefaultDbPath()),
|
||||
};
|
||||
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
|
||||
var defaultConfig = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ClaudeMailbox", "mailbox.json");
|
||||
|
||||
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
|
||||
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
|
||||
|
||||
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
||||
builder.WebHost.UseUrls(cfg.BaseUrl);
|
||||
@@ -38,10 +42,4 @@ catch (IOException ex) when (ex.Message.Contains("address already in use", Strin
|
||||
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 { }
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeMailbox.Data.Repositories;
|
||||
using ClaudeMailbox.Http;
|
||||
using ClaudeMailbox.Mcp;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||
|
||||
namespace ClaudeMailbox;
|
||||
|
||||
@@ -12,6 +13,7 @@ public static class ServerHost
|
||||
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
||||
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
||||
|
||||
builder.Services.AddSingleton(cfg);
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
59
tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs
Normal file
59
tests/ClaudeMailbox.Tests/Config/ConfigResolverTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs
Normal file
99
tests/ClaudeMailbox.Tests/Config/FileConfigTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user