@opvs-ai/cli 0.5.5 → 0.6.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,949 @@
1
+ /**
2
+ * opvs mail — OPVS Protocol messaging commands.
3
+ *
4
+ * Two layers:
5
+ * 1. Server-facing commands (send, reply, resolve, claim, pair, …) —
6
+ * hit the gateway and mirror the result to the local store.
7
+ * 2. Local-mirror commands (sync, watch, thread, list, search, unread,
8
+ * status, ack, working, decline, flag, draft, awaiting, tickle,
9
+ * sub, compact, log) — read/write the IMAP-style store at
10
+ * ``~/.opvs/mail/`` shipped by ``@opvs-ai/core``'s mailstore.
11
+ *
12
+ * Per the IMAP-mail plan (`/root/.claude/plans/read-root-opvs-...`):
13
+ * - No auto-seen — recipient must explicitly ``mail ack``.
14
+ * - SLA fields are advisory, never server-enforced.
15
+ * - Plus-addressing (``+suffix``) is first-class.
16
+ */
17
+ import { getEffectiveConfig, createClient, } from "@opvs-ai/core";
18
+ import { mailstore } from "@opvs-ai/core";
19
+ import { buildUrl, printResponse, printJson } from "../lib/format.js";
20
+ import { registerInstallCommands } from "./install.js";
21
+ import { readFileSync, existsSync, } from "node:fs";
22
+ /** Map user-friendly type names to OPVS wire-format symbols. */
23
+ const TYPE_SYMBOLS = {
24
+ "?": "?", query: "?",
25
+ "!": "!", command: "!", action: "!",
26
+ "~": "~", delegate: "~",
27
+ "^": "^", escalate: "^",
28
+ ".": ".", inform: ".",
29
+ ">": ">", handoff: ">",
30
+ "<": "<", reply: "<",
31
+ };
32
+ /** Map priority names to OPVS wire-format symbols. */
33
+ const PRIORITY_SYMBOLS = {
34
+ critical: "!!!",
35
+ high: "!!",
36
+ medium: "!",
37
+ normal: "",
38
+ low: "",
39
+ };
40
+ /**
41
+ * Build a synthetic local Message from outbound send-context + the
42
+ * message_id returned by the server. Lets ``appendOutgoing`` mirror
43
+ * the send into ``~/.opvs/mail/objects/`` without an extra GET round-trip.
44
+ */
45
+ function synthesizeOutgoingMessage(opts) {
46
+ // Split the optional +suffix out of from/to addresses.
47
+ const splitAddr = (addr) => {
48
+ const [local, tenant] = addr.includes("@") ? addr.split("@", 2) : [addr, null];
49
+ if (local.includes("+")) {
50
+ const [name, sub] = local.split("+", 2);
51
+ return { name, sub: sub || null, tenant: tenant ?? null };
52
+ }
53
+ return { name: local, sub: null, tenant: tenant ?? null };
54
+ };
55
+ const fromParts = splitAddr(opts.from);
56
+ const toParts = splitAddr(opts.to);
57
+ return {
58
+ id: opts.message_id,
59
+ thread_id: opts.thread_id ?? null,
60
+ reply_to_id: opts.reply_to_id ?? null,
61
+ from_agent: fromParts.name,
62
+ from_subaddress: fromParts.sub,
63
+ from_tenant: fromParts.tenant,
64
+ to_agent: toParts.name,
65
+ to_subaddress: toParts.sub,
66
+ to_tenant: toParts.tenant,
67
+ body: opts.body,
68
+ tags: opts.tags ?? [],
69
+ flags: [],
70
+ context: {},
71
+ message_type: opts.type,
72
+ priority: opts.priority === "critical" ? 0 : opts.priority === "high" ? 1 : opts.priority === "low" ? 3 : 2,
73
+ status: "delivered",
74
+ created_at: new Date().toISOString(),
75
+ };
76
+ }
77
+ /** Read a file, returning empty string if absent — for ``--body-file``. */
78
+ function readBodyFromFile(path) {
79
+ if (!existsSync(path)) {
80
+ throw new Error(`Body file not found: ${path}`);
81
+ }
82
+ return readFileSync(path, "utf-8");
83
+ }
84
+ /** Pretty-print a message for ``mail read`` / ``mail thread``. */
85
+ function printMessage(msg, opts) {
86
+ if (opts?.format === "json") {
87
+ printJson(msg);
88
+ return;
89
+ }
90
+ // Default: markdown-ish for agents to read.
91
+ const from = msg.from_agent + (msg.from_subaddress ? `+${msg.from_subaddress}` : "") +
92
+ (msg.from_tenant ? `::${msg.from_tenant}` : "");
93
+ const to = msg.to_agent
94
+ ? msg.to_agent + (msg.to_subaddress ? `+${msg.to_subaddress}` : "") + (msg.to_tenant ? `::${msg.to_tenant}` : "")
95
+ : msg.to_pool ? `|${msg.to_pool}` : msg.to_team ? `team:${msg.to_team}` : "*";
96
+ const status = msg.status ?? "delivered";
97
+ const tags = (msg.tags ?? []).map((t) => `#${t}`).join(" ");
98
+ console.log(`---`);
99
+ console.log(`id: ${msg.id}`);
100
+ console.log(`thread: ${msg.thread_id ?? ""}`);
101
+ console.log(`from: ${from}`);
102
+ console.log(`to: ${to}`);
103
+ console.log(`type: ${msg.message_type ?? "."} priority: ${msg.priority ?? 2} status: ${status}`);
104
+ if (tags)
105
+ console.log(`tags: ${tags}`);
106
+ console.log(`time: ${msg.created_at}`);
107
+ console.log(``);
108
+ console.log(msg.body);
109
+ console.log(``);
110
+ }
111
+ // =============================================================
112
+ // Register
113
+ // =============================================================
114
+ export function registerMailCommands(program) {
115
+ const mail = program.command("mail").description("OPVS Protocol messaging");
116
+ // ── server-facing: send / reply / inbox / read / resolve / claim ───
117
+ mail
118
+ .command("send <address> <message>")
119
+ .description("Send an OPVS protocol message")
120
+ .option("--priority <level>", "Priority: critical, high, medium, normal", "normal")
121
+ .option("--tags <tags>", "Comma-separated tags")
122
+ .option("--type <type>", "Message type: ? ! ~ ^ . > < (or query/command/delegate/escalate/inform/handoff/reply)", ".")
123
+ .option("--from <address>", "Sender address", "cli")
124
+ .option("--body-file <path>", "Read body from file instead of <message> arg")
125
+ .option("--no-mirror", "Skip writing to local store after send")
126
+ .action(async (address, message, opts) => {
127
+ const wsSlug = program.opts().workspace;
128
+ const cfg = getEffectiveConfig(wsSlug);
129
+ try {
130
+ const typeSymbol = TYPE_SYMBOLS[opts.type] || ".";
131
+ const priority = PRIORITY_SYMBOLS[opts.priority] || "";
132
+ const tags = opts.tags
133
+ ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean)
134
+ : [];
135
+ const tagsPart = tags.map((t) => `#${t}`).join(" ");
136
+ let header = `@${opts.from}>${address} ${typeSymbol}`;
137
+ if (priority)
138
+ header += ` ${priority}`;
139
+ if (tagsPart)
140
+ header += ` ${tagsPart}`;
141
+ const bodyText = opts.bodyFile ? readBodyFromFile(opts.bodyFile) : message;
142
+ const wireFormat = `${header}\n\n${bodyText}`;
143
+ const baseUrl = cfg.api_url.replace(/\/$/, "");
144
+ const url = `${baseUrl}/api/v1/opvs/send`;
145
+ const headers = { "Content-Type": "text/opvs" };
146
+ if (cfg.token)
147
+ headers["Authorization"] = `Bearer ${cfg.token}`;
148
+ if (cfg.brand_id)
149
+ headers["X-Brand-ID"] = String(cfg.brand_id);
150
+ const res = await fetch(url, { method: "POST", headers, body: wireFormat });
151
+ if (!res.ok) {
152
+ let detail;
153
+ try {
154
+ const err = (await res.json());
155
+ detail = typeof err.detail === "string"
156
+ ? err.detail
157
+ : Array.isArray(err.detail)
158
+ ? err.detail.map((d) => d.msg || JSON.stringify(d)).join("; ")
159
+ : res.statusText;
160
+ }
161
+ catch {
162
+ detail = res.statusText;
163
+ }
164
+ console.error(`Error: ${detail}`);
165
+ process.exit(1);
166
+ }
167
+ const data = await res.json();
168
+ printJson(data);
169
+ // Mirror to local store unless caller opts out.
170
+ if (opts.mirror !== false && data.message_id) {
171
+ try {
172
+ const synthesized = synthesizeOutgoingMessage({
173
+ message_id: data.message_id,
174
+ from: opts.from,
175
+ to: address,
176
+ type: typeSymbol,
177
+ priority: opts.priority,
178
+ tags,
179
+ body: bodyText,
180
+ });
181
+ mailstore.appendOutgoing(synthesized);
182
+ }
183
+ catch (e) {
184
+ // Mirror failure must not block the user — they got a server ack.
185
+ console.error(`(warning) local mirror failed: ${e.message}`);
186
+ }
187
+ }
188
+ }
189
+ catch (err) {
190
+ const e = err;
191
+ console.error(`Error: ${e.detail || e.message || "Failed to send message"}`);
192
+ process.exit(1);
193
+ }
194
+ });
195
+ mail
196
+ .command("inbox")
197
+ .description("Fetch inbox messages from the server (use ``mail list`` for the local mirror)")
198
+ .option("--limit <n>", "Max messages", "50")
199
+ .option("--tags <tags>", "Filter by comma-separated tags")
200
+ .option("--flags <flags>", "Filter by comma-separated flags")
201
+ .option("--since <id>", "Only return messages after this message id")
202
+ .option("--after <iso>", "Only return messages after this ISO timestamp")
203
+ .option("--suffix <s>", "Filter by recipient +suffix sub-address (empty string = no suffix)")
204
+ .option("--status <s>", "Filter by status (delivered, seen, working, …, or 'all')")
205
+ .option("--thread-id <id>", "Filter to one thread")
206
+ .action(async (opts) => {
207
+ const wsSlug = program.opts().workspace;
208
+ const cfg = getEffectiveConfig(wsSlug);
209
+ try {
210
+ const client = createClient(wsSlug);
211
+ const params = {};
212
+ if (opts.limit)
213
+ params.limit = opts.limit;
214
+ if (opts.tags)
215
+ params.tags = opts.tags;
216
+ if (opts.flags)
217
+ params.flags = opts.flags;
218
+ if (opts.since)
219
+ params.since = opts.since;
220
+ if (opts.after)
221
+ params.after = opts.after;
222
+ if (opts.suffix !== undefined)
223
+ params.subaddress = opts.suffix;
224
+ if (opts.status)
225
+ params.status = opts.status;
226
+ if (opts.threadId)
227
+ params.thread_id = opts.threadId;
228
+ const url = buildUrl("/api/v1/opvs/inbox", params, cfg.format);
229
+ const res = await client.get(url);
230
+ await printResponse(res);
231
+ }
232
+ catch (err) {
233
+ const e = err;
234
+ console.error(`Error: ${e.detail || "Failed to fetch inbox"}`);
235
+ process.exit(1);
236
+ }
237
+ });
238
+ mail
239
+ .command("read <id>")
240
+ .description("Read a single message — local first, server fallback")
241
+ .option("--remote", "Skip local cache and hit the server")
242
+ .option("--no-mirror", "Don't persist the fetched message locally")
243
+ .option("--format <fmt>", "Output: json or markdown (default markdown)")
244
+ .action(async (id, opts) => {
245
+ const wsSlug = program.opts().workspace;
246
+ try {
247
+ // Local-first lookup unless --remote
248
+ if (!opts.remote) {
249
+ const local = mailstore.readMessage(id);
250
+ if (local) {
251
+ printMessage(local, { format: opts.format });
252
+ // Mark viewed locally for the unread filter.
253
+ mailstore.setFlag(id, "viewed", true);
254
+ return;
255
+ }
256
+ }
257
+ const client = createClient(wsSlug);
258
+ const res = await client.get(`/api/v1/opvs/messages/${id}`);
259
+ const msg = await res.json();
260
+ if (opts.mirror !== false && msg && msg.id) {
261
+ try {
262
+ mailstore.appendIncoming(msg);
263
+ }
264
+ catch { /* non-fatal */ }
265
+ }
266
+ if (msg)
267
+ mailstore.setFlag(id, "viewed", true);
268
+ printMessage(msg, { format: opts.format });
269
+ }
270
+ catch (err) {
271
+ const e = err;
272
+ console.error(`Error: ${e.detail || "Failed to read message"}`);
273
+ process.exit(1);
274
+ }
275
+ });
276
+ mail
277
+ .command("reply <id> <message>")
278
+ .description("Reply to a message")
279
+ .option("--body-file <path>", "Read body from file instead of <message> arg")
280
+ .option("--no-mirror", "Skip writing to local store after reply")
281
+ .action(async (id, message, opts) => {
282
+ const wsSlug = program.opts().workspace;
283
+ try {
284
+ const client = createClient(wsSlug);
285
+ const bodyText = opts.bodyFile ? readBodyFromFile(opts.bodyFile) : message;
286
+ const res = await client.post("/api/v1/opvs/reply", {
287
+ message_id: id,
288
+ body: bodyText,
289
+ });
290
+ const data = await res.json();
291
+ printJson(data);
292
+ // Mirror reply locally — we know the original to recover thread_id.
293
+ if (opts.mirror !== false && data.message_id) {
294
+ try {
295
+ const original = mailstore.readMessage(id);
296
+ const cfg = getEffectiveConfig(wsSlug);
297
+ const synthesized = {
298
+ id: data.message_id,
299
+ thread_id: original?.thread_id ?? id,
300
+ reply_to_id: id,
301
+ from_agent: "cli",
302
+ from_tenant: null,
303
+ from_subaddress: null,
304
+ to_agent: original?.from_agent ?? null,
305
+ to_tenant: original?.from_tenant ?? null,
306
+ to_subaddress: original?.from_subaddress ?? null,
307
+ body: bodyText,
308
+ tags: [],
309
+ flags: [],
310
+ context: {},
311
+ message_type: "<",
312
+ priority: 2,
313
+ status: "delivered",
314
+ created_at: new Date().toISOString(),
315
+ };
316
+ void cfg;
317
+ mailstore.appendOutgoing(synthesized);
318
+ }
319
+ catch (e) {
320
+ console.error(`(warning) local mirror failed: ${e.message}`);
321
+ }
322
+ }
323
+ }
324
+ catch (err) {
325
+ const e = err;
326
+ console.error(`Error: ${e.detail || "Failed to reply"}`);
327
+ process.exit(1);
328
+ }
329
+ });
330
+ mail
331
+ .command("resolve <id>")
332
+ .description("Mark a message as resolved (recipient only)")
333
+ .action(async (id) => {
334
+ const wsSlug = program.opts().workspace;
335
+ try {
336
+ const client = createClient(wsSlug);
337
+ const res = await client.post("/api/v1/opvs/resolve", { message_id: id });
338
+ const data = await res.json();
339
+ printJson(data);
340
+ // Mirror status locally.
341
+ try {
342
+ mailstore.setStatus(id, "resolved");
343
+ }
344
+ catch { /* non-fatal */ }
345
+ }
346
+ catch (err) {
347
+ const e = err;
348
+ console.error(`Error: ${e.detail || "Failed to resolve message"}`);
349
+ process.exit(1);
350
+ }
351
+ });
352
+ mail
353
+ .command("claim")
354
+ .description("Claim next message from a pool")
355
+ .requiredOption("--pool <pool>", "Pool name to claim from")
356
+ .option("--tags <tags>", "Filter by comma-separated tags")
357
+ .option("--flags <flags>", "Filter by comma-separated flags")
358
+ .action(async (opts) => {
359
+ const wsSlug = program.opts().workspace;
360
+ try {
361
+ const client = createClient(wsSlug);
362
+ const body = { pool: opts.pool };
363
+ if (opts.tags)
364
+ body.filter_tags = opts.tags.split(",").map((t) => t.trim());
365
+ if (opts.flags)
366
+ body.filter_flags = opts.flags.split(",").map((f) => f.trim());
367
+ const res = await client.post("/api/v1/opvs/claim", body);
368
+ const data = await res.json();
369
+ printJson(data);
370
+ if (data && data.id) {
371
+ try {
372
+ mailstore.appendIncoming(data);
373
+ }
374
+ catch { /* non-fatal */ }
375
+ }
376
+ }
377
+ catch (err) {
378
+ const e = err;
379
+ console.error(`Error: ${e.detail || "Failed to claim message"}`);
380
+ process.exit(1);
381
+ }
382
+ });
383
+ mail
384
+ .command("pair <address>")
385
+ .description("Initiate pairing with another agent")
386
+ .option("--message <text>", "Intro message")
387
+ .action(async (address, opts) => {
388
+ const wsSlug = program.opts().workspace;
389
+ try {
390
+ const client = createClient(wsSlug);
391
+ const body = { target_address: address };
392
+ if (opts.message)
393
+ body.intro_message = opts.message;
394
+ const res = await client.post("/api/v1/opvs/pair", body);
395
+ const data = await res.json();
396
+ printJson(data);
397
+ }
398
+ catch (err) {
399
+ const e = err;
400
+ console.error(`Error: ${e.detail || "Failed to initiate pairing"}`);
401
+ process.exit(1);
402
+ }
403
+ });
404
+ mail
405
+ .command("agents")
406
+ .description("List registered OPVS agents")
407
+ .option("--framework <framework>", "Filter by framework (e.g. openclaw)")
408
+ .option("--status <status>", "Filter by status (online, offline, busy, away)")
409
+ .option("--limit <n>", "Max results", "50")
410
+ .action(async (opts) => {
411
+ const wsSlug = program.opts().workspace;
412
+ const cfg = getEffectiveConfig(wsSlug);
413
+ try {
414
+ const client = createClient(wsSlug);
415
+ const params = {};
416
+ if (opts.framework)
417
+ params.framework = opts.framework;
418
+ if (opts.status)
419
+ params.status = opts.status;
420
+ if (opts.limit)
421
+ params.limit = opts.limit;
422
+ const url = buildUrl("/api/v1/opvs/agents", params, cfg.format);
423
+ const res = await client.get(url);
424
+ await printResponse(res);
425
+ }
426
+ catch (err) {
427
+ const e = err;
428
+ console.error(`Error: ${e.detail || "Failed to list agents"}`);
429
+ process.exit(1);
430
+ }
431
+ });
432
+ mail
433
+ .command("register")
434
+ .description("Register a new OPVS agent address")
435
+ .option("--name <name>", "Agent address (omit for auto-generated)")
436
+ .option("--mode <mode>", "Agent mode: public, paired_only, hybrid", "public")
437
+ .option("--domain <domain>", "Domain: opvs.run or opvs.pub", "opvs.run")
438
+ .option("--framework <framework>", "Agent framework (e.g. claude, openclaw)")
439
+ .option("--capabilities <caps>", "Comma-separated capabilities")
440
+ .action(async (opts) => {
441
+ const wsSlug = program.opts().workspace;
442
+ try {
443
+ const client = createClient(wsSlug);
444
+ const body = {
445
+ mode: opts.mode,
446
+ domain: opts.domain,
447
+ };
448
+ if (opts.name)
449
+ body.name = opts.name;
450
+ if (opts.framework)
451
+ body.framework = opts.framework;
452
+ if (opts.capabilities) {
453
+ body.capabilities = opts.capabilities.split(",").map((c) => c.trim());
454
+ }
455
+ const res = await client.post("/api/v1/opvs/agents/register", body);
456
+ const data = await res.json();
457
+ printJson(data);
458
+ }
459
+ catch (err) {
460
+ const e = err;
461
+ console.error(`Error: ${e.detail || "Failed to register agent"}`);
462
+ process.exit(1);
463
+ }
464
+ });
465
+ // ─── local mirror: sync / watch ─────────────────────────────────────
466
+ mail
467
+ .command("sync")
468
+ .description("Pull new inbox messages from the server into the local store")
469
+ .option("--limit <n>", "Batch size per request", "100")
470
+ .option("--max-batches <n>", "Maximum batches per call", "50")
471
+ .option("--initial", "Force initial drain (ignore cursor file)")
472
+ .option("--refresh-status", "Also refresh server status for non-terminal sent messages")
473
+ .action(async (opts) => {
474
+ const wsSlug = program.opts().workspace;
475
+ try {
476
+ const client = createClient(wsSlug);
477
+ // Wrap the APIClient in a SyncHttpClient — patch is wired so
478
+ // ``mail ack`` shares the same client shape.
479
+ const syncClient = {
480
+ get: (path, h) => client.get(path, h),
481
+ patch: (path, body, h) => client.patch(path, body, h),
482
+ };
483
+ if (opts.initial) {
484
+ // Wipe cursor so the inbox endpoint walks everything.
485
+ mailstore.setCursor("", undefined);
486
+ }
487
+ const result = await mailstore.syncIncremental(syncClient, {
488
+ batchSize: parseInt(opts.limit, 10),
489
+ maxBatches: parseInt(opts.maxBatches, 10),
490
+ refreshStatus: !!opts.refreshStatus,
491
+ });
492
+ printJson(result);
493
+ }
494
+ catch (err) {
495
+ const e = err;
496
+ console.error(`Error: ${e.detail || e.message || "Failed to sync"}`);
497
+ process.exit(1);
498
+ }
499
+ });
500
+ mail
501
+ .command("watch")
502
+ .description("Stream live inbox events via SSE (long-running)")
503
+ .option("--filter <suffix>", "Only emit messages with this +suffix")
504
+ .option("--quiet", "Don't print arrivals (just persist)")
505
+ .action(async (opts) => {
506
+ const wsSlug = program.opts().workspace;
507
+ const cfg = getEffectiveConfig(wsSlug);
508
+ const ctrl = new AbortController();
509
+ process.on("SIGINT", () => { ctrl.abort(); });
510
+ try {
511
+ await mailstore.watchLive({
512
+ apiUrl: cfg.api_url,
513
+ token: cfg.token,
514
+ brandId: cfg.brand_id ?? null,
515
+ filterSuffix: opts.filter,
516
+ signal: ctrl.signal,
517
+ onMessage: (m) => {
518
+ if (!opts.quiet) {
519
+ console.log(`📨 ${m.id} ${m.from_agent}${m.from_subaddress ? "+" + m.from_subaddress : ""} → ${m.to_agent}${m.to_subaddress ? "+" + m.to_subaddress : ""}`);
520
+ }
521
+ },
522
+ onStatusChange: (e) => {
523
+ if (!opts.quiet) {
524
+ console.log(`🔁 ${e.message_id} ${e.old_status ?? "?"} → ${e.new_status}`);
525
+ }
526
+ },
527
+ onReady: ({ reconnects }) => {
528
+ if (!opts.quiet)
529
+ console.log(`✅ stream ready${reconnects ? ` (after ${reconnects} reconnects)` : ""}`);
530
+ },
531
+ onError: (err, attempt) => {
532
+ console.error(`(warning) stream error attempt ${attempt}: ${err.message ?? String(err)}`);
533
+ },
534
+ });
535
+ }
536
+ catch (err) {
537
+ const e = err;
538
+ console.error(`Error: ${e.detail || e.message || "Watch failed"}`);
539
+ process.exit(1);
540
+ }
541
+ });
542
+ // ─── local-only reads: thread / list / search / unread / status ───
543
+ mail
544
+ .command("thread <id>")
545
+ .description("Print full thread chronologically (local first, server fallback)")
546
+ .option("--remote", "Skip local cache and hit the server")
547
+ .option("--format <fmt>", "Output: json or markdown (default markdown)")
548
+ .action(async (id, opts) => {
549
+ const wsSlug = program.opts().workspace;
550
+ if (!opts.remote) {
551
+ const local = mailstore.readThread(id);
552
+ if (local && local.length > 0) {
553
+ if (opts.format === "json")
554
+ printJson(local);
555
+ else
556
+ for (const msg of local)
557
+ printMessage(msg, opts);
558
+ return;
559
+ }
560
+ }
561
+ // Server fallback
562
+ try {
563
+ const client = createClient(wsSlug);
564
+ const res = await client.get(`/api/v1/opvs/threads/${id}`);
565
+ const data = await res.json();
566
+ const msgs = data.messages ?? [];
567
+ // Persist on fetch.
568
+ for (const m of msgs) {
569
+ try {
570
+ mailstore.appendIncoming(m);
571
+ }
572
+ catch { /* non-fatal */ }
573
+ }
574
+ if (opts.format === "json")
575
+ printJson(msgs);
576
+ else
577
+ for (const m of msgs)
578
+ printMessage(m, opts);
579
+ }
580
+ catch (err) {
581
+ const e = err;
582
+ console.error(`Error: ${e.detail || "Failed to read thread"}`);
583
+ process.exit(1);
584
+ }
585
+ });
586
+ mail
587
+ .command("list")
588
+ .description("List local inbox messages (use ``mail inbox`` for the remote view)")
589
+ .option("--limit <n>", "Max messages", "50")
590
+ .option("--unread", "Only unread (flags.viewed unset)")
591
+ .option("--late", "Only messages past their advisory SLA")
592
+ .option("--suffix <s>", "Filter by recipient +suffix (empty string = no suffix)")
593
+ .option("--tag <t>", "Filter by tag (repeat or comma-separated)")
594
+ .option("--from <addr>", "Filter by sender")
595
+ .option("--since <iso>", "Only messages newer than this ISO timestamp")
596
+ .option("--status <s>", "Filter by server status")
597
+ .option("--format <fmt>", "Output: json or markdown (default markdown)")
598
+ .action((opts) => {
599
+ const tags = opts.tag ? opts.tag.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
600
+ const out = mailstore.readInbox({
601
+ limit: parseInt(opts.limit, 10),
602
+ unread: !!opts.unread,
603
+ suffix: opts.suffix,
604
+ tags,
605
+ since: opts.since,
606
+ status: opts.status,
607
+ });
608
+ // ``--from`` is a post-filter (cheap on already-hydrated messages).
609
+ let filtered = out;
610
+ if (opts.from) {
611
+ filtered = filtered.filter((m) => m.from_agent === opts.from);
612
+ }
613
+ if (opts.format === "json")
614
+ printJson(filtered);
615
+ else
616
+ for (const m of filtered)
617
+ printMessage(m, opts);
618
+ });
619
+ mail
620
+ .command("search <query>")
621
+ .description("Grep across all locally-stored messages (body / tags / addresses)")
622
+ .option("--limit <n>", "Max results", "50")
623
+ .option("--thread <id>", "Restrict to one thread")
624
+ .option("--suffix <s>", "Restrict to one sub-address")
625
+ .option("--format <fmt>", "Output: json or markdown (default markdown)")
626
+ .action((query, opts) => {
627
+ const hits = mailstore.searchLocal(query, {
628
+ limit: parseInt(opts.limit, 10),
629
+ thread_id: opts.thread,
630
+ suffix: opts.suffix,
631
+ });
632
+ if (opts.format === "json")
633
+ printJson(hits);
634
+ else
635
+ for (const m of hits)
636
+ printMessage(m, opts);
637
+ });
638
+ mail
639
+ .command("unread")
640
+ .description("Print unread count + a short summary")
641
+ .option("--count-only", "Print just the integer count")
642
+ .action((opts) => {
643
+ const all = mailstore.readInbox({ limit: 10000, unread: true });
644
+ if (opts.countOnly) {
645
+ console.log(String(all.length));
646
+ return;
647
+ }
648
+ console.log(`${all.length} unread`);
649
+ for (const m of all.slice(0, 20)) {
650
+ const subj = (m.body ?? "").split("\n")[0].slice(0, 100);
651
+ console.log(` ${m.id.slice(0, 8)} ${m.from_agent}${m.from_subaddress ? "+" + m.from_subaddress : ""} ${subj}`);
652
+ }
653
+ if (all.length > 20)
654
+ console.log(` … and ${all.length - 20} more`);
655
+ });
656
+ mail
657
+ .command("status [id]")
658
+ .description("Show full status state for one message, or counts across all")
659
+ .action((id) => {
660
+ if (id) {
661
+ const local = mailstore.readMessage(id);
662
+ const status = mailstore.getStatus(id);
663
+ printJson({
664
+ id,
665
+ local_status: status?.status ?? null,
666
+ updated_at: status?.updated_at ?? null,
667
+ message_status: local?.status ?? null,
668
+ flags: mailstore.getFlags(id),
669
+ });
670
+ return;
671
+ }
672
+ // Aggregate all statuses.
673
+ const all = mailstore.getAllStatus();
674
+ const counts = {};
675
+ for (const entry of Object.values(all)) {
676
+ counts[entry.status] = (counts[entry.status] ?? 0) + 1;
677
+ }
678
+ printJson(counts);
679
+ });
680
+ // ─── status mutations: ack / working / decline ─────────────────────
681
+ function statusCmd(name, description) {
682
+ mail
683
+ .command(`${name === "seen" ? "ack" : name} <id>`)
684
+ .description(description)
685
+ .action(async (id) => {
686
+ const wsSlug = program.opts().workspace;
687
+ try {
688
+ const client = createClient(wsSlug);
689
+ const result = await mailstore.patchMessageStatus({ get: (p, h) => client.get(p, h), patch: (p, b, h) => client.patch(p, b, h) }, id, name);
690
+ printJson(result);
691
+ }
692
+ catch (err) {
693
+ const e = err;
694
+ console.error(`Error: ${e.detail || e.message || `Failed to ${name}`}`);
695
+ process.exit(1);
696
+ }
697
+ });
698
+ }
699
+ statusCmd("seen", "Acknowledge receipt of a message — sets server status `seen` (no auto-seen)");
700
+ statusCmd("working", "Mark a message as in-progress — sets server status `working`");
701
+ statusCmd("declined", "Decline a message — sets server status `declined`");
702
+ statusCmd("failed", "Mark a message as failed — sets server status `failed`");
703
+ // ─── local-only flags ──────────────────────────────────────────────
704
+ mail
705
+ .command("flag <id>")
706
+ .description("Star a message locally (does not touch the server)")
707
+ .action((id) => {
708
+ mailstore.setFlag(id, "flagged", true);
709
+ console.log(`flagged ${id}`);
710
+ });
711
+ mail
712
+ .command("unflag <id>")
713
+ .description("Unstar a message locally")
714
+ .action((id) => {
715
+ mailstore.setFlag(id, "flagged", false);
716
+ console.log(`unflagged ${id}`);
717
+ });
718
+ mail
719
+ .command("mark-viewed <id>")
720
+ .description("Mark a message as viewed locally (UX only — server status untouched)")
721
+ .action((id) => {
722
+ mailstore.setFlag(id, "viewed", true);
723
+ console.log(`viewed ${id}`);
724
+ });
725
+ // ─── drafts ────────────────────────────────────────────────────────
726
+ const draft = mail.command("draft").description("Compose-and-park drafts (analog of git stash)");
727
+ draft
728
+ .command("save <to> <body>")
729
+ .description("Save a draft message (use ``--body-file`` for longer bodies)")
730
+ .option("--body-file <path>", "Read body from file instead of <body> arg")
731
+ .option("--type <t>", "Message type symbol or name", ".")
732
+ .option("--priority <p>", "Priority", "normal")
733
+ .option("--tags <tags>", "Comma-separated tags")
734
+ .action((to, body, opts) => {
735
+ const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
736
+ const bodyText = opts.bodyFile ? readBodyFromFile(opts.bodyFile) : body;
737
+ const saved = mailstore.saveDraft({
738
+ to,
739
+ type: opts.type,
740
+ priority: opts.priority,
741
+ tags,
742
+ body: bodyText,
743
+ });
744
+ printJson(saved);
745
+ });
746
+ draft
747
+ .command("list")
748
+ .description("List drafts (newest first)")
749
+ .action(() => {
750
+ const all = mailstore.listDrafts();
751
+ printJson(all);
752
+ });
753
+ draft
754
+ .command("show <id>")
755
+ .description("Print one draft")
756
+ .action((id) => {
757
+ const d = mailstore.loadDraft(id);
758
+ if (!d) {
759
+ console.error(`Draft ${id} not found`);
760
+ process.exit(1);
761
+ }
762
+ printJson(d);
763
+ });
764
+ draft
765
+ .command("send <id>")
766
+ .description("Send a saved draft and remove it from drafts/")
767
+ .option("--no-mirror", "Skip writing to local store after send")
768
+ .action(async (id, opts) => {
769
+ const d = mailstore.loadDraft(id);
770
+ if (!d) {
771
+ console.error(`Draft ${id} not found`);
772
+ process.exit(1);
773
+ }
774
+ const wsSlug = program.opts().workspace;
775
+ const cfg = getEffectiveConfig(wsSlug);
776
+ try {
777
+ const typeSymbol = TYPE_SYMBOLS[d.type ?? "."] || ".";
778
+ const priority = PRIORITY_SYMBOLS[d.priority ?? "normal"] || "";
779
+ const tagsPart = (d.tags ?? []).map((t) => `#${t}`).join(" ");
780
+ let header = `@cli>${d.to} ${typeSymbol}`;
781
+ if (priority)
782
+ header += ` ${priority}`;
783
+ if (tagsPart)
784
+ header += ` ${tagsPart}`;
785
+ const wireFormat = `${header}\n\n${d.body}`;
786
+ const baseUrl = cfg.api_url.replace(/\/$/, "");
787
+ const headers = { "Content-Type": "text/opvs" };
788
+ if (cfg.token)
789
+ headers["Authorization"] = `Bearer ${cfg.token}`;
790
+ if (cfg.brand_id)
791
+ headers["X-Brand-ID"] = String(cfg.brand_id);
792
+ const res = await fetch(`${baseUrl}/api/v1/opvs/send`, { method: "POST", headers, body: wireFormat });
793
+ if (!res.ok) {
794
+ let detail;
795
+ try {
796
+ detail = (await res.json()).detail ?? res.statusText;
797
+ }
798
+ catch {
799
+ detail = res.statusText;
800
+ }
801
+ console.error(`Error: ${detail}`);
802
+ process.exit(1);
803
+ }
804
+ const data = await res.json();
805
+ printJson(data);
806
+ if (opts.mirror !== false && data.message_id) {
807
+ try {
808
+ mailstore.appendOutgoing(synthesizeOutgoingMessage({
809
+ message_id: data.message_id,
810
+ from: "cli",
811
+ to: d.to,
812
+ type: typeSymbol,
813
+ priority: d.priority,
814
+ tags: d.tags,
815
+ body: d.body,
816
+ }));
817
+ }
818
+ catch { /* non-fatal */ }
819
+ }
820
+ mailstore.deleteDraft(id);
821
+ }
822
+ catch (err) {
823
+ const e = err;
824
+ console.error(`Error: ${e.detail || e.message || "Failed to send draft"}`);
825
+ process.exit(1);
826
+ }
827
+ });
828
+ draft
829
+ .command("delete <id>")
830
+ .description("Delete a draft without sending")
831
+ .action((id) => {
832
+ const ok = mailstore.deleteDraft(id);
833
+ console.log(ok ? `deleted ${id}` : `not found ${id}`);
834
+ });
835
+ // ─── advisory SLA ──────────────────────────────────────────────────
836
+ mail
837
+ .command("awaiting")
838
+ .description("List sent messages still pre-resolved past their advisory SLA")
839
+ .option("--late-only", "Only those past $expected_seen_in / $expected_done_in")
840
+ .option("--limit <n>", "Max results", "100")
841
+ .option("--format <fmt>", "Output: json or markdown")
842
+ .action((opts) => {
843
+ const all = mailstore.listAwaiting({
844
+ late_only: !!opts.lateOnly,
845
+ limit: parseInt(opts.limit, 10),
846
+ });
847
+ if (opts.format === "markdown" || opts.format === "md") {
848
+ for (const e of all) {
849
+ const tag = e.is_late ? "⚠️ LATE" : "⏳";
850
+ console.log(`${tag} ${e.id.slice(0, 8)} → ${e.to} status=${e.status} sent=${e.sent_at}`);
851
+ }
852
+ return;
853
+ }
854
+ printJson(all);
855
+ });
856
+ mail
857
+ .command("tickle <id>")
858
+ .description("Send a polite follow-up ping in the same thread as <id>")
859
+ .option("--message <text>", "Custom ping message", "Just bumping this — any update?")
860
+ .action(async (id, opts) => {
861
+ const wsSlug = program.opts().workspace;
862
+ try {
863
+ const client = createClient(wsSlug);
864
+ const res = await client.post("/api/v1/opvs/reply", {
865
+ message_id: id,
866
+ body: opts.message,
867
+ });
868
+ const data = await res.json();
869
+ printJson(data);
870
+ }
871
+ catch (err) {
872
+ const e = err;
873
+ console.error(`Error: ${e.detail || "Failed to tickle"}`);
874
+ process.exit(1);
875
+ }
876
+ });
877
+ // ─── sub-addresses ─────────────────────────────────────────────────
878
+ const sub = mail.command("sub").description("Sub-address (+suffix) helpers");
879
+ sub
880
+ .command("list")
881
+ .description("List sub-addresses with message counts")
882
+ .action(() => {
883
+ printJson(mailstore.listSubAddresses());
884
+ });
885
+ sub
886
+ .command("watch <suffix>")
887
+ .description("Stream live events for messages addressed to ``+<suffix>``")
888
+ .action(async (suffix) => {
889
+ const wsSlug = program.opts().workspace;
890
+ const cfg = getEffectiveConfig(wsSlug);
891
+ const ctrl = new AbortController();
892
+ process.on("SIGINT", () => { ctrl.abort(); });
893
+ try {
894
+ await mailstore.watchLive({
895
+ apiUrl: cfg.api_url,
896
+ token: cfg.token,
897
+ brandId: cfg.brand_id ?? null,
898
+ filterSuffix: suffix,
899
+ signal: ctrl.signal,
900
+ onMessage: (m) => console.log(`📨 ${m.id} ${m.body.split("\n")[0].slice(0, 80)}`),
901
+ onStatusChange: (e) => console.log(`🔁 ${e.message_id} → ${e.new_status}`),
902
+ });
903
+ }
904
+ catch (err) {
905
+ const e = err;
906
+ console.error(`Error: ${e.detail || e.message || "Watch failed"}`);
907
+ process.exit(1);
908
+ }
909
+ });
910
+ // ─── compact + log ──────────────────────────────────────────────────
911
+ mail
912
+ .command("compact")
913
+ .description("Archive + prune messages older than the cutoff (git gc analog)")
914
+ .option("--older-than <days>", "Age in days", "90")
915
+ .action((opts) => {
916
+ const result = mailstore.compact({ olderThanDays: parseInt(opts.olderThan, 10) });
917
+ printJson(result);
918
+ });
919
+ mail
920
+ .command("log")
921
+ .description("Print the local reflog (every action recorded)")
922
+ .option("--limit <n>", "Max entries", "100")
923
+ .option("--since <iso>", "Only entries newer than this ISO timestamp")
924
+ .option("--search <q>", "Substring search")
925
+ .action((opts) => {
926
+ const limit = parseInt(opts.limit, 10);
927
+ const entries = opts.search
928
+ ? mailstore.searchLog(opts.search, { limit })
929
+ : mailstore.tailLog({ limit, since: opts.since });
930
+ printJson(entries);
931
+ });
932
+ mail
933
+ .command("verify")
934
+ .description("Run integrity checks on the local store")
935
+ .action(() => {
936
+ const report = mailstore.verifyIntegrity();
937
+ printJson(report);
938
+ });
939
+ mail
940
+ .command("rebuild-index")
941
+ .description("Rebuild the message-id lookup index from objects/")
942
+ .action(() => {
943
+ const result = mailstore.rebuildIndex();
944
+ printJson(result);
945
+ });
946
+ // Phase E + F — slash commands installer + hooks installer + daemon control
947
+ registerInstallCommands(mail);
948
+ }
949
+ //# sourceMappingURL=mail.js.map