cli-chat-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/dist/add-contact.js +123 -0
- package/dist/check-inbox.js +385 -0
- package/dist/init-identity.js +271 -0
- package/dist/install.js +126 -0
- package/dist/migrate.js +221 -0
- package/dist/server-net.js +819 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# CLI Messenger
|
|
2
|
+
|
|
3
|
+
A peer-to-peer messaging layer for coding-agent CLIs. You tell your agent
|
|
4
|
+
*"write Sam: …"*; it resolves the contact, encrypts the message end-to-end, and
|
|
5
|
+
delivers it through a hosted mailbox. When the recipient opens their CLI, their
|
|
6
|
+
agent surfaces the message and helps them reply.
|
|
7
|
+
|
|
8
|
+
- **Works across CLIs** — Claude Code, Gemini CLI, Copilot CLI, Cursor, … (any
|
|
9
|
+
MCP-capable agent). Behavior ships inside the server's MCP `instructions`.
|
|
10
|
+
- **End-to-end encrypted** — libsodium sealed boxes; the mailbox only ever holds
|
|
11
|
+
ciphertext. Every request is Ed25519-signed.
|
|
12
|
+
- **Phone-number-style codes** — share a 6-character handle; a server registry
|
|
13
|
+
maps it to your keys.
|
|
14
|
+
- **Hosted mailbox** — Cloudflare Worker + D1 (store-and-forward), already
|
|
15
|
+
deployed; runs on Node locally too.
|
|
16
|
+
|
|
17
|
+
See [PLAN.md](./PLAN.md) for the full concept and roadmap.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
Node 22.6+ (uses built-in `node:sqlite`). That's all an end user needs — `npx`
|
|
21
|
+
fetches the rest. State (identity, contacts, inbox cache) lives in `~/.cli-chat`,
|
|
22
|
+
not next to the code, so it survives across `npx` runs.
|
|
23
|
+
|
|
24
|
+
## Set up to message someone (no clone)
|
|
25
|
+
|
|
26
|
+
**1. Wire up your CLI** — add one MCP server entry. On Claude Code:
|
|
27
|
+
```bash
|
|
28
|
+
claude mcp add cli-chat --scope user \
|
|
29
|
+
--env MESSENGER_MAILBOX_URL=https://mailbox.cli-chat-mcp.workers.dev \
|
|
30
|
+
-- npx -y cli-chat-mcp
|
|
31
|
+
```
|
|
32
|
+
Or paste this into any MCP-capable CLI's config (Gemini, Cursor, Codex, …):
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"cli-chat": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": ["-y", "cli-chat-mcp"],
|
|
39
|
+
"env": { "MESSENGER_MAILBOX_URL": "https://mailbox.cli-chat-mcp.workers.dev" }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
(From a clone, `npm run install-clis` auto-writes this entry for every detected CLI.)
|
|
45
|
+
|
|
46
|
+
**2. Restart your CLI**, approve the `cli-chat` server once, then say *"set me
|
|
47
|
+
up"* — `create_account` mints your identity and prints your 6-char code (e.g.
|
|
48
|
+
`dC0v6m`) to share.
|
|
49
|
+
|
|
50
|
+
**3. Swap 6-char codes** with whoever you're messaging (both directions).
|
|
51
|
+
|
|
52
|
+
**4. Message:**
|
|
53
|
+
```
|
|
54
|
+
write Sam at dC0v6m: hey # first time: by code (saves them)
|
|
55
|
+
write Sam: hey # after that: by name
|
|
56
|
+
```
|
|
57
|
+
When the recipient opens their CLI they're told a message is waiting and asked if
|
|
58
|
+
they want it read; they reply the same way.
|
|
59
|
+
|
|
60
|
+
## Develop from a clone
|
|
61
|
+
```bash
|
|
62
|
+
npm install
|
|
63
|
+
npm test # unit + integration (92 checks): encryption, server-only-ciphertext, spoofing
|
|
64
|
+
npm run test:mcp # real MCP server processes against the live cloud mailbox
|
|
65
|
+
npm run build # bundle src/ → dist/ (what gets published)
|
|
66
|
+
```
|
|
67
|
+
Running from a checkout keeps state in the repo's `users/` dir (back-compat);
|
|
68
|
+
set `MESSENGER_HOME` to override where state lives.
|
|
69
|
+
|
|
70
|
+
> Two-way chat needs both codes shared once — a message can't safely carry a
|
|
71
|
+
> reply-to key (that would let the server MITM). Mutual, out-of-band exchange is
|
|
72
|
+
> the secure choice.
|
|
73
|
+
|
|
74
|
+
## The MCP tools
|
|
75
|
+
`create_account` · `send_message` · `messages_available` · `watch` ·
|
|
76
|
+
`read_message` · `draft_reply` · `add_contact` · `my_key` · `list_contacts`.
|
|
77
|
+
Behavior (when to check, how to reply) is carried in the server's MCP
|
|
78
|
+
`instructions`, so it's the same in every CLI.
|
|
79
|
+
|
|
80
|
+
- **`create_account`** mints your identity + 6-char code from inside the CLI, so
|
|
81
|
+
you don't need `npm run init` first. The server now boots even with no identity
|
|
82
|
+
on the device — until you have one, the other tools report `no_account` and the
|
|
83
|
+
agent offers to run `create_account`.
|
|
84
|
+
- **`watch`** is a cross-CLI watch loop: it long-polls ~25s for new mail (marking
|
|
85
|
+
it read) and the agent re-calls it to keep watching. It needs no OS service and
|
|
86
|
+
works in any MCP CLI, but it's not silent — each return is a turn you see.
|
|
87
|
+
|
|
88
|
+
## Receiving: on-open or on-demand
|
|
89
|
+
- **On open** — Claude Code runs a `SessionStart` hook (`src/check-inbox.ts`)
|
|
90
|
+
that pulls waiting mail and tells you how many are waiting and from whom, then
|
|
91
|
+
offers to read them (the bodies stay private to the agent until you say yes).
|
|
92
|
+
Other CLIs check on their first turn (via the server instructions).
|
|
93
|
+
- **On demand** — ask "any messages?" anytime, or have the agent `watch` to
|
|
94
|
+
long-poll for new mail while you wait.
|
|
95
|
+
|
|
96
|
+
## Layout
|
|
97
|
+
| Path | Role |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `src/server-net.ts` | the MCP server (tools + `instructions`) |
|
|
100
|
+
| `src/core-net.ts` | seal-on-send, drain+decrypt-to-cache, read locally |
|
|
101
|
+
| `src/crypto.ts` · `auth.ts` · `canonical.ts` | sealed boxes + signed-request auth |
|
|
102
|
+
| `src/key-code.ts` · `identity.ts` · `contacts.ts` · `db.ts` | codes, identity, contacts, local cache |
|
|
103
|
+
| `src/mailbox-client.ts` | signed HTTP client |
|
|
104
|
+
| `src/init-identity.ts` · `install.ts` · `add-contact.ts` | onboarding helpers |
|
|
105
|
+
| `src/check-inbox.ts` | on-open read (SessionStart hook) |
|
|
106
|
+
| `server-mailbox/` | the Hono mailbox: `app.ts`, `node.ts` (local), `worker.ts`+`wrangler.toml` (Cloudflare/D1), `store*.ts`, `verify.ts`, `schema.sql` |
|
|
107
|
+
| `test/live-net.ts` · `live-net-mcp.ts` | in-process + real-MCP tests |
|
|
108
|
+
|
|
109
|
+
## Deploy your own mailbox (optional)
|
|
110
|
+
The mailbox is already deployed. To run your own:
|
|
111
|
+
```bash
|
|
112
|
+
npx wrangler d1 create cli-chat # put the id in wrangler.toml
|
|
113
|
+
npx wrangler d1 execute cli-chat --remote --file server-mailbox/schema.sql
|
|
114
|
+
npm run deploy
|
|
115
|
+
```
|
|
116
|
+
Point clients at it with `MESSENGER_MAILBOX_URL=https://…`.
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
- **Storage is keyed by handle, not name.** Each identity lives in
|
|
120
|
+
`users/<handle>/` (your 6-char code), and the device default `users/.current`
|
|
121
|
+
holds that handle. Your name is a cosmetic `name` field inside `identity.json`
|
|
122
|
+
— local only, never sent to the server, and free to change. `MESSENGER_USER`
|
|
123
|
+
accepts a handle, a display name, or a `signPub` and resolves to the right
|
|
124
|
+
identity. Upgrading from the older name-keyed layout? Run `npm run migrate`
|
|
125
|
+
(it claims a handle for any identity missing one, renames the dirs, and fixes
|
|
126
|
+
`.current`). After migrating, restart any running CLI so its server reloads.
|
|
127
|
+
- **Secrets:** `users/*/identity.json` holds private keys and is gitignored; only
|
|
128
|
+
public keys ever leave your machine.
|
|
129
|
+
- The deployed mailbox is currently open (no API token) — it only holds
|
|
130
|
+
ciphertext, but anyone with the URL could post blobs to a known address. Fine
|
|
131
|
+
for a small trusted group; add a token / rate-limiting before wider use.
|
|
132
|
+
- One identity per machine for now (no recovery-passphrase yet, so you can't move
|
|
133
|
+
an identity to another device).
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/add-contact.ts
|
|
4
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
5
|
+
|
|
6
|
+
// src/crypto.ts
|
|
7
|
+
var s = null;
|
|
8
|
+
async function initCrypto() {
|
|
9
|
+
if (s) return;
|
|
10
|
+
const mod = await import("libsodium-wrappers");
|
|
11
|
+
const sodium = mod.default ?? mod;
|
|
12
|
+
await sodium.ready;
|
|
13
|
+
s = sodium;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/identity.ts
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
function loadIdentity(path) {
|
|
19
|
+
const id2 = JSON.parse(readFileSync(path, "utf8"));
|
|
20
|
+
for (const k of ["boxPub", "boxSec", "signPub", "signSec"]) {
|
|
21
|
+
if (!id2[k]) throw new Error(`identity at ${path} missing ${k}`);
|
|
22
|
+
}
|
|
23
|
+
return id2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/current-user.ts
|
|
27
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join as join2 } from "node:path";
|
|
29
|
+
|
|
30
|
+
// src/paths.ts
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join, resolve } from "node:path";
|
|
34
|
+
var codeRoot = resolve(import.meta.dirname, "..");
|
|
35
|
+
function dataHome() {
|
|
36
|
+
const env = process.env.MESSENGER_HOME?.trim();
|
|
37
|
+
if (env) return env;
|
|
38
|
+
if (existsSync(join(codeRoot, "users"))) return codeRoot;
|
|
39
|
+
return join(homedir(), ".cli-chat");
|
|
40
|
+
}
|
|
41
|
+
function usersDir() {
|
|
42
|
+
return join(dataHome(), "users");
|
|
43
|
+
}
|
|
44
|
+
function userDir(user2) {
|
|
45
|
+
return join(usersDir(), user2);
|
|
46
|
+
}
|
|
47
|
+
function identityFile(user2) {
|
|
48
|
+
return join(userDir(user2), "identity.json");
|
|
49
|
+
}
|
|
50
|
+
function contactsFile(user2) {
|
|
51
|
+
return join(userDir(user2), "contacts.json");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/current-user.ts
|
|
55
|
+
var pointerPath = () => join2(usersDir(), ".current");
|
|
56
|
+
function identityDirs() {
|
|
57
|
+
const dir = usersDir();
|
|
58
|
+
if (!existsSync2(dir)) return [];
|
|
59
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((name2) => existsSync2(identityFile(name2)));
|
|
60
|
+
}
|
|
61
|
+
function readMeta(dir) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync2(identityFile(dir), "utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function resolveDir(sel) {
|
|
69
|
+
const dirs = identityDirs();
|
|
70
|
+
if (dirs.includes(sel)) return sel;
|
|
71
|
+
const low = sel.toLowerCase();
|
|
72
|
+
for (const d of dirs) {
|
|
73
|
+
const id2 = readMeta(d);
|
|
74
|
+
if (!id2) continue;
|
|
75
|
+
if (id2.handle === sel || id2.signPub === sel) return d;
|
|
76
|
+
if (id2.name && id2.name.toLowerCase() === low) return d;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function currentUser() {
|
|
81
|
+
const env = process.env.MESSENGER_USER?.trim();
|
|
82
|
+
if (env) return resolveDir(env) ?? env;
|
|
83
|
+
const pointer = pointerPath();
|
|
84
|
+
if (existsSync2(pointer)) {
|
|
85
|
+
const val = readFileSync2(pointer, "utf8").trim();
|
|
86
|
+
if (val) return resolveDir(val) ?? val;
|
|
87
|
+
}
|
|
88
|
+
const dirs = identityDirs();
|
|
89
|
+
if (dirs.length === 1) return dirs[0];
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/add-contact.ts
|
|
94
|
+
var user = currentUser();
|
|
95
|
+
if (!user) {
|
|
96
|
+
console.error("No identity on this device. Run `npm run init`, or set MESSENGER_USER.");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
var [name, signPub, boxPub] = process.argv.slice(2);
|
|
100
|
+
if (!name || !signPub || !boxPub) {
|
|
101
|
+
console.error("Usage: node src/add-contact.ts <Name> <signPub> <boxPub>");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
var isHex64 = (s2) => /^[0-9a-f]{64}$/i.test(s2);
|
|
105
|
+
if (!isHex64(signPub) || !isHex64(boxPub)) {
|
|
106
|
+
console.error("signPub and boxPub must each be 64 hex chars. Check what was pasted.");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
await initCrypto();
|
|
110
|
+
var idPath = identityFile(user);
|
|
111
|
+
if (!existsSync3(idPath)) {
|
|
112
|
+
console.error(`No identity yet for "${user}". Run: node src/init-identity.ts`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
var me = loadIdentity(idPath);
|
|
116
|
+
var contactsPath = contactsFile(user);
|
|
117
|
+
var book = existsSync3(contactsPath) ? JSON.parse(readFileSync3(contactsPath, "utf8")) : { me: me.signPub, contacts: [] };
|
|
118
|
+
book.me = me.signPub;
|
|
119
|
+
var id = name.toLowerCase();
|
|
120
|
+
book.contacts = book.contacts.filter((c) => c.id !== id && c.signPub !== signPub);
|
|
121
|
+
book.contacts.push({ id, name, signPub, boxPub });
|
|
122
|
+
writeFileSync2(contactsPath, JSON.stringify(book, null, 2) + "\n");
|
|
123
|
+
console.log(`Added "${name}". You can now message them: write ${name}: <your message>`);
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/check-inbox.ts
|
|
4
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
5
|
+
|
|
6
|
+
// src/crypto.ts
|
|
7
|
+
var s = null;
|
|
8
|
+
async function initCrypto() {
|
|
9
|
+
if (s) return;
|
|
10
|
+
const mod = await import("libsodium-wrappers");
|
|
11
|
+
const sodium = mod.default ?? mod;
|
|
12
|
+
await sodium.ready;
|
|
13
|
+
s = sodium;
|
|
14
|
+
}
|
|
15
|
+
var B64 = () => s.base64_variants.ORIGINAL;
|
|
16
|
+
function open(cipherB64, boxPubHex, boxSecHex) {
|
|
17
|
+
const pt = s.crypto_box_seal_open(
|
|
18
|
+
s.from_base64(cipherB64, B64()),
|
|
19
|
+
s.from_hex(boxPubHex),
|
|
20
|
+
s.from_hex(boxSecHex)
|
|
21
|
+
);
|
|
22
|
+
return s.to_string(pt);
|
|
23
|
+
}
|
|
24
|
+
function signDetached(message, signSecHex) {
|
|
25
|
+
const sig = s.crypto_sign_detached(s.from_string(message), s.from_hex(signSecHex));
|
|
26
|
+
return s.to_hex(sig);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/identity.ts
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
function loadIdentity(path) {
|
|
32
|
+
const id = JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
for (const k of ["boxPub", "boxSec", "signPub", "signSec"]) {
|
|
34
|
+
if (!id[k]) throw new Error(`identity at ${path} missing ${k}`);
|
|
35
|
+
}
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/contacts.ts
|
|
40
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
41
|
+
function loadContacts(path) {
|
|
42
|
+
const raw = readFileSync2(path, "utf8");
|
|
43
|
+
const book = JSON.parse(raw);
|
|
44
|
+
if (!book.me || !Array.isArray(book.contacts)) {
|
|
45
|
+
throw new Error(`Invalid contact book at ${path}: needs { me, contacts[] }`);
|
|
46
|
+
}
|
|
47
|
+
return book;
|
|
48
|
+
}
|
|
49
|
+
function displayNameByKey(book, signPub) {
|
|
50
|
+
const c = book.contacts.find((c2) => c2.signPub === signPub);
|
|
51
|
+
return c?.name ?? `${signPub.slice(0, 8)}\u2026`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/db.ts
|
|
55
|
+
import { DatabaseSync } from "node:sqlite";
|
|
56
|
+
function openMailbox(path) {
|
|
57
|
+
const db = new DatabaseSync(path);
|
|
58
|
+
try {
|
|
59
|
+
db.exec(`PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000;`);
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
recipient TEXT NOT NULL,
|
|
66
|
+
sender TEXT NOT NULL,
|
|
67
|
+
body TEXT NOT NULL,
|
|
68
|
+
tags TEXT,
|
|
69
|
+
created_at INTEGER NOT NULL,
|
|
70
|
+
fetched_at INTEGER,
|
|
71
|
+
read_at INTEGER,
|
|
72
|
+
in_reply_to TEXT
|
|
73
|
+
);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_recipient ON messages (recipient, created_at);
|
|
75
|
+
`);
|
|
76
|
+
return db;
|
|
77
|
+
}
|
|
78
|
+
function insertMessage(db, m) {
|
|
79
|
+
db.prepare(
|
|
80
|
+
`INSERT INTO messages
|
|
81
|
+
(id, recipient, sender, body, tags, created_at, fetched_at, read_at, in_reply_to)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
83
|
+
).run(
|
|
84
|
+
m.id,
|
|
85
|
+
m.recipient,
|
|
86
|
+
m.sender,
|
|
87
|
+
m.body,
|
|
88
|
+
m.tags,
|
|
89
|
+
m.created_at,
|
|
90
|
+
m.fetched_at,
|
|
91
|
+
m.read_at,
|
|
92
|
+
m.in_reply_to
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
function unreadFor(db, me) {
|
|
96
|
+
return db.prepare(
|
|
97
|
+
`SELECT * FROM messages
|
|
98
|
+
WHERE recipient = ? AND read_at IS NULL
|
|
99
|
+
ORDER BY created_at ASC`
|
|
100
|
+
).all(me);
|
|
101
|
+
}
|
|
102
|
+
function getMessage(db, id) {
|
|
103
|
+
return db.prepare(`SELECT * FROM messages WHERE id = ?`).get(id);
|
|
104
|
+
}
|
|
105
|
+
function markRead(db, id, now) {
|
|
106
|
+
db.prepare(`UPDATE messages SET read_at = ?, fetched_at = COALESCE(fetched_at, ?) WHERE id = ?`).run(
|
|
107
|
+
now,
|
|
108
|
+
now,
|
|
109
|
+
id
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/canonical.ts
|
|
114
|
+
var MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
115
|
+
function canonical(method, path, timestamp, body) {
|
|
116
|
+
return `${method.toUpperCase()}
|
|
117
|
+
${path}
|
|
118
|
+
${timestamp}
|
|
119
|
+
${body}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/auth.ts
|
|
123
|
+
function makeAuthHeaders(signPubHex, signSecHex, method, path, body, now) {
|
|
124
|
+
const sig = signDetached(canonical(method, path, now, body), signSecHex);
|
|
125
|
+
return {
|
|
126
|
+
"x-pubkey": signPubHex,
|
|
127
|
+
"x-timestamp": String(now),
|
|
128
|
+
"x-signature": sig
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/mailbox-client.ts
|
|
133
|
+
function createMailboxClient(baseUrl, identity, now) {
|
|
134
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
135
|
+
function headers(method, path, body) {
|
|
136
|
+
return makeAuthHeaders(
|
|
137
|
+
identity.signPub,
|
|
138
|
+
identity.signSec,
|
|
139
|
+
method,
|
|
140
|
+
path,
|
|
141
|
+
body,
|
|
142
|
+
now()
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
async function fail(res, what) {
|
|
146
|
+
let detail = "";
|
|
147
|
+
try {
|
|
148
|
+
detail = JSON.stringify(await res.json());
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`${what} failed: ${res.status} ${detail}`);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
async send(msg) {
|
|
155
|
+
const body = JSON.stringify(msg);
|
|
156
|
+
const res = await fetch(`${base}/messages`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "content-type": "application/json", ...headers("POST", "/messages", body) },
|
|
159
|
+
body
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) await fail(res, "send");
|
|
162
|
+
},
|
|
163
|
+
async summary() {
|
|
164
|
+
const res = await fetch(`${base}/mailbox`, {
|
|
165
|
+
headers: headers("GET", "/mailbox", "")
|
|
166
|
+
});
|
|
167
|
+
if (!res.ok) await fail(res, "summary");
|
|
168
|
+
return await res.json();
|
|
169
|
+
},
|
|
170
|
+
async drain() {
|
|
171
|
+
const res = await fetch(`${base}/messages`, {
|
|
172
|
+
headers: headers("GET", "/messages", "")
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) await fail(res, "drain");
|
|
175
|
+
const data = await res.json();
|
|
176
|
+
return data.messages;
|
|
177
|
+
},
|
|
178
|
+
async registerHandle(handle) {
|
|
179
|
+
const body = JSON.stringify({ handle, signPub: identity.signPub, boxPub: identity.boxPub });
|
|
180
|
+
const res = await fetch(`${base}/register`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "content-type": "application/json", ...headers("POST", "/register", body) },
|
|
183
|
+
body
|
|
184
|
+
});
|
|
185
|
+
if (res.status === 409) return "taken";
|
|
186
|
+
if (!res.ok) await fail(res, "register");
|
|
187
|
+
return "ok";
|
|
188
|
+
},
|
|
189
|
+
async resolveHandle(handle) {
|
|
190
|
+
const res = await fetch(`${base}/resolve/${encodeURIComponent(handle)}`);
|
|
191
|
+
if (res.status === 404) return null;
|
|
192
|
+
if (!res.ok) await fail(res, "resolve");
|
|
193
|
+
return await res.json();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/core-net.ts
|
|
199
|
+
async function sync(ctx) {
|
|
200
|
+
const blobs = await ctx.client.drain();
|
|
201
|
+
let added = 0;
|
|
202
|
+
for (const b of blobs) {
|
|
203
|
+
if (getMessage(ctx.cache, b.id)) continue;
|
|
204
|
+
let body;
|
|
205
|
+
try {
|
|
206
|
+
body = open(b.body, ctx.me.boxPub, ctx.me.boxSec);
|
|
207
|
+
} catch {
|
|
208
|
+
body = "[unable to decrypt \u2014 not sealed to this identity]";
|
|
209
|
+
}
|
|
210
|
+
const row = {
|
|
211
|
+
id: b.id,
|
|
212
|
+
recipient: ctx.me.signPub,
|
|
213
|
+
sender: b.sender,
|
|
214
|
+
body,
|
|
215
|
+
tags: b.tags,
|
|
216
|
+
created_at: b.created_at,
|
|
217
|
+
fetched_at: ctx.now(),
|
|
218
|
+
read_at: null,
|
|
219
|
+
in_reply_to: b.in_reply_to
|
|
220
|
+
};
|
|
221
|
+
insertMessage(ctx.cache, row);
|
|
222
|
+
added++;
|
|
223
|
+
}
|
|
224
|
+
return added;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/current-user.ts
|
|
228
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, writeFileSync } from "node:fs";
|
|
229
|
+
import { join as join2 } from "node:path";
|
|
230
|
+
|
|
231
|
+
// src/paths.ts
|
|
232
|
+
import { existsSync } from "node:fs";
|
|
233
|
+
import { homedir } from "node:os";
|
|
234
|
+
import { join, resolve as resolve2 } from "node:path";
|
|
235
|
+
var codeRoot = resolve2(import.meta.dirname, "..");
|
|
236
|
+
function dataHome() {
|
|
237
|
+
const env = process.env.MESSENGER_HOME?.trim();
|
|
238
|
+
if (env) return env;
|
|
239
|
+
if (existsSync(join(codeRoot, "users"))) return codeRoot;
|
|
240
|
+
return join(homedir(), ".cli-chat");
|
|
241
|
+
}
|
|
242
|
+
function usersDir() {
|
|
243
|
+
return join(dataHome(), "users");
|
|
244
|
+
}
|
|
245
|
+
function userDir(user2) {
|
|
246
|
+
return join(usersDir(), user2);
|
|
247
|
+
}
|
|
248
|
+
function identityFile(user2) {
|
|
249
|
+
return join(userDir(user2), "identity.json");
|
|
250
|
+
}
|
|
251
|
+
function contactsFile(user2) {
|
|
252
|
+
return join(userDir(user2), "contacts.json");
|
|
253
|
+
}
|
|
254
|
+
function inboxFile(user2) {
|
|
255
|
+
return join(userDir(user2), "inbox.db");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/current-user.ts
|
|
259
|
+
var pointerPath = () => join2(usersDir(), ".current");
|
|
260
|
+
function identityDirs() {
|
|
261
|
+
const dir = usersDir();
|
|
262
|
+
if (!existsSync2(dir)) return [];
|
|
263
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((name) => existsSync2(identityFile(name)));
|
|
264
|
+
}
|
|
265
|
+
function readMeta(dir) {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(readFileSync3(identityFile(dir), "utf8"));
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function resolveDir(sel) {
|
|
273
|
+
const dirs = identityDirs();
|
|
274
|
+
if (dirs.includes(sel)) return sel;
|
|
275
|
+
const low = sel.toLowerCase();
|
|
276
|
+
for (const d of dirs) {
|
|
277
|
+
const id = readMeta(d);
|
|
278
|
+
if (!id) continue;
|
|
279
|
+
if (id.handle === sel || id.signPub === sel) return d;
|
|
280
|
+
if (id.name && id.name.toLowerCase() === low) return d;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
function currentUser() {
|
|
285
|
+
const env = process.env.MESSENGER_USER?.trim();
|
|
286
|
+
if (env) return resolveDir(env) ?? env;
|
|
287
|
+
const pointer = pointerPath();
|
|
288
|
+
if (existsSync2(pointer)) {
|
|
289
|
+
const val = readFileSync3(pointer, "utf8").trim();
|
|
290
|
+
if (val) return resolveDir(val) ?? val;
|
|
291
|
+
}
|
|
292
|
+
const dirs = identityDirs();
|
|
293
|
+
if (dirs.length === 1) return dirs[0];
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/config.ts
|
|
298
|
+
var DEFAULT_MAILBOX_URL = "https://mailbox.cli-chat-mcp.workers.dev";
|
|
299
|
+
function resolveMailboxUrl() {
|
|
300
|
+
return process.env.MESSENGER_MAILBOX_URL?.trim() || DEFAULT_MAILBOX_URL;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/check-inbox.ts
|
|
304
|
+
var user = currentUser();
|
|
305
|
+
var hookEventName = "SessionStart";
|
|
306
|
+
try {
|
|
307
|
+
const payload = JSON.parse(readFileSync4(0, "utf8"));
|
|
308
|
+
if (payload?.hook_event_name) hookEventName = payload.hook_event_name;
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
var setUp = false;
|
|
312
|
+
if (user) {
|
|
313
|
+
try {
|
|
314
|
+
loadIdentity(identityFile(user));
|
|
315
|
+
setUp = true;
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!setUp) {
|
|
320
|
+
if (hookEventName === "SessionStart") {
|
|
321
|
+
const text = `You don't have a cli-chat account on this device yet \u2014 I'll create one automatically the first time you message someone. Say "set me up as Sam" if you'd like to choose your name (and get your 6-character code) now.`;
|
|
322
|
+
console.log(
|
|
323
|
+
JSON.stringify({
|
|
324
|
+
systemMessage: text,
|
|
325
|
+
hookSpecificOutput: {
|
|
326
|
+
hookEventName,
|
|
327
|
+
additionalContext: text + " (No identity exists on this device. The other messaging tools return no_account until then; when that happens, or if the user asks to get set up, call create_account automatically \u2014 defaulting the name to the OS login unless the user gave one \u2014 then continue.)"
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
process.exit(0);
|
|
333
|
+
}
|
|
334
|
+
var url = resolveMailboxUrl();
|
|
335
|
+
try {
|
|
336
|
+
await initCrypto();
|
|
337
|
+
const me = loadIdentity(identityFile(user));
|
|
338
|
+
const book = loadContacts(contactsFile(user));
|
|
339
|
+
const cache = openMailbox(inboxFile(user));
|
|
340
|
+
const now = () => Date.now();
|
|
341
|
+
const ctx = {
|
|
342
|
+
me,
|
|
343
|
+
book,
|
|
344
|
+
cache,
|
|
345
|
+
client: createMailboxClient(url, me, now),
|
|
346
|
+
now
|
|
347
|
+
};
|
|
348
|
+
const whoami = `You are acting as the messenger for ${me.name ?? user}` + (me.handle ? ` (their code is ${me.handle})` : "") + ".";
|
|
349
|
+
await sync(ctx);
|
|
350
|
+
const unread = unreadFor(cache, me.signPub);
|
|
351
|
+
if (unread.length === 0) {
|
|
352
|
+
if (hookEventName === "SessionStart") {
|
|
353
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: whoami } }));
|
|
354
|
+
}
|
|
355
|
+
process.exit(0);
|
|
356
|
+
}
|
|
357
|
+
const senders = [...new Set(unread.map((m) => displayNameByKey(book, m.sender)))];
|
|
358
|
+
const noun = `${unread.length} new message${unread.length > 1 ? "s" : ""}`;
|
|
359
|
+
let summary = `\u{1F4EC} ${noun} from ${senders.join(", ")} \u2014 want me to read ${unread.length > 1 ? "them" : "it"}?`;
|
|
360
|
+
if (hookEventName === "SessionStart") {
|
|
361
|
+
summary += `
|
|
362
|
+
\u21B3 Tip: write "watch" in a new terminal for auto-reading new messages into our chat.`;
|
|
363
|
+
}
|
|
364
|
+
const bodies = [];
|
|
365
|
+
for (const m of unread) {
|
|
366
|
+
bodies.push(`
|
|
367
|
+
From ${displayNameByKey(book, m.sender)} (id ${m.id}):
|
|
368
|
+
${m.body}`);
|
|
369
|
+
markRead(cache, m.id, now());
|
|
370
|
+
}
|
|
371
|
+
console.log(
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
systemMessage: summary,
|
|
374
|
+
hookSpecificOutput: {
|
|
375
|
+
hookEventName,
|
|
376
|
+
additionalContext: whoami + `
|
|
377
|
+
|
|
378
|
+
[inbox] ${noun} waiting (already marked read). The user has ONLY been shown a count, NOT the contents. Do NOT print the bodies below unless the user asks to hear them (e.g. "read it", "go on", "yes"); then print the relevant message in full. Do NOT call messages_available/read_message for these \u2014 use the bodies here. To reply, use draft_reply with the id, asking for any missing fact first. The on-open summary already shows a "say watch" tip, so don't repeat it; if the user says "watch", call the \`watch\` tool and auto-read new mail in full as it arrives.
|
|
379
|
+
` + bodies.join("\n")
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
} catch {
|
|
384
|
+
process.exit(0);
|
|
385
|
+
}
|