run402 4.34.0 → 4.35.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/cli.mjs CHANGED
@@ -69,7 +69,9 @@ AUTHORITY — who may act, and with what credential
69
69
  operator Operator (human/email) session — login, then overview across your wallets
70
70
 
71
71
  DELIVER — reach a human when something happens
72
- notifications Delivery records, preferences, channels and routing rules
72
+ deliveries Did a notification actually land (list, get)
73
+ contacts Where a human is reachable — paging ladder + Telegram (list, add, connect, rm, preferences)
74
+ subscriptions Which events go where (add, list, rm)
73
75
  webhook-secret Rotate the operator webhook signing secret
74
76
  email Send template-based emails from your project
75
77
 
@@ -452,6 +454,21 @@ switch (cmd) {
452
454
  await run(sub, rest);
453
455
  break;
454
456
  }
457
+ case "deliveries": {
458
+ const { run } = await import("./lib/deliveries.mjs");
459
+ await run(sub, rest);
460
+ break;
461
+ }
462
+ case "contacts": {
463
+ const { run } = await import("./lib/contacts.mjs");
464
+ await run(sub, rest);
465
+ break;
466
+ }
467
+ case "subscriptions": {
468
+ const { run } = await import("./lib/subscriptions.mjs");
469
+ await run(sub, rest);
470
+ break;
471
+ }
455
472
  case "notifications": {
456
473
  const { run } = await import("./lib/notifications.mjs");
457
474
  await run(sub, rest);
@@ -224,6 +224,17 @@ export const COMMAND_MANIFEST = [
224
224
 
225
225
  // ── events / errors (flat, merged runners) ───────────────────────────────
226
226
  { path: ["events"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
227
+ { path: ["deliveries", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
228
+ { path: ["deliveries", "get"], positionals: [p("notification_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["n_1"], runStyle: "sub" },
229
+ { path: ["contacts", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
230
+ { path: ["contacts", "add"], positionals: [p("email")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["a@b.co"], runStyle: "sub" },
231
+ { path: ["contacts", "connect"], positionals: [p("channel")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["telegram"], runStyle: "sub" },
232
+ { path: ["contacts", "rm"], positionals: [p("contact_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["c_1"], runStyle: "sub" },
233
+ { path: ["contacts", "test"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
234
+ { path: ["contacts", "preferences"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
235
+ { path: ["subscriptions", "add"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
236
+ { path: ["subscriptions", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
237
+ { path: ["subscriptions", "rm"], positionals: [p("subscription_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["r_1"], runStyle: "sub" },
227
238
  { path: ["rooms", "join"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
228
239
  { path: ["messages", "send"], positionals: [p("body")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["hello"], runStyle: "sub" },
229
240
  { path: ["messages", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
@@ -403,16 +414,6 @@ export const COMMAND_MANIFEST = [
403
414
  { path: ["cache", "inspect"], positionals: [p("url")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["https://example.com/"] },
404
415
  { path: ["cache", "invalidate"], positionals: [p("url", { required: false })], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--all", "--host", "example.com"] },
405
416
  { path: ["doctor"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--no-scan"], runStyle: "merged" },
406
- { path: ["notifications", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
407
- { path: ["notifications", "get"], positionals: [p("notification_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["ntf_gate1"] },
408
- { path: ["notifications", "preferences"], positionals: [p("set_kv", { required: false, variadic: true })], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
409
- { path: ["notifications", "test"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
410
- { path: ["notifications", "channels", "connect"], positionals: [p("channel_type")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["telegram"], skipBehavioral: "polls interactively for the Telegram connect handshake" },
411
- { path: ["notifications", "channels", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
412
- { path: ["notifications", "channels", "revoke"], positionals: [p("binding_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["bnd_gate1"] },
413
- { path: ["notifications", "rules", "add"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--binding", "bnd_gate1"] },
414
- { path: ["notifications", "rules", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
415
- { path: ["notifications", "rules", "rm"], positionals: [p("rule_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["rul_gate1"] },
416
417
  { path: ["webhook-secret", "rotate"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
417
418
  { path: ["logs"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--request-id", "req_gate123"], runStyle: "merged" },
418
419
  ];
@@ -427,6 +428,9 @@ export const SKIPPED_FAMILIES = {
427
428
  // `escalations raise`. It has no subcommands to manifest because it takes
428
429
  // none — the noun is being held for addressed agent/human messaging.
429
430
  "message": "reserved noun; fails with COMMAND_REMOVED (renamed to `feedback`)",
431
+ // Split into `deliveries` / `contacts` / `subscriptions`; every subcommand
432
+ // answers COMMAND_REMOVED naming its successor.
433
+ "notifications": "reserved group; split by legible-cli-surface",
430
434
  };
431
435
 
432
436
  /**
@@ -447,4 +451,5 @@ export const RESERVED_SUBCOMMANDS = {
447
451
  "rooms:list": "moved to `messages list` — the verb acts on a message",
448
452
  "rooms:get": "moved to `messages get` — the verb acts on a message",
449
453
  "rooms:ack": "moved to `messages ack` — the verb acts on a message",
454
+ "escalations:contacts": "merged into `contacts` — the ladder and Telegram channels are one question",
450
455
  };
@@ -0,0 +1,428 @@
1
+ /**
2
+ * `run402 contacts` — where a human is reachable.
3
+ *
4
+ * ONE noun over two backends, merged by legible-cli-surface. `escalations
5
+ * contacts` (the paging LADDER: an email at a level, climbed when nobody
6
+ * answers) and `notifications channels` (a bound Telegram CHAT that routed
7
+ * events reach) were the same idea under two names, and nothing in either
8
+ * spelling suggested they were related.
9
+ *
10
+ * They stay distinguishable where it matters: every row carries `kind`, the
11
+ * same way a feed event carries `source`. What is unified is the QUESTION —
12
+ * "how can this human be reached" — not the mechanisms, which really are
13
+ * different and are not pretended otherwise.
14
+ *
15
+ * `rm <id>` takes a bare id and works out which backend owns it, because both
16
+ * are UUIDs and a caller holding one from `contacts list` should not have to
17
+ * know which subsystem minted it. That costs one extra read and removes a
18
+ * `--kind` flag nobody could answer without looking it up.
19
+ *
20
+ * HTTP paths are unchanged (`/agent/v1/notifications/channels*`,
21
+ * `/orgs/v1/:org_id/escalation-contacts*`); the allowance auth headers below
22
+ * are PATH-scoped and must keep naming the real routes.
23
+ */
24
+ import { allowanceAuthHeaders } from "./config.mjs";
25
+ import { getSdk } from "./sdk.mjs";
26
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
27
+ import {
28
+ assertAllowedValue,
29
+ assertKnownFlags,
30
+ flagValue,
31
+ normalizeArgv,
32
+ positionalArgs,
33
+ requirePositionalCount,
34
+ failUnknownSubcommand,
35
+ hasHelp,
36
+ parseIntegerFlag,
37
+ } from "./argparse.mjs";
38
+ import { resolveOrgId } from "./org-context.mjs";
39
+
40
+ const HELP = `run402 contacts — where a human is reachable
41
+
42
+ Usage:
43
+ run402 contacts list
44
+ run402 contacts add <email> [--level <1-10>] [--name <display>]
45
+ run402 contacts connect telegram [--label <name>]
46
+ run402 contacts rm <id>
47
+ run402 contacts preferences
48
+ run402 contacts preferences set <key>=<value> [<key>=<value> ...]
49
+ run402 contacts test [--source app|platform] [--type <event_type>]
50
+
51
+ Two kinds, one question:
52
+ escalation an email in the paging LADDER — \`add\`, with a --level that
53
+ decides when it is climbed to. Paged out of band; no preference
54
+ silences a mandatory class.
55
+ telegram a bound CHAT that routed events reach — \`connect\`. Which
56
+ events reach it is \`run402 subscriptions\`.
57
+
58
+ Every row carries \`kind\`. \`rm\` takes either kind's id and works out which.
59
+
60
+ Notes:
61
+ - Adding an address with no verified operator email is ACCEPTED with a
62
+ reachability warning — you must be able to configure the chain before the
63
+ humans are bound.
64
+ - What actually landed is \`run402 deliveries\`.
65
+ `;
66
+
67
+ async function preferences(args) {
68
+ const parsedArgs = normalizeArgv(args);
69
+ assertKnownFlags(parsedArgs, ["--help", "-h"]);
70
+ const positionals = positionalArgs(parsedArgs);
71
+ allowanceAuthHeaders("/agent/v1/notifications/preferences");
72
+
73
+ if (positionals.length === 0) {
74
+ // GET preferences
75
+ try {
76
+ const data = await getSdk().admin.getNotificationPreferences();
77
+ console.log(JSON.stringify(data, null, 2));
78
+ } catch (err) {
79
+ reportSdkError(err);
80
+ }
81
+ return;
82
+ }
83
+
84
+ if (positionals[0] !== "set") {
85
+ fail({ code: "BAD_USAGE", message: "Usage: run402 notifications preferences [set <key>=<value> ...]" });
86
+ }
87
+
88
+ // SET — parse remaining positional args as key=value
89
+ const patch = {};
90
+ for (const kv of positionals.slice(1)) {
91
+ const eq = kv.indexOf("=");
92
+ if (eq <= 0) {
93
+ fail({ code: "BAD_USAGE", message: `Expected key=value, got: ${kv}` });
94
+ }
95
+ const key = kv.slice(0, eq);
96
+ const rawValue = kv.slice(eq + 1);
97
+ if (key === "digest_day_of_week" || key === "digest_hour_utc") {
98
+ patch[key] = Number(rawValue);
99
+ } else if (key === "webhook_url" && (rawValue === "null" || rawValue === "")) {
100
+ patch[key] = null;
101
+ } else {
102
+ patch[key] = rawValue;
103
+ }
104
+ }
105
+ try {
106
+ const data = await getSdk().admin.setNotificationPreferences(patch);
107
+ console.log(JSON.stringify(data, null, 2));
108
+ } catch (err) {
109
+ reportSdkError(err);
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Telegram channel bindings — notification-channel-routing-telegram.
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const TELEGRAM_CONNECT_POLL_INTERVAL_MS = 3000;
118
+ /** Fallback wait bound if the server's code_expires_at is somehow
119
+ * unparseable — the connect code's real TTL is 15 minutes server-side. */
120
+ const TELEGRAM_CONNECT_FALLBACK_TIMEOUT_MS = 20 * 60 * 1000;
121
+ /** Print a progress line every Nth poll tick (~15s at the interval above) —
122
+ * frequent enough to reassure, not so frequent it floods stderr. */
123
+ const TELEGRAM_CONNECT_PROGRESS_EVERY_N_TICKS = 5;
124
+
125
+ function sleep(ms) {
126
+ return new Promise((resolve) => setTimeout(resolve, ms));
127
+ }
128
+
129
+ /**
130
+ * Poll `GET /agent/v1/notifications/channels` until `bindingId` shows
131
+ * `status: "active"`, or the connect code's `codeExpiresAt` passes.
132
+ * Transient poll failures (network blips) are logged and retried rather
133
+ * than aborting the whole connect flow — bounded by the same expiry.
134
+ */
135
+ async function pollTelegramBindingActive(bindingId, codeExpiresAt) {
136
+ const parsedExpiry = Date.parse(codeExpiresAt);
137
+ const deadlineMs = Number.isFinite(parsedExpiry) ? parsedExpiry : Date.now() + TELEGRAM_CONNECT_FALLBACK_TIMEOUT_MS;
138
+ let tick = 0;
139
+ for (;;) {
140
+ await sleep(TELEGRAM_CONNECT_POLL_INTERVAL_MS);
141
+ tick += 1;
142
+ let channels;
143
+ try {
144
+ channels = await getSdk().admin.channels.list();
145
+ } catch (err) {
146
+ console.error(` (poll failed, retrying: ${err?.message || err})`);
147
+ if (Date.now() >= deadlineMs) return { active: false, timedOut: true, binding: null };
148
+ continue;
149
+ }
150
+ const binding = (channels.telegram || []).find((b) => b.id === bindingId) || null;
151
+ if (binding && binding.status === "active") {
152
+ return { active: true, binding };
153
+ }
154
+ if (Date.now() >= deadlineMs) {
155
+ return { active: false, timedOut: true, binding };
156
+ }
157
+ if (tick % TELEGRAM_CONNECT_PROGRESS_EVERY_N_TICKS === 1) {
158
+ const remainingSec = Math.max(0, Math.round((deadlineMs - Date.now()) / 1000));
159
+ const status = binding ? binding.status : "pending";
160
+ console.error(` waiting for you to tap the link in Telegram... (status: ${status}, ${remainingSec}s left)`);
161
+ }
162
+ }
163
+ }
164
+
165
+ async function channelsConnect(args) {
166
+ const a = normalizeArgv(args);
167
+ assertKnownFlags(a, ["--label", "--help", "-h"], ["--label"]);
168
+ const label = flagValue(a, "--label");
169
+ const positionals = positionalArgs(a, ["--label"]);
170
+ if (positionals.length === 0) {
171
+ fail({
172
+ code: "BAD_USAGE",
173
+ message: "Usage: run402 notifications channels connect telegram [--label <name>]",
174
+ });
175
+ }
176
+ if (positionals[0] !== "telegram") {
177
+ fail({
178
+ code: "BAD_USAGE",
179
+ message: `Unknown channel type: ${positionals[0]}. Only 'telegram' is supported.`,
180
+ details: { channel_type: positionals[0] },
181
+ });
182
+ }
183
+ if (positionals.length > 1) {
184
+ fail({ code: "BAD_USAGE", message: `Unexpected argument for notifications channels connect: ${positionals[1]}` });
185
+ }
186
+
187
+ allowanceAuthHeaders("/agent/v1/notifications/channels/telegram");
188
+ let pending;
189
+ try {
190
+ pending = await getSdk().admin.channels.connectTelegram(label ? { label } : {});
191
+ } catch (err) {
192
+ reportSdkError(err);
193
+ return;
194
+ }
195
+
196
+ console.error("");
197
+ console.error("Open ONE of these links in Telegram and tap Start to connect this channel:");
198
+ console.error(` Private chat: ${pending.connect_url}`);
199
+ console.error(` Group chat: ${pending.connect_group_url}`);
200
+ console.error(` Expires: ${pending.code_expires_at}`);
201
+ console.error("");
202
+ console.error("Waiting for you to tap the link...");
203
+
204
+ const outcome = await pollTelegramBindingActive(pending.binding_id, pending.code_expires_at);
205
+ if (outcome.active) {
206
+ console.error("Connected.");
207
+ console.log(JSON.stringify({ ...pending, connected: true, binding: outcome.binding }, null, 2));
208
+ return;
209
+ }
210
+ console.error("Timed out waiting for you to tap the link — the connect code has expired.");
211
+ console.error("Run `run402 notifications channels connect telegram` again for a fresh link.");
212
+ console.log(JSON.stringify({ ...pending, connected: false, timed_out: true }, null, 2));
213
+ process.exit(1);
214
+ }
215
+
216
+ async function channelsList() {
217
+ allowanceAuthHeaders("/agent/v1/notifications/channels");
218
+ try {
219
+ console.log(JSON.stringify(await getSdk().admin.channels.list(), null, 2));
220
+ } catch (err) {
221
+ reportSdkError(err);
222
+ }
223
+ }
224
+
225
+ async function channelsRevoke(args) {
226
+ const a = normalizeArgv(args);
227
+ assertKnownFlags(a, ["--help", "-h"]);
228
+ const [bindingId] = requirePositionalCount(a, [], {
229
+ min: 1,
230
+ max: 1,
231
+ command: "run402 notifications channels revoke <binding_id>",
232
+ missing: "Missing <binding_id>.",
233
+ });
234
+ allowanceAuthHeaders("/agent/v1/notifications/channels/telegram");
235
+ try {
236
+ console.log(JSON.stringify(await getSdk().admin.channels.revokeTelegram(bindingId), null, 2));
237
+ } catch (err) {
238
+ reportSdkError(err);
239
+ }
240
+ }
241
+
242
+ async function runChannels(args) {
243
+ const channelsAction = args[0];
244
+ const rest = args.slice(1);
245
+ if (!channelsAction || channelsAction === "--help" || channelsAction === "-h") {
246
+ console.log(CHANNELS_HELP);
247
+ process.exit(channelsAction ? 0 : 1);
248
+ }
249
+ if (rest.includes("--help") || rest.includes("-h")) {
250
+ console.log(CHANNELS_HELP);
251
+ process.exit(0);
252
+ }
253
+
254
+ if (channelsAction === "connect") {
255
+ await channelsConnect(rest);
256
+ return;
257
+ }
258
+ if (channelsAction === "list") {
259
+ await channelsList();
260
+ return;
261
+ }
262
+ if (channelsAction === "revoke") {
263
+ await channelsRevoke(rest);
264
+ return;
265
+ }
266
+ failUnknownSubcommand("notifications channels", channelsAction, {
267
+ hint: "Run `run402 notifications channels --help` for usage.",
268
+ });
269
+ }
270
+
271
+ async function test(args) {
272
+ const parsedArgs = normalizeArgv(args);
273
+ const valueFlags = ["--source", "--type"];
274
+ assertKnownFlags(parsedArgs, [...valueFlags, "--help", "-h"], valueFlags);
275
+ const extra = positionalArgs(parsedArgs, valueFlags);
276
+ if (extra.length > 0) {
277
+ fail({ code: "BAD_USAGE", message: `Unexpected argument for notifications test: ${extra[0]}` });
278
+ }
279
+ const source = flagValue(parsedArgs, "--source");
280
+ if (source !== null) assertAllowedValue(source, ["app", "platform"], "--source");
281
+ const eventType = flagValue(parsedArgs, "--type");
282
+ allowanceAuthHeaders("/agent/v1/notifications/test");
283
+ const opts = {};
284
+ if (source) opts.source = source;
285
+ if (eventType) opts.eventType = eventType;
286
+ try {
287
+ const data = await getSdk().admin.testNotification(opts);
288
+ console.log(JSON.stringify(data, null, 2));
289
+ } catch (err) {
290
+ reportSdkError(err);
291
+ }
292
+ }
293
+
294
+ /** Both backends, one list. `kind` discriminates — the events feed's `source`
295
+ * pattern: unify the question, never pretend the mechanisms are the same. */
296
+ async function listContacts(args) {
297
+ const a = normalizeArgv(args);
298
+ assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
299
+ const sdk = getSdk();
300
+ const rows = [];
301
+ const warnings = [];
302
+
303
+ // Escalation ladder. A caller with no org context still gets the telegram
304
+ // half rather than a hard failure — a partial answer that says so beats an
305
+ // error that hides the half it could have given.
306
+ try {
307
+ const orgId = await resolveOrgId(a, { optional: true });
308
+ if (orgId) {
309
+ const res = await sdk.escalations.listContacts(orgId);
310
+ for (const c of res.contacts ?? res.escalation_contacts ?? []) {
311
+ rows.push({ kind: "escalation", id: c.contact_id ?? c.id, ...c });
312
+ }
313
+ } else {
314
+ warnings.push("No organization resolved — escalation contacts omitted. Pass --org <org_id> to include them.");
315
+ }
316
+ } catch (err) {
317
+ warnings.push(`escalation contacts unavailable: ${err?.message ?? String(err)}`);
318
+ }
319
+
320
+ try {
321
+ allowanceAuthHeaders("/agent/v1/notifications");
322
+ const res = await sdk.admin.listNotificationChannels();
323
+ for (const ch of res.channels ?? []) {
324
+ rows.push({ kind: "telegram", id: ch.binding_id ?? ch.id, ...ch });
325
+ }
326
+ } catch (err) {
327
+ warnings.push(`telegram channels unavailable: ${err?.message ?? String(err)}`);
328
+ }
329
+
330
+ console.log(JSON.stringify({ contacts: rows, ...(warnings.length ? { warnings } : {}) }, null, 2));
331
+ }
332
+
333
+ /** Remove either kind. The id alone does not say which backend owns it (both
334
+ * are UUIDs), so resolve it against the merged list rather than making the
335
+ * caller carry a flag they would have to look up anyway. */
336
+ async function removeContact(args) {
337
+ const a = normalizeArgv(args);
338
+ assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
339
+ const positionals = positionalArgs(a, ["--org"]);
340
+ requirePositionalCount(positionals, ["--org"], {
341
+ min: 1, max: 1, command: "run402 contacts rm", missing: "<id>",
342
+ });
343
+ const id = positionals[0];
344
+ const sdk = getSdk();
345
+
346
+ try {
347
+ const orgId = await resolveOrgId(a, { optional: true });
348
+ if (orgId) {
349
+ const res = await sdk.escalations.listContacts(orgId);
350
+ const hit = (res.contacts ?? res.escalation_contacts ?? []).find((c) => (c.contact_id ?? c.id) === id);
351
+ if (hit) {
352
+ console.log(JSON.stringify({ kind: "escalation", ...(await sdk.escalations.removeContact(orgId, id)) }, null, 2));
353
+ return;
354
+ }
355
+ }
356
+ allowanceAuthHeaders("/agent/v1/notifications");
357
+ const chans = await sdk.admin.listNotificationChannels();
358
+ const chan = (chans.channels ?? []).find((c) => (c.binding_id ?? c.id) === id);
359
+ if (chan) {
360
+ console.log(JSON.stringify({ kind: "telegram", ...(await sdk.admin.revokeNotificationChannel(id)) }, null, 2));
361
+ return;
362
+ }
363
+ fail({
364
+ code: "NOT_FOUND",
365
+ message: `No contact with id ${id} in either the escalation ladder or your Telegram channels.`,
366
+ hint: "run402 contacts list",
367
+ });
368
+ } catch (err) {
369
+ reportSdkError(err);
370
+ }
371
+ }
372
+
373
+ export async function run(sub, args) {
374
+ // `--help` arrives as the SUBCOMMAND (`run402 contacts --help`), not only
375
+ // inside args — check both, the way every other family does.
376
+ if (!sub || hasHelp([sub, ...(Array.isArray(args) ? args : [])])) {
377
+ console.log(HELP);
378
+ process.exit(0);
379
+ }
380
+ const rest = Array.isArray(args) ? args : [];
381
+ switch (sub) {
382
+ case "list":
383
+ await listContacts(rest);
384
+ return;
385
+ case "add": {
386
+ // The paging ladder. `connect` is the other kind's verb — they are
387
+ // different acts, the way `wallets new` and `wallets import` are.
388
+ const a = normalizeArgv(rest);
389
+ const valueFlags = ["--org", "--level", "--name"];
390
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
391
+ const positionals = positionalArgs(a, valueFlags);
392
+ requirePositionalCount(positionals, valueFlags, {
393
+ min: 1, max: 1, command: "run402 contacts add", missing: "<email>",
394
+ });
395
+ try {
396
+ const levelRaw = flagValue(a, "--level");
397
+ const level = levelRaw != null ? parseIntegerFlag("--level", levelRaw, { min: 1, max: 10 }) : undefined;
398
+ const created = await getSdk().escalations.addContact(await resolveOrgId(a), {
399
+ email: positionals[0],
400
+ ...(level !== undefined ? { level } : {}),
401
+ ...(flagValue(a, "--name") ? { displayName: flagValue(a, "--name") } : {}),
402
+ });
403
+ console.log(JSON.stringify({ kind: "escalation", ...created }, null, 2));
404
+ for (const w of created.warnings ?? []) console.error(w);
405
+ } catch (err) {
406
+ reportSdkError(err);
407
+ }
408
+ return;
409
+ }
410
+ case "connect":
411
+ await runChannels(["connect", ...rest]);
412
+ return;
413
+ case "rm":
414
+ await removeContact(rest);
415
+ return;
416
+ case "preferences":
417
+ await preferences(rest);
418
+ return;
419
+ case "test":
420
+ // "Am I actually reachable?" — sends a synthetic event through the
421
+ // configured channels and subscriptions. It belongs to the noun that
422
+ // owns reachability, not to the retired group it arrived in.
423
+ await test(rest);
424
+ return;
425
+ default:
426
+ failUnknownSubcommand("contacts", sub);
427
+ }
428
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `run402 deliveries` — the record of whether a notification actually landed.
3
+ *
4
+ * Split out of `run402 notifications` by legible-cli-surface: that group was
5
+ * two nouns in one coat. `list`/`get` read DELIVERY RECORDS (what was sent,
6
+ * where, and whether it arrived); the rest of it was the CONFIGURATION that
7
+ * produces them, which is now `contacts` and `subscriptions`.
8
+ *
9
+ * HTTP paths are unchanged (`/agent/v1/notifications*`) — this is client
10
+ * vocabulary, and the allowance auth headers below are PATH-scoped, so they
11
+ * must keep naming the real route. Renaming them would ship broken auth
12
+ * against a live path, which is exactly how the `feedback` rename nearly went
13
+ * wrong.
14
+ */
15
+ import { allowanceAuthHeaders } from "./config.mjs";
16
+ import { getSdk } from "./sdk.mjs";
17
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
18
+ import {
19
+ assertAllowedValue,
20
+ assertKnownFlags,
21
+ flagValue,
22
+ normalizeArgv,
23
+ positionalArgs,
24
+ requirePositionalCount,
25
+ failUnknownSubcommand,
26
+ hasHelp,
27
+ } from "./argparse.mjs";
28
+
29
+ const HELP = `run402 deliveries — did a notification actually land
30
+
31
+ Usage:
32
+ run402 deliveries list [--type <event_type>] [--since <iso>] [--limit N] [--after <cursor>]
33
+ run402 deliveries get <id>
34
+
35
+ Notes:
36
+ - One row per delivery ATTEMPT, per channel, with its outcome — the audit
37
+ trail behind an email, webhook, or Telegram send.
38
+ - Who gets reached is \`run402 contacts\`; which events route where is
39
+ \`run402 subscriptions\`.
40
+ `;
41
+
42
+ async function list(args) {
43
+ const parsedArgs = normalizeArgv(args);
44
+ const valueFlags = ["--type", "--since", "--limit", "--after"];
45
+ assertKnownFlags(parsedArgs, [...valueFlags, "--help", "-h"], valueFlags);
46
+ const extra = positionalArgs(parsedArgs, valueFlags);
47
+ if (extra.length > 0) {
48
+ fail({ code: "BAD_USAGE", message: `Unexpected argument for notifications list: ${extra[0]}` });
49
+ }
50
+ allowanceAuthHeaders("/agent/v1/notifications");
51
+ const opts = {};
52
+ const type = flagValue(parsedArgs, "--type");
53
+ const since = flagValue(parsedArgs, "--since");
54
+ const limit = flagValue(parsedArgs, "--limit");
55
+ const after = flagValue(parsedArgs, "--after");
56
+ if (type) opts.type = type;
57
+ if (since) opts.since = since;
58
+ if (limit != null) opts.limit = Number(limit);
59
+ if (after != null) opts.after = after;
60
+ try {
61
+ const data = await getSdk().admin.listNotifications(opts);
62
+ console.log(JSON.stringify(data, null, 2));
63
+ } catch (err) {
64
+ reportSdkError(err);
65
+ }
66
+ }
67
+
68
+ async function get(args) {
69
+ const parsedArgs = normalizeArgv(args);
70
+ assertKnownFlags(parsedArgs, ["--help", "-h"]);
71
+ const positionals = positionalArgs(parsedArgs);
72
+ if (positionals.length !== 1) {
73
+ fail({ code: "BAD_USAGE", message: "Usage: run402 notifications get <id>" });
74
+ }
75
+ allowanceAuthHeaders("/agent/v1/notifications");
76
+ try {
77
+ const data = await getSdk().admin.getNotification(positionals[0]);
78
+ console.log(JSON.stringify(data, null, 2));
79
+ } catch (err) {
80
+ reportSdkError(err);
81
+ }
82
+ }
83
+
84
+ export async function run(sub, args) {
85
+ // `--help` arrives as the SUBCOMMAND (`run402 contacts --help`), not only
86
+ // inside args — check both, the way every other family does.
87
+ if (!sub || hasHelp([sub, ...(Array.isArray(args) ? args : [])])) {
88
+ console.log(HELP);
89
+ process.exit(0);
90
+ }
91
+ switch (sub) {
92
+ case "list":
93
+ await list(args);
94
+ return;
95
+ case "get":
96
+ await get(args);
97
+ return;
98
+ default:
99
+ failUnknownSubcommand("deliveries", sub);
100
+ }
101
+ }