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
|
@@ -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
|
+
);
|