@oxygen-agent/cli 1.184.3 → 1.209.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env node
2
- // skipcq: JS-0271 — bin entry source; build chmod+x on dist/index.js
3
1
  import { execFileSync } from "node:child_process";
4
2
  import { createHash, randomUUID } from "node:crypto";
5
3
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -374,6 +372,43 @@ function resolveCrmSetupMode(options) {
374
372
  }
375
373
  return options.live === true ? "live" : "dry_run";
376
374
  }
375
+ function buildCrmObjectCreateBody(options) {
376
+ const slug = readOption(options.slug);
377
+ if (!slug) {
378
+ throw new OxygenError("missing_slug", "--slug is required.", { exitCode: 1 });
379
+ }
380
+ const displayName = readOption(options.displayName);
381
+ if (!displayName) {
382
+ throw new OxygenError("missing_display_name", "--display-name is required.", { exitCode: 1 });
383
+ }
384
+ const columns = options.columnsJson ? parseJsonArray(options.columnsJson) : [];
385
+ const identities = options.identitiesJson ? parseJsonArray(options.identitiesJson) : [];
386
+ return {
387
+ slug,
388
+ display_name: displayName,
389
+ columns,
390
+ identities,
391
+ mode: resolveCrmSetupMode(options),
392
+ ...(readOption(options.singularName) ? { singular_name: readOption(options.singularName) } : {}),
393
+ ...(readOption(options.pluralName) ? { plural_name: readOption(options.pluralName) } : {}),
394
+ ...(readOption(options.labelColumn) ? { label_column: readOption(options.labelColumn) } : {}),
395
+ ...(options.relationshipsJson ? { relationships: parseJsonArray(options.relationshipsJson) } : {}),
396
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
397
+ };
398
+ }
399
+ function buildCrmObjectAddAttrBody(options) {
400
+ const column = readOption(options.columnJson)
401
+ ? parseJsonObject(options.columnJson)
402
+ : null;
403
+ if (!column) {
404
+ throw new OxygenError("missing_column", "--column-json is required.", { exitCode: 1 });
405
+ }
406
+ return {
407
+ column,
408
+ mode: resolveCrmSetupMode(options),
409
+ ...(options.asIdentityJson ? { identity: parseJsonObject(options.asIdentityJson) } : {}),
410
+ };
411
+ }
377
412
  function buildCrmAssertBody(object, options) {
378
413
  return {
379
414
  object,
@@ -382,6 +417,28 @@ function buildCrmAssertBody(object, options) {
382
417
  mode: resolveCrmSetupMode(options),
383
418
  };
384
419
  }
420
+ function buildCrmSyncImportBody(provider, options) {
421
+ return {
422
+ provider,
423
+ object: options.object,
424
+ mode: resolveCrmSetupMode(options),
425
+ ...(options.into ? { into: options.into } : {}),
426
+ ...(readPositiveInt(options.limit) !== undefined ? { limit: readPositiveInt(options.limit) } : {}),
427
+ ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
428
+ ...(options.approved ? { approved: true } : {}),
429
+ };
430
+ }
431
+ function buildCrmSyncStatusPath(runId) {
432
+ const params = new URLSearchParams();
433
+ params.set("run_id", runId);
434
+ return `/api/cli/crm/sync/status?${params.toString()}`;
435
+ }
436
+ function buildCrmSyncLinksPath(object, rowId) {
437
+ const params = new URLSearchParams();
438
+ params.set("object", object);
439
+ params.set("row_id", rowId);
440
+ return `/api/cli/crm/sync/links?${params.toString()}`;
441
+ }
385
442
  function buildCrmSearchBody(query, options) {
386
443
  const objects = readCsvOption(options.objects);
387
444
  const limit = readPositiveInt(options.limit);
@@ -391,6 +448,50 @@ function buildCrmSearchBody(query, options) {
391
448
  ...(limit !== undefined ? { limit } : {}),
392
449
  };
393
450
  }
451
+ function buildCrmMergeBody(object, survivorRowId, loserRowId, options) {
452
+ const mode = resolveCrmSetupMode(options);
453
+ if (mode === "live" && options.confirm !== true) {
454
+ throw new OxygenError("confirm_required", "crm merge --live requires --confirm after inspecting the dry-run field diff.", { exitCode: 1 });
455
+ }
456
+ return {
457
+ object,
458
+ survivor_row_id: survivorRowId,
459
+ loser_row_id: loserRowId,
460
+ mode,
461
+ ...(options.confirm === true ? { confirm: true } : {}),
462
+ ...(options.fieldOverridesJson ? { field_overrides: parseJsonObject(options.fieldOverridesJson) } : {}),
463
+ };
464
+ }
465
+ function buildNotetakerSetupBody(options) {
466
+ return {
467
+ mode: resolveNotetakerMode(options),
468
+ enabled: options.disabled === true ? false : true,
469
+ capture_mode: "invited_bot",
470
+ auto_record_scope: "manual",
471
+ ...(readOption(options.botName) ? { bot_name: readOption(options.botName) } : {}),
472
+ ...(readPositiveInt(options.defaultMaxMinutes) ? { default_max_minutes: readPositiveInt(options.defaultMaxMinutes) } : {}),
473
+ ...(readPositiveNumber(options.defaultMaxCredits) ? { default_max_credits: readPositiveNumber(options.defaultMaxCredits) } : {}),
474
+ ...(readPositiveInt(options.retentionDays) ? { retention_days: readPositiveInt(options.retentionDays) } : {}),
475
+ };
476
+ }
477
+ function buildNotetakerScheduleBody(meetingUrl, options) {
478
+ return {
479
+ mode: resolveNotetakerMode(options),
480
+ meeting_url: meetingUrl,
481
+ approved: options.approved === true,
482
+ ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
483
+ ...(readOption(options.joinAt) ? { join_at: readOption(options.joinAt) } : {}),
484
+ ...(readPositiveInt(options.maxMinutes) ? { max_minutes: readPositiveInt(options.maxMinutes) } : {}),
485
+ ...(readPositiveNumber(options.maxCredits) ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
486
+ ...(readOption(options.crmLinksJson) ? { crm_links: parseJsonArray(readOption(options.crmLinksJson) ?? "") } : {}),
487
+ };
488
+ }
489
+ function resolveNotetakerMode(options) {
490
+ if (options.live === true && options.dryRun === true) {
491
+ throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
492
+ }
493
+ return options.live === true ? "live" : "dry_run";
494
+ }
394
495
  function buildCrmRelationshipUpsertBody(object, rowId, options) {
395
496
  return {
396
497
  object,
@@ -403,6 +504,122 @@ function buildCrmRelationshipUpsertBody(object, rowId, options) {
403
504
  mode: resolveCrmSetupMode(options),
404
505
  };
405
506
  }
507
+ function buildCrmActivityBody(object, rowId, options) {
508
+ const body = {
509
+ mode: resolveCrmSetupMode(options),
510
+ activity_type: options.type,
511
+ links: [{ object, row_id: rowId, role: options.role ?? "actor" }],
512
+ };
513
+ if (options.summary)
514
+ body.summary = options.summary;
515
+ if (options.body)
516
+ body.body = options.body;
517
+ if (options.channel)
518
+ body.channel = options.channel;
519
+ if (options.direction)
520
+ body.direction = options.direction;
521
+ if (options.occurredAt)
522
+ body.occurred_at = options.occurredAt;
523
+ if (options.sourceProvider)
524
+ body.source_provider = options.sourceProvider;
525
+ if (options.providerEventId)
526
+ body.provider_event_id = options.providerEventId;
527
+ if (options.metadataJson)
528
+ body.metadata = parseJsonObject(options.metadataJson);
529
+ return body;
530
+ }
531
+ function buildCrmActivityNoteBody(object, rowId, text, options) {
532
+ return {
533
+ mode: resolveCrmSetupMode(options),
534
+ activity_type: "note",
535
+ summary: text,
536
+ links: [{ object, row_id: rowId, role: "actor" }],
537
+ };
538
+ }
539
+ function buildCrmTimelinePath(object, rowId, options) {
540
+ const params = new URLSearchParams();
541
+ if (options.limit)
542
+ params.set("limit", options.limit);
543
+ if (options.cursor)
544
+ params.set("cursor", options.cursor);
545
+ const query = params.toString();
546
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/timeline`;
547
+ return query ? `${base}?${query}` : base;
548
+ }
549
+ function buildCrmPipelinePath(options) {
550
+ const params = new URLSearchParams();
551
+ if (readOption(options.pipeline))
552
+ params.set("pipeline", readOption(options.pipeline));
553
+ if (options.limit)
554
+ params.set("limit", options.limit);
555
+ const query = params.toString();
556
+ return query ? `/api/cli/crm/pipeline?${query}` : "/api/cli/crm/pipeline";
557
+ }
558
+ function buildCrmDuplicatesPath(object, options) {
559
+ const params = new URLSearchParams();
560
+ if (options.limit)
561
+ params.set("limit", options.limit);
562
+ const query = params.toString();
563
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/duplicates`;
564
+ return query ? `${base}?${query}` : base;
565
+ }
566
+ function buildCrmAutomationSetBody(templateId, options) {
567
+ if (options.armed === true && options.disarmed === true) {
568
+ throw new OxygenError("conflicting_flags", "Pass either --armed or --disarmed, not both.", { exitCode: 1 });
569
+ }
570
+ if (options.armed !== true && options.disarmed !== true) {
571
+ throw new OxygenError("missing_flag", "Pass --armed or --disarmed.", { exitCode: 1 });
572
+ }
573
+ const body = {
574
+ template_id: templateId,
575
+ armed: options.armed === true,
576
+ mode: resolveCrmSetupMode(options),
577
+ };
578
+ if (options.configJson)
579
+ body.config = parseJsonObject(options.configJson);
580
+ return body;
581
+ }
582
+ function buildCrmAutomationAuditPath(options) {
583
+ const params = new URLSearchParams();
584
+ if (readOption(options.templateId))
585
+ params.set("template_id", readOption(options.templateId));
586
+ if (options.limit)
587
+ params.set("limit", options.limit);
588
+ const query = params.toString();
589
+ return query ? `/api/cli/crm/automation/audit?${query}` : "/api/cli/crm/automation/audit";
590
+ }
591
+ function buildCrmListCreateBody(slug, options) {
592
+ const body = {
593
+ slug,
594
+ base_object: options.base,
595
+ kind: options.kind,
596
+ mode: resolveCrmSetupMode(options),
597
+ };
598
+ if (options.displayName)
599
+ body.display_name = options.displayName;
600
+ if (options.project)
601
+ body.project = options.project;
602
+ if (options.filterJson)
603
+ body.filter = parseJsonObject(options.filterJson);
604
+ return body;
605
+ }
606
+ function buildCrmListEntryBody(object, rowId, options) {
607
+ return {
608
+ object,
609
+ row_id: rowId,
610
+ mode: resolveCrmSetupMode(options),
611
+ };
612
+ }
613
+ function buildCrmListMembersPath(list, options) {
614
+ const params = new URLSearchParams();
615
+ if (options.limit)
616
+ params.set("limit", options.limit);
617
+ if (options.cursor)
618
+ params.set("cursor", options.cursor);
619
+ const query = params.toString();
620
+ const base = `/api/cli/crm/lists/${encodeURIComponent(list)}/members`;
621
+ return query ? `${base}?${query}` : base;
622
+ }
406
623
  function parseCrmIdentityOption(value) {
407
624
  const separator = value.indexOf("=");
408
625
  if (separator <= 0 || separator === value.length - 1) {
@@ -425,6 +642,10 @@ function readSpecFileBody(path) {
425
642
  throw error;
426
643
  }
427
644
  }
645
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
646
+ function isUuid(value) {
647
+ return UUID_PATTERN.test(value);
648
+ }
428
649
  // Builds the `prompts` command tree (list/get/upsert/archive). The
429
650
  // deprecated `templates` alias is the identical tree — same subcommands,
430
651
  // options, request bodies, and routes — differing only in the parent command
@@ -459,7 +680,7 @@ function buildPromptTemplatesCommand(surface, description) {
459
680
  .action(async (idOrSlug, options) => {
460
681
  await handleAsyncAction(`${surface} get`, options, () => requestOxygen("/api/cli/templates/get", {
461
682
  method: "POST",
462
- body: idOrSlug.includes("-") && idOrSlug.length >= 32
683
+ body: isUuid(idOrSlug)
463
684
  ? { id: idOrSlug }
464
685
  : { slug: idOrSlug },
465
686
  }));
@@ -818,6 +1039,24 @@ export function createProgram() {
818
1039
  .option("--json", "Print a JSON envelope.")
819
1040
  .action(async (options) => {
820
1041
  await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
1042
+ }))
1043
+ .addCommand(new Command("get")
1044
+ .description("Show one support ticket with its message thread (staff only).")
1045
+ .argument("<ticketId>", "Ticket UUID.")
1046
+ .option("--json", "Print a JSON envelope.")
1047
+ .action(async (ticketId, options) => {
1048
+ await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`));
1049
+ }))
1050
+ .addCommand(new Command("reply")
1051
+ .description("Add a staff message to a support ticket without resolving it.")
1052
+ .argument("<ticketId>", "Ticket UUID.")
1053
+ .requiredOption("--body <body>", "Reply body.")
1054
+ .option("--json", "Print a JSON envelope.")
1055
+ .action(async (ticketId, options) => {
1056
+ await handleAsyncAction("support admin reply", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/messages`, {
1057
+ method: "POST",
1058
+ body: { body: readOption(options.body) },
1059
+ }));
821
1060
  }))
822
1061
  .addCommand(new Command("resolve")
823
1062
  .description("Resolve a support ticket and notify the opener (staff only).")
@@ -1013,6 +1252,50 @@ export function createProgram() {
1013
1252
  body: { name },
1014
1253
  }));
1015
1254
  }));
1255
+ program
1256
+ .command("notetaker")
1257
+ .description("AI meeting notetaker setup, scheduling, and session inspection.")
1258
+ .addCommand(new Command("setup")
1259
+ .description("Configure the Recall-backed Oxygen Notetaker. Defaults to dry-run.")
1260
+ .option("--bot-name <name>", "Meeting bot display name. Defaults to Oxygen Notetaker.")
1261
+ .option("--disabled", "Save the notetaker as disabled when used with --live.")
1262
+ .option("--default-max-minutes <n>", "Default maximum meeting minutes to reserve. Defaults to 60.")
1263
+ .option("--default-max-credits <n>", "Default spend cap for live schedules.")
1264
+ .option("--retention-days <n>", "Transcript/artifact retention window. Defaults to 30.")
1265
+ .option("--dry-run", "Preview setup without writing settings.")
1266
+ .option("--live", "Apply notetaker settings.")
1267
+ .option("--json", "Print a JSON envelope.")
1268
+ .action(async (options) => {
1269
+ await handleAsyncAction("notetaker setup", options, () => requestOxygen("/api/cli/notetaker/setup", {
1270
+ method: "POST",
1271
+ body: buildNotetakerSetupBody(options),
1272
+ }));
1273
+ }))
1274
+ .addCommand(new Command("schedule")
1275
+ .description("Schedule an invited meeting bot. Defaults to dry-run.")
1276
+ .argument("<meeting_url>", "Zoom, Google Meet, or Teams meeting URL.")
1277
+ .option("--title <title>", "Meeting title for the CRM timeline.")
1278
+ .option("--join-at <iso>", "ISO date-time for scheduled bot join.")
1279
+ .option("--max-minutes <n>", "Maximum meeting minutes to reserve.")
1280
+ .option("--max-credits <n>", "Required spend cap for live schedules.")
1281
+ .option("--crm-links-json <json>", "JSON array of CRM links: [{\"object\":\"companies\",\"row_id\":\"...\",\"role\":\"account\"}].")
1282
+ .option("--approved", "Approve the live paid Recall bot schedule.")
1283
+ .option("--dry-run", "Preview schedule and credit estimate without creating a bot.")
1284
+ .option("--live", "Create the Recall bot after approval and credit reservation.")
1285
+ .option("--json", "Print a JSON envelope.")
1286
+ .action(async (meetingUrl, options) => {
1287
+ await handleAsyncAction("notetaker schedule", options, () => requestOxygen("/api/cli/notetaker/schedule", {
1288
+ method: "POST",
1289
+ body: buildNotetakerScheduleBody(meetingUrl, options),
1290
+ }));
1291
+ }))
1292
+ .addCommand(new Command("status")
1293
+ .description("Get one notetaker session with artifacts and credit state.")
1294
+ .argument("<session_id>", "Notetaker session id.")
1295
+ .option("--json", "Print a JSON envelope.")
1296
+ .action(async (sessionId, options) => {
1297
+ await handleAsyncAction("notetaker status", options, () => requestOxygen(`/api/cli/notetaker/sessions/${encodeURIComponent(sessionId)}`));
1298
+ }));
1016
1299
  program
1017
1300
  .command("crm")
1018
1301
  .description("Agent-native CRM object setup and metadata commands.")
@@ -1029,12 +1312,56 @@ export function createProgram() {
1029
1312
  body: buildCrmSetupBody(options),
1030
1313
  }));
1031
1314
  }))
1032
- .addCommand(new Command("objects")
1033
- .description("List configured CRM objects in the current tenant database.")
1315
+ .addCommand(
1316
+ // `crm objects` lists (parent action runs when no subcommand matches);
1317
+ // `crm objects create` / `crm objects add-attr` create custom objects.
1318
+ new Command("objects")
1319
+ .description("List configured CRM objects, or create/extend custom objects.")
1034
1320
  .option("--json", "Print a JSON envelope.")
1035
1321
  .action(async (options) => {
1036
1322
  await handleAsyncAction("crm objects", options, () => requestOxygen("/api/cli/crm/objects"));
1323
+ })
1324
+ .addCommand(new Command("create")
1325
+ .description("Create or repair a custom CRM object. Defaults to dry-run.")
1326
+ .requiredOption("--slug <slug>", "Object slug (snake_case), such as projects or tasks.")
1327
+ .requiredOption("--display-name <name>", "Human-readable object name, such as Projects.")
1328
+ .requiredOption("--columns-json <json>", "JSON array of column definitions {key,label,dataType,semanticType,...}.")
1329
+ .option("--identities-json <json>", "JSON array of identity definitions {columnKey,normalization,isPrimary?}.")
1330
+ .option("--relationships-json <json>", "JSON array of relationship definitions (not yet supported).")
1331
+ .option("--label-column <key>", "Column key to use as the record label. Defaults to the isRecordLabel column or the first column.")
1332
+ .option("--singular-name <name>", "Singular display name, such as Project.")
1333
+ .option("--plural-name <name>", "Plural display name, such as Projects.")
1334
+ .option("--project <project>", "Project id or slug for the created CRM table.")
1335
+ .option("--dry-run", "Preview the object plan without creating tables.")
1336
+ .option("--live", "Apply the object creation. Default is dry-run.")
1337
+ .option("--json", "Print a JSON envelope.")
1338
+ .action(async (options, command) => {
1339
+ // The `crm objects` parent declares its own --json + a default
1340
+ // action, so commander binds `objects create --json` to the parent
1341
+ // and options.json is undefined here. optsWithGlobals() reads --json
1342
+ // wherever it landed, so the envelope is emitted like every other
1343
+ // crm_* command instead of printing the bare result.
1344
+ await handleAsyncAction("crm object create", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen("/api/cli/crm/objects", {
1345
+ method: "POST",
1346
+ body: buildCrmObjectCreateBody(options),
1347
+ }));
1037
1348
  }))
1349
+ .addCommand(new Command("add-attr")
1350
+ .description("Add one attribute to an existing custom CRM object, optionally as an identity. Defaults to dry-run.")
1351
+ .argument("<object>", "Custom CRM object slug, such as projects or tasks. Standard objects are managed by `crm setup`.")
1352
+ .requiredOption("--column-json <json>", "JSON column definition {key,label,dataType,semanticType,...}.")
1353
+ .option("--as-identity-json <json>", "JSON identity definition {columnKey,normalization,isPrimary?} promoting the new column.")
1354
+ .option("--dry-run", "Preview the attribute plan without altering the table.")
1355
+ .option("--live", "Apply the attribute. Default is dry-run.")
1356
+ .option("--json", "Print a JSON envelope.")
1357
+ .action(async (object, options, command) => {
1358
+ // See `objects create` above: optsWithGlobals() recovers --json,
1359
+ // which the parent command otherwise swallows.
1360
+ await handleAsyncAction("crm object add-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes`, {
1361
+ method: "POST",
1362
+ body: buildCrmObjectAddAttrBody(options),
1363
+ }));
1364
+ })))
1038
1365
  .addCommand(new Command("search")
1039
1366
  .description("Search CRM records by identity or record label.")
1040
1367
  .argument("<query>", "Domain, email, LinkedIn URL, or record name to search for.")
@@ -1086,6 +1413,55 @@ export function createProgram() {
1086
1413
  method: "POST",
1087
1414
  body: buildCrmRelationshipUpsertBody(object, rowId, options),
1088
1415
  }));
1416
+ })))
1417
+ .addCommand(new Command("activity")
1418
+ .description("Log and read CRM record timeline activities.")
1419
+ .addCommand(new Command("log")
1420
+ .description("Log a timeline activity on a CRM record. Defaults to dry-run.")
1421
+ .argument("<object>", "CRM object slug, such as companies or people.")
1422
+ .argument("<row_id>", "CRM record row id.")
1423
+ .requiredOption("--type <type>", "Activity type, such as note, call, meeting_booked, stage_changed.")
1424
+ .option("--summary <summary>", "Short one-line summary of the activity.")
1425
+ .option("--body <body>", "Longer activity body or details.")
1426
+ .option("--channel <channel>", "Channel, such as email, linkedin, or phone.")
1427
+ .option("--direction <direction>", "inbound, outbound, internal, or system.")
1428
+ .option("--occurred-at <iso>", "ISO-8601 timestamp the activity occurred. Defaults to now.")
1429
+ .option("--role <role>", "Link role for the record. Defaults to actor.")
1430
+ .option("--source-provider <provider>", "Provider that produced the activity (for idempotency).")
1431
+ .option("--provider-event-id <id>", "Provider event id (dedupes redelivered events).")
1432
+ .option("--metadata-json <json>", "JSON object of extra activity metadata.")
1433
+ .option("--dry-run", "Preview the activity without writing it.")
1434
+ .option("--live", "Write the activity. Default is dry-run.")
1435
+ .option("--json", "Print a JSON envelope.")
1436
+ .action(async (object, rowId, options) => {
1437
+ await handleAsyncAction("crm activity log", options, () => requestOxygen("/api/cli/crm/activities", {
1438
+ method: "POST",
1439
+ body: buildCrmActivityBody(object, rowId, options),
1440
+ }));
1441
+ }))
1442
+ .addCommand(new Command("note")
1443
+ .description("Log a free-text note on a CRM record. Defaults to dry-run.")
1444
+ .argument("<object>", "CRM object slug, such as companies or people.")
1445
+ .argument("<row_id>", "CRM record row id.")
1446
+ .argument("<text>", "Note text.")
1447
+ .option("--dry-run", "Preview the note without writing it.")
1448
+ .option("--live", "Write the note. Default is dry-run.")
1449
+ .option("--json", "Print a JSON envelope.")
1450
+ .action(async (object, rowId, text, options) => {
1451
+ await handleAsyncAction("crm activity note", options, () => requestOxygen("/api/cli/crm/activities", {
1452
+ method: "POST",
1453
+ body: buildCrmActivityNoteBody(object, rowId, text, options),
1454
+ }));
1455
+ }))
1456
+ .addCommand(new Command("timeline")
1457
+ .description("Show a CRM record's activity timeline, newest first.")
1458
+ .argument("<object>", "CRM object slug, such as companies or people.")
1459
+ .argument("<row_id>", "CRM record row id.")
1460
+ .option("--limit <limit>", "Maximum activities to return.")
1461
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
1462
+ .option("--json", "Print a JSON envelope.")
1463
+ .action(async (object, rowId, options) => {
1464
+ await handleAsyncAction("crm activity timeline", options, () => requestOxygen(buildCrmTimelinePath(object, rowId, options)));
1089
1465
  })))
1090
1466
  .addCommand(new Command("describe")
1091
1467
  .description("Describe one configured CRM object.")
@@ -1093,7 +1469,166 @@ export function createProgram() {
1093
1469
  .option("--json", "Print a JSON envelope.")
1094
1470
  .action(async (object, options) => {
1095
1471
  await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`));
1096
- }));
1472
+ }))
1473
+ .addCommand(new Command("duplicates")
1474
+ .description("List ranked duplicate-candidate record pairs for one CRM object (identity-overlap + fuzzy-label). Read-only.")
1475
+ .argument("<object>", "CRM object slug, such as companies or people.")
1476
+ .option("--limit <limit>", "Maximum candidate pairs to return. Defaults to 25, max 100.")
1477
+ .option("--json", "Print a JSON envelope.")
1478
+ .action(async (object, options) => {
1479
+ await handleAsyncAction("crm duplicates", options, () => requestOxygen(buildCrmDuplicatesPath(object, options)));
1480
+ }))
1481
+ .addCommand(new Command("merge")
1482
+ .description("Merge two CRM records: survivor-wins-with-loser-fill field write + relink/tombstone. Defaults to dry-run; --live requires --confirm.")
1483
+ .argument("<object>", "CRM object slug, such as companies or people.")
1484
+ .argument("<survivor_row_id>", "Row id of the record to KEEP (older record by default).")
1485
+ .argument("<loser_row_id>", "Row id of the record to MERGE AWAY and tombstone.")
1486
+ .option("--field-overrides-json <json>", "JSON object of resolved field values keyed by column key, overriding survivor-wins-with-loser-fill.")
1487
+ .option("--dry-run", "Preview the field-level diff and relink plan without writing. Default.")
1488
+ .option("--live", "Apply the merge. Requires --confirm.")
1489
+ .option("--confirm", "Confirm the live merge after inspecting the dry-run diff.")
1490
+ .option("--json", "Print a JSON envelope.")
1491
+ .action(async (object, survivorRowId, loserRowId, options) => {
1492
+ await handleAsyncAction("crm merge", options, () => requestOxygen("/api/cli/crm/merge", {
1493
+ method: "POST",
1494
+ body: buildCrmMergeBody(object, survivorRowId, loserRowId, options),
1495
+ }));
1496
+ }))
1497
+ .addCommand(new Command("pipeline")
1498
+ .description("Show the deals pipeline grouped by stage, with per-stage counts and amount totals.")
1499
+ .option("--pipeline <name>", "Filter to one pipeline, such as sales, expansion, or renewal.")
1500
+ .option("--limit <limit>", "Maximum deals to return per stage.")
1501
+ .option("--json", "Print a JSON envelope.")
1502
+ .action(async (options) => {
1503
+ await handleAsyncAction("crm pipeline", options, () => requestOxygen(buildCrmPipelinePath(options)));
1504
+ }))
1505
+ .addCommand(new Command("lists")
1506
+ .description("Static and dynamic CRM lists over companies, people, deals, and custom objects.")
1507
+ .addCommand(new Command("create")
1508
+ .description("Create a static or dynamic CRM list. Defaults to dry-run.")
1509
+ .argument("<slug>", "List slug, such as vip_accounts.")
1510
+ .requiredOption("--base <object>", "Base CRM object slug the list draws from, such as people.")
1511
+ .requiredOption("--kind <kind>", "List kind: static (materialized) or dynamic (filter-computed).")
1512
+ .option("--filter-json <json>", "Dynamic-list filter as a JSON FilterTree object.")
1513
+ .option("--display-name <name>", "Human-friendly list name.")
1514
+ .option("--project <project>", "Project id or slug for the backing list table.")
1515
+ .option("--dry-run", "Preview the list without creating it.")
1516
+ .option("--live", "Create the list. Default is dry-run.")
1517
+ .option("--json", "Print a JSON envelope.")
1518
+ .action(async (slug, options) => {
1519
+ await handleAsyncAction("crm lists create", options, () => requestOxygen("/api/cli/crm/lists", {
1520
+ method: "POST",
1521
+ body: buildCrmListCreateBody(slug, options),
1522
+ }));
1523
+ }))
1524
+ .addCommand(new Command("add")
1525
+ .description("Add a record to a static CRM list. Defaults to dry-run.")
1526
+ .argument("<list>", "List slug.")
1527
+ .argument("<object>", "Base CRM object slug, such as people.")
1528
+ .argument("<row_id>", "Base record row id.")
1529
+ .option("--dry-run", "Preview the add without writing it.")
1530
+ .option("--live", "Write the membership. Default is dry-run.")
1531
+ .option("--json", "Print a JSON envelope.")
1532
+ .action(async (list, object, rowId, options) => {
1533
+ await handleAsyncAction("crm lists add", options, () => requestOxygen(`/api/cli/crm/lists/${encodeURIComponent(list)}/entries`, {
1534
+ method: "POST",
1535
+ body: buildCrmListEntryBody(object, rowId, options),
1536
+ }));
1537
+ }))
1538
+ .addCommand(new Command("remove")
1539
+ .description("Remove a record from a static CRM list. Defaults to dry-run.")
1540
+ .argument("<list>", "List slug.")
1541
+ .argument("<object>", "Base CRM object slug, such as people.")
1542
+ .argument("<row_id>", "Base record row id.")
1543
+ .option("--dry-run", "Preview the removal without writing it.")
1544
+ .option("--live", "Tombstone the membership. Default is dry-run.")
1545
+ .option("--json", "Print a JSON envelope.")
1546
+ .action(async (list, object, rowId, options) => {
1547
+ await handleAsyncAction("crm lists remove", options, () => requestOxygen(`/api/cli/crm/lists/${encodeURIComponent(list)}/entries`, {
1548
+ method: "DELETE",
1549
+ body: buildCrmListEntryBody(object, rowId, options),
1550
+ }));
1551
+ }))
1552
+ .addCommand(new Command("members")
1553
+ .description("List a CRM list's members (the audience for sequence enrollment).")
1554
+ .argument("<list>", "List slug.")
1555
+ .option("--limit <limit>", "Maximum members to return.")
1556
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
1557
+ .option("--json", "Print a JSON envelope.")
1558
+ .action(async (list, options) => {
1559
+ await handleAsyncAction("crm lists members", options, () => requestOxygen(buildCrmListMembersPath(list, options)));
1560
+ }))
1561
+ .addCommand(new Command("ls")
1562
+ .description("List every configured CRM list.")
1563
+ .option("--json", "Print a JSON envelope.")
1564
+ .action(async (options) => {
1565
+ await handleAsyncAction("crm lists ls", options, () => requestOxygen("/api/cli/crm/lists"));
1566
+ })))
1567
+ .addCommand(new Command("automation")
1568
+ .description("Arm / disarm standing CRM automation templates and read the audit trail.")
1569
+ .addCommand(new Command("rules")
1570
+ .description("List the standing CRM automation templates and their armed state.")
1571
+ .option("--json", "Print a JSON envelope.")
1572
+ .action(async (options) => {
1573
+ await handleAsyncAction("crm automation rules", options, () => requestOxygen("/api/cli/crm/automation"));
1574
+ }))
1575
+ .addCommand(new Command("set")
1576
+ .description("Arm or disarm a standing CRM automation template. Defaults to dry-run.")
1577
+ .argument("<template-id>", "Template id, such as crm-lead-stage-router.")
1578
+ .option("--armed", "Arm the template (let bridged signals advance stages).")
1579
+ .option("--disarmed", "Disarm the template.")
1580
+ .option("--config-json <json>", "JSON object of per-template config.")
1581
+ .option("--dry-run", "Preview the change without writing it.")
1582
+ .option("--live", "Write the change. Default is dry-run.")
1583
+ .option("--json", "Print a JSON envelope.")
1584
+ .action(async (templateId, options) => {
1585
+ await handleAsyncAction("crm automation set", options, () => requestOxygen("/api/cli/crm/automation", {
1586
+ method: "POST",
1587
+ body: buildCrmAutomationSetBody(templateId, options),
1588
+ }));
1589
+ }))
1590
+ .addCommand(new Command("audit")
1591
+ .description("Show recent CRM automation rule changes, newest first.")
1592
+ .option("--template-id <id>", "Filter to one template id.")
1593
+ .option("--limit <limit>", "Maximum audit entries to return.")
1594
+ .option("--json", "Print a JSON envelope.")
1595
+ .action(async (options) => {
1596
+ await handleAsyncAction("crm automation audit", options, () => requestOxygen(buildCrmAutomationAuditPath(options)));
1597
+ })))
1598
+ .addCommand(new Command("sync")
1599
+ .description("Import records from a connected CRM (HubSpot/Attio) into Oxygen CRM objects, and inspect import runs and provider links.")
1600
+ .addCommand(new Command("import")
1601
+ .description("Import provider records into an Oxygen CRM object as a durable workflow run. Defaults to dry-run.")
1602
+ .argument("<provider>", "Connected CRM to import from: hubspot or attio.")
1603
+ .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1604
+ .option("--into <object>", "Oxygen CRM object slug to import into. Defaults to the natural mapping.")
1605
+ .option("--limit <n>", "Max records to pull this run.")
1606
+ .option("--max-credits <n>", "Volume + external-quota cap (BYOK reads cost 0 Oxygen credits but use your CRM API quota).")
1607
+ .option("--approved", "Confirm a live import after inspecting a dry run.")
1608
+ .option("--dry-run", "Preview the import without pulling. Default.")
1609
+ .option("--live", "Run the live import. Requires --approved and --max-credits.")
1610
+ .option("--json", "Print a JSON envelope.")
1611
+ .action(async (provider, options) => {
1612
+ await handleAsyncAction("crm sync import", options, () => requestOxygen("/api/cli/crm/sync/import", {
1613
+ method: "POST",
1614
+ body: buildCrmSyncImportBody(provider, options),
1615
+ }));
1616
+ }))
1617
+ .addCommand(new Command("status")
1618
+ .description("Show the status of a CRM import workflow run.")
1619
+ .argument("<run_id>", "Import workflow run id.")
1620
+ .option("--json", "Print a JSON envelope.")
1621
+ .action(async (runId, options) => {
1622
+ await handleAsyncAction("crm sync status", options, () => requestOxygen(buildCrmSyncStatusPath(runId)));
1623
+ }))
1624
+ .addCommand(new Command("links")
1625
+ .description("Show provider record links (HubSpot/Attio ids) mapped to one Oxygen CRM record.")
1626
+ .argument("<object>", "Oxygen CRM object slug, such as companies or people.")
1627
+ .argument("<row_id>", "Oxygen CRM record row id.")
1628
+ .option("--json", "Print a JSON envelope.")
1629
+ .action(async (object, rowId, options) => {
1630
+ await handleAsyncAction("crm sync links", options, () => requestOxygen(buildCrmSyncLinksPath(object, rowId)));
1631
+ })));
1097
1632
  const tablesCommand = program
1098
1633
  .command("tables")
1099
1634
  .description("Tenant workspace table commands.")
@@ -1621,7 +2156,7 @@ export function createProgram() {
1621
2156
  .description("Playbooks, strategies, campaigns, personas, and research notes.")
1622
2157
  .addCommand(new Command("list")
1623
2158
  .description("List workspace context assets.")
1624
- .option("--type <type>", "Filter by playbook, strategy, campaign, positioning, persona, competitor, research_note, or other.")
2159
+ .option("--type <type>", "Filter by playbook, strategy, campaign, positioning, brand, voice, persona, competitor, research_note, message_playbook, or other.")
1625
2160
  .option("--status <status>", "Filter by draft, active, or archived.")
1626
2161
  .option("--tags <csv>", "Comma-separated tags that must be present.")
1627
2162
  .option("--include-archived", "Include archived assets when no status filter is set.")
@@ -1642,7 +2177,7 @@ export function createProgram() {
1642
2177
  .addCommand(new Command("upsert")
1643
2178
  .description("Create or update a context asset.")
1644
2179
  .option("--id <asset_id>", "Existing asset UUID to update. Omit to create.")
1645
- .option("--type <type>", "Asset type. Defaults to other on create.")
2180
+ .option("--type <type>", "Asset type (positioning, brand, voice, persona, competitor, message_playbook, playbook, strategy, campaign, research_note, other). Defaults to other on create.")
1646
2181
  .option("--title <title>", "Asset title. Required on create.")
1647
2182
  .option("--status <status>", "draft, active, or archived.")
1648
2183
  .option("--tags <csv>", "Comma-separated asset tags.")
@@ -1650,12 +2185,24 @@ export function createProgram() {
1650
2185
  .option("--body <text>", "Asset body text.")
1651
2186
  .option("--data-json <json>", "Flexible JSON object for structured asset data.")
1652
2187
  .option("--asset-json <json>", "Full asset JSON object. CLI flags override matching fields.")
2188
+ .option("--default", "Pin as the canonical default for its type (auto-applied to copy generation). brand/voice/positioning defaults are injected into AI message columns.")
1653
2189
  .option("--json", "Print a JSON envelope.")
1654
2190
  .action(async (options) => {
1655
2191
  await handleAsyncAction("context asset upsert", options, () => requestOxygen("/api/cli/context/assets/upsert", {
1656
2192
  method: "POST",
1657
2193
  body: buildContextAssetUpsertBody(options),
1658
2194
  }));
2195
+ }))
2196
+ .addCommand(new Command("set-default")
2197
+ .description("Pin a context asset as the canonical default for its type. The canonical brand/voice/positioning docs are auto-applied to AI copy generation.")
2198
+ .argument("<asset_id>", "Context asset UUID.")
2199
+ .option("--off", "Unpin instead of pin (clears the canonical default for this type).")
2200
+ .option("--json", "Print a JSON envelope.")
2201
+ .action(async (assetId, options) => {
2202
+ await handleAsyncAction("context asset set-default", options, () => requestOxygen("/api/cli/context/assets/set-default", {
2203
+ method: "POST",
2204
+ body: { id: assetId, is_default: options.off ? false : true },
2205
+ }));
1659
2206
  }))
1660
2207
  .addCommand(new Command("archive")
1661
2208
  .description("Archive a context asset.")
@@ -1811,10 +2358,15 @@ export function createProgram() {
1811
2358
  }))
1812
2359
  .addCommand(new Command("archive")
1813
2360
  .description("Archive a saved blueprint (seed blueprints cannot be archived).")
1814
- .argument("<slug>", "Blueprint slug or id.")
2361
+ .argument("<id_or_slug>", "Blueprint UUID or slug.")
1815
2362
  .option("--json", "Print a JSON envelope.")
1816
- .action(async (slug, options) => {
1817
- await handleAsyncAction("blueprints archive", options, () => requestOxygen("/api/cli/blueprints/archive", { method: "POST", body: { slug } }));
2363
+ .action(async (idOrSlug, options) => {
2364
+ await handleAsyncAction("blueprints archive", options, () => requestOxygen("/api/cli/blueprints/archive", {
2365
+ method: "POST",
2366
+ body: isUuid(idOrSlug)
2367
+ ? { id: idOrSlug }
2368
+ : { slug: idOrSlug },
2369
+ }));
1818
2370
  }))
1819
2371
  .addCommand(new Command("share")
1820
2372
  .description("Create a public share URL for a saved blueprint (oxygen-agent.com/b/<code>).")
@@ -2929,7 +3481,8 @@ export function createProgram() {
2929
3481
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
2930
3482
  }))
2931
3483
  .addCommand(new Command("usage")
2932
- .description("Show credit ledger events.")
3484
+ .description("Show credit ledger events or automation action usage.")
3485
+ .option("--meter <meter>", "credits or automation_actions. Defaults to credits.")
2933
3486
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
2934
3487
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
2935
3488
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -2994,6 +3547,39 @@ export function createProgram() {
2994
3547
  ...(readOption(options.description) ? { description: readOption(options.description) } : {}),
2995
3548
  },
2996
3549
  }));
3550
+ }))
3551
+ .addCommand(new Command("set-plan")
3552
+ .description("Create or update an off-Stripe custom plan for an organization (staff only).")
3553
+ .requiredOption("--organization-id <id>", "Organization id to put on the custom plan.")
3554
+ .requiredOption("--credits <n>", "Monthly managed credits included in the plan.")
3555
+ .option("--org-id <id>", "Alias for --organization-id.")
3556
+ .option("--name <text>", "Display name for the plan, e.g. \"Custom Contract - $250/mo\".")
3557
+ .option("--price <usd>", "Monthly contract price in USD (recorded for display; not charged off-Stripe).")
3558
+ .option("--weekly-credits <n>", "Weekly spend throttle. Defaults to the monthly credits (no throttle).")
3559
+ .option("--rollover <n>", "Max balance credits roll over to. Defaults to the monthly credits.")
3560
+ .option("--base-tier <tier>", "Base pricing tier to inherit feature flags from. Defaults to growth_250.")
3561
+ .option("--byok", "Enable bring-your-own-key integrations for the plan.")
3562
+ .option("--note <text>", "Internal note stored on the contract.")
3563
+ .option("--json", "Print a JSON envelope.")
3564
+ .action(async (options) => {
3565
+ await handleAsyncAction("billing set-plan", options, () => requestOxygen("/api/cli/billing/set-plan", {
3566
+ method: "POST",
3567
+ body: {
3568
+ organization_id: readOption(options.organizationId) ?? readOption(options.orgId),
3569
+ monthly_credits: readPositiveNumber(options.credits),
3570
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
3571
+ ...(readOption(options.price) ? { price: readPositiveNumber(options.price) } : {}),
3572
+ ...(readOption(options.weeklyCredits)
3573
+ ? { weekly_credits_limit: readPositiveNumber(options.weeklyCredits) }
3574
+ : {}),
3575
+ ...(readOption(options.rollover)
3576
+ ? { rollover_cap: readPositiveNumber(options.rollover) }
3577
+ : {}),
3578
+ ...(readOption(options.baseTier) ? { base_tier: readOption(options.baseTier) } : {}),
3579
+ ...(options.byok ? { byok: true } : {}),
3580
+ ...(readOption(options.note) ? { note: readOption(options.note) } : {}),
3581
+ },
3582
+ }));
2997
3583
  }));
2998
3584
  program
2999
3585
  .command("budget")
@@ -3892,7 +4478,8 @@ export function createProgram() {
3892
4478
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
3893
4479
  .option("--unread", "Only show conversations with unread messages.")
3894
4480
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
3895
- .option("--bucket <bucket>", "Email only: primary or others.")
4481
+ .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
4482
+ .option("--segment <segment>", "Email only: top-tab folder — primary, others, sent, warmup, or dmarc.")
3896
4483
  .option("--status <keys>", "Email only: comma-separated status keys (e.g. interested,meeting_booked).")
3897
4484
  .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) ids.")
3898
4485
  .option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
@@ -3918,6 +4505,7 @@ export function createProgram() {
3918
4505
  params.set("responses_only", "true");
3919
4506
  for (const [flag, key] of [
3920
4507
  ["bucket", "bucket"],
4508
+ ["segment", "segment"],
3921
4509
  ["status", "status"],
3922
4510
  ["sequenceId", "sequence_id"],
3923
4511
  ["provider", "provider"],
@@ -3985,12 +4573,17 @@ export function createProgram() {
3985
4573
  }))
3986
4574
  .addCommand(new Command("mark-read")
3987
4575
  .description("Mark a conversation and all its messages as read.")
3988
- .argument("<conversation>", "Conversation id or Unipile chat id.")
4576
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
4577
+ .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
3989
4578
  .option("--json", "Print a JSON envelope.")
3990
4579
  .action(async (conversation, options) => {
3991
- await handleAsyncAction("inbox mark-read", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/read`, {
3992
- method: "POST",
3993
- }));
4580
+ await handleAsyncAction("inbox mark-read", options, () => {
4581
+ const channel = readOption(options.channel);
4582
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/read`, {
4583
+ method: "POST",
4584
+ body: channel ? { channel } : {},
4585
+ });
4586
+ });
3994
4587
  }))
3995
4588
  .addCommand(new Command("analyze")
3996
4589
  .description("Run the default analysis on an email conversation now: sentiment + interest category + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound mail.")
@@ -4928,7 +5521,7 @@ export function createProgram() {
4928
5521
  .description("Workflow event trigger utilities.")
4929
5522
  .addCommand(new Command("emit")
4930
5523
  .description("Emit a normalized workflow event and enqueue matching event-triggered workflows.")
4931
- .requiredOption("--source <source>", "Event source, such as instantly, calendar, or hubspot.")
5524
+ .requiredOption("--source <source>", "Event source, such as instantly, calendar, hubspot, or oxygen.notetaker.")
4932
5525
  .requiredOption("--event <event>", "Event type, such as email.reply_received.")
4933
5526
  .requiredOption("--payload-json <json>", "Normalized event payload object.")
4934
5527
  .option("--raw-payload-json <json>", "Optional raw provider payload object.")
@@ -8924,6 +9517,8 @@ function buildBillingLedgerParams(options) {
8924
9517
  params.set("days", String(days));
8925
9518
  if (limit)
8926
9519
  params.set("limit", String(limit));
9520
+ if (readOption(options.meter))
9521
+ params.set("meter", readOption(options.meter));
8927
9522
  if (readOption(options.from))
8928
9523
  params.set("from", readOption(options.from));
8929
9524
  if (readOption(options.to))
@@ -8972,6 +9567,7 @@ function buildContextAssetUpsertBody(options) {
8972
9567
  ...(readOption(options.summary) ? { summary: readOption(options.summary) } : {}),
8973
9568
  ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
8974
9569
  ...(options.dataJson ? { data: parseJsonObject(options.dataJson) } : {}),
9570
+ ...(options.default ? { is_default: true } : {}),
8975
9571
  };
8976
9572
  }
8977
9573
  function buildEnrichColumnBody(// skipcq: JS-R1005 -- CLI body builder maps enrichment aliases, selection, provider order, and safety caps.