@opvs-ai/cli 0.5.6 → 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.
@@ -1,8 +1,24 @@
1
1
  /**
2
- * opvs mail — OPVS Protocol messaging commands
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.
3
16
  */
4
- import { getEffectiveConfig, createClient } from "@opvs-ai/core";
17
+ import { getEffectiveConfig, createClient, } from "@opvs-ai/core";
18
+ import { mailstore } from "@opvs-ai/core";
5
19
  import { buildUrl, printResponse, printJson } from "../lib/format.js";
20
+ import { registerInstallCommands } from "./install.js";
21
+ import { readFileSync, existsSync, } from "node:fs";
6
22
  /** Map user-friendly type names to OPVS wire-format symbols. */
7
23
  const TYPE_SYMBOLS = {
8
24
  "?": "?", query: "?",
@@ -21,37 +37,112 @@ const PRIORITY_SYMBOLS = {
21
37
  normal: "",
22
38
  low: "",
23
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
+ // =============================================================
24
114
  export function registerMailCommands(program) {
25
115
  const mail = program.command("mail").description("OPVS Protocol messaging");
26
- // ── send ────────────────────────────────────────────────────
116
+ // ── server-facing: send / reply / inbox / read / resolve / claim ───
27
117
  mail
28
118
  .command("send <address> <message>")
29
119
  .description("Send an OPVS protocol message")
30
120
  .option("--priority <level>", "Priority: critical, high, medium, normal", "normal")
31
121
  .option("--tags <tags>", "Comma-separated tags")
32
- .option("--type <type>", "Message type: ? ! ~ ^ . > < (or query, command, delegate, escalate, inform, handoff, reply)", ".")
122
+ .option("--type <type>", "Message type: ? ! ~ ^ . > < (or query/command/delegate/escalate/inform/handoff/reply)", ".")
33
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")
34
126
  .action(async (address, message, opts) => {
35
127
  const wsSlug = program.opts().workspace;
36
128
  const cfg = getEffectiveConfig(wsSlug);
37
129
  try {
38
130
  const typeSymbol = TYPE_SYMBOLS[opts.type] || ".";
39
131
  const priority = PRIORITY_SYMBOLS[opts.priority] || "";
40
- const tagsPart = opts.tags
41
- ? opts.tags.split(",").map((t) => `#${t.trim()}`).join(" ")
42
- : "";
132
+ const tags = opts.tags
133
+ ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean)
134
+ : [];
135
+ const tagsPart = tags.map((t) => `#${t}`).join(" ");
43
136
  let header = `@${opts.from}>${address} ${typeSymbol}`;
44
137
  if (priority)
45
138
  header += ` ${priority}`;
46
139
  if (tagsPart)
47
140
  header += ` ${tagsPart}`;
48
- const wireFormat = `${header}\n\n${message}`;
49
- // Raw fetch — the core client always JSON-stringifies, but /send needs text/opvs
141
+ const bodyText = opts.bodyFile ? readBodyFromFile(opts.bodyFile) : message;
142
+ const wireFormat = `${header}\n\n${bodyText}`;
50
143
  const baseUrl = cfg.api_url.replace(/\/$/, "");
51
144
  const url = `${baseUrl}/api/v1/opvs/send`;
52
- const headers = {
53
- "Content-Type": "text/opvs",
54
- };
145
+ const headers = { "Content-Type": "text/opvs" };
55
146
  if (cfg.token)
56
147
  headers["Authorization"] = `Bearer ${cfg.token}`;
57
148
  if (cfg.brand_id)
@@ -61,15 +152,11 @@ export function registerMailCommands(program) {
61
152
  let detail;
62
153
  try {
63
154
  const err = (await res.json());
64
- if (typeof err.detail === "string") {
65
- detail = err.detail;
66
- }
67
- else if (Array.isArray(err.detail)) {
68
- detail = err.detail.map((d) => d.msg || JSON.stringify(d)).join("; ");
69
- }
70
- else {
71
- detail = res.statusText;
72
- }
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;
73
160
  }
74
161
  catch {
75
162
  detail = res.statusText;
@@ -77,21 +164,45 @@ export function registerMailCommands(program) {
77
164
  console.error(`Error: ${detail}`);
78
165
  process.exit(1);
79
166
  }
80
- await printResponse(res);
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
+ }
81
188
  }
82
189
  catch (err) {
83
190
  const e = err;
84
- console.error(`Error: ${e.detail || "Failed to send message"}`);
191
+ console.error(`Error: ${e.detail || e.message || "Failed to send message"}`);
85
192
  process.exit(1);
86
193
  }
87
194
  });
88
- // ── inbox ───────────────────────────────────────────────────
89
195
  mail
90
196
  .command("inbox")
91
- .description("Fetch inbox messages")
197
+ .description("Fetch inbox messages from the server (use ``mail list`` for the local mirror)")
92
198
  .option("--limit <n>", "Max messages", "50")
93
199
  .option("--tags <tags>", "Filter by comma-separated tags")
94
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")
95
206
  .action(async (opts) => {
96
207
  const wsSlug = program.opts().workspace;
97
208
  const cfg = getEffectiveConfig(wsSlug);
@@ -104,6 +215,16 @@ export function registerMailCommands(program) {
104
215
  params.tags = opts.tags;
105
216
  if (opts.flags)
106
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;
107
228
  const url = buildUrl("/api/v1/opvs/inbox", params, cfg.format);
108
229
  const res = await client.get(url);
109
230
  await printResponse(res);
@@ -114,18 +235,37 @@ export function registerMailCommands(program) {
114
235
  process.exit(1);
115
236
  }
116
237
  });
117
- // ── read ────────────────────────────────────────────────────
118
238
  mail
119
239
  .command("read <id>")
120
- .description("Read a single message by ID")
121
- .action(async (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) => {
122
245
  const wsSlug = program.opts().workspace;
123
- const cfg = getEffectiveConfig(wsSlug);
124
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
+ }
125
257
  const client = createClient(wsSlug);
126
- const url = buildUrl(`/api/v1/opvs/messages/${id}`, undefined, cfg.format);
127
- const res = await client.get(url);
128
- await printResponse(res);
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 });
129
269
  }
130
270
  catch (err) {
131
271
  const e = err;
@@ -133,20 +273,53 @@ export function registerMailCommands(program) {
133
273
  process.exit(1);
134
274
  }
135
275
  });
136
- // ── reply ───────────────────────────────────────────────────
137
276
  mail
138
277
  .command("reply <id> <message>")
139
278
  .description("Reply to a message")
140
- .action(async (id, 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) => {
141
282
  const wsSlug = program.opts().workspace;
142
283
  try {
143
284
  const client = createClient(wsSlug);
285
+ const bodyText = opts.bodyFile ? readBodyFromFile(opts.bodyFile) : message;
144
286
  const res = await client.post("/api/v1/opvs/reply", {
145
287
  message_id: id,
146
- body: message,
288
+ body: bodyText,
147
289
  });
148
290
  const data = await res.json();
149
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
+ }
150
323
  }
151
324
  catch (err) {
152
325
  const e = err;
@@ -154,19 +327,21 @@ export function registerMailCommands(program) {
154
327
  process.exit(1);
155
328
  }
156
329
  });
157
- // ── resolve ─────────────────────────────────────────────────
158
330
  mail
159
331
  .command("resolve <id>")
160
- .description("Mark a message as resolved")
332
+ .description("Mark a message as resolved (recipient only)")
161
333
  .action(async (id) => {
162
334
  const wsSlug = program.opts().workspace;
163
335
  try {
164
336
  const client = createClient(wsSlug);
165
- const res = await client.post("/api/v1/opvs/resolve", {
166
- message_id: id,
167
- });
337
+ const res = await client.post("/api/v1/opvs/resolve", { message_id: id });
168
338
  const data = await res.json();
169
339
  printJson(data);
340
+ // Mirror status locally.
341
+ try {
342
+ mailstore.setStatus(id, "resolved");
343
+ }
344
+ catch { /* non-fatal */ }
170
345
  }
171
346
  catch (err) {
172
347
  const e = err;
@@ -174,7 +349,6 @@ export function registerMailCommands(program) {
174
349
  process.exit(1);
175
350
  }
176
351
  });
177
- // ── claim ───────────────────────────────────────────────────
178
352
  mail
179
353
  .command("claim")
180
354
  .description("Claim next message from a pool")
@@ -193,6 +367,12 @@ export function registerMailCommands(program) {
193
367
  const res = await client.post("/api/v1/opvs/claim", body);
194
368
  const data = await res.json();
195
369
  printJson(data);
370
+ if (data && data.id) {
371
+ try {
372
+ mailstore.appendIncoming(data);
373
+ }
374
+ catch { /* non-fatal */ }
375
+ }
196
376
  }
197
377
  catch (err) {
198
378
  const e = err;
@@ -200,7 +380,6 @@ export function registerMailCommands(program) {
200
380
  process.exit(1);
201
381
  }
202
382
  });
203
- // ── pair ────────────────────────────────────────────────────
204
383
  mail
205
384
  .command("pair <address>")
206
385
  .description("Initiate pairing with another agent")
@@ -222,7 +401,6 @@ export function registerMailCommands(program) {
222
401
  process.exit(1);
223
402
  }
224
403
  });
225
- // ── agents ──────────────────────────────────────────────────
226
404
  mail
227
405
  .command("agents")
228
406
  .description("List registered OPVS agents")
@@ -251,7 +429,6 @@ export function registerMailCommands(program) {
251
429
  process.exit(1);
252
430
  }
253
431
  });
254
- // ── register ────────────────────────────────────────────────
255
432
  mail
256
433
  .command("register")
257
434
  .description("Register a new OPVS agent address")
@@ -285,5 +462,488 @@ export function registerMailCommands(program) {
285
462
  process.exit(1);
286
463
  }
287
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);
288
948
  }
289
949
  //# sourceMappingURL=mail.js.map