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