@oxygen-agent/cli 1.700.10 → 1.717.9

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/dist/help.js +2 -0
  3. package/dist/http-client.js +11 -1
  4. package/dist/index.js +523 -85
  5. package/dist/transcript.js +2 -2
  6. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +4 -0
  7. package/node_modules/@oxygen/shared/dist/billing.d.ts +1 -1
  8. package/node_modules/@oxygen/shared/dist/billing.js +7 -6
  9. package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +53 -0
  10. package/node_modules/@oxygen/shared/dist/dnc-identities.js +175 -0
  11. package/node_modules/@oxygen/shared/dist/file-import.d.ts +36 -1
  12. package/node_modules/@oxygen/shared/dist/file-import.js +80 -1
  13. package/node_modules/@oxygen/shared/dist/image-sniff.d.ts +39 -0
  14. package/node_modules/@oxygen/shared/dist/image-sniff.js +75 -0
  15. package/node_modules/@oxygen/shared/dist/import-limits.d.ts +28 -0
  16. package/node_modules/@oxygen/shared/dist/import-limits.js +30 -0
  17. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
  18. package/node_modules/@oxygen/shared/dist/index.js +2 -0
  19. package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -4
  20. package/node_modules/@oxygen/shared/dist/mailbox-import.js +18 -10
  21. package/node_modules/@oxygen/shared/dist/member-columns.d.ts +64 -0
  22. package/node_modules/@oxygen/shared/dist/member-columns.js +111 -0
  23. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +65 -0
  24. package/node_modules/@oxygen/shared/dist/object-storage.js +100 -0
  25. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +2 -1
  26. package/node_modules/@oxygen/shared/dist/plan-limits.js +12 -2
  27. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +9 -0
  28. package/node_modules/@oxygen/shared/dist/spend-safety.js +10 -0
  29. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  30. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  31. package/node_modules/@oxygen/shared/package.json +20 -0
  32. package/node_modules/@oxygen/workflows/dist/graph/lint.js +79 -11
  33. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +679 -0
  34. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +80 -1
  35. package/node_modules/@oxygen/workflows/dist/graph/remap.js +8 -1
  36. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +72 -3
  37. package/node_modules/@oxygen/workflows/dist/graph/types.js +12 -0
  38. package/node_modules/@oxygen/workflows/dist/index.d.ts +16 -0
  39. package/node_modules/@oxygen/workflows/dist/index.js +45 -3
  40. package/node_modules/@oxygen/workflows/dist/portable.d.ts +43 -0
  41. package/node_modules/@oxygen/workflows/dist/portable.js +319 -0
  42. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +18 -4
  43. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS,
13
13
  import { TAG_COLORS } from "@oxygen/shared/select-options";
14
14
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
15
15
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
16
- import { assertRecipeBundleSafe, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, } from "@oxygen/workflows";
16
+ import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
17
17
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
18
18
  import { createBrowserLoginSession, createRelayLoginSession, openBrowser } from "./browser-login.js";
19
19
  import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
@@ -204,6 +204,22 @@ function buildFindBody(capability, options) {
204
204
  body.verify = true;
205
205
  return body;
206
206
  }
207
+ function suppressionListParams(options) {
208
+ const params = new URLSearchParams();
209
+ const reason = readOption(options.reason);
210
+ const search = readOption(options.search);
211
+ const limit = readOption(options.limit);
212
+ const offset = readOption(options.offset);
213
+ if (reason)
214
+ params.set("reason", reason);
215
+ if (search)
216
+ params.set("search", search);
217
+ if (limit)
218
+ params.set("limit", limit);
219
+ if (offset)
220
+ params.set("offset", offset);
221
+ return params;
222
+ }
207
223
  // One cadence vocabulary across every scheduled surface (column refresh, table
208
224
  // feed): `translateScheduleEvery` in apps/web resolves all four forms, so this
209
225
  // string is the single place the CLI spells them out.
@@ -2619,13 +2635,13 @@ export function createProgram() {
2619
2635
  }));
2620
2636
  program
2621
2637
  .command("support")
2622
- .description("File and track Oxygen support tickets. Staff event automation: this CLI's `support admin events` command.")
2638
+ .description("Open and track Plain support conversations for the active OXYGEN organization.")
2623
2639
  .addCommand(new Command("file")
2624
- .description("File a support ticket. Use when you're stuck on an Oxygen operation.")
2640
+ .description("Create a Plain support Thread. Use when you're stuck on an OXYGEN operation.")
2625
2641
  .requiredOption("--subject <subject>", "One-line summary of the problem.")
2626
2642
  .option("--body <body>", "What you were doing, what happened, and what you tried.")
2627
2643
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
2628
- .option("--category <category>", "Optional category label.")
2644
+ .option("--category <category>", "Request type: question, bug, feature_request, billing, security_data, configuration, agency_directory, or other.")
2629
2645
  .option("--source <source>", "Where the ticket originated. Only slack is supported today.")
2630
2646
  .option("--operation <operation>", "The operation/tool that failed, e.g. oxygen_columns_run.")
2631
2647
  .option("--error-code <code>", "The error envelope code you received.")
@@ -2643,7 +2659,7 @@ export function createProgram() {
2643
2659
  }));
2644
2660
  }))
2645
2661
  .addCommand(new Command("list")
2646
- .description("List support tickets for the active organization.")
2662
+ .description("List Plain support Threads for the active organization.")
2647
2663
  .option("--status <status>", "Filter by status (open, triaging, waiting_on_user, resolved, closed).")
2648
2664
  .option("--limit <n>", "Max tickets to return.")
2649
2665
  .option("--json", "Print a JSON envelope.")
@@ -2651,15 +2667,15 @@ export function createProgram() {
2651
2667
  await handleAsyncAction("support tickets list", options, () => requestOxygen(withSupportListQuery("/api/cli/support/tickets", options)));
2652
2668
  }))
2653
2669
  .addCommand(new Command("get")
2654
- .description("Show one support ticket with its message thread.")
2655
- .argument("<ticketId>", "Ticket UUID.")
2670
+ .description("Show one Plain support Thread and its customer-visible timeline.")
2671
+ .argument("<ticketId>", "Plain Thread ID (th_...) or migrated legacy ticket UUID.")
2656
2672
  .option("--json", "Print a JSON envelope.")
2657
2673
  .action(async (ticketId, options) => {
2658
2674
  await handleAsyncAction("support ticket get", options, () => requestOxygen(`/api/cli/support/tickets/${encodeURIComponent(ticketId)}`));
2659
2675
  }))
2660
2676
  .addCommand(new Command("reply")
2661
- .description("Add a message to a support ticket. Reopens a resolved ticket.")
2662
- .argument("<ticketId>", "Ticket UUID.")
2677
+ .description("Reply to a Plain Chat thread; migrated or native-channel history continues in one linked Chat thread.")
2678
+ .argument("<ticketId>", "Plain Thread ID (th_...) or migrated legacy ticket UUID.")
2663
2679
  .requiredOption("--body <body>", "Your reply.")
2664
2680
  .option("--json", "Print a JSON envelope.")
2665
2681
  .action(async (ticketId, options) => {
@@ -2669,7 +2685,7 @@ export function createProgram() {
2669
2685
  }));
2670
2686
  }))
2671
2687
  .addCommand(new Command("ack")
2672
- .description("Mark a resolved ticket as seen so it leaves the unread count.")
2688
+ .description("Legacy command; Plain owns delivery and read state.")
2673
2689
  .argument("<ticketId>", "Ticket UUID.")
2674
2690
  .option("--json", "Print a JSON envelope.")
2675
2691
  .action(async (ticketId, options) => {
@@ -2677,11 +2693,11 @@ export function createProgram() {
2677
2693
  method: "POST",
2678
2694
  body: {},
2679
2695
  }));
2680
- }))
2696
+ }), { hidden: true })
2681
2697
  .addCommand(new Command("admin")
2682
- .description("Staff-only support triage commands.")
2698
+ .description("Legacy commands; staff triage now uses Plain Inbox or Plain MCP.")
2683
2699
  .addCommand(new Command("file")
2684
- .description("File a support ticket into a customer's workspace on their behalf (staff only).")
2700
+ .description("Retired legacy write; always returns the Plain-cutover error (staff only).")
2685
2701
  .requiredOption("--organization <organization>", "Target organization id or slug the ticket belongs to.")
2686
2702
  .requiredOption("--subject <subject>", "One-line summary of the customer's request.")
2687
2703
  .option("--body <body>", "The customer's request in their words, plus any staff notes.")
@@ -2707,7 +2723,7 @@ export function createProgram() {
2707
2723
  }));
2708
2724
  }))
2709
2725
  .addCommand(new Command("list")
2710
- .description("List support tickets across all organizations (staff only).")
2726
+ .description("Read the legacy OXYGEN ticket archive across organizations; Plain is the live queue (staff only).")
2711
2727
  .option("--status <status>", "Filter by status.")
2712
2728
  .option("--limit <n>", "Max tickets to return.")
2713
2729
  .option("--json", "Print a JSON envelope.")
@@ -2715,7 +2731,7 @@ export function createProgram() {
2715
2731
  await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
2716
2732
  }))
2717
2733
  .addCommand(new Command("events")
2718
- .description("Read-only, zero-credit poll of body-free support event envelopes (staff only). Watermark polls replay 24h; dedupe by (ticket_ref, version).")
2734
+ .description("Read-only poll of legacy OXYGEN archive events for cutover compatibility; never use it as the live queue (staff only).")
2719
2735
  .option("--after <cursor>", "If has_more=true, pass next_cursor to continue that scan. If false, persist the non-null watermark_cursor for later polls. A watermark poll intentionally replays the settled 24h window, not only unseen events; dedupe every page by (ticket_ref, version).")
2720
2736
  .option("--limit <n>", "Events to return (1-100, defaults to 50).")
2721
2737
  .option("--json", "Print a JSON envelope.")
@@ -2738,7 +2754,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2738
2754
  await handleAsyncAction("support admin events", options, () => requestOxygen(withSupportEventsQuery("/api/cli/admin/support/events", options)));
2739
2755
  }))
2740
2756
  .addCommand(new Command("get")
2741
- .description("Show one support ticket with a consistent message-thread snapshot (staff only).")
2757
+ .description("Show one legacy archived ticket snapshot; Plain is authoritative for live cases (staff only).")
2742
2758
  .argument("<ticketId>", "Ticket UUID.")
2743
2759
  .option("--if-version <version>", "Require the exact decimal version returned by support admin events; stale versions return a conflict without exposing the thread.")
2744
2760
  .option("--json", "Print a JSON envelope.")
@@ -2746,7 +2762,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2746
2762
  await handleAsyncAction("support admin get", options, () => requestOxygen(withSupportAdminGetQuery(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`, options)));
2747
2763
  }))
2748
2764
  .addCommand(new Command("reply")
2749
- .description("Add a staff message to a support ticket without resolving it.")
2765
+ .description("Retired legacy write; always returns the Plain-cutover error.")
2750
2766
  .argument("<ticketId>", "Ticket UUID.")
2751
2767
  .requiredOption("--body <body>", "Reply body.")
2752
2768
  .option("--json", "Print a JSON envelope.")
@@ -2757,7 +2773,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2757
2773
  }));
2758
2774
  }))
2759
2775
  .addCommand(new Command("resolve")
2760
- .description("Resolve a support ticket and notify the opener (staff only).")
2776
+ .description("Retired legacy write; always returns the Plain-cutover error (staff only).")
2761
2777
  .argument("<ticketId>", "Ticket UUID.")
2762
2778
  .requiredOption("--resolution <text>", "Resolution message sent to the opener.")
2763
2779
  .option("--json", "Print a JSON envelope.")
@@ -2768,7 +2784,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2768
2784
  }));
2769
2785
  }))
2770
2786
  .addCommand(new Command("workflow")
2771
- .description("Track verify/plan/draft triage progress on a support ticket (staff only).")
2787
+ .description("Retired legacy write; Plain notes and downstream engineering work own triage (staff only).")
2772
2788
  .argument("<ticketId>", "Ticket UUID.")
2773
2789
  .option("--verify-status <status>", "Verify step: pending | in_progress | done | blocked | skipped.")
2774
2790
  .option("--verify-notes <text>", "Verification notes. Pass an empty string to clear.")
@@ -2781,7 +2797,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2781
2797
  await handleSupportAdminWorkflowAction(ticketId, options);
2782
2798
  }))
2783
2799
  .addCommand(new Command("update")
2784
- .description("Change a support ticket's status or assignee, with an optional staff note (staff only).")
2800
+ .description("Retired legacy write; Plain owns status, assignee, and notes (staff only).")
2785
2801
  .argument("<ticketId>", "Ticket UUID.")
2786
2802
  .option("--status <status>", "open | triaging | waiting_on_user | closed. Use the resolve command to resolve.")
2787
2803
  .option("--assign <email>", "Assign the ticket to a staff email.")
@@ -2790,16 +2806,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2790
2806
  .option("--json", "Print a JSON envelope.")
2791
2807
  .action(async (ticketId, options) => {
2792
2808
  await handleSupportAdminUpdateAction(ticketId, options);
2793
- })));
2809
+ })), { hidden: true });
2794
2810
  program
2795
2811
  .command("feedback")
2796
- .description("Send feedback or a bug report to the Oxygen team, including your current chat transcript and environment info. Files a tracked ticket; you're notified of the reply by email and on your next session.")
2812
+ .description("Send feedback or a bug report to the OXYGEN team. Creates the canonical Plain support Thread; transcripts are never attached unless explicitly requested.")
2797
2813
  .option("-m, --message <message>", "Your feedback or bug report.")
2798
2814
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
2799
2815
  .option("--category <category>", "Optional category label. Defaults to 'feedback'.")
2800
- .option("--session-id <id>", "Attach a specific local session transcript by id. Defaults to the most recently active session.")
2801
- .option("--file <path>", "Attach a specific transcript file instead of auto-detecting the current session.")
2802
- .option("--no-transcript", "Send your note only, without attaching any chat transcript.")
2816
+ .option("--session-id <id>", "Explicitly attach a specific local session transcript by id.")
2817
+ .option("--file <path>", "Explicitly attach a specific local transcript file.")
2818
+ .option("--include-transcript", "Explicitly attach the most recently active local session after best-effort secret redaction. Review it for sensitive customer data first.")
2819
+ .option("--no-transcript", "Deprecated safety override: send the note without a transcript.")
2803
2820
  .option("--json", "Print a JSON envelope.")
2804
2821
  .action(async (options) => {
2805
2822
  await handleAsyncAction("feedback send", options, () => requestOxygen("/api/cli/feedback", {
@@ -3146,7 +3163,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3146
3163
  .option("--title <title>", "Internal title for the queue.")
3147
3164
  .option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly.")
3148
3165
  .option("--text-file <path>", "Read post text from a local file.")
3149
- .option("--content-json <json>", "Structured content. Attach Oxygen media with media_asset_ids; attachments is for provider-ready objects. Composio accepts provider_arguments or composio.arguments.")
3166
+ .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. Composio accepts provider_arguments or composio.arguments.")
3150
3167
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
3151
3168
  .option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to UTC.")
3152
3169
  .option("--status <status>", "draft or scheduled. Defaults to scheduled.")
@@ -3176,7 +3193,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3176
3193
  .option("--title <title>", "Internal title for the queue.")
3177
3194
  .option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly.")
3178
3195
  .option("--text-file <path>", "Read post text from a local file.")
3179
- .option("--content-json <json>", "Structured content. Attach Oxygen media with media_asset_ids; attachments is for provider-ready objects. This replaces the existing content object.")
3196
+ .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. This replaces the existing content object.")
3180
3197
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
3181
3198
  .option("--publish-at <iso>", "ISO date-time when the post should publish.")
3182
3199
  .option("--timezone <tz>", "Display timezone for the scheduled date.")
@@ -3189,7 +3206,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3189
3206
  }));
3190
3207
  }))
3191
3208
  .addCommand(new Command("approve")
3192
- .description("Approve a scheduled post so the worker can publish it when due.")
3209
+ .description("Approve a scheduled post so the worker can publish it when due. LinkedIn approval resolves every inline @<public-identifier> and blocks with inspectable details if any mention cannot be verified.")
3193
3210
  .argument("<post_id>", "Scheduled post id.")
3194
3211
  .option("--json", "Print a JSON envelope.")
3195
3212
  .action(async (postId, options) => {
@@ -6943,12 +6960,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6943
6960
  }));
6944
6961
  }))
6945
6962
  .addCommand(new Command("materialize")
6946
- .description("Materialize useful fields from a JSONB result column into target columns.")
6963
+ .description("Copy JSON paths from a result column into existing or new target columns. Without --mappings-json, the fixed work_email preset creates email, email_provider, email_status, and email_enriched_at; it does not target a column named work_email.")
6947
6964
  .argument("<table>", "Table id or slug.")
6948
6965
  .argument("<source_column>", "JSONB source column id or key.")
6949
- .option("--preset <preset>", "Materialization preset. Currently supports work_email.")
6950
- .option("--mappings-json <json>", "JSON array of {source_path,target_column,label?,data_type?,semantic_type?} mappings.")
6966
+ .option("--preset <preset>", "Fixed companion-column bundle. work_email creates email, email_provider, email_status, and email_enriched_at; it never binds to an existing work_email column.")
6967
+ .option("--mappings-json <json>", "JSON array of {source_path,target_column,label?,data_type?,semantic_type?} mappings. Use this form to write into an existing column such as work_email.")
6951
6968
  .option("--no-create-if-missing", "Require target companion columns to already exist.")
6969
+ .option("--overwrite-existing", "Replace target values when the materialized source differs. By default only empty targets are filled.")
6952
6970
  .option("--json", "Print a JSON envelope.")
6953
6971
  .action(async (table, sourceColumn, options) => {
6954
6972
  const mappings = options.mappingsJson ? parseJsonArray(options.mappingsJson) : undefined;
@@ -6961,6 +6979,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6961
6979
  ...(mappings ? { mappings } : {}),
6962
6980
  ...(!mappings ? { preset: preset ?? "work_email" } : preset ? { preset } : {}),
6963
6981
  create_if_missing: options.createIfMissing !== false,
6982
+ overwrite_existing: options.overwriteExisting === true,
6964
6983
  },
6965
6984
  }));
6966
6985
  }))
@@ -7543,7 +7562,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7543
7562
  }));
7544
7563
  program
7545
7564
  .command("search")
7546
- .description("Agent-operable people, signal, web, scrape, and local-business search jobs. For company search use 'oxygen companies search'.")
7565
+ .description("Agent-operable people, signal, web, scrape, and local-business search jobs. For company search use 'oxygen companies search'; for hiring-based account discovery use 'oxygen companies search plan --prompt <goal> --source-intent hiring'.")
7547
7566
  .addCommand(new Command("plan")
7548
7567
  .description("Plan an Oxygen search or scrape route before running provider jobs.")
7549
7568
  .requiredOption("--goal <file|text>", "Search/scrape goal text, or a local file path containing the goal.")
@@ -7670,7 +7689,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7670
7689
  .addCommand(new Command("plan")
7671
7690
  .description("Compile a company-search prompt into ordered provider routes without provider calls.")
7672
7691
  .requiredOption("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
7673
- .option("--target-count <n>", "Desired company count for routing and estimates.")
7692
+ .option("--target-count <n>", "Desired company count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
7674
7693
  .option("--source-intent <intent>", "Override detected intent: sizing, structured, lookalike, technology, hiring, local, known_source, concept, web, url, or fallback.")
7675
7694
  .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
7676
7695
  .option("--industries <csv>", "Comma-separated industries to include.")
@@ -7705,7 +7724,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7705
7724
  .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
7706
7725
  .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate.")
7707
7726
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
7708
- .option("--target-count <n>", "Desired company count when planning from --prompt.")
7727
+ .option("--target-count <n>", "Desired company count when planning from --prompt. Single-plan ceiling 50,000.")
7709
7728
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
7710
7729
  .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
7711
7730
  .option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
@@ -7776,10 +7795,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7776
7795
  .addCommand(new Command("plan")
7777
7796
  .description("Compile a people-search prompt and optional typed persona filters into ordered provider routes without provider calls.")
7778
7797
  .requiredOption("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
7779
- .option("--target-count <n>", "Desired contact count for routing and estimates.")
7798
+ .option("--target-count <n>", "Desired contact count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
7780
7799
  .option("--source-intent <intent>", "Override detected intent: persona_search, account_contacts, audience_sizing, profile_lookup, concept_persona, or fallback_broad.")
7781
7800
  .option("--filters-json <json-or-file>", "PeopleSearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
7782
7801
  .option("--titles <csv>", "Comma-separated job titles to include.")
7802
+ .option("--title-match <mode>", "How to match --titles: keyword (default, full-text/partial, highest recall) or exact (literal title only).")
7783
7803
  .option("--adjacent-titles <csv>", "Comma-separated adjacent/looser titles to accept.")
7784
7804
  .option("--exclude-titles <csv>", "Comma-separated job titles to exclude.")
7785
7805
  .option("--seniorities <csv>", "Comma-separated seniority levels: C-Suite, VP, Director, Manager, Staff.")
@@ -7814,10 +7834,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7814
7834
  .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
7815
7835
  .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate.")
7816
7836
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
7817
- .option("--target-count <n>", "Desired contact count when planning from --prompt.")
7837
+ .option("--target-count <n>", "Desired contact count when planning from --prompt. Single-plan ceiling 50,000.")
7818
7838
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
7819
7839
  .option("--filters-json <json-or-file>", "PeopleSearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
7820
7840
  .option("--titles <csv>", "Comma-separated job titles to include when planning from --prompt.")
7841
+ .option("--title-match <mode>", "How to match --titles: keyword (default, full-text/partial, highest recall) or exact (literal title only).")
7821
7842
  .option("--adjacent-titles <csv>", "Comma-separated adjacent titles when planning from --prompt.")
7822
7843
  .option("--exclude-titles <csv>", "Comma-separated job titles to exclude when planning from --prompt.")
7823
7844
  .option("--seniorities <csv>", "Comma-separated seniority levels when planning from --prompt.")
@@ -7896,26 +7917,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7896
7917
  }));
7897
7918
  program
7898
7919
  .command("limits")
7899
- .description("Plan-tier limits and spend-safety defaults.")
7920
+ .description("Plan-tier limits, spend-safety defaults, and storage capacity posture.")
7900
7921
  .addCommand(new Command("show")
7901
- .description("Show this workspace's effective plan-tier limits and spend-safety defaults.")
7922
+ .description("Show effective plan-tier limits, spend-safety defaults, and uncapped-by-plan storage posture.")
7902
7923
  .option("--json", "Print a JSON envelope.")
7903
7924
  .action(async (options) => {
7904
7925
  await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
7905
7926
  }))
7906
7927
  .addCommand(new Command("provider")
7907
- .description("Per-provider call-rate overrides (the self-serve override for BYOK daily caps).")
7928
+ .description("Per-provider call-rate overrides and BYOK daily warning thresholds. With no subcommand, lists policies (same as `limits provider list`).")
7908
7929
  .addCommand(new Command("list")
7909
- .description("List this workspace's provider rate-limit overrides and the plan-tier BYOK daily defaults.")
7930
+ .description("List enforced provider overrides and the plan-tier BYOK daily threshold with its enforcement mode.")
7931
+ .option("--provider <provider>", "Filter saved overrides to one provider id; plan-tier defaults are always returned.")
7910
7932
  .option("--json", "Print a JSON envelope.")
7911
7933
  .action(async (options) => {
7912
- await handleAsyncAction("limits provider list", options, () => requestOxygen("/api/cli/limits/provider"));
7913
- }))
7934
+ const query = new URLSearchParams();
7935
+ if (readOption(options.provider))
7936
+ query.set("provider", readOption(options.provider) ?? "");
7937
+ const suffix = query.toString();
7938
+ await handleAsyncAction("limits provider list", options, () => requestOxygen(`/api/cli/limits/provider${suffix ? `?${suffix}` : ""}`));
7939
+ }), { isDefault: true })
7914
7940
  .addCommand(new Command("set")
7915
- .description("Set a per-provider call ceiling. Use --window-seconds 86400 to override the BYOK daily default.")
7941
+ .description("Set an enforced per-provider call ceiling. Use --window-seconds 86400 for an explicit daily policy.")
7916
7942
  .requiredOption("--provider <provider>", "Provider id (e.g. hubspot, leadmagic).")
7917
7943
  .requiredOption("--limit <n>", "Calls allowed per window.")
7918
- .requiredOption("--window-seconds <n>", "Window size in seconds (86400 = the BYOK daily window).")
7944
+ .requiredOption("--window-seconds <n>", "Window size in seconds (86400 = one day).")
7919
7945
  .option("--operation <operation>", "Scope to one operation; omitted = provider-wide.")
7920
7946
  .option("--json", "Print a JSON envelope.")
7921
7947
  .action(async (options) => {
@@ -8529,7 +8555,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8529
8555
  .command("observability")
8530
8556
  .description("Workspace observability: redacted operation events, plus the cross-primitive runs lens and approvals inbox (list-only — decisions route to the owning primitive).")
8531
8557
  .addCommand(new Command("events")
8532
- .description("List recent redacted operation events and failures. For staff ticket-change envelopes, use this CLI's `support admin events` command.")
8558
+ .description("List recent redacted OXYGEN operation events and failures. Customer support triage and support-event history live in Plain Inbox.")
8533
8559
  // Keep in sync with OBSERVABILITY_STATUS_FILTERS in
8534
8560
  // apps/web/src/lib/observability.ts and the MCP tool enum in
8535
8561
  // packages/mcp-server/src/tools/observability.ts. The API rejects any
@@ -9254,6 +9280,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9254
9280
  .option("--no-access-check", "Skip per-tool availability checks for a fast complete-catalog listing. Tools are returned without availability info; pair with --terse for discovery sweeps.")
9255
9281
  .option("--category <category>", "Filter by Oxygen tool category, such as company_search, people_search, research, or local.")
9256
9282
  .option("--capability <tag>", "Filter by capability tag, such as mobile_phone.")
9283
+ .option("--provider <provider>", "Filter to one exact provider id, such as blitzapi.")
9257
9284
  .option("--limit <n>", "Maximum number of tools to return. Capped at 100.")
9258
9285
  .option("--providers", "Also return a per-vendor summary (provider id, display name, tool count) alongside the tools.")
9259
9286
  .option("--json", "Print a JSON envelope.")
@@ -9276,6 +9303,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9276
9303
  params.set("category", readOption(options.category) ?? "");
9277
9304
  if (readOption(options.capability))
9278
9305
  params.set("capability", readOption(options.capability) ?? "");
9306
+ if (readOption(options.provider))
9307
+ params.set("provider", readOption(options.provider) ?? "");
9279
9308
  if (options.providers)
9280
9309
  params.set("include_providers", "true");
9281
9310
  const limit = readPositiveInt(options.limit);
@@ -11561,7 +11590,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11561
11590
  .addCommand(new Command("hubspot-list-import")
11562
11591
  .description("Discover saved HubSpot contact lists and configure one bounded DNC-first automatic import into an Oxygen Sequence. Draft and paused recipients stay pending until launch or resume.")
11563
11592
  .addCommand(new Command("lists")
11564
- .description("List saved HubSpot contact segments available for a Sequence import. This is one no-bill provider read and changes no members, DNC entries, rows, workflows, or enrollments.")
11593
+ .description("List saved HubSpot contact/company segments and readable identity fields available for a Sequence import. These no-bill definition reads change no members, DNC entries, rows, workflows, or enrollments.")
11565
11594
  .argument("<sequence>", "Sequence id or slug.")
11566
11595
  .option("--query <text>", "Optional case-insensitive words to match in the HubSpot list name.")
11567
11596
  .option("--json", "Print a JSON envelope.")
@@ -11579,6 +11608,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11579
11608
  .argument("<sequence>", "Sequence id or slug.")
11580
11609
  .requiredOption("--lead-list <id>", "Exact HubSpot ILS contact-list id whose current members should be imported.")
11581
11610
  .requiredOption("--dnc-list <id>", "Different exact HubSpot ILS contact-list id synchronized additively into Oxygen DNC first.")
11611
+ .option("--company-dnc-list <id>", "Optional HubSpot company-list id synchronized as all-channel company DNC.")
11612
+ .option("--contact-linkedin-property <name>", "Optional contact LinkedIn profile property internal name.")
11613
+ .option("--contact-company-domain-property <name>", "Optional explicit company-domain property on contacts, copied to lead rows.")
11614
+ .option("--contact-company-linkedin-property <name>", "Optional explicit company LinkedIn URL property on contacts.")
11615
+ .option("--company-domain-property <name>", "Company-list domain property (default domain).", "domain")
11616
+ .option("--company-linkedin-property <name>", "Optional company-list LinkedIn URL property.")
11582
11617
  .option("--cron <expression>", "Standing cron cadence. Defaults to every 15 minutes.")
11583
11618
  .option("--timezone <iana>", "IANA timezone. Defaults to UTC.")
11584
11619
  .option("--max-records-per-list <n>", "Hard complete-snapshot cap per list (1-5000).", "5000")
@@ -11609,6 +11644,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11609
11644
  sequence,
11610
11645
  lead_list_id: options.leadList,
11611
11646
  dnc_list_id: options.dncList,
11647
+ ...(readOption(options.companyDncList) ? { company_dnc_list_id: readOption(options.companyDncList) } : {}),
11648
+ ...(readOption(options.contactLinkedinProperty) ? { contact_linkedin_property: readOption(options.contactLinkedinProperty) } : {}),
11649
+ ...(readOption(options.contactCompanyDomainProperty) ? { contact_company_domain_property: readOption(options.contactCompanyDomainProperty) } : {}),
11650
+ ...(readOption(options.contactCompanyLinkedinProperty) ? { contact_company_linkedin_property: readOption(options.contactCompanyLinkedinProperty) } : {}),
11651
+ ...(readOption(options.companyDomainProperty) ? { company_domain_property: readOption(options.companyDomainProperty) } : {}),
11652
+ ...(readOption(options.companyLinkedinProperty) ? { company_linkedin_property: readOption(options.companyLinkedinProperty) } : {}),
11612
11653
  mode: options.live ? "live" : "dry_run",
11613
11654
  max_records_per_list: maxRecords,
11614
11655
  max_credits: maxCredits,
@@ -12316,7 +12357,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12316
12357
  });
12317
12358
  }))));
12318
12359
  program.addCommand(new Command("suppressions")
12319
- .description("Do-not-contact + email blocklist. People/multichannel: lead provider ids the sequencer enroller skips at plan time (list | add | remove); list rows carry a resolved identity (name / picture / profile URL) when the workspace knows the person. Email blocklist (Instantly parity): addresses + whole-domain blocks the native email dispatcher skips before sending (addresses | domains | remove-address | remove-domain). `import` bulk-loads a mixed file: emails, bare domains, LinkedIn profile URLs, and LinkedIn member ids. Consumes 0 credits.")
12360
+ .description("Unified do-not-contact controls for LinkedIn people, email addresses/domains, phones, and explicit company domain/LinkedIn identities. `import` preserves the legacy mixed-file contract; `import-identities` is the source-neutral typed endpoint; `hubspot lists|sync` arms additive list synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
12320
12361
  .addCommand(new Command("list")
12321
12362
  .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
12322
12363
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -12381,7 +12422,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12381
12422
  });
12382
12423
  }))
12383
12424
  .addCommand(new Command("import")
12384
- .description("Bulk-import a do-not-contact blocklist from a file (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address email list; a bare domain (e.g. acme.com) blocks the WHOLE domain; a LinkedIn profile URL or member id (ACo...) lands on the people do-not-contact list; any other URL is rejected. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; otherwise manual is the default. Idempotent. Consumes 0 credits.")
12425
+ .description("Bulk-import a do-not-contact blocklist from a file (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address email list; a bare domain (e.g. acme.com) blocks email to that whole domain; a LinkedIn profile URL or member id (ACo...) lands on the people do-not-contact list; any other URL is rejected. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; otherwise manual is the default. Idempotent. Consumes 0 credits.")
12385
12426
  .requiredOption("--file <path>", "Path to a file of emails, bare domains, LinkedIn profile URLs, and/or LinkedIn member ids, separated by newlines, commas, or whitespace.")
12386
12427
  .option("--reason <reason>", "Suppression reason. Emails: hard_bounce | complaint | unsubscribe | manual. Domains: manual | complaint | policy. LinkedIn contacts: manual | replied | unsubscribed | bounced | do_not_contact | friends. Required when the file contains any domains; must be valid for every entry type present (manual always is).")
12387
12428
  .option("--source <text>", "Optional provenance note stored on every imported row (e.g. instantly_export).")
@@ -12425,6 +12466,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12425
12466
  imported_emails: parsed.imported_emails ?? 0,
12426
12467
  imported_domains: parsed.imported_domains ?? 0,
12427
12468
  imported_contacts: parsed.imported_contacts ?? 0,
12469
+ imported_phones: parsed.imported_phones ?? 0,
12470
+ imported_company_domains: parsed.imported_company_domains ?? 0,
12471
+ imported_linkedin_companies: parsed.imported_linkedin_companies ?? 0,
12472
+ accepted_identities: parsed.accepted_identities ?? 0,
12428
12473
  rejected_count: rejected.length,
12429
12474
  rejected: rejected.slice(0, 10),
12430
12475
  ...(rejected.length > 10
@@ -12439,6 +12484,157 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12439
12484
  emitCliFailure("suppressions import", error);
12440
12485
  }
12441
12486
  }))
12487
+ .addCommand(new Command("import-identities")
12488
+ .description("Import a source-neutral typed JSON array into DNC. Kinds: email, phone, linkedin_person, company_domain, linkedin_company. Preferred for integrations; company identities apply across every Sequence channel and are never inferred from a person's email.")
12489
+ .requiredOption("--file <path>", "JSON file containing [{kind,value,reason?,source?,detail?,metadata?}], max 5000.")
12490
+ .option("--reason <reason>", "Default reason for entries that omit one.")
12491
+ .option("--source <text>", "Default provenance for entries that omit one.")
12492
+ .option("--detail <text>", "Default note for entries that omit one.")
12493
+ .option("--json", "Print a JSON envelope.")
12494
+ .action(async (options) => {
12495
+ await handleAsyncAction("suppressions import-identities", options, () => {
12496
+ const file = readOption(options.file);
12497
+ if (!file)
12498
+ throw new Error("--file is required.");
12499
+ const identities = readJsonFileValue(resolve(file), "--file");
12500
+ if (!Array.isArray(identities) || identities.length === 0 || identities.length > 5000) {
12501
+ throw new Error("--file must contain a non-empty JSON array with at most 5000 identities.");
12502
+ }
12503
+ const reason = readOption(options.reason);
12504
+ const source = readOption(options.source);
12505
+ const detail = readOption(options.detail);
12506
+ return requestOxygen("/api/cli/suppressions/import", {
12507
+ method: "POST",
12508
+ body: {
12509
+ identities,
12510
+ ...(reason ? { reason } : {}),
12511
+ ...(source ? { source } : {}),
12512
+ ...(detail ? { detail } : {}),
12513
+ },
12514
+ });
12515
+ });
12516
+ }))
12517
+ .addCommand(new Command("phones")
12518
+ .description("List phone suppressions (strict E.164) used by call, WhatsApp, and Sequence safety gates.")
12519
+ .option("--reason <reason>", "do_not_call, wrong_number, complaint, or manual.")
12520
+ .option("--search <text>", "Phone substring.")
12521
+ .option("--limit <n>", "Maximum rows (1-500).")
12522
+ .option("--offset <n>", "Pagination offset.")
12523
+ .option("--json", "Print a JSON envelope.")
12524
+ .action(async (options) => {
12525
+ await handleAsyncAction("suppressions phones", options, () => {
12526
+ const params = suppressionListParams(options);
12527
+ const suffix = params.toString();
12528
+ return requestOxygen(`/api/cli/suppressions/phones${suffix ? `?${suffix}` : ""}`);
12529
+ });
12530
+ }))
12531
+ .addCommand(new Command("remove-phone")
12532
+ .description("Deliberately remove one strict E.164 phone suppression.")
12533
+ .argument("<phone>", "Strict E.164 number, e.g. +14155550123.")
12534
+ .option("--json", "Print a JSON envelope.")
12535
+ .action(async (phone, options) => {
12536
+ await handleAsyncAction("suppressions remove-phone", options, () => requestOxygen(`/api/cli/suppressions/phones?phone=${encodeURIComponent(phone)}`, { method: "DELETE" }));
12537
+ }))
12538
+ .addCommand(new Command("companies")
12539
+ .description("List explicit company-domain and LinkedIn-company suppressions. These stop every Sequence channel.")
12540
+ .option("--kind <kind>", "domain or linkedin_company.")
12541
+ .option("--reason <reason>", "manual, complaint, policy, or do_not_contact.")
12542
+ .option("--search <text>", "Identity substring.")
12543
+ .option("--limit <n>", "Maximum rows (1-500).")
12544
+ .option("--offset <n>", "Pagination offset.")
12545
+ .option("--json", "Print a JSON envelope.")
12546
+ .action(async (options) => {
12547
+ await handleAsyncAction("suppressions companies", options, () => {
12548
+ const params = suppressionListParams(options);
12549
+ const kind = readOption(options.kind);
12550
+ if (kind)
12551
+ params.set("kind", kind);
12552
+ const suffix = params.toString();
12553
+ return requestOxygen(`/api/cli/suppressions/companies${suffix ? `?${suffix}` : ""}`);
12554
+ });
12555
+ }))
12556
+ .addCommand(new Command("remove-company")
12557
+ .description("Deliberately remove one explicit company suppression.")
12558
+ .requiredOption("--kind <kind>", "domain or linkedin_company.")
12559
+ .requiredOption("--value <identity>", "Domain or canonical LinkedIn company URL.")
12560
+ .option("--json", "Print a JSON envelope.")
12561
+ .action(async (options) => {
12562
+ await handleAsyncAction("suppressions remove-company", options, () => {
12563
+ const kind = readOption(options.kind);
12564
+ const value = readOption(options.value);
12565
+ if (!kind || !value)
12566
+ throw new Error("--kind and --value are required.");
12567
+ const params = new URLSearchParams({ kind, value });
12568
+ return requestOxygen(`/api/cli/suppressions/companies?${params.toString()}`, { method: "DELETE" });
12569
+ });
12570
+ }))
12571
+ .addCommand(new Command("hubspot")
12572
+ .description("Discover and synchronize HubSpot contact/company saved lists into the shared Oxygen DNC endpoint.")
12573
+ .addCommand(new Command("lists")
12574
+ .description("List contact/company saved lists and readable identity properties. Reads definitions only; no memberships or writes.")
12575
+ .option("--query <text>", "Optional list-name search.")
12576
+ .option("--json", "Print a JSON envelope.")
12577
+ .action(async (options) => {
12578
+ await handleAsyncAction("suppressions hubspot lists", options, () => {
12579
+ const params = new URLSearchParams();
12580
+ const query = readOption(options.query);
12581
+ if (query)
12582
+ params.set("query", query);
12583
+ const suffix = params.toString();
12584
+ return requestOxygen(`/api/cli/suppressions/hubspot-sync${suffix ? `?${suffix}` : ""}`);
12585
+ });
12586
+ }))
12587
+ .addCommand(new Command("sync")
12588
+ .description("Preview or arm additive HubSpot → DNC sync. Requires at least one contact/company list. Live requires the exact dry-run fingerprint and approval; never enrolls or sends.")
12589
+ .option("--contact-list <id>", "Optional HubSpot contact DNC list id.")
12590
+ .option("--company-list <id>", "Optional HubSpot company DNC list id.")
12591
+ .option("--contact-linkedin-property <name>", "Optional contact LinkedIn profile property.")
12592
+ .option("--company-domain-property <name>", "Company domain property (default domain).", "domain")
12593
+ .option("--company-linkedin-property <name>", "Optional company LinkedIn URL property.")
12594
+ .option("--cron <expression>", "Schedule; default every 15 minutes.")
12595
+ .option("--timezone <iana>", "Schedule timezone; default UTC.")
12596
+ .option("--max-records-per-list <n>", "Complete-snapshot cap (1-5000).", "5000")
12597
+ .option("--max-credits <n>", "Hard credits cap per delivery.", "10")
12598
+ .option("--reviewed-fingerprint <hash>", "Exact dry-run fingerprint, required for live.")
12599
+ .option("--idempotency-key <key>", "Optional first-run idempotency key.")
12600
+ .option("--live", "Arm the Workflow and queue the first durable run.")
12601
+ .option("--approved", "Approve the reviewed bounded recurring reads and additive DNC writes.")
12602
+ .option("--json", "Print a JSON envelope.")
12603
+ .action(async (options) => {
12604
+ await handleAsyncAction("suppressions hubspot sync", options, () => {
12605
+ const contactList = readOption(options.contactList);
12606
+ const companyList = readOption(options.companyList);
12607
+ if (!contactList && !companyList)
12608
+ throw new Error("Pass --contact-list and/or --company-list.");
12609
+ const maxRecords = readPositiveInt(options.maxRecordsPerList);
12610
+ const maxCredits = readPositiveNumber(options.maxCredits);
12611
+ if (maxRecords === undefined || maxRecords > 5000)
12612
+ throw new Error("--max-records-per-list must be between 1 and 5000.");
12613
+ if (maxCredits === undefined)
12614
+ throw new Error("--max-credits must be positive.");
12615
+ const fingerprint = readOption(options.reviewedFingerprint);
12616
+ if (options.live && !fingerprint)
12617
+ throw new Error("Live activation requires --reviewed-fingerprint from the exact dry-run.");
12618
+ return requestOxygen("/api/cli/suppressions/hubspot-sync", {
12619
+ method: "POST",
12620
+ body: {
12621
+ contact_list_id: contactList ?? null,
12622
+ company_list_id: companyList ?? null,
12623
+ mode: options.live ? "live" : "dry_run",
12624
+ max_records_per_list: maxRecords,
12625
+ max_credits: maxCredits,
12626
+ ...(readOption(options.contactLinkedinProperty) ? { contact_linkedin_property: readOption(options.contactLinkedinProperty) } : {}),
12627
+ ...(readOption(options.companyDomainProperty) ? { company_domain_property: readOption(options.companyDomainProperty) } : {}),
12628
+ ...(readOption(options.companyLinkedinProperty) ? { company_linkedin_property: readOption(options.companyLinkedinProperty) } : {}),
12629
+ ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
12630
+ ...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
12631
+ ...(fingerprint ? { reviewed_fingerprint: fingerprint } : {}),
12632
+ ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
12633
+ ...(options.approved ? { approved: true } : {}),
12634
+ },
12635
+ });
12636
+ });
12637
+ })))
12442
12638
  .addCommand(new Command("domains")
12443
12639
  .description("List the org's whole-domain email blocks, newest first. Filter by --reason (manual | complaint | policy) or --search (case-insensitive substring of the domain).")
12444
12640
  .option("--reason <reason>", "Filter by reason: manual, complaint, policy.")
@@ -12565,7 +12761,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12565
12761
  });
12566
12762
  })));
12567
12763
  program.addCommand(new Command("managed-inboxes")
12568
- .description("Managed sending inboxes bought through OXYGEN: subscribe a domain + N InboxKit mailboxes (google/microsoft/azure) as a recurring monthly Oxygen-credit purchase, add inboxes to a domain you already own, inspect/verify, and cancel. Warmup defaults on when available: its quoted recurring credits and exact future addresses are part of the order approval, and the worker activates them in SendKit after provisioning without a second warmup approval. Subscribe/add-inboxes/cancel are approval-gated (preview → re-run with --approved --quote). The vendor is chosen for you; --vendor pins one.")
12764
+ .description("Managed sending inboxes bought through OXYGEN: subscribe a domain + N InboxKit mailboxes (google/microsoft/azure) as a recurring monthly Oxygen-credit purchase, add inboxes to a domain you already own, inspect/verify, and cancel. OXYGEN Warm-up defaults on when available: its quoted recurring credits and exact future addresses are part of the order approval, and the worker activates it after provisioning without a second warmup approval. Subscribe/add-inboxes/cancel are approval-gated (preview → re-run with --approved --quote). The inbox vendor is chosen for you; --vendor pins one.")
12569
12765
  .addCommand(new Command("verify")
12570
12766
  .description("Check that OXYGEN, STRIPE, and the VENDOR agree about what this org is buying. The truth about a managed inbox lives in three systems — what the customer asked for, what they are charged, and what is actually running — and a 200 from any one of them proves nothing. Reports every disagreement with WHO IS LOSING MONEY while it stands (customer_overbilled first, then oxygen_pays). Read-only, no writes, 0 Oxygen credits.")
12571
12767
  .option("--json", "Print a JSON envelope.")
@@ -12579,17 +12775,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12579
12775
  await handleAsyncAction("managed-inboxes registrant", options, () => requestOxygen("/api/cli/managed-inboxes/registrant"));
12580
12776
  }))
12581
12777
  .addCommand(new Command("subscribe")
12582
- .description("Subscribe a NEW domain + mailboxes as a managed monthly inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. Warmup defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, that object also carries `transport=inboxkit_sequencer_export`, `mailbox_credentials_required=false`, and `native_send_oauth_separate=true`. InboxKit warmup needs no Google/Microsoft OAuth or mailbox password. Native sending authorization is separate and may still be required before OXYGEN can send. This order authorizes automatic SendKit activation after provisioning, so no second `mailboxes warmup enable` approval is needed. Prices come from the vendor's LIVE rate card and LIVE domain price, and are refused if they sit below vendor cost. To add mailboxes to a domain you ALREADY own use `managed-inboxes add-inboxes` — this command always registers a new domain and fails on one you own.")
12778
+ .description("Subscribe a NEW domain + mailboxes as a managed monthly inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. OXYGEN Warm-up defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, that object also carries `transport=inboxkit_sequencer_export`, `mailbox_credentials_required=false`, and `native_send_oauth_separate=true`. Managed warm-up needs no Google/Microsoft OAuth or mailbox password. Native sending authorization is separate and may still be required before OXYGEN can send. This order authorizes automatic OXYGEN Warm-up activation after provisioning, so no second `mailboxes warmup enable` approval is needed. Prices come from the vendor's LIVE rate card and LIVE domain price, and are refused if they sit below vendor cost. To add mailboxes to a domain you ALREADY own use `managed-inboxes add-inboxes` — this command always registers a new domain and fails on one you own.")
12583
12779
  .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com). May also be passed as --domain.")
12584
12780
  .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
12585
12781
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
12586
12782
  .option("--vendor <vendor>", "Pin the vendor: inboxkit or cmr. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
12587
- .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\"}].")
12783
+ .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}]. profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one.")
12588
12784
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
12589
12785
  .option("--years <n>", "Years to register the domain for (1-10). Defaults to 1.")
12590
12786
  .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
12591
12787
  .option("--billing <path>", "Path to a JSON file with the registrant/WHOIS contact (first_name, last_name, phone, country, city, state, address_line_one, postal_code). Required on your FIRST order; later orders reuse the registrant already on file.")
12592
- .option("--no-warmup", "Order WITHOUT automatic managed SendKit warm-up. Warm-up is included by default and starts after provisioning under this order approval for the exact quoted Google/Microsoft/Azure addresses; opting out means using BYO warm-up or a later standalone warmup approval.")
12788
+ .option("--no-warmup", "Order WITHOUT automatic OXYGEN Warm-up. Warm-up is included by default and starts after provisioning under this order approval for the exact quoted Google/Microsoft/Azure addresses; opting out means using BYO warm-up or a later standalone warmup approval.")
12593
12789
  .option("--no-placement", "Order WITHOUT inbox placement. Placement is included by default and covers inbox-placement (spam) testing, blacklist/reputation monitoring, and authentication checks on every inbox.")
12594
12790
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
12595
12791
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
@@ -12646,10 +12842,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12646
12842
  });
12647
12843
  }))
12648
12844
  .addCommand(new Command("add-inboxes")
12649
- .description("Add mailboxes to a managed domain you ALREADY own — no new domain is registered and there is NO domain registration charge, only the extra inboxes' monthly rate (plus their add-ons). WITHOUT --approved this prints a priced PREVIEW with a quote_id and orders nothing; re-run with --approved --quote <id> to place the order. When the standing warmup add-on is enabled and available, preview and approved responses carry `warmup_activation` for only the exact newly added `mailboxes`, with `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, that object also carries `transport=inboxkit_sequencer_export`, `mailbox_credentials_required=false`, and `native_send_oauth_separate=true`. InboxKit warmup needs no Google/Microsoft OAuth or mailbox password. Native sending authorization is separate and may still be required before OXYGEN can send. The quote authorizes automatic SendKit activation after provisioning, so do not run a second warmup approval. Capped per domain by the platform the domain was bought on (5 google/microsoft, 100 azure) COUNTING the inboxes already on it. To register a NEW domain use `managed-inboxes subscribe` instead.")
12845
+ .description("Add mailboxes to a managed domain you ALREADY own — no new domain is registered and there is NO domain registration charge, only the extra inboxes' monthly rate (plus their add-ons). WITHOUT --approved this prints a priced PREVIEW with a quote_id and orders nothing; re-run with --approved --quote <id> to place the order. When the standing OXYGEN Warm-up add-on is enabled and available, preview and approved responses carry `warmup_activation` for only the exact newly added `mailboxes`, with `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, that object also carries `transport=inboxkit_sequencer_export`, `mailbox_credentials_required=false`, and `native_send_oauth_separate=true`. Managed warm-up needs no Google/Microsoft OAuth or mailbox password. Native sending authorization is separate and may still be required before OXYGEN can send. The quote authorizes automatic OXYGEN Warm-up activation after provisioning, so do not run a second warmup approval. Capped per domain by the platform the domain was bought on (5 google/microsoft, 100 azure) COUNTING the inboxes already on it. To register a NEW domain use `managed-inboxes subscribe` instead.")
12650
12846
  .argument("[domain]", "A managed domain this workspace already owns (e.g. send.acme.com). May also be passed as --domain.")
12651
12847
  .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
12652
- .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\"}]. The vendor stamps the names on each mailbox, so real names belong here.")
12848
+ .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}]. The vendor stamps the names on each mailbox, so real names belong here. profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL the vendor can fetch at provisioning; `oxygen managed-inboxes upload-avatar <path>` returns one.")
12653
12849
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
12654
12850
  .option("--count <n>", "Shorthand for --mailboxes: how many inboxes to add. Requires --prefix.")
12655
12851
  .option("--prefix <base>", "Shorthand username base for --count: `--count 3 --prefix ada` adds ada1, ada2, ada3 with placeholder names (Ada 1, Ada 2, Ada 3). Pass --mailboxes/--file instead when the inboxes need real human names.")
@@ -12762,9 +12958,54 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12762
12958
  },
12763
12959
  });
12764
12960
  });
12961
+ }))
12962
+ .addCommand(new Command("upload-avatar")
12963
+ .description("Host a mailbox profile picture and print the URL to pass as profile_picture_url. The inbox vendor FETCHES that URL from its own servers when it provisions the mailbox — often long after the order — so it must be public and permanent. A LinkedIn photo URL is neither: media.licdn.com links carry an e=<epoch> expiry and the mailbox ends up faceless. Uploads a PNG, JPEG, or WebP (max 8MB); nothing is charged.")
12964
+ .argument("<path>", "Path to a PNG, JPEG, or WebP image.")
12965
+ .option("--json", "Print a JSON envelope.")
12966
+ .action(async (path, options) => {
12967
+ await handleAsyncAction("managed-inboxes upload-avatar", options, async () => {
12968
+ const resolved = resolve(path);
12969
+ const bytes = readFileSync(resolved);
12970
+ const contentType = imageContentTypeForPath(resolved);
12971
+ // Same three hops the web wizard uses: presign, PUT the bytes
12972
+ // straight to object storage, then confirm — the confirm step is
12973
+ // where the server sniffs the real bytes.
12974
+ const ticket = await requestOxygen("/api/cli/managed-inboxes/avatars", {
12975
+ method: "POST",
12976
+ body: { content_type: contentType, content_length: bytes.byteLength },
12977
+ });
12978
+ const upload = readRecord(ticket, "upload");
12979
+ const uploadUrl = readRecordString(upload, "url");
12980
+ const storageKey = readRecordString(ticket, "storage_key");
12981
+ if (!uploadUrl || !storageKey) {
12982
+ throw new OxygenError("invalid_response", "Oxygen API response is missing the avatar upload URL.", { exitCode: 1 });
12983
+ }
12984
+ const controller = new AbortController();
12985
+ const timer = setTimeout(() => controller.abort(), 120_000);
12986
+ let putResponse;
12987
+ try {
12988
+ putResponse = await fetch(uploadUrl, {
12989
+ method: "PUT",
12990
+ body: new Uint8Array(bytes),
12991
+ headers: { "content-type": contentType },
12992
+ signal: controller.signal,
12993
+ });
12994
+ }
12995
+ finally {
12996
+ clearTimeout(timer);
12997
+ }
12998
+ if (!putResponse.ok) {
12999
+ throw new OxygenError("avatar_upload_failed", `Uploading the photo to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
13000
+ }
13001
+ return await requestOxygen("/api/cli/managed-inboxes/avatars", {
13002
+ method: "PATCH",
13003
+ body: { storage_key: storageKey },
13004
+ });
13005
+ });
12765
13006
  })));
12766
13007
  program.addCommand(new Command("mailboxes")
12767
- .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control managed SendKit warmup. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact native Sequencer exports automatically after provisioning under their approved default-on add-on. Standalone warmup plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. SendKit warms them but never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
13008
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact native warm-up exports automatically after provisioning under their approved default-on add-on. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
12768
13009
  .addCommand(new Command("list")
12769
13010
  .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), and a pool overview (including counts by source). To see only Google/Microsoft mailboxes that still need OAuth connection and the right remedy for each, use `oxygen mailboxes oauth-health --json`.")
12770
13011
  .option("--status <status>", "Filter by status: active, paused, or disabled.")
@@ -12784,7 +13025,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12784
13025
  });
12785
13026
  }))
12786
13027
  .addCommand(new Command("get")
12787
- .description("Get one sending mailbox's configuration/readiness detail (provider, status, daily cap, warmup state, auth mode, and source — managed vs bring-your-own) plus a one-row pool summary. This is not sent/replied/bounced performance; use `oxygen sequences analytics` for native Sequence attribution by mailbox/domain. An ineligible native-send transport includes transport_reason + transport_hint; it does not by itself block SendKit warmup or EmailGuard monitoring. <mailbox> accepts a mailbox id or email address.")
13028
+ .description("Get one sending mailbox's configuration/readiness detail (provider, status, daily cap, warmup state, auth mode, and source — managed vs bring-your-own) plus a one-row pool summary. This is not sent/replied/bounced performance; use `oxygen sequences analytics` for native Sequence attribution by mailbox/domain. An ineligible native-send transport includes transport_reason + transport_hint; it does not by itself block OXYGEN Warm-up or EmailGuard monitoring. <mailbox> accepts a mailbox id or email address.")
12788
13029
  .argument("<mailbox>", "Mailbox id or email address.")
12789
13030
  .option("--json", "Print a JSON envelope.")
12790
13031
  .action(async (mailbox, options) => {
@@ -12826,7 +13067,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12826
13067
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
12827
13068
  }))
12828
13069
  .addCommand(new Command("compatibility")
12829
- .description("Read-only compatibility report for every selected mailbox: generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, SendKit warmup path, EmailGuard monitoring path, and exact next actions. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
13070
+ .description("Read-only compatibility report for every selected mailbox: generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, EmailGuard monitoring path, and exact next actions. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
12830
13071
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
12831
13072
  .option("--catalog-only", "Return only the bounded import-method, field, auth-boundary, and public provider catalogs; do not read or return workspace mailbox rows.")
12832
13073
  .option("--json", "Print a JSON envelope.")
@@ -13059,10 +13300,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13059
13300
  });
13060
13301
  }))
13061
13302
  .addCommand(new Command("connect-oauth")
13062
- .description("Connect Google/Microsoft mailboxes to OXYGEN native send with fresh destination-bound OAuth. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen handles imported, external, or manual mailboxes: it requires an exact address list (max 10 per reviewed command; no domain or directory discovery), returns one OXYGEN browser link per candidate, and stores a grant only after that exact mailbox completes provider sign-in/MFA. Microsoft also returns one tenant-admin fallback per resolved Entra tenant: try exact-account links first, and use setup only if Microsoft requests admin approval. Tenant approval covers app permissions but never imports or authorizes the directory. The 10-address boundary limits one human consent run, not the mailbox pool. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. --vendor zapmail (default) hands a provisioned pool to Zapmail Custom OAuth; --vendor inboxkit requests domain-gated consent with one canary on unproven domains and a 10-write live cap. All paths cost 0 Oxygen credits. Poll --status <id>; connected means an encrypted refresh token actually landed, never merely that a request was accepted.")
13303
+ .description("Connect Google/Microsoft mailboxes to OXYGEN native send. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options. All paths cost 0 Oxygen credits.")
13304
+ .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailbox-compatibility\n")
13063
13305
  .option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
13064
13306
  .option("--vendor <vendor>", "Authorization path: oxygen for imported/manual mailboxes, zapmail (default), or inboxkit.")
13065
- .option("--mailboxes <list>", "Comma-separated mailbox addresses. Required for vendor=oxygen (max 10); omit for vendor-provisioned whole-pool flows.")
13307
+ .option("--mailboxes <list>", "Comma-separated exact mailbox addresses. Required for vendor=oxygen (tenant mode max 500; individual mode max 10).")
13308
+ .option("--authorization-mode <mode>", "vendor=oxygen only: auto, tenant, or individual. Tenant is Microsoft-only and never scans the directory.")
13066
13309
  .option("--domains <list>", "Comma-separated sending domains to limit an inboxkit run to. Omit to cover every domain in the pool.")
13067
13310
  .option("--no-canary", "inboxkit only: fan out to every eligible mailbox on a domain that has never connected one, instead of firing a single canary first.")
13068
13311
  .option("--connection <id>", "Zapmail connection id. Defaults to the org's active Zapmail connection.")
@@ -13093,6 +13336,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13093
13336
  const mailboxes = readCsvOption(options.mailboxes);
13094
13337
  const domains = readCsvOption(options.domains);
13095
13338
  const connection = readOption(options.connection);
13339
+ const authorizationMode = readOption(options.authorizationMode);
13096
13340
  return requestOxygen("/api/cli/mailboxes/connect-oauth", {
13097
13341
  method: "POST",
13098
13342
  body: {
@@ -13101,6 +13345,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13101
13345
  // without the new flags is the request this command has always
13102
13346
  // sent — the server reads an absent vendor as zapmail.
13103
13347
  ...(vendor ? { vendor } : {}),
13348
+ ...(authorizationMode
13349
+ ? { authorization_mode: authorizationMode }
13350
+ : {}),
13104
13351
  ...(mailboxes.length > 0 ? { mailboxes } : {}),
13105
13352
  ...(domains.length > 0 ? { domains } : {}),
13106
13353
  ...(options.canary === false ? { canary: false } : {}),
@@ -13112,7 +13359,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13112
13359
  });
13113
13360
  }))
13114
13361
  .addCommand(new Command("oauth-health")
13115
- .description("Show every Google/Microsoft inbox with neither a per-mailbox OAuth token nor covered Google delegation. Remedies are connect_oauth for Zapmail, connect_oauth_inboxkit for InboxKit, and connect_oauth_oxygen for imported/manual mailboxes. Zapmail rows include their 3-per-7-day export budget; OXYGEN direct rows require exact browser authorization and may invoke provider MFA. For Microsoft, the connect-oauth remedy returns a per-tenant admin fallback to use only if exact authorization asks for approval. Read-only — 0 Oxygen credits.")
13362
+ .description("Show every Google/Microsoft inbox with neither a usable per-mailbox OAuth token nor verified delegation/application access. Results name connect_oauth_oxygen for imported/manual mailboxes and connect_oauth_inboxkit for that managed path. Microsoft can use one-admin tenant authorization for exact selected addresses or individual OAuth. Read-only — 0 Oxygen credits.")
13116
13363
  .option("--json", "Print a JSON envelope.")
13117
13364
  .action(async (options) => {
13118
13365
  await handleAsyncAction("mailboxes oauth-health", options, () => requestOxygen("/api/cli/mailboxes/oauth-health"));
@@ -13176,7 +13423,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13176
13423
  });
13177
13424
  })))
13178
13425
  .addCommand(new Command("delegation")
13179
- .description("Google sending-domain delegation only — not Microsoft OAuth and not a credential-file handoff to SendKit warmup or EmailGuard. Reports covered domains plus the exact client id/scopes; --probe mints a real token per mailbox. Read-only, sends no mail, 0 Oxygen credits.")
13426
+ .description("Google sending-domain delegation only — not Microsoft OAuth and not a credential-file handoff to OXYGEN Warm-up or EmailGuard. Reports covered domains plus the exact client id/scopes; --probe mints a real token per mailbox. Read-only, sends no mail, 0 Oxygen credits.")
13180
13427
  .option("--domain <domain>", "Only report this sending domain.")
13181
13428
  .option("--probe", "Verify each delegated domain by minting a real delegated token per mailbox. Makes one Google token call per mailbox; still 0 Oxygen credits.")
13182
13429
  .option("--json", "Print a JSON envelope.")
@@ -13193,9 +13440,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13193
13440
  });
13194
13441
  }))
13195
13442
  .addCommand(new Command("warmup")
13196
- .description("Mailbox warmup for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. Oxygen's native warmup runs on ONE rail: SendKit, managed and credit-billed at 3,000 credits per warming inbox per month ($3). Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warmup add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warmup blocks the handoff so one mailbox cannot warm twice. SendKit is warmup-only: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down here with `warmup disable`; they are never silently moved onto SendKit.")
13443
+ .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It is managed and credit-billed at 3,000 credits per warming inbox per month ($3). Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks the handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
13444
+ .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailbox-compatibility\n")
13197
13445
  .addCommand(new Command("enable")
13198
- .description("STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is exported, enrolled, or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed InboxKit targets still use InboxKit's native Sequencer export with exact UIDs; auto-export remains off, and any non-cancelled InboxKit warmup must be cancelled before export (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. SendKit supplies warmup only — OXYGEN Sequences retain campaign enrollment and dispatch. New enrollments only ever land on SendKit; the retired TrulyInbox rail is refused here and only accepts teardown.")
13446
+ .description("STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is exported, enrolled, or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed InboxKit targets still use InboxKit's native Sequencer export with exact UIDs; auto-export remains off, and any non-cancelled InboxKit warmup must be cancelled before export (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
13447
+ .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailbox-compatibility\n")
13199
13448
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
13200
13449
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
13201
13450
  .option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
@@ -13239,9 +13488,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13239
13488
  });
13240
13489
  }))
13241
13490
  .addCommand(new Command("microsoft")
13242
- .description("NON-INBOXKIT FALLBACK ONLY: authorize eligible Microsoft/Outlook mailboxes for SendKit warmup with ONE browser consent PER MAILBOX, never a password or app password. Do not run this for managed InboxKit mailboxes: fresh Microsoft/Azure orders activate native InboxKit Sequencer exports automatically when the approved order includes warmup; opted-out or later separate managed enrollment uses standalone `warmup enable`. For the non-InboxKit fallback there is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact fallback mailboxes and outstanding links at 0 credits. Approve that fresh hash with --max-credits 0, open every returned consent_url, then re-run with --status. Warmup itself stays OFF until standalone `warmup enable`.")
13491
+ .description("NON-INBOXKIT FALLBACK ONLY: authorize eligible Microsoft/Outlook mailboxes for OXYGEN Warm-up with ONE browser consent PER MAILBOX, never a password or app password. Do not run this for managed InboxKit mailboxes: fresh Microsoft/Azure orders activate native InboxKit warm-up exports automatically when the approved order includes warmup; opted-out or later separate managed enrollment uses standalone `warmup enable`. For the non-InboxKit fallback there is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact fallback mailboxes and outstanding links at 0 credits. Approve that fresh hash with --max-credits 0, open every returned consent_url, then re-run with --status. Warmup itself stays OFF until standalone `warmup enable`.")
13492
+ .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailbox-compatibility\n")
13243
13493
  .option("--mailboxes <list>", "Comma-separated eligible non-InboxKit Microsoft mailbox ids or addresses. Omit to select the pool; the command refuses a scope containing managed InboxKit rows and points to warmup enable.")
13244
- .option("--tenant <id>", "Deprecated and ignored: SendKit consent is per mailbox, so there is no tenant-wide scope to bind. Still accepted so older scripts keep running.")
13494
+ .option("--tenant <id>", "Deprecated and ignored: OXYGEN Warm-up consent is per mailbox, so there is no tenant-wide scope to bind. Still accepted so older scripts keep running.")
13245
13495
  .option("--approved", "Mint the per-mailbox consent links for the exact previewed mailbox scope. Still 0 credits — a human then has to open each link.")
13246
13496
  .option("--plan <hash>", "Fresh setup preview hash (required with --approved).")
13247
13497
  .option("--max-credits <n>", "Hard Oxygen credit cap; this setup requires exactly 0 (required with --approved).")
@@ -13326,7 +13576,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13326
13576
  });
13327
13577
  }))
13328
13578
  .addCommand(new Command("pause")
13329
- .description("Pause warmup at the rail that actually enrolled each mailbox — SendKit for current enrollments, TrulyInbox for inboxes still warming there. A mailbox left on a rail Oxygen no longer drives reports unsupported instead of being retargeted, so the reply never claims a pause that did not happen. Targets the whole pool unless --mailboxes is given.")
13579
+ .description("Pause warmup at the rail that actually enrolled each mailbox — OXYGEN Warm-up for current enrollments, TrulyInbox for inboxes still warming there. A mailbox left on a rail Oxygen no longer drives reports unsupported instead of being retargeted, so the reply never claims a pause that did not happen. Targets the whole pool unless --mailboxes is given.")
13330
13580
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13331
13581
  .option("--json", "Print a JSON envelope.")
13332
13582
  .action(async (options) => {
@@ -13339,7 +13589,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13339
13589
  });
13340
13590
  }))
13341
13591
  .addCommand(new Command("resume")
13342
- .description("Resume paused warmup at each mailbox's recorded rail (SendKit, or TrulyInbox for inboxes still warming there). A mailbox on a rail Oxygen no longer drives reports unsupported instead of being retargeted. Targets the whole pool unless --mailboxes is given.")
13592
+ .description("Resume paused warmup at each mailbox's recorded rail (OXYGEN Warm-up, or TrulyInbox for inboxes still warming there). A mailbox on a rail Oxygen no longer drives reports unsupported instead of being retargeted. Targets the whole pool unless --mailboxes is given.")
13343
13593
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13344
13594
  .option("--json", "Print a JSON envelope.")
13345
13595
  .action(async (options) => {
@@ -13352,7 +13602,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13352
13602
  });
13353
13603
  }))
13354
13604
  .addCommand(new Command("disable")
13355
- .description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — SendKit today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the vendor removal. This is how you wind an inbox off the retired TrulyInbox rail. Targets the whole pool unless --mailboxes is given.")
13605
+ .description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. Targets the whole pool unless --mailboxes is given.")
13356
13606
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13357
13607
  .option("--json", "Print a JSON envelope.")
13358
13608
  .action(async (options) => {
@@ -13367,7 +13617,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13367
13617
  .addCommand(new Command("status")
13368
13618
  .description("Read warmup analytics back from the rail each mailbox is enrolled on and update its state in the pool. Read-only at the vendor, 0 Oxygen credits. Targets the whole pool unless --mailboxes is given.")
13369
13619
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to sync. Omit to sync the whole pool.")
13370
- .option("--provider <name>", "Optional. sendkit is the rail every current enrollment uses; pass trulyinbox only to read inboxes still warming on the retired rail. Omit it to read each mailbox on the rail it is recorded against.")
13620
+ .option("--provider <name>", "Optional machine routing filter. Omit it for OXYGEN Warm-up; pass trulyinbox only to read inboxes still warming on the retired rail.")
13371
13621
  .option("--dry-run", "Skip the provider call (mailboxes marked pending).")
13372
13622
  .option("--json", "Print a JSON envelope.")
13373
13623
  .action(async (options) => {
@@ -13875,6 +14125,17 @@ Authoring guide:
13875
14125
  Install or refresh the focused product skill:
13876
14126
  oxygen skills install --skill oxygen-workflow-authoring
13877
14127
 
14128
+ Fastest editable graph:
14129
+ 1. oxygen workflows events list --search "<outcome>" --kind builtin --json
14130
+ 2. oxygen workflows init --id my-workflow
14131
+ 3. oxygen workflows schema --subject graph --json
14132
+ 4. oxygen workflows lint --file my-workflow.workflow.json --json
14133
+ 5. oxygen workflows apply --file my-workflow.workflow.json --json
14134
+
14135
+ Applying saves the definition only: it costs 0 credits, calls no graph nodes,
14136
+ and creates no run. A disabled workflow stays disabled. Calling or enabling it
14137
+ is the separate execution/authorization step.
14138
+
13878
14139
  Run completion:
13879
14140
  Calls enqueue asynchronously. Follow the returned run_id with:
13880
14141
  oxygen workflows tail <run_id>
@@ -13953,20 +14214,21 @@ Run completion:
13953
14214
  })))
13954
14215
  .addCommand(new Command("schema")
13955
14216
  .description("Print workflow JSON schemas.")
13956
- .option("--subject <subject>", "Schema subject: all, apply, call, event, trigger, or manifest.")
14217
+ .option("--subject <subject>", "Schema subject: all, apply, call, event, trigger, manifest, graph, or definition.")
13957
14218
  .option("--json", "Print a JSON envelope.")
13958
14219
  .action(async (options) => {
13959
14220
  await handleAsyncAction("workflows schema", options, () => requestOxygen(`/api/cli/workflows/schema?subject=${encodeURIComponent(options.subject ?? "all")}`));
13960
14221
  }))
13961
14222
  .addCommand(new Command("init")
13962
- .description("Scaffold a local durable-recipe project (starter recipe, vendored @oxygen/recipe-sdk types, tsconfig) so your editor resolves the SDK without an npm install.")
14223
+ .description("Create a local editable workflow graph. Use --format recipe only for the legacy durable-recipe authoring path.")
13963
14224
  .option("--dir <path>", "Directory to scaffold into. Defaults to the current directory.")
13964
- .option("--id <id>", "Recipe id for the starter file. Defaults to 'my-recipe'.")
13965
- .option("--name <name>", "Recipe display name. Defaults to 'My recipe'.")
14225
+ .option("--id <id>", "Workflow id. Defaults to 'my-workflow' (or 'my-recipe' with --format recipe).")
14226
+ .option("--name <name>", "Workflow display name. Defaults to 'My workflow' (or 'My recipe' with --format recipe).")
14227
+ .addOption(new Option("--format <format>", "Authoring format: graph (default) or legacy recipe.").choices(["graph", "recipe"]).default("graph"))
13966
14228
  .option("--force", "Overwrite existing files instead of skipping them.")
13967
14229
  .option("--json", "Print a JSON envelope.")
13968
14230
  .action(async (options) => {
13969
- await handleAsyncAction("workflows init", options, async () => scaffoldRecipeProject(options));
14231
+ await handleAsyncAction("workflows init", options, async () => scaffoldWorkflowProject(options));
13970
14232
  }))
13971
14233
  .addCommand(new Command("lint")
13972
14234
  .description("Compile and lint a workflow file without saving it.")
@@ -13982,7 +14244,7 @@ Run completion:
13982
14244
  });
13983
14245
  }))
13984
14246
  .addCommand(new Command("apply")
13985
- .description("Compile and publish a workflow automation. Applying does not start a run; call it separately, then tail the returned run_id.")
14247
+ .description("Compile and publish a workflow definition. Apply costs 0 credits, calls no graph nodes, creates no run, and preserves disabled status; call or enable separately to execute.")
13986
14248
  .requiredOption("--file <path>", "Workflow module or manifest JSON file.")
13987
14249
  .option("--approved", "Authorize autonomous tool calls for this revision.")
13988
14250
  .option("--max-credits <n>", "Optional explicit positive ceiling for each autonomous delivery; omit to use the plan-tier default.")
@@ -14008,6 +14270,55 @@ Run completion:
14008
14270
  writeScheduleWarnings(data);
14009
14271
  return data;
14010
14272
  });
14273
+ }))
14274
+ .addCommand(new Command("export")
14275
+ .description("Export one graph-native workflow as a portable, editable definition with source account ids and trigger authority removed.")
14276
+ .argument("<workflow>", "Workflow id, slug, or name.")
14277
+ .option("--out <path>", "Write only the portable definition JSON to this path.")
14278
+ .option("--force", "Overwrite --out if it already exists.")
14279
+ .option("--json", "Print a JSON envelope.")
14280
+ .action(async (workflow, options) => {
14281
+ await handleAsyncAction("workflows export", options, async () => {
14282
+ const data = await requestOxygen("/api/cli/workflows/export", { method: "POST", body: { workflow } });
14283
+ const outPath = readOption(options.out);
14284
+ if (!outPath)
14285
+ return data;
14286
+ const absolutePath = resolve(outPath);
14287
+ if (existsSync(absolutePath) && !options.force) {
14288
+ throw new OxygenError("workflow_export_file_exists", `Refusing to overwrite '${absolutePath}'. Pass --force to replace it.`, { details: { path: absolutePath }, exitCode: 1 });
14289
+ }
14290
+ writeFileSync(absolutePath, `${JSON.stringify(data.definition, null, 2)}\n`, "utf8");
14291
+ return { ...data, written_to: absolutePath };
14292
+ });
14293
+ }))
14294
+ .addCommand(new Command("import")
14295
+ .description("Preflight or install a portable workflow definition. Imports are create-only and always disabled.")
14296
+ .requiredOption("--file <path>", "Portable .oxygen-workflow.json file from workflows export.")
14297
+ .option("--preflight", "Validate destination accounts and collisions without creating anything.")
14298
+ .option("--workflow-id <id>", "Override the destination workflow id.")
14299
+ .option("--workflow-name <name>", "Override the destination workflow name.")
14300
+ .option("--webhook-trigger-id <id>", "Override the regenerated destination webhook trigger id.")
14301
+ .option("--connection <binding_id=connection_id>", "Bind one exported account requirement to an active destination connection (repeatable).", collectMultiple, [])
14302
+ .option("--json", "Print a JSON envelope.")
14303
+ .action(async (options) => {
14304
+ await handleAsyncAction(options.preflight ? "workflows import preflight" : "workflows import", options, async () => {
14305
+ const definition = readPortableWorkflowDefinitionFile(options.file);
14306
+ const connectionBindings = parseWorkflowConnectionBindings(options.connection ?? []);
14307
+ return requestOxygen(options.preflight
14308
+ ? "/api/cli/workflows/import/preflight"
14309
+ : "/api/cli/workflows/import", {
14310
+ method: "POST",
14311
+ body: {
14312
+ definition,
14313
+ connection_bindings: connectionBindings,
14314
+ ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
14315
+ ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
14316
+ ...(readOption(options.webhookTriggerId)
14317
+ ? { webhook_trigger_id: readOption(options.webhookTriggerId) }
14318
+ : {}),
14319
+ },
14320
+ });
14321
+ });
14011
14322
  }))
14012
14323
  .addCommand(new Command("list")
14013
14324
  .description("List workflow automations.")
@@ -14678,7 +14989,7 @@ function asWorkflowCompileError(error, filePath) {
14678
14989
  exitCode: 1,
14679
14990
  });
14680
14991
  }
14681
- // ---- oxygen workflows init: scaffold a local durable-recipe project ----
14992
+ // ---- oxygen workflows init: graph-first authoring, recipe compatibility ----
14682
14993
  //
14683
14994
  // @oxygen/recipe-sdk is never published to npm — it ships bundled inside this CLI
14684
14995
  // (scripts/build-cli-npm-package.mjs → bundledDependencies). `init` vendors the SDK's
@@ -14688,6 +14999,66 @@ function asWorkflowCompileError(error, filePath) {
14688
14999
  // at `apply` time, so the vendored types stay locked to the CLI actually installed.
14689
15000
  const RECIPE_TYPES_VENDOR_DIR = ".oxygen/types";
14690
15001
  const RECIPE_INIT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
15002
+ function renderStarterGraph(id, name, now = new Date()) {
15003
+ const manifest = {
15004
+ manifest_version: WORKFLOW_GRAPH_MANIFEST_VERSION,
15005
+ workflow: { id, name, status: "disabled" },
15006
+ trigger: { type: "api", status: "disabled" },
15007
+ input_schema: { type: "object", properties: {} },
15008
+ nodes: [{
15009
+ id: "trigger",
15010
+ name: "API trigger",
15011
+ description: "Configure this trigger in the workflow editor.",
15012
+ kind: "trigger",
15013
+ ui: { x: 0, y: 0 },
15014
+ }],
15015
+ edges: [],
15016
+ source_hash: "",
15017
+ compiler_version: WORKFLOW_GRAPH_COMPILER_VERSION,
15018
+ created_at: now.toISOString(),
15019
+ };
15020
+ manifest.source_hash = hashPortableWorkflowGraphManifest(manifest);
15021
+ return `${JSON.stringify(manifest, null, 2)}\n`;
15022
+ }
15023
+ function scaffoldGraphProject(options) {
15024
+ const workflowId = readOption(options.id) ?? "my-workflow";
15025
+ if (!RECIPE_INIT_ID_PATTERN.test(workflowId)) {
15026
+ throw new OxygenError("invalid_workflow_id", "Workflow id must start with a letter or digit and contain only letters, digits, dashes, or underscores.", {
15027
+ details: { id: workflowId },
15028
+ exitCode: 1,
15029
+ });
15030
+ }
15031
+ const workflowName = readOption(options.name) ?? "My workflow";
15032
+ const directory = resolve(readOption(options.dir) ?? ".");
15033
+ const workflowFileName = `${workflowId}.workflow.json`;
15034
+ const workflowFile = join(directory, workflowFileName);
15035
+ const filesWritten = [];
15036
+ const filesSkipped = [];
15037
+ if (existsSync(workflowFile) && options.force !== true)
15038
+ filesSkipped.push(workflowFile);
15039
+ else {
15040
+ mkdirSync(directory, { recursive: true });
15041
+ writeFileSync(workflowFile, renderStarterGraph(workflowId, workflowName), "utf8");
15042
+ filesWritten.push(workflowFile);
15043
+ }
15044
+ return {
15045
+ format: "graph",
15046
+ directory,
15047
+ workflow_file: workflowFile,
15048
+ files_written: filesWritten,
15049
+ files_skipped: filesSkipped,
15050
+ next_steps: [
15051
+ `Edit ${workflowFileName}, then validate it: oxygen workflows lint --file ./${workflowFileName}`,
15052
+ `Install it disabled: oxygen workflows apply --file ./${workflowFileName}`,
15053
+ `Open it: oxygen workflows get ${workflowId} --json`,
15054
+ ],
15055
+ };
15056
+ }
15057
+ function scaffoldWorkflowProject(options) {
15058
+ return readOption(options.format) === "recipe"
15059
+ ? scaffoldRecipeProject(options)
15060
+ : scaffoldGraphProject(options);
15061
+ }
14691
15062
  function readBundledSdkDts(pkg) {
14692
15063
  for (const base of RECIPE_ESBUILD_NODE_PATHS) {
14693
15064
  const candidate = join(base, "@oxygen", pkg, "dist", "index.d.ts");
@@ -14803,6 +15174,34 @@ function scaffoldRecipeProject(options) {
14803
15174
  ],
14804
15175
  };
14805
15176
  }
15177
+ function readPortableWorkflowDefinitionFile(filePath) {
15178
+ const absolutePath = resolve(filePath);
15179
+ let parsed;
15180
+ try {
15181
+ parsed = parseJsonValue(readFileSync(absolutePath, "utf8"), "--file");
15182
+ assertPortableWorkflowDefinition(parsed);
15183
+ }
15184
+ catch (error) {
15185
+ throw asWorkflowCompileError(error, filePath);
15186
+ }
15187
+ return parsed;
15188
+ }
15189
+ function parseWorkflowConnectionBindings(values) {
15190
+ const result = {};
15191
+ for (const value of values) {
15192
+ const separator = value.indexOf("=");
15193
+ const bindingId = separator > 0 ? value.slice(0, separator).trim() : "";
15194
+ const connectionId = separator > 0 ? value.slice(separator + 1).trim() : "";
15195
+ if (!bindingId || !connectionId) {
15196
+ throw new OxygenError("invalid_workflow_connection_binding", `Invalid --connection '${value}'. Use <binding_id>=<connection_id>.`, { details: { value }, exitCode: 1 });
15197
+ }
15198
+ if (result[bindingId]) {
15199
+ throw new OxygenError("duplicate_workflow_connection_binding", `Connection binding '${bindingId}' was provided more than once.`, { details: { binding_id: bindingId }, exitCode: 1 });
15200
+ }
15201
+ result[bindingId] = connectionId;
15202
+ }
15203
+ return result;
15204
+ }
14806
15205
  async function compileWorkflowFile(filePath) {
14807
15206
  try {
14808
15207
  return await loadWorkflowManifestFromFile(filePath);
@@ -14835,8 +15234,15 @@ async function loadWorkflowManifestFromFile(filePath) {
14835
15234
  // this the web canvas could author a graph the CLI could not apply, and the
14836
15235
  // CLI is the primary surface.
14837
15236
  if (isWorkflowGraphManifest(manifest)) {
14838
- assertWorkflowGraphManifest(manifest);
14839
- return manifest;
15237
+ const compiled = basename(absolutePath).endsWith(".workflow.json")
15238
+ ? {
15239
+ ...manifest,
15240
+ created_at: new Date().toISOString(),
15241
+ source_hash: hashPortableWorkflowGraphManifest(manifest),
15242
+ }
15243
+ : manifest;
15244
+ assertWorkflowGraphManifest(compiled);
15245
+ return compiled;
14840
15246
  }
14841
15247
  assertWorkflowManifest(manifest);
14842
15248
  return manifest;
@@ -16712,6 +17118,16 @@ function readPeopleSearchFilters(options) {
16712
17118
  const titleExclude = readCsvOption(options.excludeTitles);
16713
17119
  if (titleExclude.length > 0)
16714
17120
  titles.exclude = titleExclude;
17121
+ const titleMatch = options.titleMatch?.trim().toLowerCase();
17122
+ if (titleMatch) {
17123
+ if (titleMatch !== "keyword" && titleMatch !== "exact") {
17124
+ throw new OxygenError("invalid_title_match", '--title-match must be "keyword" or "exact".', {
17125
+ details: { flag: "--title-match", value: options.titleMatch },
17126
+ exitCode: 1,
17127
+ });
17128
+ }
17129
+ titles.match_mode = titleMatch;
17130
+ }
16715
17131
  if (Object.keys(titles).length > 0)
16716
17132
  filters.titles = titles;
16717
17133
  const seniorities = readCsvOption(options.seniorities);
@@ -18130,6 +18546,22 @@ function chunk(values, size) {
18130
18546
  function readCount(value) {
18131
18547
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
18132
18548
  }
18549
+ /**
18550
+ * Content type from the file extension — a hint only. It picks the presigned
18551
+ * URL's type and the stored key's extension; the server sniffs the real bytes
18552
+ * before it will publish a URL, so a wrong guess here fails loudly rather than
18553
+ * hosting something mislabelled.
18554
+ */
18555
+ function imageContentTypeForPath(path) {
18556
+ const lower = path.toLowerCase();
18557
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
18558
+ return "image/jpeg";
18559
+ if (lower.endsWith(".webp"))
18560
+ return "image/webp";
18561
+ if (lower.endsWith(".png"))
18562
+ return "image/png";
18563
+ throw new OxygenError("unsupported_image", "Profile pictures must be a .png, .jpg, or .webp file.", { details: { path }, exitCode: 2 });
18564
+ }
18133
18565
  function readRecord(value, key) {
18134
18566
  if (!value || typeof value !== "object" || Array.isArray(value))
18135
18567
  return null;
@@ -20591,10 +21023,12 @@ function buildPostEngagementQuery(options) {
20591
21023
  params.set("account", account);
20592
21024
  return params.toString();
20593
21025
  }
20594
- // Assemble the POST body for `oxygen feedback`. Reads the local chat transcript
20595
- // (unless --no-transcript) and attaches a non-sensitive environment snapshot so
20596
- // the Oxygen team can triage. The transcript read happens here, inside the
20597
- // command action, so any failure surfaces in the JSON envelope.
21026
+ // Assemble the POST body for `oxygen feedback`. A transcript leaves the machine
21027
+ // only after an explicit --include-transcript, --session-id, or --file choice;
21028
+ // --no-transcript remains a backward-compatible veto. The local capture applies
21029
+ // bounded, best-effort secret redaction, but the user still owns the decision to
21030
+ // share broader session content. The environment snapshot never includes env
21031
+ // variables or credential values.
20598
21032
  function buildFeedbackBody(options) {
20599
21033
  const message = readOption(options.message);
20600
21034
  if (!message) {
@@ -20610,11 +21044,15 @@ function buildFeedbackBody(options) {
20610
21044
  };
20611
21045
  if (readOption(options.severity))
20612
21046
  body.severity = readOption(options.severity);
20613
- if (options.transcript !== false) {
21047
+ const sessionId = readOption(options.sessionId);
21048
+ const file = readOption(options.file);
21049
+ const includeTranscript = options.transcript !== false &&
21050
+ (options.includeTranscript === true || Boolean(sessionId) || Boolean(file));
21051
+ if (includeTranscript) {
20614
21052
  try {
20615
21053
  const captured = captureCurrentTranscript({
20616
- sessionId: readOption(options.sessionId),
20617
- file: readOption(options.file),
21054
+ sessionId,
21055
+ file,
20618
21056
  });
20619
21057
  if (captured) {
20620
21058
  const { path: _path, ...payload } = captured;