@audienti/cli 0.1.61 → 0.1.63

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to the Audienti CLI are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.63] - 2026-09-10
8
+
9
+ ### Fixed
10
+
11
+ - `inbox-ops queue` retries a page read that returns HTTP 502, 503, or 504 up to three times with backoff before failing, since one Inbox Ops page can take the server most of the proxy timeout.
12
+
13
+ ## [0.1.62] - 2026-09-10
14
+
15
+ ### Added
16
+
17
+ - `inbox-ops queue` now follows every page, numbers each row, saves the numbered list locally per account, and offers `--group-by domain`.
18
+ - Add bulk `inbox-ops ignore`, `filter-sender`, `filter-domain`, `allow-sender`, and `allow-domain` verbs that select rows by number, range, row id, `--domain`, or `--sender`, print a manifest, validate with `--dry-run`, and apply with `--yes` through the new `POST inbox_ops/actions` API with per-row results.
19
+ - Add `inbox_ops.queue`, `inbox_ops.filters`, `inbox_ops.actions`, `inbox_ops.rules.set`, and `inbox_ops.rules.remove` MCP tools.
20
+
7
21
  ## [0.1.61] - 2026-09-08
8
22
 
9
23
  ### Added
package/README.md CHANGED
@@ -245,22 +245,42 @@ the provider action was queued; it is not confirmation that LinkedIn completed
245
245
  the action. The same queue can be read with
246
246
  `audienti operator queue --opportunity-kind network`.
247
247
 
248
- Inbox Ops has its own owner-scoped CLI surface. Queue rows expose the stable row
249
- id used by the row-based rule command. Rules can also be set or removed directly
250
- by sender or domain; the server normalizes and validates every supplied key:
251
-
252
- ```bash
253
- audienti inbox-ops queue [--page <n>] [--offset <n>|--cursor <token>]
248
+ Inbox Ops has its own owner-scoped CLI surface built for clearing a backlog in
249
+ bulk. `inbox-ops queue` follows every page, numbers each row, and saves the
250
+ numbered list locally for the selected account. The bulk verbs select rows by
251
+ number, range, row id, `--domain`, or `--sender`, print a manifest, and apply
252
+ only with `--yes` (or an interactive confirmation). `--dry-run` validates every
253
+ row on the server and reports planned, skipped, and rejected rows without
254
+ changing anything:
255
+
256
+ ```bash
257
+ audienti inbox-ops queue
258
+ audienti inbox-ops queue --group-by domain
259
+ audienti inbox-ops ignore 1-25 --dry-run
260
+ audienti inbox-ops ignore 1-25 --yes
261
+ audienti inbox-ops filter-domain 3,7 --yes
262
+ audienti inbox-ops filter-domain --domain alerts.example.com --yes
263
+ audienti inbox-ops filter-sender 12 --yes
264
+ audienti inbox-ops allow-sender 4 --yes
265
+ audienti inbox-ops allow-domain --domain customer.example --yes
254
266
  audienti inbox-ops filters
255
267
  audienti inbox-ops rule <row_id> --scope sender --disposition filter
256
- audienti inbox-ops rule <row_id> --scope domain --disposition allow
257
268
  audienti inbox-ops rule set --scope sender --key news@example.com --disposition filter
258
269
  audienti inbox-ops rule remove --scope domain --key example.com
259
270
  ```
260
271
 
261
- The rule commands mirror the Operator card actions. They update only
262
- the authenticated owner's personal/global Inbox Ops preferences and do not
263
- archive mail, call the provider, or add a DNC entry.
272
+ Numbers stay valid until `inbox-ops queue` runs again. `ignore` hides one
273
+ thread until a newer reply arrives; `filter-*` and `allow-*` write durable rules
274
+ that apply to every current and future matching message across the owner's
275
+ accounts. Rule verbs given only `--domain` or `--sender` write the keyed rule
276
+ directly, even when the matching rows are no longer listed. Every row is
277
+ authorized individually on the server: rows another owner controls, stale rows,
278
+ and malformed ids are rejected one by one while the rest still apply. Pass
279
+ `--page`, `--offset`, or `--cursor` to read exactly one page instead.
280
+
281
+ All Inbox Ops commands update only the authenticated owner's personal/global
282
+ Inbox Ops preferences or private ignore receipts. They do not archive mail, call
283
+ the provider, or add a DNC entry.
264
284
 
265
285
  To manage simple reminders for yourself:
266
286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audienti/cli",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "Agent-first command-line client for Audienti.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,8 @@ undocumented API endpoints.
12
12
 
13
13
  Set `approach` in a Motion create/update payload, or use `motions update <id> --approach "..."`. A nonblank Approach automatically guides adaptive planning and writing; blank, nil, or whitespace keeps the existing sequence. The simple `--approach ""` flag explicitly clears the guidance and restores that sequence. There is no separate planning-mode selector, and existing sending controls still apply.
14
14
 
15
+ To clear an Inbox Ops backlog, run `inbox-ops queue` first: it follows every page, numbers each row, and saves the numbered list locally for the selected account. Then use `inbox-ops ignore`, `filter-domain`, `filter-sender`, `allow-domain`, or `allow-sender` with numbers, ranges (`1-25,30`), row ids, `--domain`, or `--sender`. Always run `--dry-run` first when acting for a person, read the per-row planned/skipped/rejected results back, and only then re-run with `--yes`. Numbers stay valid until `inbox-ops queue` runs again; ignore hides one thread until a new reply, while filter and allow rules are durable across all of the owner's accounts. Rule verbs given only `--domain` or `--sender` write the keyed rule directly. Through MCP use `inbox_ops.queue`, then `inbox_ops.actions` with `{operation, row_ids, dry_run}`.
16
+
15
17
  When Operator exposes `answer_planner_question`, read its prompt and candidate options with `operator queue` or `operator next`. Use `operator answer <row_id> --choice <id>` or `--answer "..."`, exactly one input. This command refetches the exact row and submits the current row and decision fingerprints. Do not substitute a generic outcome, a reusable steer note, or a guessed send. A 409 means refresh the question; 202 means the answer was recorded and planning continues. Answers apply only to that decision.
16
18
 
17
19
  ## Setup
@@ -140,6 +142,11 @@ audienti network-ops queue --json
140
142
  audienti network-ops accept <row_id> --account <acct_id> --json
141
143
  audienti network-ops decline <row_id> --account <acct_id> --json
142
144
  audienti inbox-ops queue --json
145
+ audienti inbox-ops queue --group-by domain
146
+ audienti inbox-ops ignore 1-25 --dry-run --json
147
+ audienti inbox-ops ignore 1-25 --yes --json
148
+ audienti inbox-ops filter-domain --domain alerts.example.com --yes --json
149
+ audienti inbox-ops filter-sender 3,7 --yes --json
143
150
  audienti inbox-ops filters --json
144
151
  audienti inbox-ops rule <row_id> --scope <sender|domain> --disposition <allow|filter> --json
145
152
  audienti analytics motions --json
package/src/api-client.js CHANGED
@@ -674,6 +674,13 @@ export class AudientiClient {
674
674
  });
675
675
  }
676
676
 
677
+ inboxOpsActions(accountId, body) {
678
+ return this.requestJson(accountPath(accountId, ["inbox_ops", "actions"]), {
679
+ method: "POST",
680
+ body
681
+ });
682
+ }
683
+
677
684
  setInboxOpsRule(accountId, body) {
678
685
  return this.requestJson(accountPath(accountId, ["inbox_ops", "rules"]), {
679
686
  method: "PATCH",
package/src/cli.js CHANGED
@@ -2,10 +2,11 @@ import { parseArgs } from "node:util";
2
2
  import { createServer } from "node:http";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomBytes } from "node:crypto";
5
- import { readFile } from "node:fs/promises";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { basename, join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { ApiError, AudientiClient, DEFAULT_HOST, normalizeHost } from "./api-client.js";
8
- import { configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
9
+ import { configDirectory, configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
9
10
 
10
11
  class CommandError extends Error {
11
12
  constructor(message, { exitCode = 1 } = {}) {
@@ -65,7 +66,27 @@ const PROSPECTS_CHECK_USAGE = "Usage: audienti prospects check [--json|--csv] [f
65
66
  const PROSPECTS_IMPORT_BATCH_USAGE = "Usage: audienti prospects import-batch --file <csv|jsonl|json> [--list <list_id>] [--motion <motn_id>] [--assigned-user <id|me>] [--json] [--account <acct_id>]";
66
67
  const OPERATOR_FAILED_DRAFTS_USAGE = "Usage: audienti operator failed-drafts [--json] [filters] [--account <acct_id>]";
67
68
  const OPERATOR_FAILED_DRAFTS_REQUEUE_USAGE = "Usage: audienti operator failed-drafts requeue (--all | <row_id> [row_id...]) [--limit <n>] [--json] [filters] [--account <acct_id>]";
68
- const INBOX_OPS_QUEUE_USAGE = "Usage: audienti inbox-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--json] [--account <acct_id>]";
69
+ const INBOX_OPS_QUEUE_USAGE = "Usage: audienti inbox-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--group-by domain] [--json] [--account <acct_id>]";
70
+ const INBOX_OPS_BULK_VERBS = {
71
+ ignore: "ignore",
72
+ "filter-sender": "filter_sender",
73
+ "filter-domain": "filter_domain",
74
+ "allow-sender": "allow_sender",
75
+ "allow-domain": "allow_domain"
76
+ };
77
+ const INBOX_OPS_BULK_LABELS = {
78
+ ignore: "Ignore",
79
+ "filter-sender": "Always filter the sender of",
80
+ "filter-domain": "Always filter the domain of",
81
+ "allow-sender": "Always show the sender of",
82
+ "allow-domain": "Always show the domain of"
83
+ };
84
+ const INBOX_OPS_SNAPSHOT_FILENAME = "inbox-ops-snapshot.json";
85
+ const INBOX_OPS_MAX_PAGES = 50;
86
+ const INBOX_OPS_BATCH_SIZE = 50;
87
+ const INBOX_OPS_PAGE_ATTEMPTS = 4;
88
+ const INBOX_OPS_RETRY_STATUSES = [502, 503, 504];
89
+ const INBOX_OPS_RETRY_DELAY_MS = 2000;
69
90
  const NETWORK_OPS_QUEUE_USAGE = "Usage: audienti network-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--json] [--account <acct_id>]";
70
91
  const NETWORK_OPS_ACCEPT_USAGE = "Usage: audienti network-ops accept <row_id> [--json] [--account <acct_id>]";
71
92
  const NETWORK_OPS_DECLINE_USAGE = "Usage: audienti network-ops decline <row_id> [--json] [--account <acct_id>]";
@@ -204,7 +225,8 @@ export async function run(argv = process.argv.slice(2), deps = {}) {
204
225
  now: deps.now || (() => new Date()),
205
226
  sleep: deps.sleep || sleep,
206
227
  stdout: deps.stdout || process.stdout,
207
- stderr: deps.stderr || process.stderr
228
+ stderr: deps.stderr || process.stderr,
229
+ stdin: deps.stdin || process.stdin
208
230
  };
209
231
 
210
232
  try {
@@ -357,6 +379,7 @@ async function dispatch(argv, context) {
357
379
  if (normalizedResource === "inbox-ops" && action === "queue") return inboxOpsQueue(rest, context, { accountOverride });
358
380
  if (normalizedResource === "inbox-ops" && action === "filters") return inboxOpsFilters(rest, context, { accountOverride });
359
381
  if (normalizedResource === "inbox-ops" && action === "rule") return inboxOpsRule(rest, context, { accountOverride });
382
+ if (normalizedResource === "inbox-ops" && Object.hasOwn(INBOX_OPS_BULK_VERBS, action)) return inboxOpsBulk(action, rest, context, { accountOverride });
360
383
  if (normalizedResource === "analytics" && ["prospects", "prospect"].includes(action)) return analyticsProspects(rest, context, { accountOverride });
361
384
  if (normalizedResource === "analytics" && ["users", "user"].includes(action)) return analyticsUsers(rest, context, { accountOverride });
362
385
  if (normalizedResource === "analytics" && ["visibility", "visops"].includes(action)) return analyticsVisibility(rest, context, { accountOverride });
@@ -3271,16 +3294,358 @@ async function operatorQueue(args, context, { accountOverride } = {}) {
3271
3294
  async function inboxOpsQueue(args, context, { accountOverride } = {}) {
3272
3295
  const { values, positionals } = parseCommandArgs(args, {
3273
3296
  ...jsonOptions(),
3274
- ...operatorPaginationOptions()
3297
+ ...operatorPaginationOptions(),
3298
+ "group-by": { type: "string" }
3275
3299
  });
3276
3300
  if (positionals.length > 0) throw new CommandError(INBOX_OPS_QUEUE_USAGE);
3301
+ const groupBy = values["group-by"];
3302
+ if (groupBy !== undefined && groupBy !== "domain") throw new CommandError("--group-by only supports domain.");
3277
3303
 
3278
3304
  const query = { opportunity_kind: "inbox", ...operatorPaginationQuery(values) };
3305
+ const singlePage = [values.page, values.offset, values.cursor].some((value) => value !== undefined);
3279
3306
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
3280
- const payload = await client.operatorQueue(accountId, query);
3307
+
3308
+ if (singlePage) {
3309
+ const payload = await client.operatorQueue(accountId, query);
3310
+ const rows = numberInboxOpsRows(inboxOpsPayloadRows(payload));
3311
+ await writeInboxOpsSnapshot(context, { accountId, rows });
3312
+ if (values.json) return writeJson(context.stdout, payload);
3313
+
3314
+ renderOperatorRead(payload, context, { command: "inbox-ops queue", accountId, query }, () => renderInboxOpsQueue(rows, context, { groupBy }));
3315
+ return;
3316
+ }
3317
+
3318
+ const { rows, pages, truncated } = await fetchAllInboxOpsRows(client, accountId, query, context);
3319
+ await writeInboxOpsSnapshot(context, { accountId, rows });
3320
+ if (values.json) {
3321
+ return writeJson(context.stdout, {
3322
+ kind: "inbox_ops_queue",
3323
+ account_id: accountId,
3324
+ row_count: rows.length,
3325
+ pages_fetched: pages,
3326
+ truncated,
3327
+ decision_queue: rows
3328
+ });
3329
+ }
3330
+
3331
+ renderInboxOpsQueue(rows, context, { groupBy });
3332
+ if (truncated) {
3333
+ writeLine(context.stdout, `Stopped after ${pages} pages; clear some rows and re-run to list the rest.`);
3334
+ }
3335
+ }
3336
+
3337
+ // One inbox page read can take the server 15-30s, so a busy proxy sometimes
3338
+ // answers 502/503/504. Retry that page a few times before failing the listing.
3339
+ async function fetchInboxOpsPageWithRetry(client, accountId, query, context) {
3340
+ for (let attempt = 1; ; attempt += 1) {
3341
+ try {
3342
+ return await client.operatorQueue(accountId, query);
3343
+ } catch (error) {
3344
+ const retryable = error instanceof ApiError && INBOX_OPS_RETRY_STATUSES.includes(error.status);
3345
+ if (!retryable || attempt >= INBOX_OPS_PAGE_ATTEMPTS) throw error;
3346
+ writeLine(context.stderr, `Page read returned HTTP ${error.status}; retrying (${attempt}/${INBOX_OPS_PAGE_ATTEMPTS - 1})...`);
3347
+ await context.sleep(INBOX_OPS_RETRY_DELAY_MS * attempt);
3348
+ }
3349
+ }
3350
+ }
3351
+
3352
+ async function fetchAllInboxOpsRows(client, accountId, baseQuery, context) {
3353
+ const rows = [];
3354
+ const seen = new Set();
3355
+ let query = { ...baseQuery };
3356
+ let pages = 0;
3357
+ let truncated = false;
3358
+
3359
+ for (;;) {
3360
+ const payload = await fetchInboxOpsPageWithRetry(client, accountId, query, context);
3361
+ pages += 1;
3362
+ for (const row of inboxOpsPayloadRows(payload)) {
3363
+ if (seen.has(row.id)) continue;
3364
+ seen.add(row.id);
3365
+ rows.push(row);
3366
+ }
3367
+ if (payload?.has_more !== true) break;
3368
+ if (pages >= INBOX_OPS_MAX_PAGES) {
3369
+ truncated = true;
3370
+ break;
3371
+ }
3372
+
3373
+ const cursor = payload?.metrics?.next_cursor;
3374
+ const nextOffset = payload?.metrics?.next_offset;
3375
+ if (!cursor && !payload?.next_page) break;
3376
+ query = compactObject({
3377
+ ...baseQuery,
3378
+ operator_page: payload?.next_page,
3379
+ operator_cursor: cursor || undefined,
3380
+ operator_offset: cursor ? undefined : (nextOffset ?? undefined)
3381
+ });
3382
+ }
3383
+
3384
+ return { rows: numberInboxOpsRows(rows), pages, truncated };
3385
+ }
3386
+
3387
+ function inboxOpsPayloadRows(payload) {
3388
+ const decisionQueue = Array.isArray(payload?.decision_queue) ? payload.decision_queue : [];
3389
+ const rows = decisionQueue.length > 0 ? decisionQueue : [payload?.next_move].filter(Boolean);
3390
+ return rows.filter((row) => row?.id).map((row) => ({
3391
+ id: String(row.id),
3392
+ display_name: row.display_name,
3393
+ sender: row?.inbox_ops?.sender,
3394
+ domain: row?.inbox_ops?.domain,
3395
+ subject: row?.inbox_ops?.subject,
3396
+ channel: row?.inbox_ops?.channel,
3397
+ connected_account: row?.inbox_ops?.connected_account,
3398
+ state: row?.inbox_ops?.state
3399
+ }));
3400
+ }
3401
+
3402
+ function numberInboxOpsRows(rows) {
3403
+ return rows.map((row, index) => ({ number: index + 1, ...row }));
3404
+ }
3405
+
3406
+ function inboxOpsSnapshotPath(context) {
3407
+ return join(configDirectory(context.env), INBOX_OPS_SNAPSHOT_FILENAME);
3408
+ }
3409
+
3410
+ async function writeInboxOpsSnapshot(context, { accountId, rows }) {
3411
+ await mkdir(configDirectory(context.env), { recursive: true });
3412
+ await writeFile(inboxOpsSnapshotPath(context), JSON.stringify({
3413
+ version: 1,
3414
+ account_id: accountId,
3415
+ listed_at: context.now().toISOString(),
3416
+ rows
3417
+ }, null, 2));
3418
+ }
3419
+
3420
+ async function readInboxOpsSnapshot(context, accountId) {
3421
+ let snapshot;
3422
+ try {
3423
+ snapshot = JSON.parse(await readFile(inboxOpsSnapshotPath(context), "utf8"));
3424
+ } catch (error) {
3425
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return null;
3426
+ throw error;
3427
+ }
3428
+ if (!Array.isArray(snapshot?.rows)) return null;
3429
+ if (String(snapshot.account_id) !== String(accountId)) {
3430
+ throw new CommandError(`The current Inbox Ops list belongs to account ${snapshot.account_id}. Run \`audienti inbox-ops queue --account ${accountId}\` first.`);
3431
+ }
3432
+ return snapshot;
3433
+ }
3434
+
3435
+ function parseInboxOpsSelectors(positionals) {
3436
+ const numbers = new Set();
3437
+ const rowIds = [];
3438
+ const tokens = positionals.flatMap((value) => String(value).split(",")).map((token) => token.trim()).filter(Boolean);
3439
+
3440
+ for (const token of tokens) {
3441
+ if (/^\d+$/.test(token)) {
3442
+ numbers.add(Number(token));
3443
+ continue;
3444
+ }
3445
+ const range = /^(\d+)-(\d+)$/.exec(token);
3446
+ if (range) {
3447
+ const [start, end] = [Number(range[1]), Number(range[2])];
3448
+ if (start < 1 || end < start) throw new CommandError(`Invalid range "${token}". Use ascending ranges like 1-25.`);
3449
+ for (let number = start; number <= end; number += 1) numbers.add(number);
3450
+ continue;
3451
+ }
3452
+ if (INBOX_OPS_ROW_ID_PATTERN.test(token)) {
3453
+ rowIds.push(token);
3454
+ continue;
3455
+ }
3456
+ throw new CommandError(`Unrecognized selector "${token}". Use numbers, ranges like 1-25, or row ids like inbox_ops_message_123.`);
3457
+ }
3458
+ if (numbers.has(0)) throw new CommandError("Row numbers start at 1.");
3459
+
3460
+ return { numbers: [...numbers].sort((left, right) => left - right), rowIds };
3461
+ }
3462
+
3463
+ function inboxOpsDomainMatches(rowDomain, domain) {
3464
+ const candidate = String(rowDomain || "").trim().toLowerCase();
3465
+ const wanted = String(domain || "").trim().toLowerCase().replace(/^@/, "").replace(/\.$/, "");
3466
+ if (!candidate || !wanted) return false;
3467
+ return candidate === wanted || candidate.endsWith(`.${wanted}`);
3468
+ }
3469
+
3470
+ function inboxOpsSenderMatches(rowSender, sender) {
3471
+ const candidate = String(rowSender || "").trim().toLowerCase();
3472
+ const wanted = String(sender || "").trim().toLowerCase();
3473
+ return Boolean(candidate) && candidate === wanted;
3474
+ }
3475
+
3476
+ async function resolveInboxOpsSelection(context, accountId, { positionals, domain, sender, usage }) {
3477
+ const { numbers, rowIds } = parseInboxOpsSelectors(positionals);
3478
+ const needsSnapshot = numbers.length > 0 || domain !== undefined || sender !== undefined;
3479
+ const snapshot = needsSnapshot ? await readInboxOpsSnapshot(context, accountId) : null;
3480
+ if (needsSnapshot && !snapshot) {
3481
+ throw new CommandError("No Inbox Ops list for this account yet. Run `audienti inbox-ops queue` first, then select rows by number.");
3482
+ }
3483
+
3484
+ const selected = [];
3485
+ const seen = new Set();
3486
+ const add = (row) => {
3487
+ if (seen.has(row.id)) return;
3488
+ seen.add(row.id);
3489
+ selected.push(row);
3490
+ };
3491
+
3492
+ for (const number of numbers) {
3493
+ const row = snapshot.rows.find((candidate) => candidate.number === number);
3494
+ if (!row) {
3495
+ throw new CommandError(`Row ${number} is not in the current Inbox Ops list (1-${snapshot.rows.length}). Re-run \`audienti inbox-ops queue\` to refresh the numbers.`);
3496
+ }
3497
+ add(row);
3498
+ }
3499
+ if (domain !== undefined) {
3500
+ const matches = snapshot.rows.filter((row) => inboxOpsDomainMatches(row.domain, domain));
3501
+ if (matches.length === 0) throw new CommandError(`No listed rows match domain ${domain}.`);
3502
+ matches.forEach(add);
3503
+ }
3504
+ if (sender !== undefined) {
3505
+ const matches = snapshot.rows.filter((row) => inboxOpsSenderMatches(row.sender, sender));
3506
+ if (matches.length === 0) throw new CommandError(`No listed rows match sender ${sender}.`);
3507
+ matches.forEach(add);
3508
+ }
3509
+ for (const rowId of rowIds) add(snapshot?.rows.find((row) => row.id === rowId) || { id: rowId });
3510
+ if (selected.length === 0) throw new CommandError(usage);
3511
+
3512
+ return selected;
3513
+ }
3514
+
3515
+ function inboxOpsBulkUsage(verb) {
3516
+ return `Usage: audienti inbox-ops ${verb} <numbers|ranges|row_ids...> [--domain <domain>] [--sender <email>] [--dry-run] [--yes] [--json] [--account <acct_id>]`;
3517
+ }
3518
+
3519
+ async function inboxOpsBulk(verb, args, context, { accountOverride } = {}) {
3520
+ const operation = INBOX_OPS_BULK_VERBS[verb];
3521
+ const usage = inboxOpsBulkUsage(verb);
3522
+ const { values, positionals } = parseCommandArgs(args, {
3523
+ ...jsonOptions(),
3524
+ domain: { type: "string" },
3525
+ sender: { type: "string" },
3526
+ "dry-run": { type: "boolean" },
3527
+ yes: { type: "boolean" }
3528
+ });
3529
+ if (positionals.length === 0 && values.domain === undefined && values.sender === undefined) throw new CommandError(usage);
3530
+ if (values["dry-run"] && values.yes) throw new CommandError("Choose either --dry-run or --yes, not both.");
3531
+ if (values.domain !== undefined && !String(values.domain).trim()) throw new CommandError("--domain must not be blank.");
3532
+ if (values.sender !== undefined && !String(values.sender).trim()) throw new CommandError("--sender must not be blank.");
3533
+
3534
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
3535
+ const keyedRule = inboxOpsKeyedRule(operation, values, positionals);
3536
+ if (keyedRule) return inboxOpsBulkKeyedRule(verb, keyedRule, values, context, { client, accountId });
3537
+
3538
+ const selected = await resolveInboxOpsSelection(context, accountId, {
3539
+ positionals, domain: values.domain, sender: values.sender, usage
3540
+ });
3541
+ if (!values.json) renderInboxOpsManifest(context, verb, selected);
3542
+
3543
+ if (values["dry-run"]) {
3544
+ const payload = await inboxOpsActionsInBatches(client, accountId, { operation, rows: selected, dryRun: true });
3545
+ if (values.json) return writeJson(context.stdout, payload);
3546
+ return renderInboxOpsBulkResults(context, { selected, payload });
3547
+ }
3548
+
3549
+ if (!values.yes) {
3550
+ const confirmed = await confirmInboxOpsAction(context);
3551
+ if (!confirmed) {
3552
+ if (values.json) return writeJson(context.stdout, { applied: false, operation, row_ids: selected.map((row) => row.id), message: "Re-run with --yes to apply." });
3553
+ writeLine(context.stdout, "");
3554
+ writeLine(context.stdout, "Nothing applied. Re-run with --yes to apply, or --dry-run to validate on the server.");
3555
+ return;
3556
+ }
3557
+ }
3558
+
3559
+ const payload = await inboxOpsActionsInBatches(client, accountId, { operation, rows: selected, dryRun: false });
3560
+ if (values.json) return writeJson(context.stdout, payload);
3561
+ renderInboxOpsBulkResults(context, { selected, payload });
3562
+ }
3563
+
3564
+ function inboxOpsKeyedRule(operation, values, positionals) {
3565
+ if (operation === "ignore" || positionals.length > 0) return null;
3566
+ const [disposition, scope] = operation.split("_");
3567
+ const key = scope === "domain" ? values.domain : values.sender;
3568
+ const otherKey = scope === "domain" ? values.sender : values.domain;
3569
+ if (key === undefined || otherKey !== undefined) return null;
3570
+ return { scope, disposition, key: String(key).trim() };
3571
+ }
3572
+
3573
+ async function inboxOpsBulkKeyedRule(verb, { scope, disposition, key }, values, context, { client, accountId }) {
3574
+ const snapshot = await readInboxOpsSnapshot(context, accountId).catch(() => null);
3575
+ const matches = (snapshot?.rows || []).filter((row) => (scope === "domain" ? inboxOpsDomainMatches(row.domain, key) : inboxOpsSenderMatches(row.sender, key)));
3576
+ const action = disposition === "allow" ? "Always show" : "Always filter";
3577
+ if (!values.json) {
3578
+ const listed = matches.length > 0 ? ` Listed rows affected: ${compressNumbers(matches.map((row) => row.number))}.` : "";
3579
+ writeLine(context.stdout, `${action} ${scope} ${key}.${listed}`);
3580
+ writeLine(context.stdout, "This rule is durable and applies to every current and future matching message across your accounts.");
3581
+ }
3582
+ if (values["dry-run"]) {
3583
+ if (values.json) return writeJson(context.stdout, { dry_run: true, rule: { scope, disposition, key }, listed_row_ids: matches.map((row) => row.id) });
3584
+ return writeLine(context.stdout, "Dry run: no rule was written.");
3585
+ }
3586
+ if (!values.yes) {
3587
+ const confirmed = await confirmInboxOpsAction(context);
3588
+ if (!confirmed) {
3589
+ if (values.json) return writeJson(context.stdout, { applied: false, rule: { scope, disposition, key }, message: "Re-run with --yes to apply." });
3590
+ writeLine(context.stdout, "Nothing applied. Re-run with --yes to apply.");
3591
+ return;
3592
+ }
3593
+ }
3594
+
3595
+ const payload = await client.setInboxOpsRule(accountId, { scope, key, disposition });
3281
3596
  if (values.json) return writeJson(context.stdout, payload);
3597
+ renderInboxOpsRule(payload, context);
3598
+ }
3282
3599
 
3283
- renderOperatorRead(payload, context, { command: "inbox-ops queue", accountId, query }, () => renderInboxOpsQueue(payload, context));
3600
+ async function inboxOpsActionsInBatches(client, accountId, { operation, rows, dryRun }) {
3601
+ const results = [];
3602
+ let last = null;
3603
+ for (let index = 0; index < rows.length; index += INBOX_OPS_BATCH_SIZE) {
3604
+ const batch = rows.slice(index, index + INBOX_OPS_BATCH_SIZE);
3605
+ last = await client.inboxOpsActions(accountId, compactObject({
3606
+ operation,
3607
+ row_ids: batch.map((row) => row.id),
3608
+ dry_run: dryRun || undefined
3609
+ }));
3610
+ results.push(...(Array.isArray(last?.results) ? last.results : []));
3611
+ }
3612
+
3613
+ const counts = {};
3614
+ for (const result of results) counts[result.status] = (counts[result.status] || 0) + 1;
3615
+ return { ...(last || {}), operation, dry_run: dryRun, counts, results };
3616
+ }
3617
+
3618
+ async function confirmInboxOpsAction(context) {
3619
+ const input = context.stdin;
3620
+ if (!input || !input.isTTY) return false;
3621
+ const prompt = createInterface({ input, output: context.stdout });
3622
+ try {
3623
+ const answer = await prompt.question("Apply? [y/N] ");
3624
+ return /^y(es)?$/i.test(String(answer).trim());
3625
+ } finally {
3626
+ prompt.close();
3627
+ }
3628
+ }
3629
+
3630
+ function compressNumbers(numbers) {
3631
+ const sorted = [...new Set(numbers.filter((value) => Number.isInteger(value)))].sort((left, right) => left - right);
3632
+ const parts = [];
3633
+ let start = null;
3634
+ let previous = null;
3635
+ for (const number of sorted) {
3636
+ if (start === null) {
3637
+ start = previous = number;
3638
+ continue;
3639
+ }
3640
+ if (number === previous + 1) {
3641
+ previous = number;
3642
+ continue;
3643
+ }
3644
+ parts.push(start === previous ? String(start) : `${start}-${previous}`);
3645
+ start = previous = number;
3646
+ }
3647
+ if (start !== null) parts.push(start === previous ? String(start) : `${start}-${previous}`);
3648
+ return parts.join(",");
3284
3649
  }
3285
3650
 
3286
3651
  async function networkOpsQueue(args, context, { accountOverride } = {}) {
@@ -6475,18 +6840,78 @@ function operatorCommandArgument(value) {
6475
6840
  return /^[a-zA-Z0-9_./:@=-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
6476
6841
  }
6477
6842
 
6478
- function renderInboxOpsQueue(payload, context) {
6479
- const decisionQueue = Array.isArray(payload?.decision_queue) ? payload.decision_queue : [];
6480
- const rows = decisionQueue.length > 0 ? decisionQueue : [payload?.next_move].filter(Boolean);
6843
+ function renderInboxOpsQueue(rows, context, { groupBy } = {}) {
6481
6844
  if (rows.length === 0) return writeLine(context.stdout, "No Inbox Ops rows found.");
6482
6845
 
6483
- writeAlignedTable(context, ["ROW ID", "SENDER", "DOMAIN", "SUBJECT", "CONNECTED INBOX"], rows.map((row) => [
6484
- display(row?.id),
6485
- display(row?.inbox_ops?.sender),
6486
- display(row?.inbox_ops?.domain),
6487
- display(row?.inbox_ops?.subject),
6488
- display(row?.inbox_ops?.connected_account)
6846
+ if (groupBy === "domain") {
6847
+ const groups = new Map();
6848
+ for (const row of rows) {
6849
+ const domain = row.domain || "-";
6850
+ if (!groups.has(domain)) groups.set(domain, []);
6851
+ groups.get(domain).push(row.number);
6852
+ }
6853
+ const sorted = [...groups.entries()].sort(([leftDomain, left], [rightDomain, right]) => right.length - left.length || leftDomain.localeCompare(rightDomain));
6854
+ writeAlignedTable(context, ["DOMAIN", "COUNT", "ROWS"], sorted.map(([domain, numbers]) => [domain, numbers.length, compressNumbers(numbers)]));
6855
+ writeLine(context.stdout, "");
6856
+ writeLine(context.stdout, `${rows.length} rows across ${groups.size} domains.`);
6857
+ writeLine(context.stdout, "Filter a whole domain: audienti inbox-ops filter-domain --domain <domain> --yes");
6858
+ writeLine(context.stdout, "Ignore listed rows: audienti inbox-ops ignore 1-25 --yes");
6859
+ return;
6860
+ }
6861
+
6862
+ writeAlignedTable(context, ["#", "ROW ID", "SENDER", "DOMAIN", "SUBJECT", "CONNECTED INBOX"], rows.map((row) => [
6863
+ row.number,
6864
+ display(row.id),
6865
+ display(row.sender),
6866
+ display(row.domain),
6867
+ display(row.subject),
6868
+ display(row.connected_account)
6869
+ ]));
6870
+ writeLine(context.stdout, "");
6871
+ writeLine(context.stdout, `${rows.length} rows. Numbers stay valid until you re-run this command.`);
6872
+ writeLine(context.stdout, "Act by number: audienti inbox-ops ignore 1-25 | filter-domain 3,7 | filter-sender 12 | allow-sender 4 (add --yes to apply, --dry-run to validate)");
6873
+ writeLine(context.stdout, "Group first: audienti inbox-ops queue --group-by domain");
6874
+ }
6875
+
6876
+ function renderInboxOpsManifest(context, verb, selected) {
6877
+ writeLine(context.stdout, `${INBOX_OPS_BULK_LABELS[verb]} ${selected.length} message${selected.length === 1 ? "" : "s"}:`);
6878
+ writeAlignedTable(context, ["#", "ROW ID", "SENDER", "DOMAIN", "SUBJECT"], selected.map((row) => [
6879
+ row.number ?? "-",
6880
+ display(row.id),
6881
+ display(row.sender),
6882
+ display(row.domain),
6883
+ display(row.subject)
6884
+ ]));
6885
+ }
6886
+
6887
+ function renderInboxOpsBulkResults(context, { selected, payload }) {
6888
+ const numbers = new Map(selected.map((row) => [row.id, row.number]));
6889
+ const results = Array.isArray(payload?.results) ? payload.results : [];
6890
+ writeLine(context.stdout, "");
6891
+ writeAlignedTable(context, ["#", "ROW ID", "RESULT", "DETAIL"], results.map((result) => [
6892
+ numbers.get(result.row_id) ?? "-",
6893
+ display(result.row_id),
6894
+ humanize(result.status),
6895
+ inboxOpsResultDetail(result)
6489
6896
  ]));
6897
+ const counts = payload?.counts || {};
6898
+ const summary = ["applied", "planned", "skipped", "rejected"]
6899
+ .filter((status) => counts[status])
6900
+ .map((status) => `${humanize(status)} ${counts[status]}`)
6901
+ .join(", ");
6902
+ writeLine(context.stdout, "");
6903
+ writeLine(context.stdout, payload?.dry_run ? `Dry run: ${summary || "nothing to do"}. No changes were made.` : `${summary || "Nothing changed"}.`);
6904
+ if (!payload?.dry_run && counts.applied) {
6905
+ writeLine(context.stdout, "Row numbers stay valid until you re-run `audienti inbox-ops queue`.");
6906
+ }
6907
+ }
6908
+
6909
+ function inboxOpsResultDetail(result) {
6910
+ const parts = [];
6911
+ if (result.reason) parts.push(humanize(result.reason));
6912
+ if (result.scope && result.key) parts.push(`${result.disposition || ""} ${result.scope} ${result.key}`.trim());
6913
+ if (result.message) parts.push(result.message);
6914
+ return parts.join(" | ") || "-";
6490
6915
  }
6491
6916
 
6492
6917
  function renderNetworkOpsQueue(payload, context, { accountId }) {
@@ -7972,7 +8397,10 @@ const HELP_TOPICS = new Map([
7972
8397
  " audienti operator failed-drafts requeue <row_id>",
7973
8398
  "",
7974
8399
  " Inbox Ops",
7975
- " audienti inbox-ops queue",
8400
+ " audienti inbox-ops queue [--group-by domain]",
8401
+ " audienti inbox-ops ignore <numbers|ranges> --yes",
8402
+ " audienti inbox-ops filter-domain <numbers|ranges> | --domain <domain> --yes",
8403
+ " audienti inbox-ops filter-sender|allow-sender|allow-domain <numbers|ranges> --yes",
7976
8404
  " audienti inbox-ops filters",
7977
8405
  " audienti inbox-ops rule <row_id> --scope <sender|domain> --disposition <allow|filter>",
7978
8406
  "",
@@ -10809,6 +11237,7 @@ const HELP_TOPICS = new Map([
10809
11237
  "Usage:",
10810
11238
  ` ${INBOX_OPS_QUEUE_USAGE.slice("Usage: ".length)}`,
10811
11239
  ` ${INBOX_OPS_FILTERS_USAGE.slice("Usage: ".length)}`,
11240
+ ...Object.keys(INBOX_OPS_BULK_VERBS).map((verb) => ` ${inboxOpsBulkUsage(verb).slice("Usage: ".length)}`),
10812
11241
  ` ${INBOX_OPS_RULE_USAGE.slice("Usage: ".length)}`,
10813
11242
  ` ${INBOX_OPS_RULE_SET_USAGE.slice("Usage: ".length)}`,
10814
11243
  ` ${INBOX_OPS_RULE_REMOVE_USAGE.slice("Usage: ".length)}`,
@@ -10816,10 +11245,16 @@ const HELP_TOPICS = new Map([
10816
11245
  "Status: implemented",
10817
11246
  "",
10818
11247
  "Purpose:",
10819
- " Inspect the authenticated owner's private Inbox Ops queue and personal/global email rules, then set or remove sender and domain rules.",
11248
+ " Inspect the authenticated owner's private Inbox Ops queue and personal/global email rules, then clear rows in bulk by number or set and remove sender and domain rules.",
11249
+ "",
11250
+ "Workflow:",
11251
+ " 1. audienti inbox-ops queue (numbers every row and saves the list locally)",
11252
+ " 2. audienti inbox-ops queue --group-by domain (see which domains dominate)",
11253
+ " 3. audienti inbox-ops ignore 1-25 --yes (or filter-domain / filter-sender / allow-sender / allow-domain)",
10820
11254
  "",
10821
11255
  "Safety:",
10822
11256
  " Rules always apply to the token owner. Row-based updates derive identity from an authorized current Inbox Ops row.",
11257
+ " Bulk verbs print a manifest and apply only with --yes or an interactive confirmation; --dry-run validates every row on the server without changing anything.",
10823
11258
  " Key-based set/remove commands normalize and validate the supplied sender or domain on the server.",
10824
11259
  "",
10825
11260
  "Rule values:",
@@ -10829,6 +11264,7 @@ const HELP_TOPICS = new Map([
10829
11264
  "API:",
10830
11265
  " queue: GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=inbox",
10831
11266
  " filters: GET /api/v1/accounts/:account_id/inbox_ops/filters.json",
11267
+ " bulk: POST /api/v1/accounts/:account_id/inbox_ops/actions.json",
10832
11268
  " rule: PATCH /api/v1/accounts/:account_id/inbox_ops/:row_id/rule.json",
10833
11269
  " keyed rules: PATCH|DELETE /api/v1/accounts/:account_id/inbox_ops/rules.json"
10834
11270
  ].join("\n")],
@@ -10838,15 +11274,47 @@ const HELP_TOPICS = new Map([
10838
11274
  "",
10839
11275
  "Status: implemented",
10840
11276
  "",
10841
- ...OPERATOR_PAGINATION_HELP,
10842
11277
  "Purpose:",
10843
- " List one page of the authenticated owner's current private Inbox Ops rows with the authoritative sender/domain rule identity, subject, and connected inbox.",
10844
- " When more rows exist, the plain output prints the exact continuation command with the same account.",
11278
+ " List every current private Inbox Ops row for the authenticated owner with a short number, the authoritative sender/domain rule identity, subject, and connected inbox.",
11279
+ " The numbered list is saved locally per account so the bulk verbs can select rows by number until you re-run this command.",
11280
+ " --group-by domain summarizes the same list by sender domain with the row numbers in each group.",
11281
+ "",
11282
+ "Paging:",
11283
+ " Without paging flags the command follows every page itself. Pass --page, --offset, or --cursor to read exactly one page instead;",
11284
+ " that page is numbered and saved the same way and prints the exact continuation command when more rows remain.",
10845
11285
  "",
10846
11286
  "API:",
10847
11287
  " GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=inbox"
10848
11288
  ].join("\n")],
10849
11289
 
11290
+ ...Object.keys(INBOX_OPS_BULK_VERBS).map((verb) => [`inbox-ops ${verb}`, [
11291
+ inboxOpsBulkUsage(verb),
11292
+ "",
11293
+ "Status: implemented",
11294
+ "",
11295
+ "Purpose:",
11296
+ verb === "ignore"
11297
+ ? " Hide the selected private inbound threads from Inbox Ops. A newer reply in the same thread reopens it."
11298
+ : ` ${INBOX_OPS_BULK_LABELS[verb]} the selected rows by writing one durable ${verb.endsWith("domain") ? "domain" : "sender"} rule per distinct key. Matching current and future messages ${verb.startsWith("allow") ? "always stay visible" : "leave the queue"}.`,
11299
+ "",
11300
+ "Selectors:",
11301
+ " Numbers and ranges from the last `audienti inbox-ops queue` list: 1 4 7-12 or 1-25,30",
11302
+ " Row ids: inbox_ops_message_<id>",
11303
+ " --domain <domain> selects every listed row whose sender domain matches (subdomains included)",
11304
+ " --sender <email> selects every listed row from that sender",
11305
+ ...(verb === "ignore" ? [] : [
11306
+ ` With only --${verb.endsWith("domain") ? "domain" : "sender"} and no row selectors, the rule is written directly by key even when the rows are no longer listed.`
11307
+ ]),
11308
+ "",
11309
+ "Safety:",
11310
+ " Prints the manifest first. Applies only with --yes or an interactive confirmation.",
11311
+ " --dry-run sends the batch to the server with dry_run and reports planned/skipped/rejected per row without changing anything.",
11312
+ " Rows another owner controls, stale rows, and malformed ids are rejected individually; the rest still apply.",
11313
+ "",
11314
+ "API:",
11315
+ " POST /api/v1/accounts/:account_id/inbox_ops/actions.json {operation, row_ids, dry_run}"
11316
+ ].join("\n")]),
11317
+
10850
11318
  ["inbox-ops filters", [
10851
11319
  INBOX_OPS_FILTERS_USAGE,
10852
11320
  "",
@@ -11498,6 +11966,10 @@ const HELP_TOPICS = new Map([
11498
11966
  " audienti network-ops accept <row_id>",
11499
11967
  " audienti network-ops decline <row_id>",
11500
11968
  " audienti inbox-ops queue",
11969
+ " audienti inbox-ops queue --group-by domain",
11970
+ " audienti inbox-ops ignore 1-25 --dry-run",
11971
+ " audienti inbox-ops ignore 1-25 --yes",
11972
+ " audienti inbox-ops filter-domain --domain alerts.example.com --yes",
11501
11973
  " audienti inbox-ops filters",
11502
11974
  " audienti inbox-ops rule <row_id> --scope sender --disposition filter",
11503
11975
  " audienti inbox-ops rule set --scope sender --key news@example.com --disposition filter",