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.
@@ -0,0 +1,819 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server-net.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { mkdirSync, writeFileSync as writeFileSync3 } from "node:fs";
8
+ import { randomBytes } from "node:crypto";
9
+ import { userInfo } from "node:os";
10
+
11
+ // src/crypto.ts
12
+ var s = null;
13
+ async function initCrypto() {
14
+ if (s) return;
15
+ const mod = await import("libsodium-wrappers");
16
+ const sodium = mod.default ?? mod;
17
+ await sodium.ready;
18
+ s = sodium;
19
+ }
20
+ var B64 = () => s.base64_variants.ORIGINAL;
21
+ function generateIdentity() {
22
+ const box = s.crypto_box_keypair();
23
+ const sign = s.crypto_sign_keypair();
24
+ return {
25
+ boxPub: s.to_hex(box.publicKey),
26
+ boxSec: s.to_hex(box.privateKey),
27
+ signPub: s.to_hex(sign.publicKey),
28
+ signSec: s.to_hex(sign.privateKey)
29
+ };
30
+ }
31
+ function seal(plaintext, recipientBoxPubHex) {
32
+ const ct = s.crypto_box_seal(
33
+ s.from_string(plaintext),
34
+ s.from_hex(recipientBoxPubHex)
35
+ );
36
+ return s.to_base64(ct, B64());
37
+ }
38
+ function open(cipherB64, boxPubHex, boxSecHex) {
39
+ const pt = s.crypto_box_seal_open(
40
+ s.from_base64(cipherB64, B64()),
41
+ s.from_hex(boxPubHex),
42
+ s.from_hex(boxSecHex)
43
+ );
44
+ return s.to_string(pt);
45
+ }
46
+ function signDetached(message, signSecHex) {
47
+ const sig = s.crypto_sign_detached(s.from_string(message), s.from_hex(signSecHex));
48
+ return s.to_hex(sig);
49
+ }
50
+
51
+ // src/identity.ts
52
+ import { readFileSync } from "node:fs";
53
+ function loadIdentity(path) {
54
+ const id = JSON.parse(readFileSync(path, "utf8"));
55
+ for (const k of ["boxPub", "boxSec", "signPub", "signSec"]) {
56
+ if (!id[k]) throw new Error(`identity at ${path} missing ${k}`);
57
+ }
58
+ return id;
59
+ }
60
+
61
+ // src/contacts.ts
62
+ import { readFileSync as readFileSync2 } from "node:fs";
63
+ function loadContacts(path) {
64
+ const raw = readFileSync2(path, "utf8");
65
+ const book = JSON.parse(raw);
66
+ if (!book.me || !Array.isArray(book.contacts)) {
67
+ throw new Error(`Invalid contact book at ${path}: needs { me, contacts[] }`);
68
+ }
69
+ return book;
70
+ }
71
+ function resolve(book, query) {
72
+ const q = query.trim().toLowerCase();
73
+ const namesOf = (c) => [c.name, ...c.aliases ?? []].map((n) => n.toLowerCase());
74
+ const exact = book.contacts.filter((c) => namesOf(c).includes(q));
75
+ if (exact.length === 1) return { status: "resolved", contact: exact[0] };
76
+ if (exact.length > 1) return { status: "ambiguous", query, candidates: exact };
77
+ if (!q) return { status: "none", query };
78
+ const fuzzy = book.contacts.filter((c) => namesOf(c).some((n) => n.includes(q)));
79
+ if (fuzzy.length === 1) return { status: "resolved", contact: fuzzy[0] };
80
+ if (fuzzy.length > 1) return { status: "ambiguous", query, candidates: fuzzy };
81
+ return { status: "none", query };
82
+ }
83
+ function displayNameByKey(book, signPub) {
84
+ const c = book.contacts.find((c2) => c2.signPub === signPub);
85
+ return c?.name ?? `${signPub.slice(0, 8)}\u2026`;
86
+ }
87
+ function contactByKey(book, signPub) {
88
+ return book.contacts.find((c) => c.signPub === signPub);
89
+ }
90
+
91
+ // src/db.ts
92
+ import { DatabaseSync } from "node:sqlite";
93
+ function openMailbox(path) {
94
+ const db = new DatabaseSync(path);
95
+ try {
96
+ db.exec(`PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000;`);
97
+ } catch {
98
+ }
99
+ db.exec(`
100
+ CREATE TABLE IF NOT EXISTS messages (
101
+ id TEXT PRIMARY KEY,
102
+ recipient TEXT NOT NULL,
103
+ sender TEXT NOT NULL,
104
+ body TEXT NOT NULL,
105
+ tags TEXT,
106
+ created_at INTEGER NOT NULL,
107
+ fetched_at INTEGER,
108
+ read_at INTEGER,
109
+ in_reply_to TEXT
110
+ );
111
+ CREATE INDEX IF NOT EXISTS idx_recipient ON messages (recipient, created_at);
112
+ `);
113
+ return db;
114
+ }
115
+ function insertMessage(db, m) {
116
+ db.prepare(
117
+ `INSERT INTO messages
118
+ (id, recipient, sender, body, tags, created_at, fetched_at, read_at, in_reply_to)
119
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
120
+ ).run(
121
+ m.id,
122
+ m.recipient,
123
+ m.sender,
124
+ m.body,
125
+ m.tags,
126
+ m.created_at,
127
+ m.fetched_at,
128
+ m.read_at,
129
+ m.in_reply_to
130
+ );
131
+ }
132
+ function unreadFor(db, me) {
133
+ return db.prepare(
134
+ `SELECT * FROM messages
135
+ WHERE recipient = ? AND read_at IS NULL
136
+ ORDER BY created_at ASC`
137
+ ).all(me);
138
+ }
139
+ function getMessage(db, id) {
140
+ return db.prepare(`SELECT * FROM messages WHERE id = ?`).get(id);
141
+ }
142
+ function markRead(db, id, now2) {
143
+ db.prepare(`UPDATE messages SET read_at = ?, fetched_at = COALESCE(fetched_at, ?) WHERE id = ?`).run(
144
+ now2,
145
+ now2,
146
+ id
147
+ );
148
+ }
149
+
150
+ // src/canonical.ts
151
+ var MAX_SKEW_MS = 5 * 60 * 1e3;
152
+ function canonical(method, path, timestamp, body) {
153
+ return `${method.toUpperCase()}
154
+ ${path}
155
+ ${timestamp}
156
+ ${body}`;
157
+ }
158
+
159
+ // src/auth.ts
160
+ function makeAuthHeaders(signPubHex, signSecHex, method, path, body, now2) {
161
+ const sig = signDetached(canonical(method, path, now2, body), signSecHex);
162
+ return {
163
+ "x-pubkey": signPubHex,
164
+ "x-timestamp": String(now2),
165
+ "x-signature": sig
166
+ };
167
+ }
168
+
169
+ // src/mailbox-client.ts
170
+ function createMailboxClient(baseUrl, identity, now2) {
171
+ const base = baseUrl.replace(/\/$/, "");
172
+ function headers(method, path, body) {
173
+ return makeAuthHeaders(
174
+ identity.signPub,
175
+ identity.signSec,
176
+ method,
177
+ path,
178
+ body,
179
+ now2()
180
+ );
181
+ }
182
+ async function fail(res, what) {
183
+ let detail = "";
184
+ try {
185
+ detail = JSON.stringify(await res.json());
186
+ } catch {
187
+ }
188
+ throw new Error(`${what} failed: ${res.status} ${detail}`);
189
+ }
190
+ return {
191
+ async send(msg) {
192
+ const body = JSON.stringify(msg);
193
+ const res = await fetch(`${base}/messages`, {
194
+ method: "POST",
195
+ headers: { "content-type": "application/json", ...headers("POST", "/messages", body) },
196
+ body
197
+ });
198
+ if (!res.ok) await fail(res, "send");
199
+ },
200
+ async summary() {
201
+ const res = await fetch(`${base}/mailbox`, {
202
+ headers: headers("GET", "/mailbox", "")
203
+ });
204
+ if (!res.ok) await fail(res, "summary");
205
+ return await res.json();
206
+ },
207
+ async drain() {
208
+ const res = await fetch(`${base}/messages`, {
209
+ headers: headers("GET", "/messages", "")
210
+ });
211
+ if (!res.ok) await fail(res, "drain");
212
+ const data = await res.json();
213
+ return data.messages;
214
+ },
215
+ async registerHandle(handle) {
216
+ const body = JSON.stringify({ handle, signPub: identity.signPub, boxPub: identity.boxPub });
217
+ const res = await fetch(`${base}/register`, {
218
+ method: "POST",
219
+ headers: { "content-type": "application/json", ...headers("POST", "/register", body) },
220
+ body
221
+ });
222
+ if (res.status === 409) return "taken";
223
+ if (!res.ok) await fail(res, "register");
224
+ return "ok";
225
+ },
226
+ async resolveHandle(handle) {
227
+ const res = await fetch(`${base}/resolve/${encodeURIComponent(handle)}`);
228
+ if (res.status === 404) return null;
229
+ if (!res.ok) await fail(res, "resolve");
230
+ return await res.json();
231
+ }
232
+ };
233
+ }
234
+
235
+ // src/key-code.ts
236
+ var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
237
+ var KEY_BYTES = 64;
238
+ var WIDTH = 86;
239
+ function hexToBytes(hex) {
240
+ const out = new Uint8Array(hex.length / 2);
241
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
242
+ return out;
243
+ }
244
+ function bytesToHex(bytes) {
245
+ let s2 = "";
246
+ for (const b of bytes) s2 += b.toString(16).padStart(2, "0");
247
+ return s2;
248
+ }
249
+ function bytesToBase62(bytes) {
250
+ let n = 0n;
251
+ for (const b of bytes) n = n << 8n | BigInt(b);
252
+ let out = "";
253
+ while (n > 0n) {
254
+ out = ALPHABET[Number(n % 62n)] + out;
255
+ n /= 62n;
256
+ }
257
+ return out.padStart(WIDTH, ALPHABET[0]);
258
+ }
259
+ function base62ToBytes(str, len) {
260
+ let n = 0n;
261
+ for (const ch of str) {
262
+ const v = ALPHABET.indexOf(ch);
263
+ if (v < 0) return null;
264
+ n = n * 62n + BigInt(v);
265
+ }
266
+ const bytes = new Uint8Array(len);
267
+ for (let i = len - 1; i >= 0; i--) {
268
+ bytes[i] = Number(n & 0xffn);
269
+ n >>= 8n;
270
+ }
271
+ return n === 0n ? bytes : null;
272
+ }
273
+ function encodeKey(signPubHex, boxPubHex) {
274
+ const blob = new Uint8Array(KEY_BYTES);
275
+ blob.set(hexToBytes(signPubHex), 0);
276
+ blob.set(hexToBytes(boxPubHex), 32);
277
+ return bytesToBase62(blob);
278
+ }
279
+ var HANDLE_LEN = 6;
280
+ function isHandle(s2) {
281
+ return new RegExp(`^[0-9A-Za-z]{${HANDLE_LEN}}$`).test(s2.trim());
282
+ }
283
+ function randomHandle(randomBytes2) {
284
+ let h = "";
285
+ for (let i = 0; i < HANDLE_LEN; i++) h += ALPHABET[randomBytes2[i] % 62];
286
+ return h;
287
+ }
288
+ function parseKey(raw) {
289
+ const s2 = raw.trim();
290
+ if (s2.length !== WIDTH) return null;
291
+ const bytes = base62ToBytes(s2, KEY_BYTES);
292
+ if (!bytes) return null;
293
+ return {
294
+ signPub: bytesToHex(bytes.slice(0, 32)),
295
+ boxPub: bytesToHex(bytes.slice(32, 64))
296
+ };
297
+ }
298
+
299
+ // src/current-user.ts
300
+ import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, writeFileSync } from "node:fs";
301
+ import { join as join2 } from "node:path";
302
+
303
+ // src/paths.ts
304
+ import { existsSync } from "node:fs";
305
+ import { homedir } from "node:os";
306
+ import { join, resolve as resolve2 } from "node:path";
307
+ var codeRoot = resolve2(import.meta.dirname, "..");
308
+ function dataHome() {
309
+ const env = process.env.MESSENGER_HOME?.trim();
310
+ if (env) return env;
311
+ if (existsSync(join(codeRoot, "users"))) return codeRoot;
312
+ return join(homedir(), ".cli-chat");
313
+ }
314
+ function usersDir() {
315
+ return join(dataHome(), "users");
316
+ }
317
+ function userDir(user) {
318
+ return join(usersDir(), user);
319
+ }
320
+ function identityFile(user) {
321
+ return join(userDir(user), "identity.json");
322
+ }
323
+ function contactsFile(user) {
324
+ return join(userDir(user), "contacts.json");
325
+ }
326
+ function inboxFile(user) {
327
+ return join(userDir(user), "inbox.db");
328
+ }
329
+
330
+ // src/current-user.ts
331
+ var pointerPath = () => join2(usersDir(), ".current");
332
+ function identityDirs() {
333
+ const dir = usersDir();
334
+ if (!existsSync2(dir)) return [];
335
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((name) => existsSync2(identityFile(name)));
336
+ }
337
+ function readMeta(dir) {
338
+ try {
339
+ return JSON.parse(readFileSync3(identityFile(dir), "utf8"));
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+ function resolveDir(sel) {
345
+ const dirs = identityDirs();
346
+ if (dirs.includes(sel)) return sel;
347
+ const low = sel.toLowerCase();
348
+ for (const d of dirs) {
349
+ const id = readMeta(d);
350
+ if (!id) continue;
351
+ if (id.handle === sel || id.signPub === sel) return d;
352
+ if (id.name && id.name.toLowerCase() === low) return d;
353
+ }
354
+ return null;
355
+ }
356
+ function currentUser() {
357
+ const env = process.env.MESSENGER_USER?.trim();
358
+ if (env) return resolveDir(env) ?? env;
359
+ const pointer = pointerPath();
360
+ if (existsSync2(pointer)) {
361
+ const val = readFileSync3(pointer, "utf8").trim();
362
+ if (val) return resolveDir(val) ?? val;
363
+ }
364
+ const dirs = identityDirs();
365
+ if (dirs.length === 1) return dirs[0];
366
+ return null;
367
+ }
368
+ function setCurrentUser(key) {
369
+ writeFileSync(pointerPath(), key + "\n");
370
+ }
371
+
372
+ // src/config.ts
373
+ var DEFAULT_MAILBOX_URL = "https://mailbox.cli-chat-mcp.workers.dev";
374
+ function resolveMailboxUrl() {
375
+ return process.env.MESSENGER_MAILBOX_URL?.trim() || DEFAULT_MAILBOX_URL;
376
+ }
377
+
378
+ // src/core-net.ts
379
+ import { randomUUID } from "node:crypto";
380
+ import { writeFileSync as writeFileSync2 } from "node:fs";
381
+ function rememberContact(ctx, c) {
382
+ const id = c.name.toLowerCase();
383
+ ctx.book.contacts = ctx.book.contacts.filter(
384
+ (x) => x.id !== id && x.signPub !== c.signPub
385
+ );
386
+ ctx.book.contacts.push({ id, name: c.name, signPub: c.signPub, boxPub: c.boxPub });
387
+ if (ctx.contactsPath) {
388
+ writeFileSync2(ctx.contactsPath, JSON.stringify(ctx.book, null, 2) + "\n");
389
+ }
390
+ }
391
+ async function resolveCode(ctx, code) {
392
+ const full = parseKey(code);
393
+ if (full) return full;
394
+ if (isHandle(code)) return await ctx.client.resolveHandle(code.trim());
395
+ return null;
396
+ }
397
+ async function addContact(ctx, args) {
398
+ if (!parseKey(args.key) && !isHandle(args.key)) return { ok: false, reason: "bad_key" };
399
+ const keys = await resolveCode(ctx, args.key);
400
+ if (!keys) return { ok: false, reason: "not_found" };
401
+ rememberContact(ctx, { name: args.name, signPub: keys.signPub, boxPub: keys.boxPub });
402
+ return { ok: true, name: args.name };
403
+ }
404
+ async function sync(ctx) {
405
+ const blobs = await ctx.client.drain();
406
+ let added = 0;
407
+ for (const b of blobs) {
408
+ if (getMessage(ctx.cache, b.id)) continue;
409
+ let body;
410
+ try {
411
+ body = open(b.body, ctx.me.boxPub, ctx.me.boxSec);
412
+ } catch {
413
+ body = "[unable to decrypt \u2014 not sealed to this identity]";
414
+ }
415
+ const row = {
416
+ id: b.id,
417
+ recipient: ctx.me.signPub,
418
+ sender: b.sender,
419
+ body,
420
+ tags: b.tags,
421
+ created_at: b.created_at,
422
+ fetched_at: ctx.now(),
423
+ read_at: null,
424
+ in_reply_to: b.in_reply_to
425
+ };
426
+ insertMessage(ctx.cache, row);
427
+ added++;
428
+ }
429
+ return added;
430
+ }
431
+ async function sendSealed(ctx, to, body, in_reply_to) {
432
+ const wire = {
433
+ id: randomUUID(),
434
+ recipient: to.signPub,
435
+ sender: ctx.me.signPub,
436
+ body: seal(body, to.boxPub),
437
+ tags: null,
438
+ created_at: ctx.now(),
439
+ in_reply_to
440
+ };
441
+ await ctx.client.send(wire);
442
+ return wire.id;
443
+ }
444
+ async function sendMessage(ctx, args) {
445
+ const r = resolve(ctx.book, args.to);
446
+ if (r.status === "none") {
447
+ if (args.key) {
448
+ if (!parseKey(args.key) && !isHandle(args.key))
449
+ return { ok: false, reason: "bad_key", query: args.to };
450
+ const keys = await resolveCode(ctx, args.key);
451
+ if (!keys) return { ok: false, reason: "bad_key", query: args.to };
452
+ rememberContact(ctx, { name: args.to, signPub: keys.signPub, boxPub: keys.boxPub });
453
+ const id2 = await sendSealed(ctx, { name: args.to, ...keys }, args.body, null);
454
+ return { ok: true, id: id2, to: { name: args.to, signPub: keys.signPub }, saved: true };
455
+ }
456
+ return { ok: false, reason: "no_contact", query: args.to };
457
+ }
458
+ if (r.status === "ambiguous")
459
+ return {
460
+ ok: false,
461
+ reason: "ambiguous",
462
+ query: args.to,
463
+ candidates: r.candidates.map((c2) => c2.name)
464
+ };
465
+ const c = r.contact;
466
+ if (!c.signPub || !c.boxPub)
467
+ return { ok: false, reason: "no_keys", query: args.to };
468
+ const id = await sendSealed(ctx, { name: c.name, signPub: c.signPub, boxPub: c.boxPub }, args.body, null);
469
+ return { ok: true, id, to: { name: c.name, signPub: c.signPub } };
470
+ }
471
+ async function messagesAvailable(ctx) {
472
+ await sync(ctx);
473
+ const rows = unreadFor(ctx.cache, ctx.me.signPub);
474
+ return {
475
+ count: rows.length,
476
+ messages: rows.map((m) => ({
477
+ id: m.id,
478
+ from: displayNameByKey(ctx.book, m.sender),
479
+ preview: m.body.length > 200 ? m.body.slice(0, 197) + "..." : m.body,
480
+ at: m.created_at
481
+ }))
482
+ };
483
+ }
484
+ async function readMessage(ctx, args) {
485
+ await sync(ctx);
486
+ let row;
487
+ if (args.id) {
488
+ row = getMessage(ctx.cache, args.id);
489
+ if (!row || row.recipient !== ctx.me.signPub) return { ok: false, reason: "not_found" };
490
+ } else {
491
+ row = unreadFor(ctx.cache, ctx.me.signPub)[0];
492
+ if (!row) return { ok: false, reason: "empty" };
493
+ }
494
+ markRead(ctx.cache, row.id, ctx.now());
495
+ return {
496
+ ok: true,
497
+ id: row.id,
498
+ from: displayNameByKey(ctx.book, row.sender),
499
+ body: row.body,
500
+ at: row.created_at,
501
+ in_reply_to: row.in_reply_to
502
+ };
503
+ }
504
+ async function draftReply(ctx, args) {
505
+ const original = getMessage(ctx.cache, args.in_reply_to);
506
+ if (!original) return { ok: false, reason: "not_found" };
507
+ const c = contactByKey(ctx.book, original.sender);
508
+ if (!c || !c.signPub || !c.boxPub) return { ok: false, reason: "no_keys" };
509
+ const id = await sendSealed(ctx, { name: c.name, signPub: c.signPub, boxPub: c.boxPub }, args.body, original.id);
510
+ return { ok: true, id, to: { name: c.name, signPub: c.signPub } };
511
+ }
512
+
513
+ // src/server-net.ts
514
+ var mailboxUrl = resolveMailboxUrl();
515
+ var now = () => Date.now();
516
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
517
+ var WATCH_MS = Number(process.env.MESSENGER_WATCH_MS ?? 5e4);
518
+ await initCrypto();
519
+ function buildSession(user) {
520
+ const contactsPath = contactsFile(user);
521
+ const me = loadIdentity(identityFile(user));
522
+ const book = loadContacts(contactsPath);
523
+ const cache = openMailbox(inboxFile(user));
524
+ const ctx = {
525
+ me,
526
+ book,
527
+ cache,
528
+ client: createMailboxClient(mailboxUrl, me, now),
529
+ now,
530
+ contactsPath
531
+ };
532
+ return { user, me, book, cache, ctx };
533
+ }
534
+ var S = null;
535
+ var existing = currentUser();
536
+ if (existing) {
537
+ try {
538
+ S = buildSession(existing);
539
+ } catch (e) {
540
+ console.error(`Found user "${existing}" but couldn't load identity: ${e.message}`);
541
+ }
542
+ }
543
+ async function claimHandle(client) {
544
+ for (let i = 0; i < 8; i++) {
545
+ const candidate = randomHandle(randomBytes(8));
546
+ if (await client.registerHandle(candidate) === "ok") return candidate;
547
+ }
548
+ throw new Error("couldn't find a free handle after several tries");
549
+ }
550
+ var INSTRUCTIONS = `You are the user's personal CLI messenger, backed by the cli-chat MCP server.
551
+
552
+ GETTING STARTED: a tool returning \`no_account\` means this device has no account
553
+ yet. Just fix it automatically \u2014 call \`create_account\` (pass name=their name if
554
+ the user gave one, otherwise let it default to the OS login name), then retry
555
+ whatever they were doing. You don't need to ask permission for this. If the user
556
+ explicitly asks to be set up ("set me up as Sam"), do the same. After creating,
557
+ report the new 6-char code in one line so they can share it. If they already have
558
+ an account, \`create_account\` just returns their existing code.
559
+
560
+ AT THE START OF A SESSION: a startup hook may inject an inbox notice telling you
561
+ how many messages are waiting and who they're from \u2014 but NOT the bodies (those
562
+ are given to you privately, hidden from the user). Do NOT print the bodies. Just
563
+ tell the user how many are waiting and from whom, then ASK if they want them read
564
+ ("1 new message from Sam \u2014 want me to read it?"). Only when the user says yes
565
+ (e.g. "read it", "go on", "yes") do you print the message in full. Also, once per
566
+ session, you may add a short suggestion that they can have you watch for incoming
567
+ messages live with the \`watch\` tool. If no hook ran, call \`messages_available\`
568
+ to get the count and offer the same way.
569
+
570
+ REPLYING: when the user's input answers a message they've had read out (e.g.
571
+ "reply not much", "tell him yes", or just "not much"), send it immediately with
572
+ \`draft_reply\` (in_reply_to = that message's id) and confirm in one line. Only
573
+ pause to ask if you're missing a fact you can't infer.
574
+
575
+ REPLYING: draft a reply and send it with \`draft_reply\` (in_reply_to = the
576
+ message id). Don't ask "want me to send this?" \u2014 just send, then say what you
577
+ sent. The ONE exception: if the reply needs a fact you don't have (the user's
578
+ availability, a yes/no decision, a preference), ask that one question first, then
579
+ send once they answer. Never invent the answer.
580
+
581
+ SENDING by name: when the user says "write <name>: ..." call \`send_message\`
582
+ right away, then report what you sent. The resolver already matches partial names
583
+ (so "Niels" finds a saved "Niels - bankdata"). It only returns no_contact when
584
+ nothing matches at all \u2014 then offer to add them by code. It returns ambiguous
585
+ with a list of candidates when several match \u2014 name them and ask which; don't
586
+ guess.
587
+
588
+ MESSAGING SOMEONE NEW: people share a short 6-character code. When the user says
589
+ "write Sam at AbC123: hey", call \`send_message\` with to="Sam", body=the message,
590
+ key="AbC123". It saves them, so next time just "write Sam".
591
+
592
+ WATCHING: when the user says "watch" (or "watch for"/"wait for"/"listen for"/
593
+ "keep an eye out for" messages), call \`watch\`. It blocks up to ~50s and returns
594
+ any new mail (already marked read). After it returns \u2014 whether it found messages
595
+ or was idle \u2014 call it AGAIN to keep watching, and keep looping until the user
596
+ tells you to stop. On an idle return, re-call SILENTLY: print nothing to the user
597
+ (no "still watching" heartbeat). In watch mode the user has opted into hands-free
598
+ chat, so when mail arrives READ IT OUT IN FULL automatically (sender + body,
599
+ straight into the chat) and offer to reply \u2014 do NOT ask "want me to read it?"
600
+ here; that ask is only for the passive inbox notice.
601
+
602
+ OTHER: \`add_contact\` saves a person from their code; \`list_contacts\` shows the
603
+ user's saved address book; \`my_key\` returns the user's own 6-char code to share.
604
+
605
+ RENAMING: when the user says "rename Niels to Bob" (or "call Niels something
606
+ else"), call \`list_contacts\`, take that contact's \`fullKey\`, then call
607
+ \`add_contact\` with name="Bob" and key=that fullKey. Saving a name against a key
608
+ already on file replaces the old entry, so it renames in place with no duplicate
609
+ and no need to ask the user for a code. Confirm in one line ("Renamed Niels to
610
+ Bob.").
611
+
612
+ Always keep the human in control of what's sent.`;
613
+ var server = new McpServer({ name: "cli-chat", version: "0.2.0" }, { instructions: INSTRUCTIONS });
614
+ var ok = (data) => ({
615
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
616
+ });
617
+ var noAccount = () => ok({
618
+ ok: false,
619
+ reason: "no_account",
620
+ note: "No account on this device yet. Call create_account to generate your identity and 6-char code."
621
+ });
622
+ server.registerTool(
623
+ "create_account",
624
+ {
625
+ title: "Create your account and get your 6-char code",
626
+ description: "Set up the USER'S OWN identity on this device: generate their keypair (private keys never leave the machine), claim a short 6-character code (their 'number') in the registry, and return it to share. Use when the user wants to get set up / join / get their code, or when another tool reported `no_account`. Idempotent: if they already have an account it just returns their existing code. (This is for the user themselves \u2014 to save OTHER people, use add_contact.)",
627
+ inputSchema: {
628
+ name: z.string().optional().describe("What to call this user, e.g. 'Sam'. Defaults to the OS login name.")
629
+ }
630
+ },
631
+ async ({ name }) => {
632
+ if (S) {
633
+ let changed = false;
634
+ if (name && name.trim() && S.me.name !== name.trim()) {
635
+ S.me.name = name.trim();
636
+ changed = true;
637
+ } else if (!S.me.name) {
638
+ S.me.name = S.user;
639
+ changed = true;
640
+ }
641
+ if (!S.me.handle) {
642
+ S.me.handle = await claimHandle(S.ctx.client);
643
+ changed = true;
644
+ }
645
+ if (changed) {
646
+ writeFileSync3(identityFile(S.user), JSON.stringify(S.me, null, 2) + "\n");
647
+ }
648
+ return ok({
649
+ ok: true,
650
+ created: false,
651
+ name: S.me.name,
652
+ handle: S.me.handle,
653
+ fullKey: encodeKey(S.me.signPub, S.me.boxPub),
654
+ note: "You already have an account \u2014 this is your code to share."
655
+ });
656
+ }
657
+ const display = (name ?? process.env.MESSENGER_USER ?? userInfo().username ?? "me").trim() || "me";
658
+ const id = generateIdentity();
659
+ id.name = display;
660
+ id.handle = await claimHandle(createMailboxClient(mailboxUrl, id, now));
661
+ mkdirSync(userDir(id.handle), { recursive: true });
662
+ writeFileSync3(identityFile(id.handle), JSON.stringify(id, null, 2) + "\n");
663
+ writeFileSync3(
664
+ contactsFile(id.handle),
665
+ JSON.stringify({ me: id.signPub, contacts: [] }, null, 2) + "\n"
666
+ );
667
+ setCurrentUser(id.handle);
668
+ S = buildSession(id.handle);
669
+ return ok({
670
+ ok: true,
671
+ created: true,
672
+ name: S.me.name,
673
+ handle: S.me.handle,
674
+ fullKey: encodeKey(S.me.signPub, S.me.boxPub),
675
+ note: `Account ready. Share this 6-character code so people can message you: ${S.me.handle}`
676
+ });
677
+ }
678
+ );
679
+ server.registerTool(
680
+ "send_message",
681
+ {
682
+ title: "Send an encrypted message by name or key",
683
+ description: "Seal a message and post it to the hosted mailbox. Normally pass `to` = a known contact name; matching is partial, so a short name like 'Niels' resolves a saved 'Niels - bankdata'. To message someone NEW, the user gives you their key code (a long string of letters and numbers) \u2014 pass it as `key` and put their name in `to`; they'll be saved as a contact so next time just use the name. Returns the resolved contact; `no_contact` means nothing matched (offer to add by code), `ambiguous` returns the candidates to disambiguate.",
684
+ inputSchema: {
685
+ to: z.string().describe("Contact name, e.g. 'Sam'"),
686
+ body: z.string().describe("The message text (encrypted end-to-end)"),
687
+ key: z.string().optional().describe("Key code for a new person (long letters+numbers); saves them under `to`")
688
+ }
689
+ },
690
+ async ({ to, body, key }) => S ? ok(await sendMessage(S.ctx, { to, body, key })) : noAccount()
691
+ );
692
+ server.registerTool(
693
+ "add_contact",
694
+ {
695
+ title: "Save or rename a contact",
696
+ description: "Remember a person by name from the key code they shared, so the user can later just say 'write <name>'. Use when the user says something like 'add my mate Sam, his key is \u2026'. ALSO renames an existing contact: saving a name against a key that's already on file REPLACES the old entry (the book is upserted by key, not name), so there's no duplicate. To rename (e.g. 'rename Niels to Bob'), first call `list_contacts`, copy that contact's `fullKey`, then call this with name=the new name and key=that fullKey. No need to ask the user for a code \u2014 it's already saved.",
697
+ inputSchema: {
698
+ name: z.string().describe("What to call them, e.g. 'Sam'"),
699
+ key: z.string().describe("Their key code (a long string of letters and numbers)")
700
+ }
701
+ },
702
+ async ({ name, key }) => S ? ok(await addContact(S.ctx, { name, key })) : noAccount()
703
+ );
704
+ server.registerTool(
705
+ "my_key",
706
+ {
707
+ title: "Show my own code to share",
708
+ description: "Return the user's own short handle (a 6-character code) to hand to anyone who wants to message them. Use when the user asks 'what's my key/number/handle/invite?'.",
709
+ inputSchema: {}
710
+ },
711
+ async () => S ? ok({
712
+ name: S.me.name ?? S.user,
713
+ handle: S.me.handle ?? null,
714
+ note: S.me.handle ? void 0 : "No handle yet \u2014 call create_account to claim one.",
715
+ fullKey: encodeKey(S.me.signPub, S.me.boxPub)
716
+ }) : noAccount()
717
+ );
718
+ server.registerTool(
719
+ "list_contacts",
720
+ {
721
+ title: "List my saved contacts",
722
+ description: "Return all people the user has saved, with the name to address them by, any aliases, and their shareable key. Use when the user asks 'who are my contacts?', 'who can I message?', or 'show my address book'.",
723
+ inputSchema: {}
724
+ },
725
+ async () => S ? ok({
726
+ count: S.book.contacts.length,
727
+ contacts: S.book.contacts.map((c) => ({
728
+ name: c.name,
729
+ aliases: c.aliases ?? [],
730
+ fullKey: c.signPub && c.boxPub ? encodeKey(c.signPub, c.boxPub) : null
731
+ }))
732
+ }) : noAccount()
733
+ );
734
+ server.registerTool(
735
+ "messages_available",
736
+ {
737
+ title: "Check for waiting messages",
738
+ description: "Proactive inbox signal. Pulls and decrypts any new mail, then returns the count and previews of unread messages. Call this when the CLI opens.",
739
+ inputSchema: {}
740
+ },
741
+ async () => S ? ok(await messagesAvailable(S.ctx)) : noAccount()
742
+ );
743
+ server.registerTool(
744
+ "watch",
745
+ {
746
+ title: "Watch for incoming messages (long-poll loop)",
747
+ description: "Block for up to ~50 seconds waiting for new mail, then return it (already marked read) or report idle if none arrived. This is the building block of a watch loop: after it returns, call it AGAIN to keep watching, and repeat until the user says to stop. On an idle return, re-call SILENTLY \u2014 print nothing to the user; only speak when mail actually arrives. Use when the user asks to watch for / wait for / keep an eye out for messages.",
748
+ inputSchema: {}
749
+ },
750
+ async (_args, extra) => {
751
+ if (!S) return noAccount();
752
+ const deadline = now() + WATCH_MS;
753
+ const progressToken = extra?._meta?.progressToken;
754
+ let ticks = 0;
755
+ for (; ; ) {
756
+ if (extra?.signal?.aborted) return ok({ status: "idle", count: 0, note: "Watch cancelled." });
757
+ await sync(S.ctx);
758
+ const rows = unreadFor(S.cache, S.me.signPub);
759
+ if (rows.length > 0) {
760
+ const messages = rows.map((m) => {
761
+ markRead(S.cache, m.id, now());
762
+ return {
763
+ id: m.id,
764
+ from: S.book.contacts.find((c) => c.signPub === m.sender)?.name ?? m.sender,
765
+ body: m.body,
766
+ at: m.created_at,
767
+ in_reply_to: m.in_reply_to
768
+ };
769
+ });
770
+ return ok({
771
+ status: "messages",
772
+ count: messages.length,
773
+ messages,
774
+ note: "Read these out in full (sender + body) and offer to reply (draft_reply). Call watch again to keep watching."
775
+ });
776
+ }
777
+ if (now() >= deadline) {
778
+ return ok({
779
+ status: "idle",
780
+ count: 0,
781
+ note: "No new messages. Silently call watch again to keep watching \u2014 do NOT print anything to the user on idle (no heartbeat); only speak when mail actually arrives or the user is done."
782
+ });
783
+ }
784
+ if (progressToken !== void 0) {
785
+ await extra.sendNotification({
786
+ method: "notifications/progress",
787
+ params: { progressToken, progress: ++ticks }
788
+ });
789
+ }
790
+ await sleep(3e3);
791
+ }
792
+ }
793
+ );
794
+ server.registerTool(
795
+ "read_message",
796
+ {
797
+ title: "Read a waiting message",
798
+ description: "Read a decrypted message by id (or oldest unread). Marks it read.",
799
+ inputSchema: { id: z.string().optional().describe("Message id; omit for oldest unread") }
800
+ },
801
+ async ({ id }) => S ? ok(await readMessage(S.ctx, { id })) : noAccount()
802
+ );
803
+ server.registerTool(
804
+ "draft_reply",
805
+ {
806
+ title: "Send an encrypted reply",
807
+ description: "Reply to a message, sealed and threaded. Draft it yourself; if it needs a fact you lack (the human's availability, a decision), ask the human first.",
808
+ inputSchema: {
809
+ in_reply_to: z.string().describe("Id of the message being replied to"),
810
+ body: z.string().describe("The reply text")
811
+ }
812
+ },
813
+ async ({ in_reply_to, body }) => S ? ok(await draftReply(S.ctx, { in_reply_to, body })) : noAccount()
814
+ );
815
+ var transport = new StdioServerTransport();
816
+ await server.connect(transport);
817
+ console.error(
818
+ S ? `cli-chat (Phase 1) up as "${S.user}" \u2192 mailbox ${mailboxUrl}` : `cli-chat (Phase 1) up with NO account \u2192 mailbox ${mailboxUrl} (call create_account)`
819
+ );