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
|
## Install
|
||||||
|
|
||||||
|
The recommended path is the npm package — it works on Windows, macOS, and Linux.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time per machine: point the @kuns scope at the public Gitea npm registry
|
||||||
|
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
|
||||||
|
|
||||||
|
# install
|
||||||
|
npm install -g @kuns/claude-mailbox
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the bootstrap one-liner:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
|
# Windows
|
||||||
|
irm https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
Put the resulting `claude-mailbox.exe` on your `PATH`.
|
```sh
|
||||||
|
# macOS / Linux
|
||||||
|
curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
## Daemon lifecycle
|
macOS users can also install via Homebrew once the tap is published:
|
||||||
|
|
||||||
The daemon is a normal console process. Pick whichever level of automation you want:
|
```sh
|
||||||
|
brew install kuns/tap/claude-mailbox
|
||||||
|
```
|
||||||
|
|
||||||
1. **Manual.** Open a terminal, run `claude-mailbox serve`, leave it open. Stops when you close the window.
|
### Autostart
|
||||||
2. **Startup shortcut.** Drop a shortcut to `claude-mailbox serve` in `shell:startup` — starts on login.
|
|
||||||
3. **Windows Service.** `sc.exe create ClaudeMailbox binPath= "C:\path\to\claude-mailbox.exe serve" start= auto` — same pattern ClaudeDo uses.
|
|
||||||
|
|
||||||
Defaults: port `47822`, bind `127.0.0.1`, database at `%USERPROFILE%\.claude-mailbox\mailbox.db`. All overridable:
|
```sh
|
||||||
|
claude-mailbox install-autostart # per-user, no admin
|
||||||
|
claude-mailbox install-autostart --service # Windows only: register as a Windows Service (admin)
|
||||||
|
claude-mailbox status # Running | Stopped | NotInstalled
|
||||||
|
claude-mailbox uninstall-autostart [--purge]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Platform | Default mechanism | `--service` mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows | Scheduled Task at logon (no admin) | Windows Service (admin, via `node-windows`) |
|
||||||
|
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
||||||
|
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
||||||
|
|
||||||
|
### Config precedence
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-mailbox serve [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
|
CLI flag > mailbox.json > built-in defaults
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Pass `--config <path>` to override.
|
||||||
|
|
||||||
|
Defaults: port `47822`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
|
||||||
|
|
||||||
|
### Smoke test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox install-autostart
|
||||||
|
claude-mailbox status
|
||||||
|
curl http://127.0.0.1:47822/health
|
||||||
|
claude-mailbox uninstall-autostart --purge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the .NET binary (alternative)
|
||||||
|
|
||||||
|
The original .NET 8 implementation still lives in `src/ClaudeMailbox/`. Build a self-contained Windows exe with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the resulting `claude-mailbox.exe` on your `PATH` and use the legacy `install-service` verbs (Windows-only, admin shell):
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
|
||||||
|
claude-mailbox uninstall-service [--purge]
|
||||||
|
```
|
||||||
|
|
||||||
|
The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema), so a `.mcp.json` configured against one works against the other.
|
||||||
|
|
||||||
## Use from a Claude session
|
## Use from a Claude session
|
||||||
|
|
||||||
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
|
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
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);
|
return await ClientCommands.RunAsync(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip the optional leading "serve" verb so WebApplication.CreateBuilder
|
if (args.Length > 0 && args[0] is "install-service" or "uninstall-service" or "start" or "stop" or "status")
|
||||||
// doesn't try to treat it as an unknown flag.
|
{
|
||||||
|
return await ServiceCommands.RunAsync(args);
|
||||||
|
}
|
||||||
|
|
||||||
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
var serveArgs = (args.Length > 0 && args[0] == "serve") ? args[1..] : args;
|
||||||
|
|
||||||
var cfg = new DaemonConfig
|
var explicitConfig = ClientCommands.GetOption(serveArgs, "--config");
|
||||||
{
|
var defaultConfig = Path.Combine(
|
||||||
Port = ParseInt(serveArgs, "--port", DaemonConfig.DefaultPort),
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
BindAddress = ClientCommands.GetOption(serveArgs, "--bind") ?? DaemonConfig.DefaultBindAddress,
|
"ClaudeMailbox", "mailbox.json");
|
||||||
DbPath = Paths.Expand(ClientCommands.GetOption(serveArgs, "--db-path") ?? Paths.DefaultDbPath()),
|
|
||||||
};
|
var fileConfig = FileConfig.Load(explicitConfig, defaultConfig);
|
||||||
|
var cfg = ConfigResolver.Build(serveArgs, fileConfig);
|
||||||
|
|
||||||
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
var builder = ServerHost.CreateBuilder(cfg, serveArgs);
|
||||||
builder.WebHost.UseUrls(cfg.BaseUrl);
|
builder.WebHost.UseUrls(cfg.BaseUrl);
|
||||||
@@ -38,10 +42,4 @@ catch (IOException ex) when (ex.Message.Contains("address already in use", Strin
|
|||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int ParseInt(string[] args, string name, int fallback)
|
|
||||||
{
|
|
||||||
var raw = ClientCommands.GetOption(args, name);
|
|
||||||
return int.TryParse(raw, out var v) ? v : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Program { }
|
public partial class Program { }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClaudeMailbox.Data.Repositories;
|
|||||||
using ClaudeMailbox.Http;
|
using ClaudeMailbox.Http;
|
||||||
using ClaudeMailbox.Mcp;
|
using ClaudeMailbox.Mcp;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||||
|
|
||||||
namespace ClaudeMailbox;
|
namespace ClaudeMailbox;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ public static class ServerHost
|
|||||||
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
public static WebApplicationBuilder CreateBuilder(DaemonConfig cfg, string[]? args = null)
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
var builder = WebApplication.CreateBuilder(args ?? Array.Empty<string>());
|
||||||
|
builder.Host.UseWindowsService(opt => opt.ServiceName = "ClaudeMailbox");
|
||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
builder.Services.AddSingleton(cfg);
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|||||||
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