@audienti/cli 0.1.61 → 0.1.62

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,14 @@ All notable changes to the Audienti CLI are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.62] - 2026-09-10
8
+
9
+ ### Added
10
+
11
+ - `inbox-ops queue` now follows every page, numbers each row, saves the numbered list locally per account, and offers `--group-by domain`.
12
+ - 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.
13
+ - Add `inbox_ops.queue`, `inbox_ops.filters`, `inbox_ops.actions`, `inbox_ops.rules.set`, and `inbox_ops.rules.remove` MCP tools.
14
+
7
15
  ## [0.1.61] - 2026-09-08
8
16
 
9
17
  ### 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.62",
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,24 @@ 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;
69
87
  const NETWORK_OPS_QUEUE_USAGE = "Usage: audienti network-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--json] [--account <acct_id>]";
70
88
  const NETWORK_OPS_ACCEPT_USAGE = "Usage: audienti network-ops accept <row_id> [--json] [--account <acct_id>]";
71
89
  const NETWORK_OPS_DECLINE_USAGE = "Usage: audienti network-ops decline <row_id> [--json] [--account <acct_id>]";
@@ -204,7 +222,8 @@ export async function run(argv = process.argv.slice(2), deps = {}) {
204
222
  now: deps.now || (() => new Date()),
205
223
  sleep: deps.sleep || sleep,
206
224
  stdout: deps.stdout || process.stdout,
207
- stderr: deps.stderr || process.stderr
225
+ stderr: deps.stderr || process.stderr,
226
+ stdin: deps.stdin || process.stdin
208
227
  };
209
228
 
210
229
  try {
@@ -357,6 +376,7 @@ async function dispatch(argv, context) {
357
376
  if (normalizedResource === "inbox-ops" && action === "queue") return inboxOpsQueue(rest, context, { accountOverride });
358
377
  if (normalizedResource === "inbox-ops" && action === "filters") return inboxOpsFilters(rest, context, { accountOverride });
359
378
  if (normalizedResource === "inbox-ops" && action === "rule") return inboxOpsRule(rest, context, { accountOverride });
379
+ if (normalizedResource === "inbox-ops" && Object.hasOwn(INBOX_OPS_BULK_VERBS, action)) return inboxOpsBulk(action, rest, context, { accountOverride });
360
380
  if (normalizedResource === "analytics" && ["prospects", "prospect"].includes(action)) return analyticsProspects(rest, context, { accountOverride });
361
381
  if (normalizedResource === "analytics" && ["users", "user"].includes(action)) return analyticsUsers(rest, context, { accountOverride });
362
382
  if (normalizedResource === "analytics" && ["visibility", "visops"].includes(action)) return analyticsVisibility(rest, context, { accountOverride });
@@ -3271,16 +3291,343 @@ async function operatorQueue(args, context, { accountOverride } = {}) {
3271
3291
  async function inboxOpsQueue(args, context, { accountOverride } = {}) {
3272
3292
  const { values, positionals } = parseCommandArgs(args, {
3273
3293
  ...jsonOptions(),
3274
- ...operatorPaginationOptions()
3294
+ ...operatorPaginationOptions(),
3295
+ "group-by": { type: "string" }
3275
3296
  });
3276
3297
  if (positionals.length > 0) throw new CommandError(INBOX_OPS_QUEUE_USAGE);
3298
+ const groupBy = values["group-by"];
3299
+ if (groupBy !== undefined && groupBy !== "domain") throw new CommandError("--group-by only supports domain.");
3277
3300
 
3278
3301
  const query = { opportunity_kind: "inbox", ...operatorPaginationQuery(values) };
3302
+ const singlePage = [values.page, values.offset, values.cursor].some((value) => value !== undefined);
3279
3303
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
3280
- const payload = await client.operatorQueue(accountId, query);
3304
+
3305
+ if (singlePage) {
3306
+ const payload = await client.operatorQueue(accountId, query);
3307
+ const rows = numberInboxOpsRows(inboxOpsPayloadRows(payload));
3308
+ await writeInboxOpsSnapshot(context, { accountId, rows });
3309
+ if (values.json) return writeJson(context.stdout, payload);
3310
+
3311
+ renderOperatorRead(payload, context, { command: "inbox-ops queue", accountId, query }, () => renderInboxOpsQueue(rows, context, { groupBy }));
3312
+ return;
3313
+ }
3314
+
3315
+ const { rows, pages, truncated } = await fetchAllInboxOpsRows(client, accountId, query);
3316
+ await writeInboxOpsSnapshot(context, { accountId, rows });
3317
+ if (values.json) {
3318
+ return writeJson(context.stdout, {
3319
+ kind: "inbox_ops_queue",
3320
+ account_id: accountId,
3321
+ row_count: rows.length,
3322
+ pages_fetched: pages,
3323
+ truncated,
3324
+ decision_queue: rows
3325
+ });
3326
+ }
3327
+
3328
+ renderInboxOpsQueue(rows, context, { groupBy });
3329
+ if (truncated) {
3330
+ writeLine(context.stdout, `Stopped after ${pages} pages; clear some rows and re-run to list the rest.`);
3331
+ }
3332
+ }
3333
+
3334
+ async function fetchAllInboxOpsRows(client, accountId, baseQuery) {
3335
+ const rows = [];
3336
+ const seen = new Set();
3337
+ let query = { ...baseQuery };
3338
+ let pages = 0;
3339
+ let truncated = false;
3340
+
3341
+ for (;;) {
3342
+ const payload = await client.operatorQueue(accountId, query);
3343
+ pages += 1;
3344
+ for (const row of inboxOpsPayloadRows(payload)) {
3345
+ if (seen.has(row.id)) continue;
3346
+ seen.add(row.id);
3347
+ rows.push(row);
3348
+ }
3349
+ if (payload?.has_more !== true) break;
3350
+ if (pages >= INBOX_OPS_MAX_PAGES) {
3351
+ truncated = true;
3352
+ break;
3353
+ }
3354
+
3355
+ const cursor = payload?.metrics?.next_cursor;
3356
+ const nextOffset = payload?.metrics?.next_offset;
3357
+ if (!cursor && !payload?.next_page) break;
3358
+ query = compactObject({
3359
+ ...baseQuery,
3360
+ operator_page: payload?.next_page,
3361
+ operator_cursor: cursor || undefined,
3362
+ operator_offset: cursor ? undefined : (nextOffset ?? undefined)
3363
+ });
3364
+ }
3365
+
3366
+ return { rows: numberInboxOpsRows(rows), pages, truncated };
3367
+ }
3368
+
3369
+ function inboxOpsPayloadRows(payload) {
3370
+ const decisionQueue = Array.isArray(payload?.decision_queue) ? payload.decision_queue : [];
3371
+ const rows = decisionQueue.length > 0 ? decisionQueue : [payload?.next_move].filter(Boolean);
3372
+ return rows.filter((row) => row?.id).map((row) => ({
3373
+ id: String(row.id),
3374
+ display_name: row.display_name,
3375
+ sender: row?.inbox_ops?.sender,
3376
+ domain: row?.inbox_ops?.domain,
3377
+ subject: row?.inbox_ops?.subject,
3378
+ channel: row?.inbox_ops?.channel,
3379
+ connected_account: row?.inbox_ops?.connected_account,
3380
+ state: row?.inbox_ops?.state
3381
+ }));
3382
+ }
3383
+
3384
+ function numberInboxOpsRows(rows) {
3385
+ return rows.map((row, index) => ({ number: index + 1, ...row }));
3386
+ }
3387
+
3388
+ function inboxOpsSnapshotPath(context) {
3389
+ return join(configDirectory(context.env), INBOX_OPS_SNAPSHOT_FILENAME);
3390
+ }
3391
+
3392
+ async function writeInboxOpsSnapshot(context, { accountId, rows }) {
3393
+ await mkdir(configDirectory(context.env), { recursive: true });
3394
+ await writeFile(inboxOpsSnapshotPath(context), JSON.stringify({
3395
+ version: 1,
3396
+ account_id: accountId,
3397
+ listed_at: context.now().toISOString(),
3398
+ rows
3399
+ }, null, 2));
3400
+ }
3401
+
3402
+ async function readInboxOpsSnapshot(context, accountId) {
3403
+ let snapshot;
3404
+ try {
3405
+ snapshot = JSON.parse(await readFile(inboxOpsSnapshotPath(context), "utf8"));
3406
+ } catch (error) {
3407
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return null;
3408
+ throw error;
3409
+ }
3410
+ if (!Array.isArray(snapshot?.rows)) return null;
3411
+ if (String(snapshot.account_id) !== String(accountId)) {
3412
+ throw new CommandError(`The current Inbox Ops list belongs to account ${snapshot.account_id}. Run \`audienti inbox-ops queue --account ${accountId}\` first.`);
3413
+ }
3414
+ return snapshot;
3415
+ }
3416
+
3417
+ function parseInboxOpsSelectors(positionals) {
3418
+ const numbers = new Set();
3419
+ const rowIds = [];
3420
+ const tokens = positionals.flatMap((value) => String(value).split(",")).map((token) => token.trim()).filter(Boolean);
3421
+
3422
+ for (const token of tokens) {
3423
+ if (/^\d+$/.test(token)) {
3424
+ numbers.add(Number(token));
3425
+ continue;
3426
+ }
3427
+ const range = /^(\d+)-(\d+)$/.exec(token);
3428
+ if (range) {
3429
+ const [start, end] = [Number(range[1]), Number(range[2])];
3430
+ if (start < 1 || end < start) throw new CommandError(`Invalid range "${token}". Use ascending ranges like 1-25.`);
3431
+ for (let number = start; number <= end; number += 1) numbers.add(number);
3432
+ continue;
3433
+ }
3434
+ if (INBOX_OPS_ROW_ID_PATTERN.test(token)) {
3435
+ rowIds.push(token);
3436
+ continue;
3437
+ }
3438
+ throw new CommandError(`Unrecognized selector "${token}". Use numbers, ranges like 1-25, or row ids like inbox_ops_message_123.`);
3439
+ }
3440
+ if (numbers.has(0)) throw new CommandError("Row numbers start at 1.");
3441
+
3442
+ return { numbers: [...numbers].sort((left, right) => left - right), rowIds };
3443
+ }
3444
+
3445
+ function inboxOpsDomainMatches(rowDomain, domain) {
3446
+ const candidate = String(rowDomain || "").trim().toLowerCase();
3447
+ const wanted = String(domain || "").trim().toLowerCase().replace(/^@/, "").replace(/\.$/, "");
3448
+ if (!candidate || !wanted) return false;
3449
+ return candidate === wanted || candidate.endsWith(`.${wanted}`);
3450
+ }
3451
+
3452
+ function inboxOpsSenderMatches(rowSender, sender) {
3453
+ const candidate = String(rowSender || "").trim().toLowerCase();
3454
+ const wanted = String(sender || "").trim().toLowerCase();
3455
+ return Boolean(candidate) && candidate === wanted;
3456
+ }
3457
+
3458
+ async function resolveInboxOpsSelection(context, accountId, { positionals, domain, sender, usage }) {
3459
+ const { numbers, rowIds } = parseInboxOpsSelectors(positionals);
3460
+ const needsSnapshot = numbers.length > 0 || domain !== undefined || sender !== undefined;
3461
+ const snapshot = needsSnapshot ? await readInboxOpsSnapshot(context, accountId) : null;
3462
+ if (needsSnapshot && !snapshot) {
3463
+ throw new CommandError("No Inbox Ops list for this account yet. Run `audienti inbox-ops queue` first, then select rows by number.");
3464
+ }
3465
+
3466
+ const selected = [];
3467
+ const seen = new Set();
3468
+ const add = (row) => {
3469
+ if (seen.has(row.id)) return;
3470
+ seen.add(row.id);
3471
+ selected.push(row);
3472
+ };
3473
+
3474
+ for (const number of numbers) {
3475
+ const row = snapshot.rows.find((candidate) => candidate.number === number);
3476
+ if (!row) {
3477
+ 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.`);
3478
+ }
3479
+ add(row);
3480
+ }
3481
+ if (domain !== undefined) {
3482
+ const matches = snapshot.rows.filter((row) => inboxOpsDomainMatches(row.domain, domain));
3483
+ if (matches.length === 0) throw new CommandError(`No listed rows match domain ${domain}.`);
3484
+ matches.forEach(add);
3485
+ }
3486
+ if (sender !== undefined) {
3487
+ const matches = snapshot.rows.filter((row) => inboxOpsSenderMatches(row.sender, sender));
3488
+ if (matches.length === 0) throw new CommandError(`No listed rows match sender ${sender}.`);
3489
+ matches.forEach(add);
3490
+ }
3491
+ for (const rowId of rowIds) add(snapshot?.rows.find((row) => row.id === rowId) || { id: rowId });
3492
+ if (selected.length === 0) throw new CommandError(usage);
3493
+
3494
+ return selected;
3495
+ }
3496
+
3497
+ function inboxOpsBulkUsage(verb) {
3498
+ return `Usage: audienti inbox-ops ${verb} <numbers|ranges|row_ids...> [--domain <domain>] [--sender <email>] [--dry-run] [--yes] [--json] [--account <acct_id>]`;
3499
+ }
3500
+
3501
+ async function inboxOpsBulk(verb, args, context, { accountOverride } = {}) {
3502
+ const operation = INBOX_OPS_BULK_VERBS[verb];
3503
+ const usage = inboxOpsBulkUsage(verb);
3504
+ const { values, positionals } = parseCommandArgs(args, {
3505
+ ...jsonOptions(),
3506
+ domain: { type: "string" },
3507
+ sender: { type: "string" },
3508
+ "dry-run": { type: "boolean" },
3509
+ yes: { type: "boolean" }
3510
+ });
3511
+ if (positionals.length === 0 && values.domain === undefined && values.sender === undefined) throw new CommandError(usage);
3512
+ if (values["dry-run"] && values.yes) throw new CommandError("Choose either --dry-run or --yes, not both.");
3513
+ if (values.domain !== undefined && !String(values.domain).trim()) throw new CommandError("--domain must not be blank.");
3514
+ if (values.sender !== undefined && !String(values.sender).trim()) throw new CommandError("--sender must not be blank.");
3515
+
3516
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
3517
+ const keyedRule = inboxOpsKeyedRule(operation, values, positionals);
3518
+ if (keyedRule) return inboxOpsBulkKeyedRule(verb, keyedRule, values, context, { client, accountId });
3519
+
3520
+ const selected = await resolveInboxOpsSelection(context, accountId, {
3521
+ positionals, domain: values.domain, sender: values.sender, usage
3522
+ });
3523
+ if (!values.json) renderInboxOpsManifest(context, verb, selected);
3524
+
3525
+ if (values["dry-run"]) {
3526
+ const payload = await inboxOpsActionsInBatches(client, accountId, { operation, rows: selected, dryRun: true });
3527
+ if (values.json) return writeJson(context.stdout, payload);
3528
+ return renderInboxOpsBulkResults(context, { selected, payload });
3529
+ }
3530
+
3531
+ if (!values.yes) {
3532
+ const confirmed = await confirmInboxOpsAction(context);
3533
+ if (!confirmed) {
3534
+ if (values.json) return writeJson(context.stdout, { applied: false, operation, row_ids: selected.map((row) => row.id), message: "Re-run with --yes to apply." });
3535
+ writeLine(context.stdout, "");
3536
+ writeLine(context.stdout, "Nothing applied. Re-run with --yes to apply, or --dry-run to validate on the server.");
3537
+ return;
3538
+ }
3539
+ }
3540
+
3541
+ const payload = await inboxOpsActionsInBatches(client, accountId, { operation, rows: selected, dryRun: false });
3281
3542
  if (values.json) return writeJson(context.stdout, payload);
3543
+ renderInboxOpsBulkResults(context, { selected, payload });
3544
+ }
3545
+
3546
+ function inboxOpsKeyedRule(operation, values, positionals) {
3547
+ if (operation === "ignore" || positionals.length > 0) return null;
3548
+ const [disposition, scope] = operation.split("_");
3549
+ const key = scope === "domain" ? values.domain : values.sender;
3550
+ const otherKey = scope === "domain" ? values.sender : values.domain;
3551
+ if (key === undefined || otherKey !== undefined) return null;
3552
+ return { scope, disposition, key: String(key).trim() };
3553
+ }
3554
+
3555
+ async function inboxOpsBulkKeyedRule(verb, { scope, disposition, key }, values, context, { client, accountId }) {
3556
+ const snapshot = await readInboxOpsSnapshot(context, accountId).catch(() => null);
3557
+ const matches = (snapshot?.rows || []).filter((row) => (scope === "domain" ? inboxOpsDomainMatches(row.domain, key) : inboxOpsSenderMatches(row.sender, key)));
3558
+ const action = disposition === "allow" ? "Always show" : "Always filter";
3559
+ if (!values.json) {
3560
+ const listed = matches.length > 0 ? ` Listed rows affected: ${compressNumbers(matches.map((row) => row.number))}.` : "";
3561
+ writeLine(context.stdout, `${action} ${scope} ${key}.${listed}`);
3562
+ writeLine(context.stdout, "This rule is durable and applies to every current and future matching message across your accounts.");
3563
+ }
3564
+ if (values["dry-run"]) {
3565
+ if (values.json) return writeJson(context.stdout, { dry_run: true, rule: { scope, disposition, key }, listed_row_ids: matches.map((row) => row.id) });
3566
+ return writeLine(context.stdout, "Dry run: no rule was written.");
3567
+ }
3568
+ if (!values.yes) {
3569
+ const confirmed = await confirmInboxOpsAction(context);
3570
+ if (!confirmed) {
3571
+ if (values.json) return writeJson(context.stdout, { applied: false, rule: { scope, disposition, key }, message: "Re-run with --yes to apply." });
3572
+ writeLine(context.stdout, "Nothing applied. Re-run with --yes to apply.");
3573
+ return;
3574
+ }
3575
+ }
3282
3576
 
3283
- renderOperatorRead(payload, context, { command: "inbox-ops queue", accountId, query }, () => renderInboxOpsQueue(payload, context));
3577
+ const payload = await client.setInboxOpsRule(accountId, { scope, key, disposition });
3578
+ if (values.json) return writeJson(context.stdout, payload);
3579
+ renderInboxOpsRule(payload, context);
3580
+ }
3581
+
3582
+ async function inboxOpsActionsInBatches(client, accountId, { operation, rows, dryRun }) {
3583
+ const results = [];
3584
+ let last = null;
3585
+ for (let index = 0; index < rows.length; index += INBOX_OPS_BATCH_SIZE) {
3586
+ const batch = rows.slice(index, index + INBOX_OPS_BATCH_SIZE);
3587
+ last = await client.inboxOpsActions(accountId, compactObject({
3588
+ operation,
3589
+ row_ids: batch.map((row) => row.id),
3590
+ dry_run: dryRun || undefined
3591
+ }));
3592
+ results.push(...(Array.isArray(last?.results) ? last.results : []));
3593
+ }
3594
+
3595
+ const counts = {};
3596
+ for (const result of results) counts[result.status] = (counts[result.status] || 0) + 1;
3597
+ return { ...(last || {}), operation, dry_run: dryRun, counts, results };
3598
+ }
3599
+
3600
+ async function confirmInboxOpsAction(context) {
3601
+ const input = context.stdin;
3602
+ if (!input || !input.isTTY) return false;
3603
+ const prompt = createInterface({ input, output: context.stdout });
3604
+ try {
3605
+ const answer = await prompt.question("Apply? [y/N] ");
3606
+ return /^y(es)?$/i.test(String(answer).trim());
3607
+ } finally {
3608
+ prompt.close();
3609
+ }
3610
+ }
3611
+
3612
+ function compressNumbers(numbers) {
3613
+ const sorted = [...new Set(numbers.filter((value) => Number.isInteger(value)))].sort((left, right) => left - right);
3614
+ const parts = [];
3615
+ let start = null;
3616
+ let previous = null;
3617
+ for (const number of sorted) {
3618
+ if (start === null) {
3619
+ start = previous = number;
3620
+ continue;
3621
+ }
3622
+ if (number === previous + 1) {
3623
+ previous = number;
3624
+ continue;
3625
+ }
3626
+ parts.push(start === previous ? String(start) : `${start}-${previous}`);
3627
+ start = previous = number;
3628
+ }
3629
+ if (start !== null) parts.push(start === previous ? String(start) : `${start}-${previous}`);
3630
+ return parts.join(",");
3284
3631
  }
3285
3632
 
3286
3633
  async function networkOpsQueue(args, context, { accountOverride } = {}) {
@@ -6475,18 +6822,78 @@ function operatorCommandArgument(value) {
6475
6822
  return /^[a-zA-Z0-9_./:@=-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
6476
6823
  }
6477
6824
 
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);
6825
+ function renderInboxOpsQueue(rows, context, { groupBy } = {}) {
6481
6826
  if (rows.length === 0) return writeLine(context.stdout, "No Inbox Ops rows found.");
6482
6827
 
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)
6828
+ if (groupBy === "domain") {
6829
+ const groups = new Map();
6830
+ for (const row of rows) {
6831
+ const domain = row.domain || "-";
6832
+ if (!groups.has(domain)) groups.set(domain, []);
6833
+ groups.get(domain).push(row.number);
6834
+ }
6835
+ const sorted = [...groups.entries()].sort(([leftDomain, left], [rightDomain, right]) => right.length - left.length || leftDomain.localeCompare(rightDomain));
6836
+ writeAlignedTable(context, ["DOMAIN", "COUNT", "ROWS"], sorted.map(([domain, numbers]) => [domain, numbers.length, compressNumbers(numbers)]));
6837
+ writeLine(context.stdout, "");
6838
+ writeLine(context.stdout, `${rows.length} rows across ${groups.size} domains.`);
6839
+ writeLine(context.stdout, "Filter a whole domain: audienti inbox-ops filter-domain --domain <domain> --yes");
6840
+ writeLine(context.stdout, "Ignore listed rows: audienti inbox-ops ignore 1-25 --yes");
6841
+ return;
6842
+ }
6843
+
6844
+ writeAlignedTable(context, ["#", "ROW ID", "SENDER", "DOMAIN", "SUBJECT", "CONNECTED INBOX"], rows.map((row) => [
6845
+ row.number,
6846
+ display(row.id),
6847
+ display(row.sender),
6848
+ display(row.domain),
6849
+ display(row.subject),
6850
+ display(row.connected_account)
6851
+ ]));
6852
+ writeLine(context.stdout, "");
6853
+ writeLine(context.stdout, `${rows.length} rows. Numbers stay valid until you re-run this command.`);
6854
+ 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)");
6855
+ writeLine(context.stdout, "Group first: audienti inbox-ops queue --group-by domain");
6856
+ }
6857
+
6858
+ function renderInboxOpsManifest(context, verb, selected) {
6859
+ writeLine(context.stdout, `${INBOX_OPS_BULK_LABELS[verb]} ${selected.length} message${selected.length === 1 ? "" : "s"}:`);
6860
+ writeAlignedTable(context, ["#", "ROW ID", "SENDER", "DOMAIN", "SUBJECT"], selected.map((row) => [
6861
+ row.number ?? "-",
6862
+ display(row.id),
6863
+ display(row.sender),
6864
+ display(row.domain),
6865
+ display(row.subject)
6866
+ ]));
6867
+ }
6868
+
6869
+ function renderInboxOpsBulkResults(context, { selected, payload }) {
6870
+ const numbers = new Map(selected.map((row) => [row.id, row.number]));
6871
+ const results = Array.isArray(payload?.results) ? payload.results : [];
6872
+ writeLine(context.stdout, "");
6873
+ writeAlignedTable(context, ["#", "ROW ID", "RESULT", "DETAIL"], results.map((result) => [
6874
+ numbers.get(result.row_id) ?? "-",
6875
+ display(result.row_id),
6876
+ humanize(result.status),
6877
+ inboxOpsResultDetail(result)
6489
6878
  ]));
6879
+ const counts = payload?.counts || {};
6880
+ const summary = ["applied", "planned", "skipped", "rejected"]
6881
+ .filter((status) => counts[status])
6882
+ .map((status) => `${humanize(status)} ${counts[status]}`)
6883
+ .join(", ");
6884
+ writeLine(context.stdout, "");
6885
+ writeLine(context.stdout, payload?.dry_run ? `Dry run: ${summary || "nothing to do"}. No changes were made.` : `${summary || "Nothing changed"}.`);
6886
+ if (!payload?.dry_run && counts.applied) {
6887
+ writeLine(context.stdout, "Row numbers stay valid until you re-run `audienti inbox-ops queue`.");
6888
+ }
6889
+ }
6890
+
6891
+ function inboxOpsResultDetail(result) {
6892
+ const parts = [];
6893
+ if (result.reason) parts.push(humanize(result.reason));
6894
+ if (result.scope && result.key) parts.push(`${result.disposition || ""} ${result.scope} ${result.key}`.trim());
6895
+ if (result.message) parts.push(result.message);
6896
+ return parts.join(" | ") || "-";
6490
6897
  }
6491
6898
 
6492
6899
  function renderNetworkOpsQueue(payload, context, { accountId }) {
@@ -7972,7 +8379,10 @@ const HELP_TOPICS = new Map([
7972
8379
  " audienti operator failed-drafts requeue <row_id>",
7973
8380
  "",
7974
8381
  " Inbox Ops",
7975
- " audienti inbox-ops queue",
8382
+ " audienti inbox-ops queue [--group-by domain]",
8383
+ " audienti inbox-ops ignore <numbers|ranges> --yes",
8384
+ " audienti inbox-ops filter-domain <numbers|ranges> | --domain <domain> --yes",
8385
+ " audienti inbox-ops filter-sender|allow-sender|allow-domain <numbers|ranges> --yes",
7976
8386
  " audienti inbox-ops filters",
7977
8387
  " audienti inbox-ops rule <row_id> --scope <sender|domain> --disposition <allow|filter>",
7978
8388
  "",
@@ -10809,6 +11219,7 @@ const HELP_TOPICS = new Map([
10809
11219
  "Usage:",
10810
11220
  ` ${INBOX_OPS_QUEUE_USAGE.slice("Usage: ".length)}`,
10811
11221
  ` ${INBOX_OPS_FILTERS_USAGE.slice("Usage: ".length)}`,
11222
+ ...Object.keys(INBOX_OPS_BULK_VERBS).map((verb) => ` ${inboxOpsBulkUsage(verb).slice("Usage: ".length)}`),
10812
11223
  ` ${INBOX_OPS_RULE_USAGE.slice("Usage: ".length)}`,
10813
11224
  ` ${INBOX_OPS_RULE_SET_USAGE.slice("Usage: ".length)}`,
10814
11225
  ` ${INBOX_OPS_RULE_REMOVE_USAGE.slice("Usage: ".length)}`,
@@ -10816,10 +11227,16 @@ const HELP_TOPICS = new Map([
10816
11227
  "Status: implemented",
10817
11228
  "",
10818
11229
  "Purpose:",
10819
- " Inspect the authenticated owner's private Inbox Ops queue and personal/global email rules, then set or remove sender and domain rules.",
11230
+ " 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.",
11231
+ "",
11232
+ "Workflow:",
11233
+ " 1. audienti inbox-ops queue (numbers every row and saves the list locally)",
11234
+ " 2. audienti inbox-ops queue --group-by domain (see which domains dominate)",
11235
+ " 3. audienti inbox-ops ignore 1-25 --yes (or filter-domain / filter-sender / allow-sender / allow-domain)",
10820
11236
  "",
10821
11237
  "Safety:",
10822
11238
  " Rules always apply to the token owner. Row-based updates derive identity from an authorized current Inbox Ops row.",
11239
+ " 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
11240
  " Key-based set/remove commands normalize and validate the supplied sender or domain on the server.",
10824
11241
  "",
10825
11242
  "Rule values:",
@@ -10829,6 +11246,7 @@ const HELP_TOPICS = new Map([
10829
11246
  "API:",
10830
11247
  " queue: GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=inbox",
10831
11248
  " filters: GET /api/v1/accounts/:account_id/inbox_ops/filters.json",
11249
+ " bulk: POST /api/v1/accounts/:account_id/inbox_ops/actions.json",
10832
11250
  " rule: PATCH /api/v1/accounts/:account_id/inbox_ops/:row_id/rule.json",
10833
11251
  " keyed rules: PATCH|DELETE /api/v1/accounts/:account_id/inbox_ops/rules.json"
10834
11252
  ].join("\n")],
@@ -10838,15 +11256,47 @@ const HELP_TOPICS = new Map([
10838
11256
  "",
10839
11257
  "Status: implemented",
10840
11258
  "",
10841
- ...OPERATOR_PAGINATION_HELP,
10842
11259
  "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.",
11260
+ " 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.",
11261
+ " The numbered list is saved locally per account so the bulk verbs can select rows by number until you re-run this command.",
11262
+ " --group-by domain summarizes the same list by sender domain with the row numbers in each group.",
11263
+ "",
11264
+ "Paging:",
11265
+ " Without paging flags the command follows every page itself. Pass --page, --offset, or --cursor to read exactly one page instead;",
11266
+ " that page is numbered and saved the same way and prints the exact continuation command when more rows remain.",
10845
11267
  "",
10846
11268
  "API:",
10847
11269
  " GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=inbox"
10848
11270
  ].join("\n")],
10849
11271
 
11272
+ ...Object.keys(INBOX_OPS_BULK_VERBS).map((verb) => [`inbox-ops ${verb}`, [
11273
+ inboxOpsBulkUsage(verb),
11274
+ "",
11275
+ "Status: implemented",
11276
+ "",
11277
+ "Purpose:",
11278
+ verb === "ignore"
11279
+ ? " Hide the selected private inbound threads from Inbox Ops. A newer reply in the same thread reopens it."
11280
+ : ` ${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"}.`,
11281
+ "",
11282
+ "Selectors:",
11283
+ " Numbers and ranges from the last `audienti inbox-ops queue` list: 1 4 7-12 or 1-25,30",
11284
+ " Row ids: inbox_ops_message_<id>",
11285
+ " --domain <domain> selects every listed row whose sender domain matches (subdomains included)",
11286
+ " --sender <email> selects every listed row from that sender",
11287
+ ...(verb === "ignore" ? [] : [
11288
+ ` 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.`
11289
+ ]),
11290
+ "",
11291
+ "Safety:",
11292
+ " Prints the manifest first. Applies only with --yes or an interactive confirmation.",
11293
+ " --dry-run sends the batch to the server with dry_run and reports planned/skipped/rejected per row without changing anything.",
11294
+ " Rows another owner controls, stale rows, and malformed ids are rejected individually; the rest still apply.",
11295
+ "",
11296
+ "API:",
11297
+ " POST /api/v1/accounts/:account_id/inbox_ops/actions.json {operation, row_ids, dry_run}"
11298
+ ].join("\n")]),
11299
+
10850
11300
  ["inbox-ops filters", [
10851
11301
  INBOX_OPS_FILTERS_USAGE,
10852
11302
  "",
@@ -11498,6 +11948,10 @@ const HELP_TOPICS = new Map([
11498
11948
  " audienti network-ops accept <row_id>",
11499
11949
  " audienti network-ops decline <row_id>",
11500
11950
  " audienti inbox-ops queue",
11951
+ " audienti inbox-ops queue --group-by domain",
11952
+ " audienti inbox-ops ignore 1-25 --dry-run",
11953
+ " audienti inbox-ops ignore 1-25 --yes",
11954
+ " audienti inbox-ops filter-domain --domain alerts.example.com --yes",
11501
11955
  " audienti inbox-ops filters",
11502
11956
  " audienti inbox-ops rule <row_id> --scope sender --disposition filter",
11503
11957
  " audienti inbox-ops rule set --scope sender --key news@example.com --disposition filter",