@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.
- package/dist/commands/install.d.ts +19 -0
- package/dist/commands/install.js +253 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/mail.d.ts +18 -0
- package/dist/commands/mail.js +949 -0
- package/dist/commands/mail.js.map +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/templates/hooks/claude-code-stop.json +16 -0
- package/templates/launchd/com.opvs.mail.plist +28 -0
- package/templates/slash-commands/ack.md +8 -0
- package/templates/slash-commands/awaiting.md +14 -0
- package/templates/slash-commands/inbox.md +10 -0
- package/templates/slash-commands/mail.md +17 -0
- package/templates/slash-commands/reply.md +11 -0
- package/templates/slash-commands/thread.md +12 -0
- package/templates/systemd/opvs-mail.service +21 -0
|
@@ -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
|