@oxygen-agent/cli 1.1014.1696 → 1.1014.1763

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 (96) hide show
  1. package/README.md +1 -1
  2. package/dist/command-families/program.js +4 -0
  3. package/dist/command-manifest.js +14 -2
  4. package/dist/command-support.js +1 -1
  5. package/dist/commands/admin.js +82 -1
  6. package/dist/commands/billing.js +1 -1
  7. package/dist/commands/copilot.js +29 -7
  8. package/dist/commands/domains.js +7 -0
  9. package/dist/commands/email-assets.d.ts +3 -0
  10. package/dist/commands/email-assets.js +78 -0
  11. package/dist/commands/email-templates.d.ts +3 -0
  12. package/dist/commands/email-templates.js +101 -0
  13. package/dist/commands/sequences.js +47 -12
  14. package/dist/commands/subscriptions.d.ts +2 -4
  15. package/dist/commands/subscriptions.js +297 -51
  16. package/dist/sequence-start-at.d.ts +7 -0
  17. package/dist/sequence-start-at.js +21 -0
  18. package/dist/sequences-brand-kit-command.d.ts +28 -0
  19. package/dist/sequences-brand-kit-command.js +83 -0
  20. package/dist/sequences-content-commands.d.ts +28 -0
  21. package/dist/sequences-content-commands.js +176 -0
  22. package/dist/sequences-newsletter-commands.d.ts +6 -0
  23. package/dist/sequences-newsletter-commands.js +108 -0
  24. package/dist/sequences-test-send-command.d.ts +3 -0
  25. package/dist/sequences-test-send-command.js +47 -0
  26. package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
  27. package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
  28. package/node_modules/@oxygen/formula/dist/expression.js +26 -4
  29. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
  30. package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
  31. package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
  32. package/node_modules/@oxygen/shared/dist/billing.js +5 -7
  33. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
  34. package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
  35. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
  36. package/node_modules/@oxygen/shared/dist/capability-discovery.js +174 -10
  37. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +1 -0
  38. package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +28 -2
  39. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
  40. package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
  41. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
  42. package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
  43. package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
  44. package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
  45. package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
  46. package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
  47. package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
  48. package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
  49. package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
  50. package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
  51. package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
  52. package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
  53. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
  54. package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
  55. package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
  56. package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
  57. package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
  58. package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
  59. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
  60. package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
  61. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
  62. package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
  63. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
  64. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
  65. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
  66. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
  67. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
  68. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
  69. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
  70. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
  71. package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
  72. package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
  73. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +24 -1
  74. package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +51 -4
  75. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
  76. package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
  77. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
  78. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
  79. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
  80. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  81. package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
  82. package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
  83. package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
  84. package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
  85. package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
  86. package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
  87. package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
  88. package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
  89. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
  90. package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
  91. package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
  92. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
  93. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  94. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  95. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
  96. package/package.json +2 -2
@@ -3,7 +3,12 @@ import { parseJsonObject, readNonNegativeInt, readPositiveInt } from "../cli-val
3
3
  import { ansi, collectRepeatable, handleReadActionWithLens, parseJsonValue, readCreditCeilingOrZero, readCsvOption, readJsonFileValue, readPositiveNumber, readPublishingPostText, renderTextTable, splitCommaList } from "../command-support.js";
4
4
  import { addContractFlags, readContractFlags } from "../contract-flags.js";
5
5
  import { requestOxygen } from "../http-client.js";
6
+ import { sequencesBrandKitCommand } from "../sequences-brand-kit-command.js";
7
+ import { sequencesContentCommand } from "../sequences-content-commands.js";
8
+ import { formatNewsletterStatsLines, readNewsletterIntervalFlag, sequencesRecipientsCommand } from "../sequences-newsletter-commands.js";
9
+ import { sequencesTestSendCommand } from "../sequences-test-send-command.js";
6
10
  import { networkMiningHostServed } from "../network-mining-host.js";
11
+ import { parseStartAtFlag } from "../sequence-start-at.js";
7
12
  import { parseWorkingDaysOption } from "../sender-limits-flags.js";
8
13
  import { isRecord, readOption } from "../util.js";
9
14
  import { OxygenError, success } from "@oxygen/shared/cli-result";
@@ -576,6 +581,10 @@ function formatSequenceChannelFunnels(funnels, replies) {
576
581
  const channels = ["linkedin", "email", "whatsapp"].filter((channel) => isRecord(byChannel[channel]));
577
582
  if (channels.length === 0)
578
583
  return [];
584
+ // OXYGEN offers no open tracking, so on such a deployment every channel's
585
+ // opens are not_supported and the column would read n/a on every row (Tim,
586
+ // 2026-10-22: hide it). It stays while any channel can report opens.
587
+ const showOpened = channels.some((channel) => stringValue(recordValue(recordValue(recordValue(byChannel[channel]).coverage).opened).state) !== "not_supported");
579
588
  const rows = channels.map((channel) => {
580
589
  const funnel = recordValue(byChannel[channel]);
581
590
  const invites = recordValue(funnel.invites);
@@ -585,13 +594,13 @@ function formatSequenceChannelFunnels(funnels, replies) {
585
594
  numberText(funnel.sends),
586
595
  numericValue(invites.sent) === null ? "n/a" : `${numberText(invites.accepted)}/${numberText(invites.sent)}`,
587
596
  funnelMetricText(funnel, "delivered"),
588
- funnelMetricText(funnel, "opened"),
597
+ ...(showOpened ? [funnelMetricText(funnel, "opened")] : []),
589
598
  funnelMetricText(funnel, "replied"),
590
599
  funnelRateText(funnel, "replyRate", "replied"),
591
600
  funnelMetricText(funnel, "positiveReplied"),
592
601
  ];
593
602
  });
594
- const lines = ["", ...renderTextTable(["CHANNEL", "PEOPLE", "SENDS", "ACCEPTED", "DELIVERED", "OPENED", "REPLIED", "REPLY RATE", "POSITIVE"], rows)];
603
+ const lines = ["", ...renderTextTable(["CHANNEL", "PEOPLE", "SENDS", "ACCEPTED", "DELIVERED", ...(showOpened ? ["OPENED"] : []), "REPLIED", "REPLY RATE", "POSITIVE"], rows)];
595
604
  // Name the remedy once per distinct gap rather than once per row.
596
605
  const remedies = new Map();
597
606
  for (const channel of channels) {
@@ -688,6 +697,7 @@ function formatSequenceStatsHealth(data) {
688
697
  ["Replies", numberText(stats.totalReplies ?? stats.replied)],
689
698
  ]));
690
699
  }
700
+ lines.push(...formatNewsletterStatsLines(data.newsletter));
691
701
  const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
692
702
  if (link)
693
703
  lines.push("", link);
@@ -760,7 +770,12 @@ function operationalStateText(sequence, operational) {
760
770
  if (!state)
761
771
  return null;
762
772
  // The server's own `lifecycle`; the shared helper covers a server that predates it.
763
- const lifecycle = stringValue(sequence.lifecycle) ?? sequenceDisplayStatus(stringValue(sequence.status) ?? "", state);
773
+ const settings = isRecord(sequence.settings) ? sequence.settings : null;
774
+ const lifecycle = stringValue(sequence.lifecycle) ?? sequenceDisplayStatus(stringValue(sequence.status) ?? "", state, settings);
775
+ if (lifecycle === "scheduled") {
776
+ const startAt = stringValue(settings?.start_at);
777
+ return `${state} (scheduled${startAt ? ` for ${startAt}` : ""})`;
778
+ }
764
779
  return lifecycle === "completed" ? `${state} (completed)` : state;
765
780
  }
766
781
  /** A listed Sequence's launch warnings (e.g. two sending schedules), one line each. */
@@ -1342,7 +1357,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1342
1357
  .description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, paused, blocked, degraded, drained, or empty work without SQL. An active sequence whose every lead has finished shows drained (completed); leads enrolled into it later are contacted on its next dispatch tick. --stats joins each row's LIFETIME funnel.")
1343
1358
  .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
1344
1359
  .addOption(new Option("--operational-state <state>", "Filter by current work state; fleet counts remain unfiltered: empty, drained, never_started (enrolled leads, never launched), blocked, degraded, flowing, paced, or paused (work waiting on a paused sequence).").choices(["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced", "paused"]))
1345
- .addOption(new Option("--lifecycle <lifecycle>", "Filter by the lifecycle a person reads; fleet counts remain unfiltered: draft, active, completed (active, every lead finished), paused, or archived.").choices([...SEQUENCE_LIFECYCLES]))
1360
+ .addOption(new Option("--lifecycle <lifecycle>", "Filter by the lifecycle a person reads; fleet counts remain unfiltered: draft, scheduled (active, start_at still ahead), active, completed (active, every lead finished), paused, or archived.").choices([...SEQUENCE_LIFECYCLES]))
1346
1361
  .addOption(new Option("--channel <channel>", "Only sequences carrying this channel.").choices(["email", "linkedin", "whatsapp"]))
1347
1362
  .option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
1348
1363
  .option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces)."), SEQUENCE_LIST_PROJECTION_CONTRACT)
@@ -1672,8 +1687,8 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1672
1687
  .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
1673
1688
  .option("--auto-optimize", "Enable the sequence-wide A/B auto-winner (default on).")
1674
1689
  .option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
1675
- .option("--reply-to-stop-text <text>", "Optional first-touch email footer (max 500 characters); empty clears.")
1676
- .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. Use --reply-to-stop-text for an optional first-touch email footer. `sequences update` can switch on the link or the headers alone.")
1690
+ .option("--reply-to-stop-text <text>", "Optional first-touch email footer (max 500 characters); with the unsubscribe option on, plain-text emails show it in place of the link. Empty clears.")
1691
+ .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible opt-out (HTML a linked Unsubscribe label; plain text the --reply-to-stop-text line, else a default opt-out line, never the URL) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. Use --reply-to-stop-text for an optional first-touch email footer. `sequences update` can switch on the link or the headers alone.")
1677
1692
  .option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
1678
1693
  .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
1679
1694
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
@@ -1822,12 +1837,12 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1822
1837
  .option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
1823
1838
  .option("--auto-optimize", "Turn the A/B auto-winner on (the default): every step with variants is judged on replies, pausing losers once each variant has 100 sends and 5 replies, unless the step sets its own auto_optimize.")
1824
1839
  .option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
1825
- .option("--reply-to-stop-text <text>", "Optional first-touch email footer (max 500 characters); empty clears.")
1826
- .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. Use --reply-to-stop-text for an optional first-touch email footer.")
1840
+ .option("--reply-to-stop-text <text>", "Optional first-touch email footer (max 500 characters); with the unsubscribe option on, plain-text emails show it in place of the link. Empty clears.")
1841
+ .option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as the Sending settings switch: a visible opt-out (HTML a linked Unsubscribe label; plain text the --reply-to-stop-text line, else a default opt-out line, never the URL) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. Use --reply-to-stop-text for an optional first-touch email footer.")
1827
1842
  .option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
1828
1843
  .option("--include-unsubscribe-headers", "Only the one-click unsubscribe headers (default off); --include-unsubscribe turns on the headers and the visible link together.")
1829
1844
  .option("--no-include-unsubscribe-headers", "Omit one-click headers.")
1830
- .option("--include-unsubscribe-link", "Only the visible recipient-bound unsubscribe footer on first-touch cold emails (default off; --include-unsubscribe adds the one-click headers too). Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
1845
+ .option("--include-unsubscribe-link", "Only the visible recipient-bound unsubscribe footer on first-touch cold emails (default off; --include-unsubscribe adds the one-click headers too). HTML messages show a linked Unsubscribe label; plain-text messages show the --reply-to-stop-text line, else a default opt-out line, instead of the URL.")
1831
1846
  .option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
1832
1847
  .option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
1833
1848
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
@@ -2038,6 +2053,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
2038
2053
  .option("--max-live-sends <n>", "Total live-send and CRM-write ceiling across channels, including LinkedIn invites/messages (positive integer). Required for email, WhatsApp, or crm_task steps; guided LinkedIn binds its positive cap in launch_review.")
2039
2054
  .option("--launch-review-json <json>", "Guided onboarding: echo the exact preview.launch_review (digest, expiry, positive action ceiling and nonnegative credit cap). Changed inputs require a new preview.")
2040
2055
  .option("--launch-review-file <path>", "Read the exact launch_review object from a JSON file instead of --launch-review-json.")
2056
+ .option("--at <time>", "Schedule a draft's start: an ISO timestamp with a timezone (2026-11-02T09:00:00Z) or \"in 30m\" / \"in 2h\" / \"in 3d\", at most 30 days ahead. With --approved it activates now and sends nothing before then (lifecycle scheduled); cancel with `sequences unschedule`.")
2041
2057
  .option("--dry-run", "Activate the sequence (status draft → active, startedAt set) in dry-run mode: every step advances with simulated actions — no sends, CRM writes, provider calls, or credits. This is NOT the preview: `sequences start` with no flags is the side-effect-free launch preview. A later live start (--approved) restarts those leads from the first step so they are contacted for real, except deleted source rows, a person another live campaign contacts under exclude_contacted, and older duplicates of a person.")
2042
2058
  .option("--json", "Print a JSON envelope.")
2043
2059
  .action(async (sequence, options) => {
@@ -2051,6 +2067,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
2051
2067
  : options.launchReviewJson !== undefined ? parseJsonObject(options.launchReviewJson) : undefined;
2052
2068
  if (launchReview !== undefined && !isRecord(launchReview))
2053
2069
  throw new OxygenError("invalid_request", "The launch review must be the exact object returned by the preview.", { exitCode: 2 });
2070
+ const startAt = options.at === undefined ? undefined : parseStartAtFlag(options.at);
2054
2071
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, {
2055
2072
  method: "POST",
2056
2073
  body: {
@@ -2059,6 +2076,7 @@ export function registerSequencesFamily(program, handleAsyncAction) {
2059
2076
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
2060
2077
  ...(maxLiveSends !== undefined ? { max_live_sends: maxLiveSends } : {}),
2061
2078
  ...(launchReview !== undefined ? { launch_review: launchReview } : {}),
2079
+ ...(startAt !== undefined ? { start_at: startAt } : {}),
2062
2080
  },
2063
2081
  });
2064
2082
  });
@@ -2147,6 +2165,15 @@ export function registerSequencesFamily(program, handleAsyncAction) {
2147
2165
  await handleAsyncAction("sequences pause", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
2148
2166
  method: "POST", body: { status: "paused" },
2149
2167
  }));
2168
+ }))
2169
+ .addCommand(new Command("unschedule")
2170
+ .description("Cancel a scheduled start (`sequences start --at`) before it comes: the Sequence returns to draft, its leads to pending, and the start time is cleared. Refused once the start has come or anything was planned; pause it then. Repeating it changes nothing.")
2171
+ .argument("<sequence>", "Sequence id or slug.")
2172
+ .option("--json", "Print a JSON envelope.")
2173
+ .action(async (sequence, options) => {
2174
+ await handleAsyncAction("sequences unschedule", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/unschedule`, {
2175
+ method: "POST", body: {},
2176
+ }));
2150
2177
  }))
2151
2178
  .addCommand(new Command("resume")
2152
2179
  .description("Resume a paused sequence. A campaign paused at its live send cap pauses again at once: raise the cap and resume in one step with `sequences start <sequence> --max-live-sends <n>` (preview, then --approved).")
@@ -2440,12 +2467,17 @@ export function registerSequencesFamily(program, handleAsyncAction) {
2440
2467
  });
2441
2468
  }))
2442
2469
  .addCommand(new Command("stats")
2443
- .description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. Today's completed sends, reservations and remaining capacity appear in the default output and daily_budget. operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`.")
2470
+ .description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. Today's completed sends, reservations and remaining capacity appear in the default output and daily_budget. operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`. A newsletter (sending-domain Sequence) adds a newsletter block: delivered, estimated opens, clicks per link, bounces, complaints, unsubscribes and a first-week timeline; per-recipient status is `sequences recipients`.")
2444
2471
  .argument("<sequence>", "Sequence id or slug.")
2472
+ .option("--interval <hour|day>", "Newsletter timeline buckets over the first 7 days (default day).")
2445
2473
  .option("--json", "Print a JSON envelope.")
2446
2474
  .action(async (sequence, options) => {
2447
- await handleReadActionWithLens("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`), formatSequenceStatsHealth);
2475
+ await handleReadActionWithLens("sequences stats", options, () => {
2476
+ const interval = readNewsletterIntervalFlag(options.interval);
2477
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats${interval ? `?interval=${interval}` : ""}`);
2478
+ }, formatSequenceStatsHealth);
2448
2479
  }))
2480
+ .addCommand(sequencesRecipientsCommand())
2449
2481
  .addCommand(new Command("esp")
2450
2482
  .description("ESP breakdown of this sequence's email sending scope: which provider each enrolled lead's mail routes through (google/microsoft, plus named gateways like Proofpoint/Mimecast and non-matchable hosts), folded against your sending mailbox pool. Shows how many leads the CURRENT --esp-matching mode would defer before you turn it on. Resolves domains from MX + SPF (0 credits, DNS only) and caches the result, warming the send path. Preview before flipping strict/prefer.")
2451
2483
  .argument("<sequence>", "Sequence id or slug.")
@@ -2480,5 +2512,8 @@ export function registerSequencesFamily(program, handleAsyncAction) {
2480
2512
  .option("--json", "Print a JSON envelope.")
2481
2513
  .action(async (sequence, options) => {
2482
2514
  await handleSequenceVariantsAction(sequence, options);
2483
- })));
2515
+ }))
2516
+ .addCommand(sequencesContentCommand(handleAsyncAction))
2517
+ .addCommand(sequencesBrandKitCommand(handleAsyncAction))
2518
+ .addCommand(sequencesTestSendCommand(handleAsyncAction)));
2484
2519
  }
@@ -1,9 +1,7 @@
1
1
  import { ExtractedCommandActionHandler } from "../command-support.js";
2
+ import { type EmailContactImportRow } from "@oxygen/shared/email-subscriptions";
2
3
  import { Command } from "commander";
3
- type ImportEntry = {
4
- email: string;
5
- person_id?: string;
6
- };
4
+ type ImportEntry = EmailContactImportRow;
7
5
  /** Read --file as JSON (by extension or content) or as CSV with an email column. */
8
6
  export declare function readSubscriptionImportFile(path: string): ImportEntry[];
9
7
  export declare function registerSubscriptionsFamily(program: Command, handleAsyncAction: ExtractedCommandActionHandler): void;
@@ -1,9 +1,16 @@
1
1
  import { requestOxygen } from "../http-client.js";
2
2
  import { isRecord, readOption } from "../util.js";
3
3
  import { OxygenError } from "@oxygen/shared/cli-result";
4
+ import { EmailSubscriptionInputError, chunkContactImport, keepContactProperties, parseContactsCsv, } from "@oxygen/shared/email-subscriptions";
4
5
  import { Command } from "commander";
5
6
  import { readFileSync } from "node:fs";
6
7
  import { resolve } from "node:path";
8
+ /** --description / --parent on update: absent leaves it, "" clears it (null). */
9
+ function clearableOption(value) {
10
+ if (value === undefined)
11
+ return {};
12
+ return { value: readOption(value) ?? null };
13
+ }
7
14
  function evidenceBody(options) {
8
15
  const noticeVersion = readOption(options.noticeVersion);
9
16
  const noticeTextHash = readOption(options.noticeTextHash);
@@ -25,48 +32,25 @@ function requireSubscribeEvidence(options, status) {
25
32
  throw new OxygenError("consent_evidence_required", "Subscribing needs consent evidence: --notice-version with --notice-text-hash, or --basis naming where consent came from.", { details: { field: "evidence" }, exitCode: 2 });
26
33
  }
27
34
  }
28
- /** Split one CSV line, honouring double-quoted fields with "" escapes. */
29
- function splitCsvLine(line) {
30
- const cells = [];
31
- let cell = "";
32
- let quoted = false;
33
- for (let index = 0; index < line.length; index += 1) {
34
- const char = line[index];
35
- if (quoted) {
36
- if (char === "\"" && line[index + 1] === "\"") {
37
- cell += "\"";
38
- index += 1;
39
- }
40
- else if (char === "\"")
41
- quoted = false;
42
- else
43
- cell += char;
44
- }
45
- else if (char === "\"")
46
- quoted = true;
47
- else if (char === ",") {
48
- cells.push(cell.trim());
49
- cell = "";
50
- }
51
- else
52
- cell += char;
53
- }
54
- cells.push(cell.trim());
55
- return cells;
56
- }
57
35
  function csvEntries(text) {
58
- const lines = text.replace(/^/, "").split(/\r?\n/).filter((line) => line.trim());
59
- const header = splitCsvLine(lines[0] ?? "").map((cell) => cell.toLowerCase());
60
- const emailIndex = header.findIndex((cell) => cell === "email" || cell === "email_address" || cell === "e-mail");
61
- if (emailIndex < 0) {
62
- throw new OxygenError("invalid_request", "The CSV needs an email column in its header row.", { exitCode: 2 });
36
+ try {
37
+ return parseContactsCsv(text).entries;
63
38
  }
64
- const personIndex = header.findIndex((cell) => cell === "person_id");
65
- return lines.slice(1).map((line) => {
66
- const cells = splitCsvLine(line);
67
- const personId = personIndex >= 0 ? cells[personIndex] : undefined;
68
- return { email: cells[emailIndex] ?? "", ...(personId ? { person_id: personId } : {}) };
69
- });
39
+ catch (error) {
40
+ if (error instanceof EmailSubscriptionInputError)
41
+ throw new OxygenError("invalid_request", error.message, { details: { field: error.field }, exitCode: 2 });
42
+ throw error;
43
+ }
44
+ }
45
+ /**
46
+ * Keep only the columns that are contact properties of this workspace, so a
47
+ * stray column (a CRM export's "company", an index) is reported, not refused.
48
+ */
49
+ async function keepKnownProperties(entries) {
50
+ if (!entries.some((entry) => entry.properties && Object.keys(entry.properties).length > 0))
51
+ return { entries, ignored: [] };
52
+ const known = await requestOxygen("/api/cli/subscriptions/properties");
53
+ return keepContactProperties(entries, new Set((known.properties ?? []).map((property) => property.key)));
70
54
  }
71
55
  function jsonEntries(value) {
72
56
  const list = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.entries) ? value.entries : null;
@@ -77,7 +61,16 @@ function jsonEntries(value) {
77
61
  if (typeof entry === "string")
78
62
  return { email: entry };
79
63
  if (isRecord(entry) && typeof entry.email === "string") {
80
- return { email: entry.email, ...(typeof entry.person_id === "string" ? { person_id: entry.person_id } : {}) };
64
+ const properties = isRecord(entry.properties)
65
+ ? Object.fromEntries(Object.entries(entry.properties).flatMap(([key, value]) => (typeof value === "string" || typeof value === "number" ? [[key, String(value)]] : [])))
66
+ : {};
67
+ return {
68
+ email: entry.email,
69
+ ...(typeof entry.person_id === "string" ? { person_id: entry.person_id } : {}),
70
+ ...(typeof entry.first_name === "string" && entry.first_name ? { first_name: entry.first_name } : {}),
71
+ ...(typeof entry.last_name === "string" && entry.last_name ? { last_name: entry.last_name } : {}),
72
+ ...(Object.keys(properties).length > 0 ? { properties } : {}),
73
+ };
81
74
  }
82
75
  return { email: "" };
83
76
  });
@@ -121,24 +114,133 @@ function runSubscriptionsSet(email, options) {
121
114
  },
122
115
  });
123
116
  }
124
- function runSubscriptionsImport(options) {
117
+ /**
118
+ * Send the import in route-sized calls (one consent evidence record covers
119
+ * every call: the same source vouched for the whole file) and add up the
120
+ * counts. Invalid indexes refer to the file's own rows.
121
+ */
122
+ async function runSubscriptionsImport(options) {
125
123
  const list = requireFlag(options.list, "--list");
126
124
  const source = requireFlag(options.source, "--source");
127
125
  const status = readOption(options.status) ?? "subscribed";
128
126
  requireSubscribeEvidence(options, status);
129
- const entries = readSubscriptionImportFile(requireFlag(options.file, "--file"));
127
+ const read = readSubscriptionImportFile(requireFlag(options.file, "--file"));
128
+ const { entries, ignored } = await keepKnownProperties(read);
130
129
  const evidence = evidenceBody(options);
131
- return requestOxygen("/api/cli/subscriptions/import", {
130
+ const chunks = chunkContactImport(entries);
131
+ let last = {};
132
+ const totals = { received: 0, created: 0, updated: 0, unchanged: 0, kept_cleaned: 0, people_written: 0, people_matched: 0 };
133
+ const invalid = [];
134
+ let offset = 0;
135
+ for (const chunk of chunks) {
136
+ last = await requestOxygen("/api/cli/subscriptions/import", {
137
+ method: "POST",
138
+ body: {
139
+ list,
140
+ entries: chunk,
141
+ status,
142
+ source,
143
+ ...(evidence ? { evidence } : {}),
144
+ },
145
+ });
146
+ for (const key of Object.keys(totals))
147
+ totals[key] += Number(last[key] ?? 0);
148
+ for (const entry of last.invalid ?? [])
149
+ invalid.push({ ...entry, index: entry.index + offset });
150
+ offset += chunk.length;
151
+ }
152
+ if (chunks.length === 1 && ignored.length === 0)
153
+ return last;
154
+ const written = totals.created + totals.updated;
155
+ return {
156
+ ...last,
157
+ ...totals,
158
+ invalid,
159
+ calls: chunks.length,
160
+ ...(ignored.length > 0 ? { ignored_columns: ignored } : {}),
161
+ changed: written > 0,
162
+ effect_outcome: written > 0 ? (invalid.length > 0 ? "partial" : "applied") : totals.received === 0 ? "no_data" : "no_change",
163
+ message: `${written} of ${totals.received} addresses changed on ${list} in ${chunks.length} call(s)`
164
+ + (ignored.length > 0 ? `; columns that are not contact properties were skipped: ${ignored.join(", ")}` : "")
165
+ + ".",
166
+ };
167
+ }
168
+ /** --property key=value, repeatable; key= (empty) clears the property. */
169
+ function readPropertyFlags(values) {
170
+ if (!values || values.length === 0)
171
+ return undefined;
172
+ const properties = {};
173
+ for (const value of values) {
174
+ const at = value.indexOf("=");
175
+ if (at <= 0) {
176
+ throw new OxygenError("invalid_request", "--property takes key=value (key= clears it).", { details: { field: "properties", value }, exitCode: 2 });
177
+ }
178
+ const raw = value.slice(at + 1);
179
+ properties[value.slice(0, at).trim()] = raw === "" ? null : raw;
180
+ }
181
+ return properties;
182
+ }
183
+ function contactFieldBody(options) {
184
+ const properties = readPropertyFlags(options.property);
185
+ return {
186
+ // An explicit empty flag clears the field on the person.
187
+ ...(options.firstName !== undefined ? { first_name: options.firstName === "" ? null : options.firstName } : {}),
188
+ ...(options.lastName !== undefined ? { last_name: options.lastName === "" ? null : options.lastName } : {}),
189
+ ...(properties ? { properties } : {}),
190
+ };
191
+ }
192
+ function collect(value, previous = []) {
193
+ return [...previous, value];
194
+ }
195
+ function runContactsList(options) {
196
+ const params = new URLSearchParams({ list: requireFlag(options.list, "--list") });
197
+ for (const [key, value] of [["search", options.search], ["status", options.status], ["after", options.after], ["limit", options.limit]]) {
198
+ const read = readOption(value);
199
+ if (read)
200
+ params.set(key, read);
201
+ }
202
+ return requestOxygen(`/api/cli/subscriptions/contacts?${params.toString()}`);
203
+ }
204
+ function runContactsAdd(email, options) {
205
+ const list = requireFlag(options.list, "--list");
206
+ const status = readOption(options.status) ?? "subscribed";
207
+ const note = readOption(options.note);
208
+ if (!note)
209
+ requireSubscribeEvidence(options, status);
210
+ const source = readOption(options.source);
211
+ const evidence = evidenceBody(options);
212
+ return requestOxygen("/api/cli/subscriptions/contacts", {
132
213
  method: "POST",
133
214
  body: {
134
215
  list,
135
- entries,
216
+ email,
136
217
  status,
137
- source,
218
+ ...(source ? { source } : {}),
219
+ ...(note ? { note } : {}),
138
220
  ...(evidence ? { evidence } : {}),
221
+ ...contactFieldBody(options),
139
222
  },
140
223
  });
141
224
  }
225
+ function runContactsEdit(email, options) {
226
+ return requestOxygen("/api/cli/subscriptions/contacts", {
227
+ method: "PATCH",
228
+ body: { list: requireFlag(options.list, "--list"), email, ...contactFieldBody(options) },
229
+ });
230
+ }
231
+ function runContactsRemove(email, options) {
232
+ const params = new URLSearchParams({ list: requireFlag(options.list, "--list"), email });
233
+ return requestOxygen(`/api/cli/subscriptions/contacts?${params.toString()}`, { method: "DELETE" });
234
+ }
235
+ async function runSubscriptionsExport(options) {
236
+ const list = requireFlag(options.list, "--list");
237
+ const output = resolve(requireFlag(options.output, "--output"));
238
+ const params = new URLSearchParams({ list, format: "csv" });
239
+ const saved = await requestOxygen(`/api/cli/subscriptions/contacts/export?${params.toString()}`, {
240
+ binaryOutputPath: output,
241
+ });
242
+ return { list, path: saved.path, bytes: saved.bytes, message: `Saved ${list}'s contacts to ${saved.path}.` };
243
+ }
142
244
  function addEvidenceFlags(command) {
143
245
  return command
144
246
  .option("--notice-version <version>", "Version of the consent notice the person saw (with --notice-text-hash).")
@@ -158,18 +260,64 @@ export function registerSubscriptionsFamily(program, handleAsyncAction) {
158
260
  await handleAsyncAction("subscriptions lists list", options, () => requestOxygen("/api/cli/subscriptions/lists"));
159
261
  }))
160
262
  .addCommand(new Command("create")
161
- .description("Create a subscription list.")
263
+ .description("Create a subscription list or a topic. A topic (--kind topic --parent <list>) is a narrower subject inside an audience list; a newsletter on a topic mails only its subscribers. --default opt_out puts the list's subscribers on the topic (same consent); opt_in waits for each recipient's choice on their preference page.")
162
264
  .argument("<key>", "Stable key: lowercase letters, digits, - and _, such as newsletter.")
163
265
  .requiredOption("--name <name>", "Display name.")
164
- .requiredOption("--kind <kind>", "marketing, product_updates or onboarding.")
266
+ .requiredOption("--kind <kind>", "marketing, product_updates, onboarding or topic.")
165
267
  .option("--description <text>", "What subscribers get.")
268
+ .option("--visibility <visibility>", "public (default; on the preference page) or private (shown only to subscribers).")
269
+ .option("--default <subscription>", "Topics: opt_in (default) or opt_out (joined with the parent list).")
270
+ .option("--parent <key-or-id>", "Topics: the audience list this topic belongs to.")
166
271
  .option("--json", "Print a JSON envelope.")
167
272
  .action(async (key, options) => {
168
273
  await handleAsyncAction("subscriptions lists create", options, () => {
169
274
  const description = readOption(options.description);
275
+ const visibility = readOption(options.visibility);
276
+ const defaultSubscription = readOption(options.default);
277
+ const parent = readOption(options.parent);
170
278
  return requestOxygen("/api/cli/subscriptions/lists", {
171
279
  method: "POST",
172
- body: { key, name: options.name, kind: options.kind, ...(description ? { description } : {}) },
280
+ body: {
281
+ key,
282
+ name: options.name,
283
+ kind: options.kind,
284
+ ...(description ? { description } : {}),
285
+ ...(visibility ? { visibility } : {}),
286
+ ...(defaultSubscription ? { default_subscription: defaultSubscription } : {}),
287
+ ...(parent ? { parent } : {}),
288
+ },
289
+ });
290
+ });
291
+ }))
292
+ .addCommand(new Command("update")
293
+ .description("Change a list or topic: name, description, kind, visibility, default subscription or parent. Turning a topic opt_out puts its list's current subscribers on it, each with their own consent evidence. The key never changes.")
294
+ .argument("<list>", "List key or id.")
295
+ .option("--name <name>", "Display name.")
296
+ .option("--kind <kind>", "marketing, product_updates, onboarding or topic.")
297
+ .option("--description <text>", "What subscribers get (\"\" clears it).")
298
+ .option("--visibility <visibility>", "public or private.")
299
+ .option("--default <subscription>", "Topics: opt_in or opt_out.")
300
+ .option("--parent <key-or-id>", "Topics: the audience list (\"\" clears it).")
301
+ .option("--json", "Print a JSON envelope.")
302
+ .action(async (list, options) => {
303
+ await handleAsyncAction("subscriptions lists update", options, () => {
304
+ const name = readOption(options.name);
305
+ const kind = readOption(options.kind);
306
+ const visibility = readOption(options.visibility);
307
+ const defaultSubscription = readOption(options.default);
308
+ const description = clearableOption(options.description);
309
+ const parent = clearableOption(options.parent);
310
+ return requestOxygen("/api/cli/subscriptions/lists", {
311
+ method: "PATCH",
312
+ body: {
313
+ list,
314
+ ...(name ? { name } : {}),
315
+ ...(kind ? { kind } : {}),
316
+ ...(visibility ? { visibility } : {}),
317
+ ...(defaultSubscription ? { default_subscription: defaultSubscription } : {}),
318
+ ...("value" in description ? { description: description.value } : {}),
319
+ ...("value" in parent ? { parent: parent.value } : {}),
320
+ },
173
321
  });
174
322
  });
175
323
  })))
@@ -191,7 +339,7 @@ export function registerSubscriptionsFamily(program, handleAsyncAction) {
191
339
  await handleAsyncAction("subscriptions set", options, () => runSubscriptionsSet(email, options));
192
340
  }))
193
341
  .addCommand(addEvidenceFlags(new Command("import")
194
- .description("Import up to 5,000 addresses onto one list under one consent evidence record. --file is JSON (emails or { email, person_id }) or CSV with an email column. Addresses cleaned by a complaint or hard bounce stay off; invalid rows are reported by index.")
342
+ .description("Import addresses onto one list under one consent evidence record. --file is JSON (emails or { email, first_name, last_name, properties }) or CSV with an email column; first_name, last_name and contact-property columns go to each person on Records People (created or matched by email). Large files are sent in batches. Addresses cleaned by a complaint or hard bounce stay off; invalid rows are reported by index.")
195
343
  .requiredOption("--list <key-or-id>", "Subscription list key or id.")
196
344
  .requiredOption("--file <path>", "JSON or CSV file of addresses.")
197
345
  .requiredOption("--source <source>", "Where these addresses came from, e.g. webinar_2026_10.")
@@ -199,5 +347,103 @@ export function registerSubscriptionsFamily(program, handleAsyncAction) {
199
347
  .option("--json", "Print a JSON envelope.")
200
348
  .action(async (options) => {
201
349
  await handleAsyncAction("subscriptions import", options, () => runSubscriptionsImport(options));
202
- })));
350
+ }))
351
+ .addCommand(new Command("export")
352
+ .description("Save every contact of a list as CSV: each status, the consent that subscribed it (when, source, basis or notice), name and contact properties.")
353
+ .requiredOption("--list <key-or-id>", "Subscription list key or id.")
354
+ .requiredOption("--output <path>", "CSV file to create (must not exist).")
355
+ .option("--json", "Print a JSON envelope.")
356
+ .action(async (options) => {
357
+ await handleAsyncAction("subscriptions export", options, () => runSubscriptionsExport(options));
358
+ }))
359
+ .addCommand(registerContactsCommands(handleAsyncAction))
360
+ .addCommand(registerPropertiesCommands(handleAsyncAction)));
361
+ }
362
+ function addContactFieldFlags(command) {
363
+ return command
364
+ .requiredOption("--list <key-or-id>", "Subscription list key or id.")
365
+ .option("--first-name <name>", "First name on the person's Records People record (\"\" clears it).")
366
+ .option("--last-name <name>", "Last name on the person's Records People record (\"\" clears it).")
367
+ .option("--property <key=value>", "A contact property, repeatable; key= clears it. List keys with subscriptions properties list.", collect);
368
+ }
369
+ function registerContactsCommands(handleAsyncAction) {
370
+ return new Command("contacts")
371
+ .description("Contacts of a subscription list: each address with its status, consent source and its Records person (name and contact properties).")
372
+ .addCommand(new Command("list")
373
+ .description("List a list's contacts with status, source, subscribed date, name and properties; search by email or name.")
374
+ .requiredOption("--list <key-or-id>", "Subscription list key or id.")
375
+ .option("--search <text>", "Match the email or the person's name.")
376
+ .option("--status <status>", "Only subscribed, unsubscribed, pending or cleaned.")
377
+ .option("--after <email>", "next_cursor of the previous page.")
378
+ .option("--limit <n>", "Contacts per page, 1 to 200 (default 50).")
379
+ .option("--json", "Print a JSON envelope.")
380
+ .action(async (options) => {
381
+ await handleAsyncAction("subscriptions contacts list", options, () => runContactsList(options));
382
+ }))
383
+ .addCommand(addEvidenceFlags(addContactFieldFlags(new Command("add")
384
+ .description("Add one contact to a list by hand, with consent evidence (--note says where consent came from), and write its name and properties to Records People.")
385
+ .argument("<email>", "Email address."))
386
+ .option("--status <status>", "Status to set (default subscribed).")
387
+ .option("--source <source>", "Where this contact came from (default manual).")
388
+ .option("--note <text>", "How you got this person's consent; recorded as the consent basis with your name."))
389
+ .option("--json", "Print a JSON envelope.")
390
+ .action(async (email, options) => {
391
+ await handleAsyncAction("subscriptions contacts add", options, () => runContactsAdd(email, options));
392
+ }))
393
+ .addCommand(addContactFieldFlags(new Command("edit")
394
+ .description("Edit a contact's name or properties on its Records People record. Status and consent stay as they are.")
395
+ .argument("<email>", "Email address."))
396
+ .option("--json", "Print a JSON envelope.")
397
+ .action(async (email, options) => {
398
+ await handleAsyncAction("subscriptions contacts edit", options, () => runContactsEdit(email, options));
399
+ }))
400
+ .addCommand(new Command("remove")
401
+ .description("Remove a contact from a list. Its consent history is kept and its Records person is untouched; to stop mail but keep it listed, use subscriptions set --status unsubscribed.")
402
+ .argument("<email>", "Email address.")
403
+ .requiredOption("--list <key-or-id>", "Subscription list key or id.")
404
+ .option("--json", "Print a JSON envelope.")
405
+ .action(async (email, options) => {
406
+ await handleAsyncAction("subscriptions contacts remove", options, () => runContactsRemove(email, options));
407
+ }));
408
+ }
409
+ function registerPropertiesCommands(handleAsyncAction) {
410
+ return new Command("properties")
411
+ .description("Contact properties: text or number fields on Records People that newsletters use as {{contact.<key>}}.")
412
+ .addCommand(new Command("list")
413
+ .description("List contact properties (key, label, type).")
414
+ .option("--json", "Print a JSON envelope.")
415
+ .action(async (options) => {
416
+ await handleAsyncAction("subscriptions properties list", options, () => requestOxygen("/api/cli/subscriptions/properties"));
417
+ }))
418
+ .addCommand(new Command("create")
419
+ .description("Create a contact property: a new text or number field on Records People.")
420
+ .argument("<key>", "Field key, e.g. plan; {{contact.plan}} in a template.")
421
+ .option("--label <label>", "Display name (default the key).")
422
+ .option("--type <type>", "string or number (default string).")
423
+ .option("--json", "Print a JSON envelope.")
424
+ .action(async (key, options) => {
425
+ await handleAsyncAction("subscriptions properties create", options, () => {
426
+ const label = readOption(options.label);
427
+ const type = readOption(options.type);
428
+ return requestOxygen("/api/cli/subscriptions/properties", {
429
+ method: "POST",
430
+ body: { key, ...(label ? { label } : {}), ...(type ? { type } : {}) },
431
+ });
432
+ });
433
+ }))
434
+ .addCommand(new Command("update")
435
+ .description("Rename a contact property. Its key never changes.")
436
+ .argument("<key>", "Property key.")
437
+ .requiredOption("--label <label>", "New display name.")
438
+ .option("--json", "Print a JSON envelope.")
439
+ .action(async (key, options) => {
440
+ await handleAsyncAction("subscriptions properties update", options, () => requestOxygen("/api/cli/subscriptions/properties", { method: "PATCH", body: { key, label: options.label } }));
441
+ }))
442
+ .addCommand(new Command("delete")
443
+ .description("Delete a contact property: the People field is removed and its values archived (restorable on the People table).")
444
+ .argument("<key>", "Property key.")
445
+ .option("--json", "Print a JSON envelope.")
446
+ .action(async (key, options) => {
447
+ await handleAsyncAction("subscriptions properties delete", options, () => requestOxygen(`/api/cli/subscriptions/properties?${new URLSearchParams({ key }).toString()}`, { method: "DELETE" }));
448
+ }));
203
449
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `sequences start --at`: an ISO 8601 timestamp with a timezone, or a relative
3
+ * "in 30m" / "in 2h" / "in 3d". Returns the canonical UTC timestamp the start
4
+ * route stores; the same future and 30-day bounds apply as on the server, so a
5
+ * bad value fails before any request.
6
+ */
7
+ export declare function parseStartAtFlag(value: string, now?: Date): string;