Compare commits
41 Commits
ec42e8e4bd
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b65545600 | ||
|
|
b10ac36ed0 | ||
|
|
8832eab6c7 | ||
|
|
8747d638fb | ||
|
|
d456f29138 | ||
|
|
d37d2419d6 | ||
|
|
ee0b72f43b | ||
|
|
d3abc762fd | ||
|
|
d0eb2af183 | ||
|
|
42237149a1 | ||
|
|
ac626f678b | ||
|
|
73a49e405f | ||
|
|
43e8d0d4ca | ||
|
|
50f2b5a7cb | ||
|
|
19d7a591df | ||
|
|
48b6ba6452 | ||
|
|
9fd321043f | ||
|
|
462d6561e1 | ||
|
|
c231f8c18c | ||
|
|
5c5843e62d | ||
|
|
66967167bc | ||
| a5a2895725 | |||
|
|
05d87d2aa7 | ||
|
|
757a095c10 | ||
|
|
83afd0ddb3 | ||
|
|
8cdc7bac16 | ||
|
|
e3b51122ae | ||
|
|
e7407b1b3a | ||
|
|
c3e5bc2ba2 | ||
|
|
202bb692e0 | ||
|
|
dbc6844db6 | ||
|
|
ebc0319384 | ||
|
|
452dc8514b | ||
|
|
f91d3644fb | ||
|
|
5c6f4b8b6e | ||
|
|
d8f25dc01b | ||
|
|
870431d0b8 | ||
|
|
81906e7274 | ||
|
|
f397008ff5 | ||
|
|
948c6d4abe | ||
|
|
0586d67a41 |
14
.claude-plugin/marketplace.json
Normal file
14
.claude-plugin/marketplace.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-mailbox",
|
||||||
|
"owner": {
|
||||||
|
"name": "Mika Kuns"
|
||||||
|
},
|
||||||
|
"description": "Plugins for the Claude-Mailbox project.",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "claude-mailbox",
|
||||||
|
"source": "./plugin",
|
||||||
|
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and injects pending messages."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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."
|
||||||
127
.gitea/workflows/release-node.yml
Normal file
127
.gitea/workflows/release-node.yml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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 --allow-same-version "$VERSION"
|
||||||
|
|
||||||
|
- name: Sync plugin.json version
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
jq --arg v "$VERSION" '.version = $v' plugin/.claude-plugin/plugin.json > plugin/.claude-plugin/plugin.json.tmp
|
||||||
|
mv plugin/.claude-plugin/plugin.json.tmp plugin/.claude-plugin/plugin.json
|
||||||
|
cat plugin/.claude-plugin/plugin.json
|
||||||
|
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- 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.NPM_PUBLISH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${NPM_TOKEN:-}" ]; then
|
||||||
|
echo "::error::NPM_PUBLISH_TOKEN secret is not set (needs Gitea token with write:package scope)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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
|
||||||
251
README.md
251
README.md
@@ -1,76 +1,159 @@
|
|||||||
# ClaudeMailbox
|
# ClaudeMailbox
|
||||||
|
|
||||||
A standalone MCP mail server that lets parallel Claude sessions coordinate with each other. Any Claude session (plain terminal, ClaudeDo worktree, anything that consumes `.mcp.json`) can send messages to a peer session's inbox, check for pending messages, and discover other active mailboxes.
|
A standalone MCP mail server that lets parallel Claude sessions coordinate with each other. Messages are queued in a tiny SQLite database via a local HTTP daemon. Any Claude session — Claude Code, ClaudeDo worktree, plain MCP client — can send to a peer's inbox, check for pending messages, and discover other active mailboxes.
|
||||||
|
|
||||||
Not a substitute for `run_in_background: true` — that handles single-session responsiveness. This handles **session-to-session** coordination.
|
Not a substitute for `run_in_background: true` (which handles single-session responsiveness). This handles **session-to-session** coordination.
|
||||||
|
|
||||||
## Architecture
|
---
|
||||||
|
|
||||||
One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` and a small REST API at `/v1/*`, and persists state in a single SQLite file. Sessions declare themselves via an `X-Mailbox` header in their `.mcp.json`.
|
## Getting started
|
||||||
|
|
||||||
|
Pick one path. Most users want path A.
|
||||||
|
|
||||||
|
### A. Claude Code plugin (recommended — three prompts)
|
||||||
|
|
||||||
|
Inside Claude Code:
|
||||||
|
|
||||||
```
|
```
|
||||||
session-backend session-frontend external sender
|
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox.git
|
||||||
(X-Mailbox: backend) (X-Mailbox: frontend) (CLI / UI / hook)
|
/plugin install claude-mailbox@claude-mailbox
|
||||||
| | |
|
/claude-mailbox:mailbox-doctor
|
||||||
| HTTP | |
|
|
||||||
+--------------+-----------------+--------------------------+
|
|
||||||
v
|
|
||||||
claude-mailbox serve (ASP.NET Core + Kestrel)
|
|
||||||
/mcp MCP tools
|
|
||||||
/v1/* REST for non-MCP senders
|
|
||||||
/health
|
|
||||||
v
|
|
||||||
~/.claude-mailbox/mailbox.db (SQLite WAL)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install
|
The doctor command does the rest:
|
||||||
|
|
||||||
|
1. installs the daemon binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
|
||||||
|
2. registers the daemon for autostart and starts it
|
||||||
|
3. optionally lets you pick a base prefix (e.g. `backend`, `frontend`); without one, mailbox names are anonymous (`claude-a8b3c1d2`)
|
||||||
|
4. runs a self → self smoke test
|
||||||
|
|
||||||
|
After that, every Claude Code session automatically:
|
||||||
|
|
||||||
|
- gets a **unique mailbox identity** derived from its session UUID (so two parallel sessions never collide),
|
||||||
|
- announces that identity and the **list of currently active peers** at session start,
|
||||||
|
- pulls unread mailbox messages into context before every prompt.
|
||||||
|
|
||||||
|
You can then say things like:
|
||||||
|
|
||||||
|
> "I started a second session, coordinate with it on the refactor."
|
||||||
|
|
||||||
|
Claude already has the peer's mailbox name in context from the SessionStart announcement, so it calls `mcp__mailbox__send(from="<my-name>", to="<peer>", body="...")` directly.
|
||||||
|
|
||||||
|
See [`plugin/README.md`](./plugin/README.md) for the full walkthrough, including the `mailbox-status` and `mailbox-update` slash commands.
|
||||||
|
|
||||||
|
### B. Manual install (no Claude Code plugin)
|
||||||
|
|
||||||
|
If you're using a different MCP client, scripts, or you don't want the plugin:
|
||||||
|
|
||||||
|
```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 + autostart
|
||||||
|
npm install -g @kuns/claude-mailbox
|
||||||
|
claude-mailbox install-autostart
|
||||||
|
```
|
||||||
|
|
||||||
|
Or 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
|
||||||
## Daemon lifecycle
|
curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
|
||||||
|
|
||||||
The daemon is a normal console process. Pick whichever level of automation you want:
|
|
||||||
|
|
||||||
1. **Manual.** Open a terminal, run `claude-mailbox serve`, leave it open. Stops when you close the window.
|
|
||||||
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:
|
|
||||||
|
|
||||||
```
|
|
||||||
claude-mailbox serve [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use from a Claude session
|
Then drop this into your project's `.mcp.json`:
|
||||||
|
|
||||||
Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"mailbox": {
|
"mailbox": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://127.0.0.1:47822/mcp",
|
"url": "http://127.0.0.1:37849/mcp"
|
||||||
"headers": {
|
|
||||||
"X-Mailbox": "backend"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Four MCP tools are exposed:
|
Optionally add a static identity (so your client doesn't need to pass `from` / `name` on every call):
|
||||||
|
|
||||||
| Tool | Purpose |
|
```json
|
||||||
|
"headers": { "X-Mailbox": "backend" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Build the .NET binary from source
|
||||||
|
|
||||||
|
The original .NET 8 implementation lives in `src/ClaudeMailbox/`. Wire-compatible with the npm build (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the resulting `claude-mailbox.exe` on `PATH`. Windows-only `install-service` verbs (admin shell):
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mailbox install-service [--port 37849] [--bind 127.0.0.1] [--db-path <path>]
|
||||||
|
claude-mailbox uninstall-service [--purge]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How identity works
|
||||||
|
|
||||||
|
Every Claude Code session gets a unique mailbox name automatically derived as `<project>-<8-hex-of-session-id>`:
|
||||||
|
|
||||||
|
| Setup | Resulting mailbox name |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mcp__mailbox__send(to, body)` | Send a message to another mailbox |
|
| Inside a git repo | `<repo-basename>-<8-hex>` (e.g. `claude-mailbox-a3f91b2c`) |
|
||||||
| `mcp__mailbox__check_inbox()` | Pull all pending messages for this mailbox (marks delivered) |
|
| Outside a git repo | `<cwd-basename>-<8-hex>` |
|
||||||
| `mcp__mailbox__peek_inbox()` | Non-consuming check — returns `{ pending, oldestAt }` |
|
| No cwd available (rare) | `claude-<8-hex>` |
|
||||||
| `mcp__mailbox__list_mailboxes()` | Discover known mailboxes and who has mail for you |
|
| Manual `.mcp.json` with `X-Mailbox: backend` header (no plugin) | `backend` (legacy mode) |
|
||||||
|
|
||||||
|
Project names are sanitized (lowercased, non-alphanumerics → dashes, capped at 40 chars). The plugin's `SessionStart` hook prints the session's identity and the list of peers active in the last hour into the conversation context, so Claude knows who it is and who's around without needing to call any tools first.
|
||||||
|
|
||||||
|
### Renaming at runtime
|
||||||
|
|
||||||
|
Claude can refine its own mailbox name during the session — useful when a session focuses on a specific area (e.g. only frontend work):
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__mailbox__rename(current_name="claude-mailbox-a3f91b2c", new_name="claude-mailbox-frontend-a3f91b2c")
|
||||||
|
```
|
||||||
|
|
||||||
|
Pending messages are transferred to the new name in a single transaction. The old name is removed — peers using it must re-discover via `list_mailboxes`. The endpoint returns `409` if the target name is already in use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autostart
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox install-autostart # per-user, no admin
|
||||||
|
claude-mailbox install-autostart --service # Windows only: 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); falls back to HKCU Run-key if Group Policy blocks schtasks | Windows Service (admin, via `node-windows`) |
|
||||||
|
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
|
||||||
|
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP tools
|
||||||
|
|
||||||
|
| Tool | Required args | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `mcp__mailbox__send` | `to`, `body`, `from` | Send a message. `from` falls back to X-Mailbox header. |
|
||||||
|
| `mcp__mailbox__check_inbox` | `name` | Pull all pending messages and mark delivered. Falls back to header. |
|
||||||
|
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming `{ pending, oldestAt }`. Falls back to header. |
|
||||||
|
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes + `pendingForYou`. Falls back to header. |
|
||||||
|
|
||||||
|
The plugin's SessionStart announcement tells Claude exactly which name to pass for the current session, so the args are filled in automatically.
|
||||||
|
|
||||||
### Suggested CLAUDE.md snippet for poll discipline
|
### Suggested CLAUDE.md snippet for poll discipline
|
||||||
|
|
||||||
@@ -80,41 +163,93 @@ after each subagent completes. If pending > 0, call mcp__mailbox__check_inbox
|
|||||||
and treat the messages as input with priority over the current plan.
|
and treat the messages as input with priority over the current plan.
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI client mode
|
---
|
||||||
|
|
||||||
Any external process (scripts, UIs, hooks) can talk to a running daemon without needing MCP:
|
## CLI
|
||||||
|
|
||||||
|
Any external process — scripts, UIs, manual debugging — can talk to a running daemon directly:
|
||||||
|
|
||||||
```
|
```
|
||||||
claude-mailbox send --to <mailbox> --from <mailbox> --body <text> [--url http://127.0.0.1:47822]
|
claude-mailbox send --from <mailbox> --to <mailbox> --body <text>
|
||||||
claude-mailbox peek --name <mailbox> [--url ...]
|
claude-mailbox peek --name <mailbox>
|
||||||
claude-mailbox check --name <mailbox> [--url ...]
|
claude-mailbox check --name <mailbox> [--hook]
|
||||||
claude-mailbox list [--url ...]
|
claude-mailbox list
|
||||||
|
claude-mailbox status
|
||||||
|
claude-mailbox session-announce # hook helper, reads stdin JSON
|
||||||
|
claude-mailbox install-hook --name <mailbox> [--user|--project]
|
||||||
|
claude-mailbox uninstall-hook [--user|--project]
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI subcommands are thin HTTP clients against the `/v1/*` endpoints.
|
All subcommands accept `--url <url>` to target a non-default daemon address.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## REST surface
|
## REST surface
|
||||||
|
|
||||||
| Method | Path | Requires `X-Mailbox` | Purpose |
|
| Method | Path | `X-Mailbox` required | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/health` | no | `{ status, version, dbPath }` |
|
| `GET` | `/health` | no | `{ status, version, dbPath }` |
|
||||||
| `POST` | `/v1/send` | yes (sender) | `{ to, body }` |
|
| `POST` | `/v1/send` | yes (sender) | body: `{ to, body }` |
|
||||||
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
|
| `GET` | `/v1/peek?name=<mailbox>` | no | read-only status |
|
||||||
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
|
| `POST` | `/v1/check-inbox?name=<mailbox>` | yes (must match `name`) | consume inbox |
|
||||||
| `GET` | `/v1/list` | no | list all mailboxes |
|
| `GET` | `/v1/list` | optional (presence registers caller) | list all mailboxes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config precedence
|
||||||
|
|
||||||
|
```
|
||||||
|
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). Override with `--config <path>`.
|
||||||
|
|
||||||
|
Defaults: port `37849`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` and a small REST API at `/v1/*`, and persists state in a single SQLite file.
|
||||||
|
|
||||||
|
```
|
||||||
|
session-A session-B external sender
|
||||||
|
mailbox: claude-a8b3c1d2 mailbox: claude-d4e5f6a7 (CLI / UI / script)
|
||||||
|
| | |
|
||||||
|
| HTTP | |
|
||||||
|
+--------------+-----------------+--------------------------+
|
||||||
|
v
|
||||||
|
claude-mailbox serve (npm: Fastify; .NET: Kestrel)
|
||||||
|
/mcp MCP tools
|
||||||
|
/v1/* REST for non-MCP senders
|
||||||
|
/health
|
||||||
|
v
|
||||||
|
~/.claude-mailbox/mailbox.db (SQLite WAL)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```
|
```sh
|
||||||
|
# Node port (the recommended runtime)
|
||||||
|
cd node
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# .NET 8 port (wire-compatible alternative)
|
||||||
dotnet build
|
dotnet build
|
||||||
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
dotnet test tests/ClaudeMailbox.Tests/ClaudeMailbox.Tests.csproj
|
||||||
dotnet run --project src/ClaudeMailbox -- serve
|
dotnet run --project src/ClaudeMailbox -- serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Test suite covers end-to-end coordination, concurrent `check_inbox` race safety, and schema idempotency.
|
The test suites cover end-to-end coordination, concurrent `check_inbox` race safety, schema idempotency, hook stdin parsing, session-id derivation, and settings-file patching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- Loopback bind only (v1). Cross-machine coordination is a future extension — swap the middleware for token auth and change the bind address.
|
- Loopback bind only (v1). Cross-machine coordination is a future extension — swap the middleware for token auth and change the bind address.
|
||||||
- No auth on loopback. Local filesystem permissions are the trust boundary.
|
- No auth on loopback. Local filesystem permissions are the trust boundary.
|
||||||
- No message expiry or cleanup. Delivered messages stay as a timeline/audit log.
|
- No message expiry. Delivered messages remain as an audit log.
|
||||||
|
|||||||
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
|
||||||
52
node/README.md
Normal file
52
node/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# @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](https://git.kuns.dev/releases/ClaudeMailbox/src/branch/main/README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.
|
||||||
|
|
||||||
|
## Claude Code hook (auto-check inbox)
|
||||||
|
|
||||||
|
Register a `UserPromptSubmit` hook so Claude pulls pending mailbox messages before every prompt:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox install-hook --name alice # patches ~/.claude/settings.json
|
||||||
|
claude-mailbox install-hook --name alice --project # patches <cwd>/.claude/settings.json
|
||||||
|
claude-mailbox uninstall-hook # remove again
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook is idempotent (running `install-hook` twice does nothing the second time) and only touches the `UserPromptSubmit` block — other hooks and settings are preserved.
|
||||||
|
|
||||||
|
Under the hood the hook runs `claude-mailbox check --name <mailbox> --hook`, which:
|
||||||
|
|
||||||
|
- prints unread messages in a Claude-friendly format,
|
||||||
|
- silently exits 0 if the inbox is empty or the daemon is unreachable (no context noise),
|
||||||
|
- marks the messages delivered so they aren't injected again next prompt.
|
||||||
|
|
||||||
|
Cost: one local HTTP round-trip plus Node coldstart per prompt (~100ms on Windows).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
`npm install` returns `401 Unauthorized`
|
||||||
|
: The Gitea registry usually serves the `releases` scope publicly, but if your instance requires auth you'll need a read token:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm config set //git.kuns.dev/api/packages/releases/npm/:_authToken=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cannot find module 'node:sqlite'` or similar
|
||||||
|
: claude-mailbox uses Node's built-in `node:sqlite`, stable since Node 24. On Node 22.5–23.x it works only with `--experimental-sqlite`. Upgrade to Node 24 LTS or newer: `nvm install 24 && nvm use 24` (or `winget install OpenJS.NodeJS.LTS` on Windows).
|
||||||
3193
node/package-lock.json
generated
Normal file
3193
node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
node/package.json
Normal file
54
node/package.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@kuns/claude-mailbox",
|
||||||
|
"version": "1.4.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",
|
||||||
|
"pretest": "npm run build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"start": "node dist/cli.js serve",
|
||||||
|
"prepack": "npm run build"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"commander": "^12.1.0",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"zod": "^3.25.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"node-windows": "^1.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.7.4",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
},
|
||||||
|
"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";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
353
node/src/autostart/windows.ts
Normal file
353
node/src/autostart/windows.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
||||||
|
import { userConfigPath } from "../config.js";
|
||||||
|
|
||||||
|
function markerPath(): string {
|
||||||
|
return join(dirname(userConfigPath()), MARKER_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActiveMode(): "task" | "run-key" | null {
|
||||||
|
const path = markerPath();
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const raw = readFileSync(path, "utf8").trim();
|
||||||
|
if (raw === "task" || raw === "run-key") return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeActiveMode(mode: "task" | "run-key"): void {
|
||||||
|
const path = markerPath();
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, mode, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveMode(): void {
|
||||||
|
const path = markerPath();
|
||||||
|
if (existsSync(path)) rmSync(path, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessDenied(stderr: string): boolean {
|
||||||
|
return /access is denied|0x80070005/i.test(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_NAME = "ClaudeMailbox";
|
||||||
|
const SERVICE_NAME = "ClaudeMailbox";
|
||||||
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||||
|
const RUN_VALUE = "ClaudeMailbox";
|
||||||
|
const MARKER_FILE = "autostart-mode";
|
||||||
|
|
||||||
|
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 buildServeCommandString(configPath: string): string {
|
||||||
|
const { node, script } = buildServeCommand();
|
||||||
|
return `"${node}" "${script}" serve --config "${configPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; stderr: string } {
|
||||||
|
const configPath = ensureConfigSeeded(opts);
|
||||||
|
const tr = buildServeCommandString(configPath);
|
||||||
|
const r = run("schtasks.exe", [
|
||||||
|
"/Create",
|
||||||
|
"/SC",
|
||||||
|
"ONLOGON",
|
||||||
|
"/TN",
|
||||||
|
TASK_NAME,
|
||||||
|
"/TR",
|
||||||
|
tr,
|
||||||
|
"/RL",
|
||||||
|
"LIMITED",
|
||||||
|
"/F",
|
||||||
|
]);
|
||||||
|
if (r.status !== 0) return { ok: false, stderr: r.stderr || r.stdout };
|
||||||
|
run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||||
|
return { ok: true, stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyInstall(opts: AutostartInstallOpts): void {
|
||||||
|
const configPath = ensureConfigSeeded(opts);
|
||||||
|
const cmd = buildServeCommandString(configPath);
|
||||||
|
const r = run("reg.exe", [
|
||||||
|
"add",
|
||||||
|
RUN_KEY,
|
||||||
|
"/v",
|
||||||
|
RUN_VALUE,
|
||||||
|
"/t",
|
||||||
|
"REG_SZ",
|
||||||
|
"/d",
|
||||||
|
cmd,
|
||||||
|
"/f",
|
||||||
|
]);
|
||||||
|
if (r.status !== 0) {
|
||||||
|
throw new Error(`reg add (HKCU Run) failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
|
}
|
||||||
|
spawnRunKeyDaemon(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnRunKeyDaemon(configPath: string): void {
|
||||||
|
if (runKeyDaemonRunning()) return;
|
||||||
|
const { node, script } = buildServeCommand();
|
||||||
|
const ps = `Start-Process -WindowStyle Hidden -FilePath "${node}" -ArgumentList @('"${script}"','serve','--config','"${configPath}"')`;
|
||||||
|
run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyDaemonRunning(): boolean {
|
||||||
|
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | Select-Object -First 1 -ExpandProperty ProcessId`;
|
||||||
|
const r = run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
return r.status === 0 && r.stdout.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killRunKeyDaemon(): void {
|
||||||
|
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
|
||||||
|
run("powershell.exe", ["-NoProfile", "-Command", ps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKeyUninstall(): void {
|
||||||
|
const r = run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
|
||||||
|
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||||
|
}
|
||||||
|
killRunKeyDaemon();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
||||||
|
const attempt = tryScheduledTaskInstall(opts);
|
||||||
|
if (attempt.ok) {
|
||||||
|
writeActiveMode("task");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAccessDenied(attempt.stderr)) {
|
||||||
|
console.warn(
|
||||||
|
"schtasks /Create denied by Windows policy — falling back to HKCU Run-key autostart (per-user, no admin).",
|
||||||
|
);
|
||||||
|
runKeyInstall(opts);
|
||||||
|
writeActiveMode("run-key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`schtasks /Create failed: ${attempt.stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledTaskUninstall(purge: boolean): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
runKeyUninstall();
|
||||||
|
clearActiveMode();
|
||||||
|
if (purge) purgeData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default to task uninstall, also clean up Run-key in case of mixed state
|
||||||
|
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) && !/does not exist/i.test(r.stderr)) {
|
||||||
|
// Fall through — try Run-key cleanup anyway
|
||||||
|
}
|
||||||
|
// Best-effort Run-key cleanup
|
||||||
|
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
|
||||||
|
killRunKeyDaemon();
|
||||||
|
clearActiveMode();
|
||||||
|
if (purge) purgeData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
const r = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
|
||||||
|
if (r.status !== 0) return "NotInstalled";
|
||||||
|
return runKeyDaemonRunning() ? "Running" : "Stopped";
|
||||||
|
}
|
||||||
|
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)) {
|
||||||
|
// Maybe a Run-key install happened without a marker (legacy / manual). Check reg.
|
||||||
|
const reg = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
|
||||||
|
if (reg.status === 0) return runKeyDaemonRunning() ? "Running" : "Stopped";
|
||||||
|
return "NotInstalled";
|
||||||
|
}
|
||||||
|
return "Stopped";
|
||||||
|
}
|
||||||
|
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
||||||
|
return "Stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduledTaskRun(): void {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
const cfgPath = userConfigPath();
|
||||||
|
spawnRunKeyDaemon(cfgPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
const mode = readActiveMode();
|
||||||
|
if (mode === "run-key") {
|
||||||
|
killRunKeyDaemon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
431
node/src/cli.ts
Normal file
431
node/src/cli.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { Command } from "commander";
|
||||||
|
import { existsSync, 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";
|
||||||
|
import { runStdioMcp } from "./mcp-stdio.js";
|
||||||
|
import {
|
||||||
|
applyInstall,
|
||||||
|
applyUninstall,
|
||||||
|
buildHookCommand,
|
||||||
|
deriveSessionName,
|
||||||
|
formatActivePeerList,
|
||||||
|
formatMessagesForHook,
|
||||||
|
parseHookStdin,
|
||||||
|
readSettings,
|
||||||
|
readStdinIfPiped,
|
||||||
|
settingsPathFor,
|
||||||
|
writeSettings,
|
||||||
|
type HookMessage,
|
||||||
|
type HookScope,
|
||||||
|
type PeerEntry,
|
||||||
|
} from "./hook.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 HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||||
|
const ENV_URL = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim();
|
||||||
|
const DEFAULT_URL = ENV_URL || HARDCODED_DEFAULT_URL;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveHookMailboxName(explicit: string | undefined): string | null {
|
||||||
|
if (explicit && explicit.trim()) return explicit.trim();
|
||||||
|
const stdin = parseHookStdin(readStdinIfPiped());
|
||||||
|
const sid = stdin?.session_id?.trim();
|
||||||
|
if (!sid) return null;
|
||||||
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
|
return deriveSessionName(sid, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("check")
|
||||||
|
.description(
|
||||||
|
"Pull pending messages and mark delivered. In --hook mode the name is auto-derived from the SessionStart/UserPromptSubmit stdin: <project>-<session-short>, where <project> is the git-repo or cwd basename from stdin.",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--name <name>",
|
||||||
|
"Explicit mailbox name. Overrides hook stdin auto-derivation.",
|
||||||
|
)
|
||||||
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
|
.option(
|
||||||
|
"--hook",
|
||||||
|
"Hook mode: human-readable output, silent when no mailbox configured or daemon unreachable. Emits a one-line setup hint when name resolves but daemon is unreachable.",
|
||||||
|
)
|
||||||
|
.action(async (opts: { name?: string; url: string; hook?: boolean }) => {
|
||||||
|
const name = opts.hook
|
||||||
|
? resolveHookMailboxName(opts.name)
|
||||||
|
: (opts.name ?? "").trim() || null;
|
||||||
|
if (!name) {
|
||||||
|
if (opts.hook) return;
|
||||||
|
console.error("Missing --name.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await callJson(
|
||||||
|
"POST",
|
||||||
|
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(name)}`,
|
||||||
|
{ headers: { "X-Mailbox": name } },
|
||||||
|
);
|
||||||
|
if (opts.hook) {
|
||||||
|
const messages = (Array.isArray(out) ? out : []) as HookMessage[];
|
||||||
|
const text = formatMessagesForHook(name, messages);
|
||||||
|
if (text) process.stdout.write(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
if (opts.hook) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||||
|
process.stdout.write(
|
||||||
|
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reportClientError(err, opts.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("session-announce")
|
||||||
|
.description(
|
||||||
|
"SessionStart-hook helper: derives the session's mailbox name from stdin session_id, registers it with the daemon, and announces the identity + currently active peers to context.",
|
||||||
|
)
|
||||||
|
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||||
|
.option(
|
||||||
|
"--peer-window-minutes <minutes>",
|
||||||
|
"Only show peers seen within this many minutes (default 60)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--max-peers <n>",
|
||||||
|
"Maximum number of peers to list (default 10)",
|
||||||
|
(v) => parseInt(v, 10),
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.action(async (opts: { url: string; peerWindowMinutes: number; maxPeers: number }) => {
|
||||||
|
const stdin = parseHookStdin(readStdinIfPiped());
|
||||||
|
const sid = stdin?.session_id?.trim();
|
||||||
|
if (!sid) return;
|
||||||
|
const cwd = typeof stdin?.cwd === "string" ? stdin.cwd : process.cwd();
|
||||||
|
const name = deriveSessionName(sid, cwd);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
|
||||||
|
`The name is auto-derived as <project>-<session-short>. You can rename it (e.g. to tag your working area) with mcp__mailbox__rename(current_name="${name}", new_name="<project>-<area>-<short>"); after that, use the new name everywhere.`,
|
||||||
|
`When using mcp__mailbox__* tools, ALWAYS pass your current name explicitly:`,
|
||||||
|
` - mcp__mailbox__send: from="${name}"`,
|
||||||
|
` - mcp__mailbox__check_inbox: name="${name}"`,
|
||||||
|
` - mcp__mailbox__peek_inbox: name="${name}"`,
|
||||||
|
` - mcp__mailbox__list_mailboxes: name="${name}"`,
|
||||||
|
`Peers reach you with: mcp__mailbox__send(from="<their-name>", to="${name}", body="...")`,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await callJson("GET", `${opts.url}/v1/list`, {
|
||||||
|
headers: { "X-Mailbox": name },
|
||||||
|
});
|
||||||
|
const all = (Array.isArray(out) ? out : []) as PeerEntry[];
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
...formatActivePeerList(all, name, {
|
||||||
|
windowMinutes: opts.peerWindowMinutes,
|
||||||
|
maxPeers: opts.maxPeers,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ECONNREFUSED|fetch failed|ENOTFOUND|connect/i.test(msg)) {
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
`[Claude-Mailbox] Daemon not reachable at ${opts.url}. Run \`claude-mailbox install-autostart\` (one-time) or \`claude-mailbox start\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
process.stdout.write(lines.join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
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("mcp-stdio")
|
||||||
|
.description(
|
||||||
|
"Run a stdio MCP server that proxies tool calls to the local daemon's REST API. The daemon URL comes from $CLAUDE_MAILBOX_URL (default http://127.0.0.1:37849). Used by the Claude Code plugin's .mcp.json so the URL is configurable per machine without env-substitution in the URL field.",
|
||||||
|
)
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
await runStdioMcp();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("install-hook")
|
||||||
|
.description(
|
||||||
|
"Install a Claude Code UserPromptSubmit hook that checks the mailbox on every prompt. Idempotent.",
|
||||||
|
)
|
||||||
|
.requiredOption("--name <name>", "Mailbox name to check")
|
||||||
|
.option("--user", "Patch ~/.claude/settings.json (default)")
|
||||||
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
||||||
|
.option("--url <url>", "Daemon base URL to embed in the hook command")
|
||||||
|
.action(async (opts: { name: string; user?: boolean; project?: boolean; url?: string }) => {
|
||||||
|
if (opts.user && opts.project) {
|
||||||
|
console.error("Pick either --user or --project, not both.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const scope: HookScope = opts.project ? "project" : "user";
|
||||||
|
const path = settingsPathFor(scope);
|
||||||
|
const settings = readSettings(path);
|
||||||
|
const command = buildHookCommand(opts.name, opts.url);
|
||||||
|
const result = applyInstall(settings, command);
|
||||||
|
if (result.changed) {
|
||||||
|
writeSettings(path, settings);
|
||||||
|
console.log(`Hook installed in ${path}`);
|
||||||
|
console.log(`Command: ${command}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Hook already present in ${path}; nothing to do.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("uninstall-hook")
|
||||||
|
.description("Remove the claude-mailbox UserPromptSubmit hook from Claude Code settings.")
|
||||||
|
.option("--user", "Patch ~/.claude/settings.json (default)")
|
||||||
|
.option("--project", "Patch <cwd>/.claude/settings.json")
|
||||||
|
.action(async (opts: { user?: boolean; project?: boolean }) => {
|
||||||
|
if (opts.user && opts.project) {
|
||||||
|
console.error("Pick either --user or --project, not both.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const scope: HookScope = opts.project ? "project" : "user";
|
||||||
|
const path = settingsPathFor(scope);
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
console.log(`No settings file at ${path}; nothing to remove.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = readSettings(path);
|
||||||
|
const result = applyUninstall(settings);
|
||||||
|
if (result.changed) {
|
||||||
|
writeSettings(path, settings);
|
||||||
|
console.log(`Hook removed from ${path}`);
|
||||||
|
} else {
|
||||||
|
console.log(`No claude-mailbox hook found in ${path}; nothing to remove.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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")
|
||||||
|
.option(
|
||||||
|
"--skip-port-check",
|
||||||
|
"Skip the pre-install probe for a foreign occupant on the daemon's port",
|
||||||
|
)
|
||||||
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
service?: boolean;
|
||||||
|
port?: number;
|
||||||
|
bind?: string;
|
||||||
|
dbPath?: string;
|
||||||
|
skipPortCheck?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!opts.skipPortCheck) {
|
||||||
|
const cfg = resolveConfig({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
||||||
|
const probeUrl = `http://${cfg.bind}:${cfg.port}/health`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(probeUrl, { headers: { Accept: "application/json" } });
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: { status?: string; version?: string } | null = null;
|
||||||
|
try {
|
||||||
|
parsed = text.length ? (JSON.parse(text) as { status?: string; version?: string }) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
if (res.ok && parsed?.status === "ok" && parsed.version) {
|
||||||
|
console.log(
|
||||||
|
`Port ${cfg.port} already serves a claude-mailbox daemon (version ${parsed.version}). Autostart will manage that one.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Port ${cfg.port} is held by a non-claude-mailbox service (status ${res.status}). Pick a free port via \`--port <n>\` or write {"port": <n>} to ~/.claude-mailbox/mailbox.json. Use --skip-port-check to bypass.`,
|
||||||
|
);
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Connection refused or similar — port is free, proceed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = 37849;
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
234
node/src/db.ts
Normal file
234
node/src/db.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenameFailure = "invalid" | "source-missing" | "target-exists";
|
||||||
|
|
||||||
|
export class RenameError extends Error {
|
||||||
|
constructor(message: string, public readonly reason: RenameFailure) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RenameError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||||
|
db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
db.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// ignore: original error already on its way up
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MailboxStore {
|
||||||
|
private readonly db: DatabaseSync;
|
||||||
|
|
||||||
|
private readonly stmts: {
|
||||||
|
findMailbox: StatementSync;
|
||||||
|
insertMailbox: StatementSync;
|
||||||
|
touchMailbox: StatementSync;
|
||||||
|
listMailboxes: StatementSync;
|
||||||
|
insertMessage: StatementSync;
|
||||||
|
countPending: StatementSync;
|
||||||
|
oldestPending: StatementSync;
|
||||||
|
selectPending: StatementSync;
|
||||||
|
markDelivered: StatementSync;
|
||||||
|
pendingByRecipient: StatementSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(public readonly dbPath: string) {
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
this.db = new DatabaseSync(dbPath);
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
for (const sql of DDL_STATEMENTS) this.db.exec(sql);
|
||||||
|
|
||||||
|
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 unknown 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 } {
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
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) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] {
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const pending = this.stmts.selectPending.all(name) as unknown as MessageRow[];
|
||||||
|
if (pending.length > 0) {
|
||||||
|
const ids = pending.map((m) => m.id);
|
||||||
|
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(from: string, to: string): { from: string; to: string; messagesTransferred: number } {
|
||||||
|
const oldName = from.trim();
|
||||||
|
const newName = to.trim();
|
||||||
|
if (!oldName) throw new RenameError("from is required", "invalid");
|
||||||
|
if (!newName) throw new RenameError("to is required", "invalid");
|
||||||
|
if (oldName === newName) {
|
||||||
|
this.upsertMailbox(oldName);
|
||||||
|
return { from: oldName, to: newName, messagesTransferred: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return runInTransaction(this.db, () => {
|
||||||
|
const source = this.stmts.findMailbox.get(oldName) as unknown as MailboxRow | undefined;
|
||||||
|
if (!source) throw new RenameError(`Mailbox '${oldName}' does not exist.`, "source-missing");
|
||||||
|
const target = this.stmts.findMailbox.get(newName) as unknown as MailboxRow | undefined;
|
||||||
|
if (target) throw new RenameError(`Mailbox '${newName}' already exists.`, "target-exists");
|
||||||
|
|
||||||
|
const now = nowIso();
|
||||||
|
this.stmts.insertMailbox.run(newName, source.created_at, now);
|
||||||
|
const movedTo = this.db
|
||||||
|
.prepare("UPDATE messages SET to_mailbox = ? WHERE to_mailbox = ?")
|
||||||
|
.run(newName, oldName);
|
||||||
|
this.db
|
||||||
|
.prepare("UPDATE messages SET from_mailbox = ? WHERE from_mailbox = ?")
|
||||||
|
.run(newName, oldName);
|
||||||
|
this.db.prepare("DELETE FROM mailboxes WHERE name = ?").run(oldName);
|
||||||
|
return { from: oldName, to: newName, messagesTransferred: Number(movedTo.changes ?? 0) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listMailboxes(forName?: string): MailboxInfo[] {
|
||||||
|
const rows = this.stmts.listMailboxes.all() as unknown 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
244
node/src/hook.ts
Normal file
244
node/src/hook.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export interface HookStdinPayload {
|
||||||
|
session_id?: string;
|
||||||
|
hook_event_name?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHookStdin(raw: string | null | undefined): HookStdinPayload | null {
|
||||||
|
if (!raw || !raw.trim()) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (parsed && typeof parsed === "object") return parsed as HookStdinPayload;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStdinIfPiped(): string | null {
|
||||||
|
if (process.stdin.isTTY) return null;
|
||||||
|
try {
|
||||||
|
return readFileSync(0, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortSessionId(sessionId: string): string {
|
||||||
|
const hex = sessionId.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
|
||||||
|
if (hex.length >= 8) return hex.slice(0, 8);
|
||||||
|
return sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "00000000";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PROJECT_NAME_LENGTH = 40;
|
||||||
|
|
||||||
|
export function sanitizeProjectName(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
const cleaned = raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
return cleaned.slice(0, MAX_PROJECT_NAME_LENGTH).replace(/-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveProjectName(cwd?: string | null): string {
|
||||||
|
const dir = (cwd ?? "").trim();
|
||||||
|
if (dir) {
|
||||||
|
const gitTop = gitToplevel(dir);
|
||||||
|
if (gitTop) {
|
||||||
|
const sanitized = sanitizeProjectName(basename(gitTop));
|
||||||
|
if (sanitized) return sanitized;
|
||||||
|
}
|
||||||
|
const sanitized = sanitizeProjectName(basename(dir));
|
||||||
|
if (sanitized) return sanitized;
|
||||||
|
}
|
||||||
|
return "claude";
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitToplevel(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 1500,
|
||||||
|
});
|
||||||
|
if (r.status !== 0) return null;
|
||||||
|
const out = (r.stdout ?? "").trim();
|
||||||
|
return out || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSessionName(sessionId: string, cwd?: string | null): string {
|
||||||
|
const short = shortSessionId(sessionId);
|
||||||
|
const project = deriveProjectName(cwd);
|
||||||
|
return `${project}-${short}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerEntry {
|
||||||
|
name: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatActivePeerList(
|
||||||
|
peers: PeerEntry[],
|
||||||
|
selfName: string,
|
||||||
|
options: { windowMinutes: number; maxPeers: number; now?: number },
|
||||||
|
): string[] {
|
||||||
|
const others = peers.filter((p) => p.name !== selfName);
|
||||||
|
const cutoff = (options.now ?? Date.now()) - options.windowMinutes * 60_000;
|
||||||
|
const active = others
|
||||||
|
.filter((p) => {
|
||||||
|
const t = new Date(p.lastSeenAt).getTime();
|
||||||
|
return Number.isFinite(t) && t >= cutoff;
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
|
||||||
|
.slice(0, options.maxPeers);
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return [
|
||||||
|
`No other mailboxes seen within the last ${options.windowMinutes} minutes (${others.length} total registered).`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`Active peers (seen within last ${options.windowMinutes} min, ${active.length} of ${others.length} total):`,
|
||||||
|
];
|
||||||
|
for (const p of active) {
|
||||||
|
lines.push(` - ${p.name} (last seen ${p.lastSeenAt})`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookMessage {
|
||||||
|
id: number;
|
||||||
|
from: string;
|
||||||
|
body: string;
|
||||||
|
sentAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessagesForHook(name: string, messages: HookMessage[]): string {
|
||||||
|
if (messages.length === 0) return "";
|
||||||
|
const header =
|
||||||
|
messages.length === 1
|
||||||
|
? `You have 1 new mailbox message for "${name}":`
|
||||||
|
: `You have ${messages.length} new mailbox messages for "${name}":`;
|
||||||
|
const lines: string[] = [header, ""];
|
||||||
|
for (const m of messages) {
|
||||||
|
lines.push(`[#${m.id}] from ${m.from} (${m.sentAt}):`);
|
||||||
|
for (const bodyLine of m.body.split(/\r?\n/)) {
|
||||||
|
lines.push(` ${bodyLine}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
return lines.join("\n").trimEnd() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HookScope = "user" | "project";
|
||||||
|
|
||||||
|
export function settingsPathFor(scope: HookScope, cwd: string = process.cwd()): string {
|
||||||
|
if (scope === "user") return join(homedir(), ".claude", "settings.json");
|
||||||
|
return join(cwd, ".claude", "settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeHookCommand {
|
||||||
|
type: "command";
|
||||||
|
command: string;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeHookGroup {
|
||||||
|
matcher?: string;
|
||||||
|
hooks: ClaudeHookCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeSettings {
|
||||||
|
hooks?: Record<string, ClaudeHookGroup[]>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_EVENT = "UserPromptSubmit";
|
||||||
|
|
||||||
|
export function buildHookCommand(name: string, url?: string): string {
|
||||||
|
const parts = ["claude-mailbox", "check", "--name", quoteIfNeeded(name), "--hook"];
|
||||||
|
if (url) parts.push("--url", quoteIfNeeded(url));
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIfNeeded(value: string): string {
|
||||||
|
if (/^[A-Za-z0-9._:/@\-]+$/.test(value)) return value;
|
||||||
|
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOurHookCommand(command: string): boolean {
|
||||||
|
const c = command.trim();
|
||||||
|
return /(^|\W)claude-mailbox\s+check\s/.test(c) && /(^|\s)--hook(\s|$)/.test(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSettings(path: string): ClaudeSettings {
|
||||||
|
if (!existsSync(path)) return {};
|
||||||
|
const raw = readFileSync(path, "utf8");
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
return JSON.parse(raw) as ClaudeSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSettings(path: string, settings: ClaudeSettings): void {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchResult {
|
||||||
|
changed: boolean;
|
||||||
|
reason: "added" | "already-present" | "removed" | "not-present";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInstall(settings: ClaudeSettings, command: string): PatchResult {
|
||||||
|
settings.hooks ??= {};
|
||||||
|
settings.hooks[HOOK_EVENT] ??= [];
|
||||||
|
const groups = settings.hooks[HOOK_EVENT];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const hook of group.hooks) {
|
||||||
|
if (hook.command.trim() === command.trim()) {
|
||||||
|
return { changed: false, reason: "already-present" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = groups.find((g) => (g.matcher ?? "") === "");
|
||||||
|
if (!target) {
|
||||||
|
target = { matcher: "", hooks: [] };
|
||||||
|
groups.push(target);
|
||||||
|
}
|
||||||
|
target.hooks.push({ type: "command", command });
|
||||||
|
return { changed: true, reason: "added" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUninstall(settings: ClaudeSettings): PatchResult {
|
||||||
|
const groups = settings.hooks?.[HOOK_EVENT];
|
||||||
|
if (!groups || groups.length === 0) return { changed: false, reason: "not-present" };
|
||||||
|
|
||||||
|
let removed = false;
|
||||||
|
for (const group of groups) {
|
||||||
|
const before = group.hooks.length;
|
||||||
|
group.hooks = group.hooks.filter((h) => !isOurHookCommand(h.command));
|
||||||
|
if (group.hooks.length !== before) removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.hooks![HOOK_EVENT] = groups.filter((g) => g.hooks.length > 0);
|
||||||
|
if (settings.hooks![HOOK_EVENT].length === 0) {
|
||||||
|
delete settings.hooks![HOOK_EVENT];
|
||||||
|
}
|
||||||
|
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
||||||
|
delete settings.hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed ? { changed: true, reason: "removed" } : { changed: false, reason: "not-present" };
|
||||||
|
}
|
||||||
195
node/src/mcp-stdio.ts
Normal file
195
node/src/mcp-stdio.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { DEFAULT_PORT } from "./config.js";
|
||||||
|
|
||||||
|
const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||||
|
|
||||||
|
function resolveDaemonUrl(): string {
|
||||||
|
const env = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim();
|
||||||
|
if (!env || env.includes("${")) return HARDCODED_DEFAULT_URL;
|
||||||
|
return env.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireIdentity(value: string | undefined, argName: "from" | "name"): string {
|
||||||
|
const v = (value ?? "").trim();
|
||||||
|
if (!v) {
|
||||||
|
throw new Error(
|
||||||
|
`Pass \`${argName}\` (your mailbox name from the SessionStart announcement).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rest(
|
||||||
|
method: "GET" | "POST",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStdioMcpServer(daemonUrl: string = resolveDaemonUrl()): McpServer {
|
||||||
|
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"send",
|
||||||
|
{
|
||||||
|
title: "Send mail",
|
||||||
|
description:
|
||||||
|
"Send a message to another mailbox. Pass `from` with your own mailbox name (see the SessionStart announcement).",
|
||||||
|
inputSchema: {
|
||||||
|
to: z.string().describe("Name of the recipient mailbox."),
|
||||||
|
body: z.string().describe("Message body (plain text or markdown)."),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ to, body, from }) => {
|
||||||
|
const sender = requireIdentity(from, "from");
|
||||||
|
const out = (await rest("POST", `${daemonUrl}/v1/send`, {
|
||||||
|
headers: { "X-Mailbox": sender },
|
||||||
|
body: { to, body },
|
||||||
|
})) as { id: number; queuedAt: string };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"check_inbox",
|
||||||
|
{
|
||||||
|
title: "Check inbox",
|
||||||
|
description:
|
||||||
|
"Pull all undelivered messages for your mailbox and mark them delivered. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const messages = (await rest(
|
||||||
|
"POST",
|
||||||
|
`${daemonUrl}/v1/check-inbox?name=${encodeURIComponent(me)}`,
|
||||||
|
{ headers: { "X-Mailbox": me } },
|
||||||
|
)) as { id: number; from: string; body: string; sentAt: string }[];
|
||||||
|
const arr = Array.isArray(messages) ? messages : [];
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(arr) }],
|
||||||
|
structuredContent: { messages: arr },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"peek_inbox",
|
||||||
|
{
|
||||||
|
title: "Peek inbox",
|
||||||
|
description:
|
||||||
|
"Non-consuming check of your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const out = (await rest("GET", `${daemonUrl}/v1/peek?name=${encodeURIComponent(me)}`)) as {
|
||||||
|
pending: number;
|
||||||
|
oldestAt: string | 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. Pass `name` with your own mailbox name.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }) => {
|
||||||
|
const me = requireIdentity(name, "name");
|
||||||
|
const list = (await rest("GET", `${daemonUrl}/v1/list`, {
|
||||||
|
headers: { "X-Mailbox": me },
|
||||||
|
})) as { name: string; lastSeenAt: string; pendingForYou: number }[];
|
||||||
|
const arr = Array.isArray(list) ? list : [];
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(arr) }],
|
||||||
|
structuredContent: { mailboxes: arr },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rename",
|
||||||
|
{
|
||||||
|
title: "Rename your mailbox",
|
||||||
|
description:
|
||||||
|
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||||
|
inputSchema: {
|
||||||
|
current_name: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Your current mailbox name (from the SessionStart announcement or last rename).",
|
||||||
|
),
|
||||||
|
new_name: z
|
||||||
|
.string()
|
||||||
|
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ current_name, new_name }) => {
|
||||||
|
const from = requireIdentity(current_name, "name");
|
||||||
|
const out = (await rest("POST", `${daemonUrl}/v1/rename`, {
|
||||||
|
headers: { "X-Mailbox": from },
|
||||||
|
body: { to: new_name },
|
||||||
|
})) as { from: string; to: string; messagesTransferred: number };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStdioMcp(): Promise<void> {
|
||||||
|
const server = buildStdioMcpServer();
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
195
node/src/mcp.ts
Normal file
195
node/src/mcp.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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, RenameError, rowToMessage } from "./db.js";
|
||||||
|
import { HEADER_NAME } from "./server.js";
|
||||||
|
|
||||||
|
function headerFallback(extra: unknown): string {
|
||||||
|
const headers =
|
||||||
|
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
|
||||||
|
?.requestInfo?.headers ?? {};
|
||||||
|
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
|
||||||
|
return (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIdentity(
|
||||||
|
argValue: string | undefined,
|
||||||
|
extra: unknown,
|
||||||
|
argName: "from" | "name",
|
||||||
|
): string {
|
||||||
|
const explicit = (argValue ?? "").trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const fallback = headerFallback(extra);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
throw new Error(
|
||||||
|
`Pass \`${argName}\` (your mailbox name from the SessionStart announcement) or set the X-Mailbox header in .mcp.json.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMcpServer(store: MailboxStore): McpServer {
|
||||||
|
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"send",
|
||||||
|
{
|
||||||
|
title: "Send mail",
|
||||||
|
description:
|
||||||
|
"Send a message to another mailbox. Pass `from` with your own mailbox name (see the SessionStart announcement); falls back to the X-Mailbox header for single-session HTTP setups.",
|
||||||
|
inputSchema: {
|
||||||
|
to: z.string().describe("Name of the recipient mailbox."),
|
||||||
|
body: z.string().describe("Message body (plain text or markdown)."),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name (the sender). Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ to, body, from }, extra) => {
|
||||||
|
const sender = resolveIdentity(from, extra, "from");
|
||||||
|
const r = store.send(sender, 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 your mailbox and mark them delivered. Pass `name` with your own mailbox name (from the SessionStart announcement); falls back to X-Mailbox header.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }, extra) => {
|
||||||
|
const me = resolveIdentity(name, extra, "name");
|
||||||
|
const messages = store.checkInbox(me).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 your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name; falls back to X-Mailbox header.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }, extra) => {
|
||||||
|
const me = resolveIdentity(name, extra, "name");
|
||||||
|
const status = store.peek(me);
|
||||||
|
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. Pass `name` with your own mailbox name to get accurate `pendingForYou` counts; falls back to X-Mailbox header.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name }, extra) => {
|
||||||
|
const me = resolveIdentity(name, extra, "name");
|
||||||
|
const list = store.listMailboxes(me).map((m) => ({
|
||||||
|
name: m.name,
|
||||||
|
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||||
|
pendingForYou: m.pendingForYou,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(list) }],
|
||||||
|
structuredContent: { mailboxes: list },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rename",
|
||||||
|
{
|
||||||
|
title: "Rename your mailbox",
|
||||||
|
description:
|
||||||
|
"Rename your own mailbox (e.g. to add a working-area tag like `myproject-frontend-a3f9`). Pending messages are transferred to the new name. After this returns, USE THE NEW NAME for all subsequent send/check/peek/list calls. Peers using the old name will fail until they re-discover via list_mailboxes.",
|
||||||
|
inputSchema: {
|
||||||
|
current_name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Your current mailbox name (the one to rename away from). Required unless X-Mailbox is set in .mcp.json.",
|
||||||
|
),
|
||||||
|
new_name: z
|
||||||
|
.string()
|
||||||
|
.describe("The new mailbox name. Must be unique. Convention: <project>-<area>-<short>."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ current_name, new_name }, extra) => {
|
||||||
|
const from = resolveIdentity(current_name, extra, "name");
|
||||||
|
try {
|
||||||
|
const r = store.rename(from, new_name);
|
||||||
|
const out = { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||||
|
structuredContent: out,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RenameError) {
|
||||||
|
throw new Error(`${err.message} (${err.reason})`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
132
node/src/server.ts
Normal file
132
node/src/server.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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, RenameError, 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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { to?: string } }>("/v1/rename", async (req, reply) => {
|
||||||
|
const from = req.mailboxName!;
|
||||||
|
const to = (req.body?.to ?? "").trim();
|
||||||
|
if (!to) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: "to is required" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = store.rename(from, to);
|
||||||
|
return { from: r.from, to: r.to, messagesTransferred: r.messagesTransferred };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RenameError) {
|
||||||
|
reply.code(err.reason === "target-exists" ? 409 : 400);
|
||||||
|
return { error: err.message, reason: err.reason };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
node/tests/cli-hook.test.ts
Normal file
125
node/tests/cli-hook.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const cliPath = resolve(__dirname, "..", "dist", "cli.js");
|
||||||
|
|
||||||
|
function runCli(
|
||||||
|
args: string[],
|
||||||
|
opts: { env?: Record<string, string | undefined>; stdin?: string } = {},
|
||||||
|
): { status: number; stdout: string; stderr: string } {
|
||||||
|
const r = spawnSync(process.execPath, [cliPath, ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ...(opts.env ?? {}) },
|
||||||
|
input: opts.stdin,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: r.status ?? -1,
|
||||||
|
stdout: typeof r.stdout === "string" ? r.stdout : "",
|
||||||
|
stderr: typeof r.stderr === "string" ? r.stderr : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_STDIN = JSON.stringify({
|
||||||
|
session_id: "abc12345-de67-89f0-1234-567890abcdef",
|
||||||
|
hook_event_name: "UserPromptSubmit",
|
||||||
|
cwd: "/tmp",
|
||||||
|
prompt: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("`check --hook` CLI behavior", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!existsSync(cliPath)) {
|
||||||
|
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 0 silently when no stdin and no --name", () => {
|
||||||
|
const r = runCli(["check", "--hook"]);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe("");
|
||||||
|
expect(r.stderr).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives session-id-based name from stdin and emits daemon hint when down", () => {
|
||||||
|
const r = runCli(["check", "--hook", "--url", "http://127.0.0.1:1"], {
|
||||||
|
stdin: HOOK_STDIN,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit --name overrides session-id derivation", () => {
|
||||||
|
const r = runCli(
|
||||||
|
["check", "--hook", "--name", "explicit", "--url", "http://127.0.0.1:1"],
|
||||||
|
{ stdin: HOOK_STDIN },
|
||||||
|
);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses CLAUDE_MAILBOX_URL env as default base URL when --url is not given", () => {
|
||||||
|
const r = runCli(["check", "--hook"], {
|
||||||
|
env: { CLAUDE_MAILBOX_URL: "http://127.0.0.1:1" },
|
||||||
|
stdin: HOOK_STDIN,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable at http://127.0.0.1:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-hook mode errors out when no name resolved", () => {
|
||||||
|
const r = runCli(["check"]);
|
||||||
|
expect(r.status).not.toBe(0);
|
||||||
|
expect(r.stderr).toContain("Missing --name");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("`session-announce` CLI behavior", () => {
|
||||||
|
const UNREACHABLE = "http://127.0.0.1:1";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!existsSync(cliPath)) {
|
||||||
|
throw new Error(`CLI not built. Run \`npm run build\` first. Missing: ${cliPath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the derived mailbox name from a SessionStart payload (project-prefixed)", () => {
|
||||||
|
// cwd "/tmp" is not a git repo → basename "tmp" → project prefix "tmp".
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||||
|
stdin: HOOK_STDIN,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
// The exact prefix depends on the runtime cwd if git resolves; the deterministic
|
||||||
|
// assertion is the session-short suffix and the announcement structure.
|
||||||
|
expect(r.stdout).toMatch(/`[a-z0-9-]+-abc12345`/);
|
||||||
|
expect(r.stdout).toContain("mcp__mailbox__send");
|
||||||
|
expect(r.stdout).toMatch(/from="[a-z0-9-]+-abc12345"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a hint about the rename tool", () => {
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("mcp__mailbox__rename");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits daemon-not-reachable hint when daemon is down", () => {
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], { stdin: HOOK_STDIN });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toContain("[Claude-Mailbox] Daemon not reachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent when no session_id in stdin", () => {
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE], {
|
||||||
|
stdin: JSON.stringify({ hook_event_name: "SessionStart" }),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent when no stdin at all", () => {
|
||||||
|
const r = runCli(["session-announce", "--url", UNREACHABLE]);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
181
node/tests/db.test.ts
Normal file
181
node/tests/db.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
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, RenameError } 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("rename", () => {
|
||||||
|
it("renames a mailbox and transfers undelivered messages", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("alice", "bob-old", "hi");
|
||||||
|
store.send("alice", "bob-old", "again");
|
||||||
|
|
||||||
|
const r = store.rename("bob-old", "bob-new");
|
||||||
|
expect(r.from).toBe("bob-old");
|
||||||
|
expect(r.to).toBe("bob-new");
|
||||||
|
expect(r.messagesTransferred).toBe(2);
|
||||||
|
|
||||||
|
// Old name is gone.
|
||||||
|
const list = store.listMailboxes().map((m) => m.name);
|
||||||
|
expect(list).toContain("bob-new");
|
||||||
|
expect(list).not.toContain("bob-old");
|
||||||
|
|
||||||
|
// Messages still pending under the new name.
|
||||||
|
const peek = store.peek("bob-new");
|
||||||
|
expect(peek.pending).toBe(2);
|
||||||
|
|
||||||
|
// checkInbox under the new name yields the original bodies and the original from.
|
||||||
|
const pulled = store.checkInbox("bob-new");
|
||||||
|
expect(pulled.map((m) => m.body)).toEqual(["hi", "again"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also rewrites the from-side when the renamed mailbox was a sender", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.send("sender-old", "bob", "msg-1");
|
||||||
|
store.rename("sender-old", "sender-new");
|
||||||
|
const pulled = store.checkInbox("bob");
|
||||||
|
expect(pulled).toHaveLength(1);
|
||||||
|
expect(pulled[0]!.from_mailbox).toBe("sender-new");
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats rename-to-same-name as a no-op touch", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
const r = store.rename("alice", "alice");
|
||||||
|
expect(r.messagesTransferred).toBe(0);
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when target already exists", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
store.upsertMailbox("alice");
|
||||||
|
store.upsertMailbox("bob");
|
||||||
|
expect(() => store.rename("alice", "bob")).toThrow(RenameError);
|
||||||
|
try {
|
||||||
|
store.rename("alice", "bob");
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as RenameError).reason).toBe("target-exists");
|
||||||
|
}
|
||||||
|
// Source still present after the failed attempt.
|
||||||
|
expect(store.listMailboxes().map((m) => m.name)).toEqual(["alice", "bob"]);
|
||||||
|
} finally {
|
||||||
|
store.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when source is missing", () => {
|
||||||
|
const store = new MailboxStore(dbPath);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
store.rename("nope", "fresh");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(RenameError);
|
||||||
|
expect((e as RenameError).reason).toBe("source-missing");
|
||||||
|
}
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
415
node/tests/hook.test.ts
Normal file
415
node/tests/hook.test.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
applyInstall,
|
||||||
|
applyUninstall,
|
||||||
|
buildHookCommand,
|
||||||
|
deriveProjectName,
|
||||||
|
deriveSessionName,
|
||||||
|
formatActivePeerList,
|
||||||
|
formatMessagesForHook,
|
||||||
|
parseHookStdin,
|
||||||
|
readSettings,
|
||||||
|
sanitizeProjectName,
|
||||||
|
shortSessionId,
|
||||||
|
writeSettings,
|
||||||
|
type PeerEntry,
|
||||||
|
} from "../src/hook.js";
|
||||||
|
|
||||||
|
describe("formatMessagesForHook", () => {
|
||||||
|
it("returns empty string for empty inbox", () => {
|
||||||
|
expect(formatMessagesForHook("bob", [])).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a single message", () => {
|
||||||
|
const out = formatMessagesForHook("bob", [
|
||||||
|
{ id: 1, from: "alice", body: "hi bob", sentAt: "2026-05-19T10:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
expect(out).toContain("1 new mailbox message");
|
||||||
|
expect(out).toContain("[#1] from alice (2026-05-19T10:00:00.000Z):");
|
||||||
|
expect(out).toContain(" hi bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats multiple messages with plural header", () => {
|
||||||
|
const out = formatMessagesForHook("bob", [
|
||||||
|
{ id: 1, from: "alice", body: "one", sentAt: "2026-05-19T10:00:00.000Z" },
|
||||||
|
{ id: 2, from: "carol", body: "two", sentAt: "2026-05-19T10:01:00.000Z" },
|
||||||
|
]);
|
||||||
|
expect(out).toContain("2 new mailbox messages");
|
||||||
|
expect(out).toContain("[#1] from alice");
|
||||||
|
expect(out).toContain("[#2] from carol");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves multi-line bodies with indentation", () => {
|
||||||
|
const out = formatMessagesForHook("bob", [
|
||||||
|
{ id: 1, from: "alice", body: "line1\nline2", sentAt: "2026-05-19T10:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
expect(out).toContain(" line1");
|
||||||
|
expect(out).toContain(" line2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildHookCommand", () => {
|
||||||
|
it("builds a basic command", () => {
|
||||||
|
expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends --url when provided", () => {
|
||||||
|
expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe(
|
||||||
|
"claude-mailbox check --name alice --hook --url http://127.0.0.1:9000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes names with spaces", () => {
|
||||||
|
const cmd = buildHookCommand("my mailbox");
|
||||||
|
expect(cmd).toContain('--name "my mailbox"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyInstall", () => {
|
||||||
|
it("creates hooks structure from empty settings", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
expect(r).toEqual({ changed: true, reason: "added" });
|
||||||
|
expect(s).toMatchObject({
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{
|
||||||
|
matcher: "",
|
||||||
|
hooks: [{ type: "command", command: "claude-mailbox check --name bob --hook" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — does not duplicate the same command", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
const r = applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
expect(r).toEqual({ changed: false, reason: "already-present" });
|
||||||
|
const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit;
|
||||||
|
expect(groups[0]!.hooks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing unrelated hooks", () => {
|
||||||
|
const s: Record<string, unknown> = {
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{
|
||||||
|
matcher: "",
|
||||||
|
hooks: [{ type: "command", command: "echo something-else" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
|
||||||
|
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2);
|
||||||
|
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
|
||||||
|
expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe(
|
||||||
|
"claude-mailbox check --name bob --hook",
|
||||||
|
);
|
||||||
|
expect((s.hooks as Record<string, unknown>).PostToolUse).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a new empty-matcher group when only non-empty matchers exist", () => {
|
||||||
|
const s: Record<string, unknown> = {
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{ matcher: "Bash", hooks: [{ type: "command", command: "echo bash-only" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit;
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[1]!.matcher).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyUninstall", () => {
|
||||||
|
it("removes the hook and cleans up empty structures", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
const r = applyUninstall(s);
|
||||||
|
expect(r).toEqual({ changed: true, reason: "removed" });
|
||||||
|
expect(s.hooks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves unrelated hooks in the same group", () => {
|
||||||
|
const s: Record<string, unknown> = {
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [
|
||||||
|
{
|
||||||
|
matcher: "",
|
||||||
|
hooks: [
|
||||||
|
{ type: "command", command: "echo something-else" },
|
||||||
|
{ type: "command", command: "claude-mailbox check --name bob --hook" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
applyUninstall(s);
|
||||||
|
const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] };
|
||||||
|
expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(1);
|
||||||
|
expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not-present when there is nothing to remove", () => {
|
||||||
|
const s: Record<string, unknown> = {
|
||||||
|
hooks: { UserPromptSubmit: [{ matcher: "", hooks: [{ type: "command", command: "x" }] }] },
|
||||||
|
};
|
||||||
|
const r = applyUninstall(s);
|
||||||
|
expect(r).toEqual({ changed: false, reason: "not-present" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes hooks installed with --url arg", () => {
|
||||||
|
const s: Record<string, unknown> = {};
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook --url http://x");
|
||||||
|
applyUninstall(s);
|
||||||
|
expect(s.hooks).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseHookStdin", () => {
|
||||||
|
it("returns null for empty or whitespace input", () => {
|
||||||
|
expect(parseHookStdin(null)).toBeNull();
|
||||||
|
expect(parseHookStdin("")).toBeNull();
|
||||||
|
expect(parseHookStdin(" \n ")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-JSON input", () => {
|
||||||
|
expect(parseHookStdin("not json")).toBeNull();
|
||||||
|
expect(parseHookStdin("{")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for JSON primitives (only objects allowed)", () => {
|
||||||
|
expect(parseHookStdin("42")).toBeNull();
|
||||||
|
expect(parseHookStdin("\"foo\"")).toBeNull();
|
||||||
|
expect(parseHookStdin("null")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a hook payload", () => {
|
||||||
|
const out = parseHookStdin(
|
||||||
|
JSON.stringify({
|
||||||
|
session_id: "abc12345-de67-89f0-1234-567890abcdef",
|
||||||
|
hook_event_name: "UserPromptSubmit",
|
||||||
|
prompt: "hi",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(out?.session_id).toBe("abc12345-de67-89f0-1234-567890abcdef");
|
||||||
|
expect(out?.hook_event_name).toBe("UserPromptSubmit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shortSessionId", () => {
|
||||||
|
it("takes first 8 hex chars from a UUID", () => {
|
||||||
|
expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes case and ignores hyphens", () => {
|
||||||
|
expect(shortSessionId("ABC12345-DE67-89F0-1234-567890ABCDEF")).toBe("abc12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a sanitized prefix for non-hex ids", () => {
|
||||||
|
expect(shortSessionId("session-Test123")).toBe("sessiont");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeProjectName", () => {
|
||||||
|
it("lowercases and replaces non-alnum with dashes", () => {
|
||||||
|
expect(sanitizeProjectName("My Project!")).toBe("my-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses runs of separators", () => {
|
||||||
|
expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading/trailing dashes", () => {
|
||||||
|
expect(sanitizeProjectName("--foo--")).toBe("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for purely non-alnum input", () => {
|
||||||
|
expect(sanitizeProjectName("---")).toBe("");
|
||||||
|
expect(sanitizeProjectName("")).toBe("");
|
||||||
|
expect(sanitizeProjectName(null)).toBe("");
|
||||||
|
expect(sanitizeProjectName(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps long names", () => {
|
||||||
|
const out = sanitizeProjectName("a".repeat(120));
|
||||||
|
expect(out.length).toBeLessThanOrEqual(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveProjectName", () => {
|
||||||
|
it("uses cwd basename when not in a git repo", () => {
|
||||||
|
// tmpdir is virtually never inside a git repo; basename is platform-dependent.
|
||||||
|
const got = deriveProjectName(tmpdir());
|
||||||
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'claude' when cwd is empty", () => {
|
||||||
|
expect(deriveProjectName("")).toBe("claude");
|
||||||
|
expect(deriveProjectName(null)).toBe("claude");
|
||||||
|
expect(deriveProjectName(undefined)).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses git toplevel basename when called from inside a repo", () => {
|
||||||
|
// The test harness itself runs inside the claude-mailbox checkout.
|
||||||
|
let inRepo = false;
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
|
||||||
|
inRepo = true;
|
||||||
|
} catch {
|
||||||
|
inRepo = false;
|
||||||
|
}
|
||||||
|
if (!inRepo) return; // CI without git in PATH — skip.
|
||||||
|
const got = deriveProjectName(process.cwd());
|
||||||
|
// Anywhere in the repo, we should resolve to the repo's basename — sanitized.
|
||||||
|
expect(got).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
expect(got.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveSessionName", () => {
|
||||||
|
it("composes <project>-<short>", () => {
|
||||||
|
const got = deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "");
|
||||||
|
expect(got).toBe("claude-abc12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives different names for different sessions in the same project", () => {
|
||||||
|
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "");
|
||||||
|
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "");
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatActivePeerList", () => {
|
||||||
|
const NOW = new Date("2026-05-19T12:00:00.000Z").getTime();
|
||||||
|
|
||||||
|
const peer = (name: string, isoOffsetMinutes: number): PeerEntry => ({
|
||||||
|
name,
|
||||||
|
lastSeenAt: new Date(NOW - isoOffsetMinutes * 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes self from the list", () => {
|
||||||
|
const out = formatActivePeerList(
|
||||||
|
[peer("self", 1), peer("alice", 1)],
|
||||||
|
"self",
|
||||||
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
||||||
|
);
|
||||||
|
const joined = out.join("\n");
|
||||||
|
expect(joined).not.toContain("self");
|
||||||
|
expect(joined).toContain("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out peers older than the window", () => {
|
||||||
|
const out = formatActivePeerList(
|
||||||
|
[peer("recent", 5), peer("stale", 120)],
|
||||||
|
"self",
|
||||||
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
||||||
|
);
|
||||||
|
const joined = out.join("\n");
|
||||||
|
expect(joined).toContain("recent");
|
||||||
|
expect(joined).not.toContain("stale");
|
||||||
|
expect(out[0]).toContain("1 of 2 total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a no-peers message when nothing is active", () => {
|
||||||
|
const out = formatActivePeerList(
|
||||||
|
[peer("ancient", 9999)],
|
||||||
|
"self",
|
||||||
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
||||||
|
);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]).toMatch(/No other mailboxes seen within the last 60 minutes/);
|
||||||
|
expect(out[0]).toContain("1 total registered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at maxPeers and sorts most-recent first", () => {
|
||||||
|
const out = formatActivePeerList(
|
||||||
|
[peer("p1", 30), peer("p2", 20), peer("p3", 10)],
|
||||||
|
"self",
|
||||||
|
{ windowMinutes: 60, maxPeers: 2, now: NOW },
|
||||||
|
);
|
||||||
|
const joined = out.join("\n");
|
||||||
|
expect(joined).toContain("p3");
|
||||||
|
expect(joined).toContain("p2");
|
||||||
|
expect(joined).not.toContain("p1");
|
||||||
|
expect(out[0]).toContain("2 of 3 total");
|
||||||
|
expect(joined.indexOf("p3")).toBeLessThan(joined.indexOf("p2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores peers with invalid lastSeenAt", () => {
|
||||||
|
const out = formatActivePeerList(
|
||||||
|
[{ name: "garbage", lastSeenAt: "not-a-date" }, peer("ok", 5)],
|
||||||
|
"self",
|
||||||
|
{ windowMinutes: 60, maxPeers: 10, now: NOW },
|
||||||
|
);
|
||||||
|
const joined = out.join("\n");
|
||||||
|
expect(joined).toContain("ok");
|
||||||
|
expect(joined).not.toContain("garbage");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readSettings / writeSettings roundtrip", () => {
|
||||||
|
it("survives an install → write → read cycle", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||||
|
try {
|
||||||
|
const path = join(dir, "settings.json");
|
||||||
|
const s = readSettings(path);
|
||||||
|
expect(s).toEqual({});
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
writeSettings(path, s);
|
||||||
|
const reloaded = readSettings(path);
|
||||||
|
expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe(
|
||||||
|
"claude-mailbox check --name bob --hook",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates parent .claude directory when missing", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||||
|
try {
|
||||||
|
const path = join(dir, "nested", ".claude", "settings.json");
|
||||||
|
writeSettings(path, { hooks: {} });
|
||||||
|
expect(readFileSync(path, "utf8")).toContain('"hooks"');
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-hook settings keys", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-"));
|
||||||
|
try {
|
||||||
|
const path = join(dir, "settings.json");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2),
|
||||||
|
);
|
||||||
|
const s = readSettings(path);
|
||||||
|
applyInstall(s, "claude-mailbox check --name bob --hook");
|
||||||
|
writeSettings(path, s);
|
||||||
|
const reloaded = readSettings(path) as {
|
||||||
|
model?: string;
|
||||||
|
permissions?: { allow?: string[] };
|
||||||
|
hooks?: unknown;
|
||||||
|
};
|
||||||
|
expect(reloaded.model).toBe("sonnet");
|
||||||
|
expect(reloaded.permissions?.allow).toEqual(["Bash"]);
|
||||||
|
expect(reloaded.hooks).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
44
node/tests/mcp.test.ts
Normal file
44
node/tests/mcp.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveIdentity } from "../src/mcp.js";
|
||||||
|
|
||||||
|
function fakeExtra(header?: string): unknown {
|
||||||
|
if (header === undefined) return {};
|
||||||
|
return { requestInfo: { headers: { "x-mailbox": header } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveIdentity", () => {
|
||||||
|
it("prefers the explicit argument when present", () => {
|
||||||
|
expect(resolveIdentity("alice", fakeExtra("bob"), "from")).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to X-Mailbox header when arg missing", () => {
|
||||||
|
expect(resolveIdentity(undefined, fakeExtra("bob"), "from")).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from explicit arg and header", () => {
|
||||||
|
expect(resolveIdentity(" alice ", fakeExtra(), "from")).toBe("alice");
|
||||||
|
expect(resolveIdentity(undefined, fakeExtra(" bob "), "name")).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats empty arg as missing and falls back", () => {
|
||||||
|
expect(resolveIdentity("", fakeExtra("bob"), "name")).toBe("bob");
|
||||||
|
expect(resolveIdentity(" ", fakeExtra("bob"), "name")).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws with a helpful message when neither is provided", () => {
|
||||||
|
expect(() => resolveIdentity(undefined, fakeExtra(), "from")).toThrow(
|
||||||
|
/Pass `from`.*SessionStart announcement/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws referencing the correct arg name", () => {
|
||||||
|
expect(() => resolveIdentity(undefined, fakeExtra(), "name")).toThrow(
|
||||||
|
/Pass `name`/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles extra without requestInfo", () => {
|
||||||
|
expect(() => resolveIdentity(undefined, {}, "from")).toThrow(/Pass `from`/);
|
||||||
|
expect(() => resolveIdentity(undefined, null, "from")).toThrow(/Pass `from`/);
|
||||||
|
});
|
||||||
|
});
|
||||||
166
node/tests/server.test.ts
Normal file
166
node/tests/server.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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("POST /v1/rename transfers pending messages and exposes the new name", async () => {
|
||||||
|
// alice sends to bob-old.
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob-old", body: "hi old bob" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rename = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "bob-old" },
|
||||||
|
body: { to: "bob-new" },
|
||||||
|
});
|
||||||
|
expect(rename.status).toBe(200);
|
||||||
|
expect(rename.body).toMatchObject({
|
||||||
|
from: "bob-old",
|
||||||
|
to: "bob-new",
|
||||||
|
messagesTransferred: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Peek under new name shows the pending msg; old name is empty.
|
||||||
|
const peekNew = await call("GET", "/v1/peek?name=bob-new");
|
||||||
|
expect(peekNew.body).toMatchObject({ pending: 1 });
|
||||||
|
const peekOld = await call("GET", "/v1/peek?name=bob-old");
|
||||||
|
expect(peekOld.body).toMatchObject({ pending: 0 });
|
||||||
|
|
||||||
|
// check-inbox under new name pulls the message.
|
||||||
|
const check = await call("POST", "/v1/check-inbox?name=bob-new", {
|
||||||
|
headers: { "X-Mailbox": "bob-new" },
|
||||||
|
});
|
||||||
|
const arr = check.body as Array<{ from: string; body: string }>;
|
||||||
|
expect(arr).toHaveLength(1);
|
||||||
|
expect(arr[0]!.body).toBe("hi old bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename returns 409 when target name is taken", async () => {
|
||||||
|
await call("POST", "/v1/send", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: { to: "bob", body: "x" },
|
||||||
|
});
|
||||||
|
// 'taken' already exists thanks to upsert on X-Mailbox.
|
||||||
|
const r = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "bob" },
|
||||||
|
body: { to: "alice" },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(409);
|
||||||
|
expect(r.body).toMatchObject({ reason: "target-exists" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /v1/rename requires X-Mailbox and body.to", async () => {
|
||||||
|
const missingHeader = await call("POST", "/v1/rename", { body: { to: "x" } });
|
||||||
|
expect(missingHeader.status).toBe(400);
|
||||||
|
const missingTo = await call("POST", "/v1/rename", {
|
||||||
|
headers: { "X-Mailbox": "alice" },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
expect(missingTo.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
11
plugin/.claude-plugin/plugin.json
Normal file
11
plugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-mailbox",
|
||||||
|
"version": "1.4.0",
|
||||||
|
"description": "Auto-checks the local Claude-Mailbox daemon before every prompt and after each subagent run, and injects pending messages into the conversation context.",
|
||||||
|
"author": {
|
||||||
|
"name": "Mika Kuns"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.kuns.dev/releases/ClaudeMailbox",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["mailbox", "ipc", "coordination", "mcp"]
|
||||||
|
}
|
||||||
9
plugin/.mcp.json
Normal file
9
plugin/.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mailbox": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "claude-mailbox",
|
||||||
|
"args": ["mcp-stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
plugin/README.md
Normal file
92
plugin/README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# claude-mailbox plugin
|
||||||
|
|
||||||
|
Lets Claude Code pull unread messages from a local `claude-mailbox` daemon before every prompt and inject them into the conversation context. Each Claude session gets a **unique mailbox identity** auto-derived from its session id, so two sessions in the same project never collide.
|
||||||
|
|
||||||
|
## Setup (three prompts, all inside Claude Code)
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.kuns.dev/releases/ClaudeMailbox.git
|
||||||
|
/plugin install claude-mailbox@claude-mailbox
|
||||||
|
/claude-mailbox:mailbox-doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
The doctor walks the rest:
|
||||||
|
|
||||||
|
1. installs the `claude-mailbox` binary via `npm install -g @kuns/claude-mailbox` if missing (asks first)
|
||||||
|
2. registers the daemon for autostart and starts it if needed
|
||||||
|
3. health-probes `http://127.0.0.1:37849/health`
|
||||||
|
4. runs a self → self smoke test
|
||||||
|
|
||||||
|
After that, every prompt auto-pulls unread messages.
|
||||||
|
|
||||||
|
## Mailbox identity (the important bit)
|
||||||
|
|
||||||
|
Each Claude Code session gets its own mailbox name, automatically derived as `<project>-<session-short>`:
|
||||||
|
|
||||||
|
| Where the session runs | Resulting mailbox name |
|
||||||
|
|---|---|
|
||||||
|
| Inside a git repo | `<repo-basename>-a8b3c1d2` (e.g. `claude-mailbox-a8b3c1d2`) |
|
||||||
|
| Outside a git repo | `<cwd-basename>-a8b3c1d2` |
|
||||||
|
| No cwd in stdin (rare) | `claude-a8b3c1d2` |
|
||||||
|
|
||||||
|
So if you open two Claude Code sessions in the same project, they'll share the project prefix but differ in the session-short — e.g. `claude-mailbox-a8b3c1d2` and `claude-mailbox-d4e5f6a7`. No env-var, no manual prefix step.
|
||||||
|
|
||||||
|
If a session focuses on a sub-area (frontend, backend, …), Claude can call `mcp__mailbox__rename(current_name="…", new_name="claude-mailbox-frontend-a8b3c1d2")` to tag itself; pending messages are transferred. Peers using the old name re-discover via `list_mailboxes`.
|
||||||
|
|
||||||
|
The `SessionStart` hook announces the current session's mailbox name in the conversation context on startup. Peers discover each other via `claude-mailbox list` or the `mcp__mailbox__list_mailboxes` MCP tool.
|
||||||
|
|
||||||
|
## What the hooks do
|
||||||
|
|
||||||
|
| Hook | Command | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `SessionStart` | `claude-mailbox session-announce` | Registers the session with the daemon, then prints (a) this session's mailbox name, (b) the exact `from` / `name` args to pass to MCP tools, and (c) a list of other mailboxes active in the last hour — so Claude knows who's around without needing to call `list_mailboxes` first. |
|
||||||
|
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
|
||||||
|
| `SubagentStop` | `claude-mailbox check --hook` | Same as `UserPromptSubmit`, but fires when a subagent finishes (Task tool). Lets the parent see peer messages that arrived during a long-running subagent run, instead of waiting until the next user prompt. |
|
||||||
|
|
||||||
|
Cost: one local HTTP round-trip per prompt and per subagent stop + Node coldstart (~100ms on Windows).
|
||||||
|
|
||||||
|
## MCP tools
|
||||||
|
|
||||||
|
The plugin ships a `.mcp.json` that spawns a **stdio MCP wrapper** (`claude-mailbox mcp-stdio`) so the daemon URL is configurable per machine via the `CLAUDE_MAILBOX_URL` env var (Claude Code doesn't yet support env substitution in HTTP MCP URLs — see issue #46889). The wrapper proxies tool calls to the daemon's REST API.
|
||||||
|
|
||||||
|
Each MCP tool takes the caller's mailbox name as an explicit argument (from the SessionStart announcement):
|
||||||
|
|
||||||
|
| Tool | Required args | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `mcp__mailbox__send` | `from`, `to`, `body` | Send a message to another mailbox. |
|
||||||
|
| `mcp__mailbox__check_inbox` | `name` | Pull all undelivered messages for your mailbox (marks delivered). |
|
||||||
|
| `mcp__mailbox__peek_inbox` | `name` | Non-consuming count of pending messages. |
|
||||||
|
| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes and `pendingForYou` counts. |
|
||||||
|
| `mcp__mailbox__rename` | `current_name`, `new_name` | Rename your own mailbox (e.g. add an area tag). Pending messages are transferred. Use the new name afterward. |
|
||||||
|
|
||||||
|
The SessionStart announcement spells out the exact args to pass, so Claude picks them up automatically.
|
||||||
|
|
||||||
|
## Slash commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `/claude-mailbox:mailbox-doctor` | Diagnose + auto-fix the full setup. |
|
||||||
|
| `/claude-mailbox:mailbox-status` | Read-only health check. No changes. |
|
||||||
|
| `/claude-mailbox:mailbox-update` | Update the daemon to the latest npm version and restart it. |
|
||||||
|
|
||||||
|
## Coordinating two Claude Code sessions
|
||||||
|
|
||||||
|
1. Open two Claude Code sessions in the same (or different) project.
|
||||||
|
2. Each session's SessionStart hook registers itself with the daemon and prints both its own mailbox name and the **list of currently active peers** into context.
|
||||||
|
3. In session A you can simply say: *"I started a second session, coordinate with it."* Because the peer's mailbox name is already in context, Claude can call `mcp__mailbox__send(from="<my-name>", to="<peer-name>", body="...")` straight away — no manual `list_mailboxes` step needed.
|
||||||
|
4. Session B's `UserPromptSubmit` hook pulls the message on its next prompt and injects it as context.
|
||||||
|
|
||||||
|
You can also send from any shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude-mailbox list
|
||||||
|
claude-mailbox send --from probe --to backend-a8b3c1d2 --body "hi"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin uninstall claude-mailbox@claude-mailbox
|
||||||
|
npm uninstall -g @kuns/claude-mailbox
|
||||||
|
claude-mailbox uninstall-autostart # if you registered it
|
||||||
|
```
|
||||||
123
plugin/commands/mailbox-doctor.md
Normal file
123
plugin/commands/mailbox-doctor.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
description: Diagnose and auto-fix the Claude-Mailbox setup (Node version, binary install, port-conflict detection, daemon autostart, smoke test, optional base-prefix).
|
||||||
|
allowed-tools: Bash, Read, Edit, Write
|
||||||
|
---
|
||||||
|
|
||||||
|
You are running the **Claude-Mailbox doctor**. Walk through these checks in order. After each step, print a one-line `✓` / `✗` with the action you took. End with a summary block.
|
||||||
|
|
||||||
|
Use `Bash` only for `claude-mailbox` subcommands, `npm`, `node`, `where`/`which`, and HTTP probes. Use `Read`/`Edit`/`Write` for `.claude/settings.json` and `mailbox.json`. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
||||||
|
|
||||||
|
## Step 1 — Node.js version
|
||||||
|
|
||||||
|
Run: `node --version`
|
||||||
|
|
||||||
|
claude-mailbox uses Node's built-in `node:sqlite` and therefore requires **Node 24 or newer**. Parse the major version from the output.
|
||||||
|
|
||||||
|
- **Major ≥ 24** → ✓ record the version, continue.
|
||||||
|
- **Major == 22 or 23** → ✗ Stop. `node:sqlite` is experimental on these and requires `--experimental-sqlite`. Print:
|
||||||
|
> Found Node `<X.Y.Z>`. claude-mailbox needs Node 24 LTS or newer. Install via `nvm install 24 && nvm use 24` (or `nvs` / `winget install OpenJS.NodeJS.LTS` on Windows), then re-run the doctor.
|
||||||
|
- **Major < 22** → ✗ Stop with the same message; this Node is end-of-life.
|
||||||
|
- **Major ≥ 26** with `better-sqlite3` still installed globally from a previous version → just note: "Node `<X.Y.Z>` is fine for the current claude-mailbox (no native deps); ignore any old `better-sqlite3` build warnings from a prior install."
|
||||||
|
|
||||||
|
If `node --version` itself fails (`command not found`), stop and tell the user to install Node 24+ first.
|
||||||
|
|
||||||
|
## Step 2 — daemon binary on PATH
|
||||||
|
|
||||||
|
Run: `claude-mailbox --version`
|
||||||
|
|
||||||
|
- **Exit 0** → ✓ record the version. Continue.
|
||||||
|
- **Command not found** → binary missing. Install path:
|
||||||
|
|
||||||
|
| Platform | Command |
|
||||||
|
|---|---|
|
||||||
|
| Windows | `npm install -g @kuns/claude-mailbox` (no admin) |
|
||||||
|
| macOS / Linux | `npm install -g @kuns/claude-mailbox` (may fail with EACCES — never run sudo automatically; ask the user) |
|
||||||
|
|
||||||
|
Prerequisite: `npm config get @kuns:registry` must point at `https://git.kuns.dev/api/packages/releases/npm/`. If not:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
After install, re-run `claude-mailbox --version`. If it still fails, stop and report.
|
||||||
|
|
||||||
|
## Step 3 — port-conflict check (before autostart!)
|
||||||
|
|
||||||
|
Default port is 37849. Probe whether anything is already on it:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -sf http://127.0.0.1:37849/health
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Returns a JSON body with `"status":"ok"` and a `version` field that matches `claude-mailbox --version`** → it's already our daemon, ✓ skip to Step 5.
|
||||||
|
- **Returns 200 with `"status":"ok"` but a different `version`** → it's an older claude-mailbox; treat as running, ✓.
|
||||||
|
- **Returns non-200, non-JSON, or any other foreign response** → **port conflict**. Some other process owns 37849.
|
||||||
|
- **Connection refused** → port is free, ✓ continue to Step 4.
|
||||||
|
|
||||||
|
If port conflict detected:
|
||||||
|
1. Tell the user which process holds the port (Windows: `Get-NetTCPConnection -LocalPort 37849 | Select-Object OwningProcess`, then `Get-Process -Id <pid>`; macOS/Linux: `lsof -i :37849`).
|
||||||
|
2. Pick a free port. Default suggestion: **47900**. Verify it's free: `curl -sf http://127.0.0.1:47900/health` should fail with connection refused.
|
||||||
|
3. Read `~/.claude-mailbox/mailbox.json` (create empty `{}` if missing) and merge `{"port": <chosen>}`. Write back.
|
||||||
|
4. Also write the override into `.claude/settings.json` env so the plugin's hooks find the right URL:
|
||||||
|
```json
|
||||||
|
"env": { "CLAUDE_MAILBOX_URL": "http://127.0.0.1:<chosen>" }
|
||||||
|
```
|
||||||
|
Merge into existing env, preserving other keys.
|
||||||
|
5. Mark `restart_needed = true`.
|
||||||
|
|
||||||
|
## Step 4 — daemon autostart and running state
|
||||||
|
|
||||||
|
Run: `claude-mailbox status`
|
||||||
|
|
||||||
|
- `Running` → ✓ continue.
|
||||||
|
- `Stopped` → `claude-mailbox start`, re-check.
|
||||||
|
- `NotInstalled` → `claude-mailbox install-autostart`, then `claude-mailbox start`, re-check.
|
||||||
|
|
||||||
|
**Behavior on `install-autostart`:** The CLI tries a Scheduled Task first (`schtasks /RL LIMITED`, no admin). If Windows Group Policy returns "Access is denied", it falls back transparently to an `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` registry entry plus a hidden `node serve` process — same per-user persistence, no admin needed. The chosen mechanism is recorded in `~/.claude-mailbox/autostart-mode` and respected by `status`/`start`/`stop`/`uninstall-autostart`.
|
||||||
|
|
||||||
|
If `install-autostart` still fails after both attempts (very rare — would mean both `schtasks` and `reg add` are blocked), stop and report what `status` and `start` printed.
|
||||||
|
|
||||||
|
## Step 5 — health probe
|
||||||
|
|
||||||
|
Hit `http://127.0.0.1:<port>/health` (use the configured port, not necessarily 37849). Expect a JSON body with `"status":"ok"` AND a `version` matching `claude-mailbox --version`. If unreachable or version mismatch, stop and report.
|
||||||
|
|
||||||
|
## Step 6 — mailbox identity
|
||||||
|
|
||||||
|
**No prompt.** Each Claude Code session gets a unique mailbox name auto-derived as `<project>-<short_session_id>`, where `<project>` is the git-repo basename of the session's `cwd` (or the cwd basename if not a git repo). Example: `claude-mailbox-a8b3c1d2`.
|
||||||
|
|
||||||
|
✓ "Mailbox name will be auto-derived as `<project>-<short_session_id>`."
|
||||||
|
|
||||||
|
Sessions can also rename themselves at runtime via the `mcp__mailbox__rename` MCP tool — e.g. to add an area tag like `claude-mailbox-frontend-a8b3c1d2`. No config involved.
|
||||||
|
|
||||||
|
## Step 7 — smoke test
|
||||||
|
|
||||||
|
Use two ephemeral names — we don't need the real session name here:
|
||||||
|
|
||||||
|
```
|
||||||
|
claude-mailbox send --from doctor-probe-a --to doctor-probe-b --body "ping from doctor"
|
||||||
|
claude-mailbox check --name doctor-probe-b
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the port was changed in Step 3, pass `--url http://127.0.0.1:<port>` to both.)
|
||||||
|
|
||||||
|
The `check` output must be a JSON array with one message: `from: doctor-probe-a`, body matches. ✓ on success, ✗ otherwise.
|
||||||
|
|
||||||
|
## Step 8 — summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude-Mailbox doctor
|
||||||
|
node: <version>
|
||||||
|
binary: <version>
|
||||||
|
daemon: Running (port: <port>, what you did if anything)
|
||||||
|
health: ok
|
||||||
|
port conflict: none | resolved (moved from 37849 to <port>)
|
||||||
|
base prefix: <name from settings, or "auto-derived (anonymous)">
|
||||||
|
smoke test: passed | failed
|
||||||
|
restart hint: yes if restart_needed, otherwise no
|
||||||
|
```
|
||||||
|
|
||||||
|
End with one of:
|
||||||
|
|
||||||
|
- All ✓ and no restart needed → "Setup is healthy. Your mailbox name this session will be revealed by the SessionStart hook on next session start."
|
||||||
|
- All ✓ and restart needed → "Restart Claude Code (or open a new session) so the SessionStart hook picks up the new env values."
|
||||||
|
- Anything ✗ → "Setup incomplete: <first failure>."
|
||||||
23
plugin/commands/mailbox-status.md
Normal file
23
plugin/commands/mailbox-status.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Read-only Claude-Mailbox health check. No changes, no installs — just report.
|
||||||
|
allowed-tools: Bash, Read
|
||||||
|
---
|
||||||
|
|
||||||
|
Report the Claude-Mailbox setup status without making any changes. If something is wrong, **tell** the user but **do not** fix it — suggest `/claude-mailbox:mailbox-doctor` for that.
|
||||||
|
|
||||||
|
Print exactly this block, filling in each line:
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude-Mailbox status
|
||||||
|
binary: <output of `claude-mailbox --version`, or "not installed">
|
||||||
|
daemon: <output of `claude-mailbox status`>
|
||||||
|
health: <"ok" if GET http://127.0.0.1:37849/health returns 200, else "unreachable">
|
||||||
|
mailbox name: auto-derived per session as <project>-<short-session-id> (see SessionStart announcement)
|
||||||
|
pending: n/a (the session's mailbox name isn't known until SessionStart runs in this session's context)
|
||||||
|
```
|
||||||
|
|
||||||
|
End with one line:
|
||||||
|
|
||||||
|
- All good → `Status: OK`
|
||||||
|
- Missing daemon → `Status: Setup incomplete. Run /claude-mailbox:mailbox-doctor to fix.`
|
||||||
|
- Daemon installed but stopped → `Status: Daemon is not running. Try \`claude-mailbox start\` or run /claude-mailbox:mailbox-doctor.`
|
||||||
56
plugin/commands/mailbox-update.md
Normal file
56
plugin/commands/mailbox-update.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
description: Update the Claude-Mailbox daemon to the latest published npm version and restart it.
|
||||||
|
allowed-tools: Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
You are running the **Claude-Mailbox update** command. Update the `@kuns/claude-mailbox` npm package and restart the daemon. Never run `sudo` automatically — if elevation is needed, stop and ask.
|
||||||
|
|
||||||
|
## Step 1 — current version
|
||||||
|
|
||||||
|
Run: `claude-mailbox --version`
|
||||||
|
|
||||||
|
- Exit 0 → record the version string as `CURRENT`.
|
||||||
|
- Non-zero → tell the user the daemon binary is not installed yet. Suggest `/claude-mailbox:mailbox-doctor` to do a full setup and stop.
|
||||||
|
|
||||||
|
## Step 2 — latest published version
|
||||||
|
|
||||||
|
Run: `npm view @kuns/claude-mailbox version`
|
||||||
|
|
||||||
|
If the npm registry config is missing, the call may fail with a 404. Fall back to:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm view --registry=https://git.kuns.dev/api/packages/releases/npm/ @kuns/claude-mailbox version
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the result as `LATEST`.
|
||||||
|
|
||||||
|
## Step 3 — compare
|
||||||
|
|
||||||
|
- If `CURRENT === LATEST`: print "Already up to date (vX.Y.Z)." and stop. Do not run any further steps.
|
||||||
|
- Otherwise: tell the user `CURRENT` → `LATEST` and ask for confirmation before proceeding.
|
||||||
|
|
||||||
|
## Step 4 — perform the update
|
||||||
|
|
||||||
|
On user confirmation, run these in order. Stop on the first failure and report it:
|
||||||
|
|
||||||
|
1. `claude-mailbox stop`
|
||||||
|
2. `npm install -g @kuns/claude-mailbox@latest`
|
||||||
|
- On Linux/macOS this may fail with EACCES. **Do not run sudo automatically.** Ask the user how they want to proceed (e.g., `sudo npm install -g …`, or switch to a user-scoped Node setup with nvm/fnm).
|
||||||
|
3. `claude-mailbox start`
|
||||||
|
4. `claude-mailbox --version` to verify the upgrade landed.
|
||||||
|
5. `claude-mailbox status` to verify the daemon is `Running`.
|
||||||
|
|
||||||
|
## Step 5 — summary
|
||||||
|
|
||||||
|
Print exactly this block:
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude-Mailbox update
|
||||||
|
previous version: <CURRENT>
|
||||||
|
new version: <whatever --version now reports>
|
||||||
|
daemon: Running | Stopped | NotInstalled
|
||||||
|
pending messages survived: <count of messages still in inbox via `claude-mailbox list`, if applicable>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the new version matches `LATEST` and daemon is `Running`, end with: "Update complete."
|
||||||
|
Otherwise, end with the first thing that went wrong.
|
||||||
34
plugin/hooks/hooks.json
Normal file
34
plugin/hooks/hooks.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox session-announce"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "claude-mailbox check --hook"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ClaudeMailbox.Cli;
|
|||||||
|
|
||||||
public static class ClientCommands
|
public static class ClientCommands
|
||||||
{
|
{
|
||||||
private const string DefaultUrl = "http://127.0.0.1:47822";
|
private const string DefaultUrl = "http://127.0.0.1:37849";
|
||||||
|
|
||||||
public static async Task<int> RunAsync(string[] args)
|
public static async Task<int> RunAsync(string[] args)
|
||||||
{
|
{
|
||||||
|
|||||||
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 : 37849;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ namespace ClaudeMailbox.Config;
|
|||||||
|
|
||||||
public sealed class DaemonConfig
|
public sealed class DaemonConfig
|
||||||
{
|
{
|
||||||
public const int DefaultPort = 47822;
|
public const int DefaultPort = 37849;
|
||||||
public const string DefaultBindAddress = "127.0.0.1";
|
public const string DefaultBindAddress = "127.0.0.1";
|
||||||
|
|
||||||
public int Port { get; init; } = DefaultPort;
|
public int Port { get; init; } = DefaultPort;
|
||||||
|
|||||||
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