@oxygen-agent/cli 1.632.3 → 1.662.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, CommanderError, Option } from "commander";
10
10
  import { applyOxygenHelp } from "./help.js";
11
11
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
12
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, exitCodeForOxygenError, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
12
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
13
13
  import { TAG_COLORS } from "@oxygen/shared/select-options";
14
14
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
15
15
  import { assertRecipeBundleSafe, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, } from "@oxygen/workflows";
@@ -58,6 +58,115 @@ function tagRegistryBody(options) {
58
58
  body.pinned = false;
59
59
  return body;
60
60
  }
61
+ // --- Collaboration: comments + approvals (Control layer) ---------------------
62
+ //
63
+ // EVERY enumeration below is mapped over a shared total map in
64
+ // @oxygen/shared/collab.ts — never typed out here. A blind-user eval found
65
+ // `tags --help` advertising 7 of the 13 taggable kinds because the prose sat
66
+ // beside a hand-maintained marker map and drifted; six kinds were invisible
67
+ // from the one place a user looks. The collab maps are total over their key
68
+ // type, so adding a subject kind or a gate is a compile error until it is
69
+ // glossed, and the moment it compiles it is already in this help text.
70
+ /** `--on` / `--path` help, shared by every command that addresses a subject. */
71
+ const COLLAB_SUBJECT_REF_HELP = `Subject as "<kind>:<id>" — e.g. sequence:9f1c2b7e. Kinds: ${COLLAB_SUBJECT_KINDS.join(", ")}.`;
72
+ /** What a thread on each kind is usually about, for the group descriptions. */
73
+ const COLLAB_SUBJECT_GLOSS = COLLAB_SUBJECT_KINDS
74
+ .map((kind) => `${kind} (${COLLAB_SUBJECT_PROSE[kind]})`)
75
+ .join("; ");
76
+ /** Every gate, what it means, and which kinds it can be required on. */
77
+ const COLLAB_GATE_GLOSS = COLLAB_GATE_KINDS
78
+ .map((gate) => `${gate} — ${COLLAB_GATE_PROSE[gate]}; set it on ${GATE_KIND_SUBJECTS[gate]
79
+ .map((kind) => COLLAB_SUBJECT_LABELS[kind].many)
80
+ .join(" or ")}`)
81
+ .join(". ");
82
+ const COLLAB_GATE_FLAG_HELP = `Which gate: ${COLLAB_GATE_KINDS.join(" | ")}. ${COLLAB_GATE_GLOSS}.`;
83
+ const COLLAB_SUBJECT_PATH_HELP = `Narrow to a sub-object: ${SUBJECT_PATH_FORMS_PROSE}.`;
84
+ /**
85
+ * Both group descriptions end with this, because a surface that is one release
86
+ * old should say so where a user meets it. Beta here means exactly what it means
87
+ * for Agents, the Knowledge Graph, and Recipes: WEB NAV VISIBILITY. Nothing
88
+ * about the gate, the client role, or credit rules is softened by the toggle,
89
+ * and this command group is reachable at either setting — which is what keeps a
90
+ * gate armed from the terminal answerable from the terminal.
91
+ */
92
+ const COLLAB_BETA_NOTE = "BETA: the /collab entry appears in the web sidebar once a workspace enables Settings -> Beta features; "
93
+ + "these commands, the gate, and the client role behave identically either way.";
94
+ /**
95
+ * Parse `--on <kind>:<id>`. Client-side so a typo costs no round trip and the
96
+ * error can list every legal kind — `parseSubjectRef` already throws a typed
97
+ * `invalid_subject_ref` (exit 2) carrying `details.valid_kinds`.
98
+ */
99
+ function requireCollabSubject(value) {
100
+ const raw = readOption(value);
101
+ if (!raw) {
102
+ throw new OxygenError("invalid_subject_ref", `--on is required. ${COLLAB_SUBJECT_REF_HELP}`, {
103
+ details: { valid_kinds: [...COLLAB_SUBJECT_KINDS] },
104
+ exitCode: 2,
105
+ });
106
+ }
107
+ return parseSubjectRef(raw);
108
+ }
109
+ /** Validate and canonicalize `--path`; undefined when the flag was not passed. */
110
+ function readCollabPath(value) {
111
+ const raw = readOption(value);
112
+ return raw ? formatSubjectPath(parseSubjectPath(raw)) : undefined;
113
+ }
114
+ function requireCollabGate(value) {
115
+ const raw = readOption(value);
116
+ const gate = COLLAB_GATE_KINDS.find((candidate) => candidate === raw);
117
+ if (!gate) {
118
+ throw new OxygenError("invalid_request", `--gate must be one of: ${COLLAB_GATE_KINDS.join(", ")}. ${COLLAB_GATE_GLOSS}.`, {
119
+ details: { gate: raw, valid_gate_kinds: [...COLLAB_GATE_KINDS] },
120
+ exitCode: 2,
121
+ });
122
+ }
123
+ return gate;
124
+ }
125
+ function requireCollabSubjectKind(value) {
126
+ const raw = readOption(value);
127
+ const kind = COLLAB_SUBJECT_KINDS.find((candidate) => candidate === raw);
128
+ if (!kind) {
129
+ throw new OxygenError("invalid_subject_ref", `--default must be one of: ${COLLAB_SUBJECT_KINDS.join(", ")}.`, {
130
+ details: { subject_kind: raw, valid_kinds: [...COLLAB_SUBJECT_KINDS] },
131
+ exitCode: 2,
132
+ });
133
+ }
134
+ return kind;
135
+ }
136
+ /**
137
+ * At least one assignee. Commander cannot enforce this (the repeatable
138
+ * collector's `[]` default satisfies its mandatory check), and a request with
139
+ * nobody on it is the exact failure the API refuses to create: it looks filed,
140
+ * blocks the gate, and is discovered the next morning.
141
+ */
142
+ function requireCollabAssignees(values) {
143
+ const assignees = values.map((value) => value.trim()).filter(Boolean);
144
+ if (assignees.length === 0) {
145
+ throw new OxygenError("invalid_request", "--assignee <email> is required: name at least one workspace member who can decide.", { exitCode: 2 });
146
+ }
147
+ return assignees;
148
+ }
149
+ /**
150
+ * Exactly one of --approve / --reject / --changes-requested. Neither zero nor
151
+ * two is guessable: `changes_requested` is a real decision that does NOT open
152
+ * the gate, so silently defaulting either way would either stall a campaign or
153
+ * launch one the client never agreed to.
154
+ */
155
+ function requireCollabDecision(options) {
156
+ const selected = [];
157
+ if (options.approve)
158
+ selected.push("approve");
159
+ if (options.reject)
160
+ selected.push("reject");
161
+ if (options.changesRequested)
162
+ selected.push("changes_requested");
163
+ if (selected.length !== 1) {
164
+ throw new OxygenError("invalid_request", selected.length === 0
165
+ ? "Pass exactly one decision: --approve, --reject, or --changes-requested (asks for edits; does NOT open the gate)."
166
+ : `Pass only one decision flag — got ${selected.length}: --approve, --reject, and --changes-requested are mutually exclusive.`, { details: { decisions_passed: selected }, exitCode: 2 });
167
+ }
168
+ return selected[0];
169
+ }
61
170
  function buildFindBody(capability, options) {
62
171
  const body = { capability };
63
172
  const set = (key, value) => {
@@ -667,6 +776,28 @@ function writeMaxCreditsHint(error) {
667
776
  return;
668
777
  }
669
778
  case "approval_required": {
779
+ // A COLLABORATION gate reuses this code but is not a spend gate: no flag
780
+ // on this command can unblock it, because the whole point is that a
781
+ // second party decides. Printing the "--approved --max-credits" hint here
782
+ // would send the operator hunting for a flag that does not exist, so the
783
+ // gate branch runs first and prints the command the OTHER person runs.
784
+ const gateKind = readDetailsString(error.details, "gate_kind");
785
+ if (gateKind) {
786
+ const nextStep = readDetailsString(error.details, "next_step");
787
+ process.stderr.write(`hint: blocked by the ${gateKind} approval gate — ${nextStep ?? "ask an assignee to decide it"}\n`);
788
+ const lastDecision = isRecord(error.details) && isRecord(error.details.last_decision)
789
+ ? error.details.last_decision
790
+ : null;
791
+ if (lastDecision) {
792
+ const note = readDetailsString(lastDecision, "note");
793
+ process.stderr.write(`hint: the last answer was ${readDetailsString(lastDecision, "status") ?? "recorded"}${note ? ` — "${note}"` : ""}; revise, then ask again\n`);
794
+ }
795
+ const requestUrl = readDetailsString(error.details, "pending_request_web_url")
796
+ ?? readDetailsString(error.details, "subject_web_url");
797
+ if (requestUrl)
798
+ process.stderr.write(`hint: ${requestUrl}\n`);
799
+ return;
800
+ }
670
801
  if (error.message.startsWith("Public replies require --approved")) {
671
802
  process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
672
803
  return;
@@ -705,6 +836,12 @@ function readDetailsNumber(details, key) {
705
836
  const value = details[key];
706
837
  return typeof value === "number" && Number.isFinite(value) ? value : null;
707
838
  }
839
+ function readDetailsString(details, key) {
840
+ if (!isRecord(details))
841
+ return null;
842
+ const value = details[key];
843
+ return typeof value === "string" && value.trim() ? value.trim() : null;
844
+ }
708
845
  function parseJsonArray(value) {
709
846
  let parsed;
710
847
  try {
@@ -888,9 +1025,9 @@ const MAILBOX_IMPORT_ROW_LIMIT = 500;
888
1025
  * Normalize common mailbox-vendor export labels locally, then send only
889
1026
  * Oxygen's canonical fields. Passwords are accepted only in credential mode
890
1027
  * and only become Google app passwords; Microsoft passwords are dropped because
891
- * tenant consent is its only warmup path. OAuth grants, MFA/TOTP seeds, and
892
- * delegation keys are rejected in every mode. Host columns may prove the
893
- * provider but never cross the request boundary.
1028
+ * one Outlook OAuth consent per mailbox is its only warmup path. OAuth grants,
1029
+ * MFA/TOTP seeds, and delegation keys are rejected in every mode. Host columns
1030
+ * may prove the provider but never cross the request boundary.
894
1031
  */
895
1032
  function normalizeMailboxExportRow(row, index, mode) {
896
1033
  const byHeader = new Map();
@@ -1435,6 +1572,17 @@ function buildCrmPhotosBody(options) {
1435
1572
  ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
1436
1573
  };
1437
1574
  }
1575
+ function buildCrmRollupBody(options) {
1576
+ const object = readOption(options.object);
1577
+ const row = readOption(options.row);
1578
+ const limit = readOption(options.limit);
1579
+ return {
1580
+ mode: resolveLiveDryRunMode(options),
1581
+ ...(object ? { object } : {}),
1582
+ ...(row ? { row } : {}),
1583
+ ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
1584
+ };
1585
+ }
1438
1586
  function readCrmSetupObjects(value) {
1439
1587
  const objects = readCsvOption(value);
1440
1588
  return objects.length > 0 ? [...new Set(objects)] : DEFAULT_CRM_SETUP_OBJECTS;
@@ -2040,8 +2188,14 @@ function buildPublishingAnalyticsPath(base, params) {
2040
2188
  return suffix ? `${base}?${suffix}` : base;
2041
2189
  }
2042
2190
  function buildPublishingCommentsListPath(options) {
2191
+ const view = readOption(options.view);
2192
+ const status = readOption(options.status);
2193
+ if (view && status) {
2194
+ throw new OxygenError("conflicting_flags", "Pass either --view or --status, not both.", { exitCode: 1 });
2195
+ }
2043
2196
  return buildPublishingAnalyticsPath("/api/cli/publishing/comments", {
2044
- status: readOption(options.status),
2197
+ view,
2198
+ status,
2045
2199
  post_id: readOption(options.post),
2046
2200
  assignee: readOption(options.assignee),
2047
2201
  channel: readOption(options.channel),
@@ -2511,6 +2665,7 @@ function buildPromptTemplatesCommand(surface, description) {
2511
2665
  export function createProgram() {
2512
2666
  const program = new Command();
2513
2667
  const binaryName = resolveCliBinaryName();
2668
+ const directoryDocsUrl = `${defaultApiUrl()}/docs/agencies`;
2514
2669
  program
2515
2670
  .name(binaryName)
2516
2671
  .description("Revenue infrastructure for B2B startups — agent-operated GTM: tables, enrichment, sequences, workflows, CRM, knowledge. MCP + CLI first; every state-changing action returns a web_url deep-link.")
@@ -2774,7 +2929,7 @@ export function createProgram() {
2774
2929
  });
2775
2930
  program
2776
2931
  .command("orgs")
2777
- .description("Organization selection commands.")
2932
+ .description("Switch between the organizations (workspaces) you belong to, and share one paid plan across them. If this workspace has no plan of its own, `orgs billing-link` puts it on a plan you already pay for somewhere else instead of buying a second subscription.")
2778
2933
  .addCommand(new Command("list")
2779
2934
  .description("List organizations available to the current CLI identity.")
2780
2935
  .option("--json", "Print a JSON envelope.")
@@ -2794,30 +2949,43 @@ export function createProgram() {
2794
2949
  .option("--json", "Print a JSON envelope.")
2795
2950
  .action(async (organization, options) => {
2796
2951
  await handleOrgUseAction(organization, options, "orgs select");
2952
+ }))
2953
+ .addCommand(new Command("billing-owners")
2954
+ .description("List the organizations that could pay for this workspace: which of the organizations you belong to can cover another workspace's credits, and for each of the rest, why it cannot. Read-only, 0 Oxygen credits.")
2955
+ .option("--json", "Print a JSON envelope.")
2956
+ .action(async (options) => {
2957
+ await handleAsyncAction("orgs billing-owners", options, () => requestOxygen("/api/cli/orgs/billing-owners"));
2797
2958
  }))
2798
2959
  .addCommand(new Command("billing-link")
2799
- .description("Bill a workspace through another organization you administer.")
2800
- .requiredOption("--owner <organization>", "Billing owner organization id, Clerk org id, or slug.")
2960
+ .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. Credit billing moves, workspace data access does not.")
2961
+ .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
2801
2962
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
2802
2963
  .option("--organization-id <id>", "Alias for --organization.")
2803
- .option("--monthly-credit-cap <credits>", "Optional monthly credit cap for this workspace.")
2964
+ .option("--monthly-credit-cap <credits>", "Optional monthly credit cap for this workspace. Omit it to leave any existing cap untouched.")
2804
2965
  .option("--json", "Print a JSON envelope.")
2805
2966
  .action(async (options) => {
2806
- await handleAsyncAction("orgs billing-link", options, () => requestOxygen("/api/cli/orgs/billing-link", {
2807
- method: "POST",
2808
- body: {
2809
- billing_owner_organization_id: readOption(options.owner),
2810
- ...(readOption(options.organization) || readOption(options.organizationId)
2811
- ? { organization_id: readOption(options.organization) ?? readOption(options.organizationId) }
2812
- : {}),
2813
- ...(readOption(options.monthlyCreditCap)
2814
- ? { monthly_credit_cap: readPositiveNumber(options.monthlyCreditCap) }
2815
- : {}),
2816
- },
2817
- }));
2967
+ await handleAsyncAction("orgs billing-link", options, async () => {
2968
+ const billingOwner = readOption(options.owner) ?? await resolveSoleBillingOwnerRef();
2969
+ return requestOxygen("/api/cli/orgs/billing-link", {
2970
+ method: "POST",
2971
+ body: {
2972
+ billing_owner_organization_id: billingOwner,
2973
+ ...(readOption(options.organization) || readOption(options.organizationId)
2974
+ ? { organization_id: readOption(options.organization) ?? readOption(options.organizationId) }
2975
+ : {}),
2976
+ // The key is omitted, never sent as null, when the flag is
2977
+ // absent: the server treats a present key as an explicit write,
2978
+ // so sending it unconditionally would wipe an agency's
2979
+ // per-client cap on every re-link.
2980
+ ...(readOption(options.monthlyCreditCap)
2981
+ ? { monthly_credit_cap: readPositiveNumber(options.monthlyCreditCap) }
2982
+ : {}),
2983
+ },
2984
+ });
2985
+ });
2818
2986
  }))
2819
2987
  .addCommand(new Command("billing-unlink")
2820
- .description("Return a workspace to owning its own billing.")
2988
+ .description("Return a workspace to owning its own billing: it stops being covered by another organization's plan and needs a subscription of its own again.")
2821
2989
  .option("--organization <organization>", "Workspace organization to unlink. Defaults to the active organization.")
2822
2990
  .option("--organization-id <id>", "Alias for --organization.")
2823
2991
  .option("--json", "Print a JSON envelope.")
@@ -3316,7 +3484,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3316
3484
  }));
3317
3485
  program
3318
3486
  .command("publishing")
3319
- .description("Social publishing, performance, and public-comment operations.")
3487
+ .description("Social publishing, performance, and public-comment operations. Start Community triage with `oxygen publishing comments list --view unanswered --json`.")
3320
3488
  .addCommand(new Command("mentions")
3321
3489
  .description("Resolve LinkedIn identities for a publish-faithful post preview.")
3322
3490
  .addCommand(new Command("resolve")
@@ -3644,10 +3812,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3644
3812
  }));
3645
3813
  })))
3646
3814
  .addCommand(new Command("comments")
3647
- .description("Public comments on posts published through OXYGEN. These are network conversations, not workspace review notes. The local queue targets a six-hour background poll; provider quota/backoff can delay it, and list reports the exact freshness plus empty_queue_is_current. If that field is false, do not conclude there are no comments: wait for next_sync_at. The worker retries automatically; there is no manual retry by design because it could loop against LinkedIn quota. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
3815
+ .description("Publishing Community for public comments on every recent post owned by a connected LinkedIn account, including posts created directly on LinkedIn. Start with `oxygen publishing comments list --view unanswered --json`. These are network conversations, not workspace review notes. The local queue targets a six-hour background discovery + comment poll; provider quota/backoff can delay it, and list reports the exact freshness plus empty_queue_is_current. If that field is false, do not conclude there are no comments: wait for next_sync_at. The worker retries automatically; there is no manual retry by design because it could loop against LinkedIn quota. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
3648
3816
  .addCommand(new Command("list")
3649
- .description("List the local public-comment work queue, oldest first, with polling freshness/backoff even when empty. Defaults to LinkedIn comments needing a reply. Read-only: no provider call, 0 credits.")
3650
- .option("--status <status>", "Filter by needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable.")
3817
+ .description("List the Publishing Community work queue, oldest first, with polling freshness/backoff even when empty. Defaults to the Unanswered view: needs_reply, draft, awaiting_approval, and action_unavailable. All means every lifecycle state inside the rolling 30-day recent-owned-post scope, not lifetime LinkedIn history. Read-only: no provider call, 0 credits.")
3818
+ .option("--view <view>", "Community view: unanswered (default) or all; all is bounded to recent owned posts.")
3819
+ .option("--status <status>", "Advanced exact-state filter: needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable. Cannot be combined with --view.")
3651
3820
  .option("--post <post_id>", "Only comments on one scheduled Publishing post.")
3652
3821
  .option("--assignee <actor>", "Only one actor id; pass me for your own assignments.")
3653
3822
  .option("--channel <channel>", "Social channel. Defaults to linkedin.")
@@ -3741,9 +3910,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3741
3910
  }));
3742
3911
  })))
3743
3912
  .addCommand(new Command("analytics")
3744
- .description("Engagement metrics the worker syncs back from the provider. Free reads. LinkedIn exposes reactions, comments, and reshares only — impressions, saves, and sends come back null, never zero.")
3913
+ .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Stale or pending snapshots retry automatically; no manual provider retry is available or needed.")
3745
3914
  .addCommand(new Command("summary")
3746
- .description("Workspace LinkedIn performance for posts published through OXYGEN. Uses each post's latest stored snapshot; unsupported metrics stay null. Read-only: no provider call, 0 credits.")
3915
+ .description("Workspace LinkedIn or X analytics. LinkedIn includes owned posts discovered from the provider; X currently includes posts published through OXYGEN. Uses each post's latest stored snapshot; snapshotted_posts (legacy measured_posts) counts sync passes, while coverage counts non-null values. Read-only: no provider call, 0 credits.")
3916
+ .option("--channel <channel>", "Channel to summarize: linkedin (default) or x.")
3747
3917
  .option("--range <range>", "Publication window: 7d, 30d (default), 90d, or 365d.")
3748
3918
  .option("--from <date>", "Custom inclusive UTC start date.")
3749
3919
  .option("--to <date>", "Custom inclusive UTC end date.")
@@ -3751,6 +3921,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3751
3921
  .option("--json", "Print a JSON envelope.")
3752
3922
  .action(async (options) => {
3753
3923
  await handleAsyncAction("publishing analytics summary", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
3924
+ channel: readOption(options.channel),
3754
3925
  range: readOption(options.range),
3755
3926
  from: readOption(options.from),
3756
3927
  to: readOption(options.to),
@@ -3758,7 +3929,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3758
3929
  })));
3759
3930
  }))
3760
3931
  .addCommand(new Command("post")
3761
- .description("One post's stored daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number. Read-only: no provider call, 0 credits.")
3932
+ .description("One post's stored daily metric series, totals, and earned-media value. EMV is impressions-derived; absent impressions return a reason instead of a made-up number. Read-only: no provider call, 0 credits.")
3762
3933
  .argument("<post_id>", "Scheduled post id.")
3763
3934
  .option("--since <date>", "ISO date to start the series from.")
3764
3935
  .option("--json", "Print a JSON envelope.")
@@ -3785,7 +3956,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3785
3956
  method: "POST",
3786
3957
  body: { cpm: parseJsonObject(options.cpmJson ?? "") },
3787
3958
  }));
3788
- })))
3959
+ }))
3960
+ .addHelpText("after", "\nExample (stored X snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel x --range 30d --json\n"))
3789
3961
  .addCommand(new Command("import")
3790
3962
  .description("Bulk-load scheduled posts from a .csv or .json file. Dry-run by DEFAULT: it parses, lints, and reports invalid rows while writing nothing. Pass --approved to actually write. Imported posts land as drafts that still need approval — import never approves and never publishes. A row whose idempotency_key was already used is skipped, so re-importing the same file cannot duplicate the queue.")
3791
3963
  .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
@@ -3977,6 +4149,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3977
4149
  method: "POST",
3978
4150
  body: buildCrmPhotosBody(options),
3979
4151
  }));
4152
+ }))
4153
+ .addCommand(new Command("rollup")
4154
+ .description("Recompute the Open Deal column on companies (and the copy stamped on their people) from this workspace's deals. It normally converges on every deal write; run this to repair after a bulk import, or on a workspace whose deals predate the column. Free: no provider calls, no credits. Defaults to dry-run.")
4155
+ .option("--object <object>", "CRM object to recompute. Only companies today. Defaults to companies.")
4156
+ .option("--row <id>", "Recompute one company record by row id instead of sweeping the object.")
4157
+ .option("--limit <n>", "Maximum company rows to scan in one sweep. Defaults to 2000.")
4158
+ .option("--dry-run", "Report how many companies disagree with their deals, without writing. A deal with no stage set is not an open deal and is excluded from the comparison, so a blank-stage deal is never reported as drift.")
4159
+ .option("--live", "Write the recomputed Open Deal cells. Default is dry-run.")
4160
+ .option("--json", "Print a JSON envelope.")
4161
+ .action(async (options) => {
4162
+ await handleAsyncAction("crm rollup", options, () => requestOxygen("/api/cli/crm/rollup", {
4163
+ method: "POST",
4164
+ body: buildCrmRollupBody(options),
4165
+ }));
3980
4166
  }))
3981
4167
  .addCommand(
3982
4168
  // `crm objects` lists (parent action runs when no subcommand matches);
@@ -5247,7 +5433,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5247
5433
  return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
5248
5434
  })))
5249
5435
  .addCommand(new Command("create")
5250
- .description("Create a direct webhook endpoint that writes inbound JSON into a table.")
5436
+ .description("Create a direct webhook endpoint that writes inbound JSON into a table (600 requests/60s per endpoint and sender IP; 429 returns Retry-After).")
5251
5437
  .argument("<table>", "Table id or slug.")
5252
5438
  .option("--name <name>", "Display name for the webhook.")
5253
5439
  .option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
@@ -5257,6 +5443,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5257
5443
  .option("--event-id-path <path>", "Dot path to the event id. Defaults to event_id, eventId, id, or payload hash.")
5258
5444
  .option("--event-type-path <path>", "Dot path to the event type. Defaults to type or event.")
5259
5445
  .option("--occurred-at-path <path>", "Dot path to the event timestamp.")
5446
+ .option("--source <source>", "http (default) writes inbound JSON anyone can POST. unipile subscribes the table to your connected LinkedIn/WhatsApp accounts' events instead — no endpoint URL, no secret to share, and 0 credits: rows appear as the events reach Oxygen.")
5447
+ .option("--account <unipile_account_id>", "unipile source only. Listen to ONE connected account. Omit to listen to every connected account in the workspace.")
5448
+ .option("--events <csv>", "unipile source only. Comma-separated event types, e.g. relation.new,message.new. Omit for every event the receiver handles. Post reactions, post comments, profile viewers and followers are NOT pushed by the provider — capture those with a scheduled feed (`oxygen feeds bind`).")
5449
+ .option("--mapping <mapping>", "unipile source only. curated (default) flattens known events into stable named columns; passthrough lands the raw provider event so you can map any type yourself.")
5260
5450
  .option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
5261
5451
  .option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached. Defaults to your plan's per-delivery ceiling when omitted.")
5262
5452
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
@@ -5266,6 +5456,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5266
5456
  .option("--json", "Print a JSON envelope.")
5267
5457
  .action((table, options) => {
5268
5458
  const autoRun = readTableWebhookAutoRunOptions(options);
5459
+ const unipile = readOption(options.source) === "unipile";
5269
5460
  return handleAsyncAction("tables webhook create", options, () => requestOxygen("/api/cli/tables/webhooks", {
5270
5461
  method: "POST",
5271
5462
  body: {
@@ -5278,6 +5469,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5278
5469
  ...(readOption(options.eventIdPath) ? { event_id_path: readOption(options.eventIdPath) } : {}),
5279
5470
  ...(readOption(options.eventTypePath) ? { event_type_path: readOption(options.eventTypePath) } : {}),
5280
5471
  ...(readOption(options.occurredAtPath) ? { occurred_at_path: readOption(options.occurredAtPath) } : {}),
5472
+ ...(unipile
5473
+ ? {
5474
+ feed_kind: "unipile",
5475
+ ...(readOption(options.account) ? { unipile_account_id: readOption(options.account) } : {}),
5476
+ ...(readOption(options.events) ? { event_types: readOption(options.events) } : {}),
5477
+ ...(readOption(options.mapping) ? { mapping: readOption(options.mapping) } : {}),
5478
+ }
5479
+ : {}),
5281
5480
  ...(autoRun ? { auto_run: autoRun } : {}),
5282
5481
  },
5283
5482
  }));
@@ -5319,7 +5518,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5319
5518
  .description("List direct table webhook deliveries and auto-run enqueue status.")
5320
5519
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
5321
5520
  .option("--table <table>", "Filter by table id or slug.")
5322
- .option("--status <status>", "Filter by received, completed, or failed.")
5521
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
5323
5522
  .option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
5324
5523
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
5325
5524
  .option("--json", "Print a JSON envelope.")
@@ -5602,7 +5801,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5602
5801
  .description("List the shared table delivery ledger: pull-feed sync cycles and inbound webhook deliveries land in the same place. Filter by --table (the identifier `feeds list` gives you) or by a webhook endpoint id. Read-only.")
5603
5802
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
5604
5803
  .option("--table <table>", "Table id or slug whose ledger to read.")
5605
- .option("--status <status>", "Filter by received, completed, or failed.")
5804
+ .option("--status <status>", "Filter by received, completed, failed, or rejected (rejected = arrived while the feed was paused and was turned away; no rows written).")
5606
5805
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
5607
5806
  .option("--json", "Print a JSON envelope.")
5608
5807
  .action((endpointId, options) => handleAsyncAction("feeds deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
@@ -6197,6 +6396,258 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6197
6396
  body: { action: "delete", tags, ...(options.apply ? { apply: true } : {}) },
6198
6397
  }));
6199
6398
  }));
6399
+ program
6400
+ .command("comments")
6401
+ .description(`Threaded comments on any workspace object — ${COLLAB_SUBJECT_KINDS_PROSE} — so the client's go/no-go stops living in a Google Doc. A comment is state ABOUT a GTM artifact, never the artifact: resolving a thread changes no campaign. Address the subject with --on <kind>:<id>, and one row, cell, or column of a table with --path row:<rowId>[#<columnKey>] | column:<columnKey>. Threads are open until somebody resolves them; any member may resolve. The thing that actually BLOCKS work is an approval gate — see \`oxygen approvals\`. What a thread is usually about, per kind: ${COLLAB_SUBJECT_GLOSS}. ${COLLAB_BETA_NOTE}`)
6402
+ .addCommand(new Command("add")
6403
+ .description("Comment on a workspace object. Without --reply this opens a NEW thread with this comment as its first; with --reply it answers inside the existing one. @email tokens in the body are resolved to workspace members and notified, so --mention is only needed for someone you did not name in the text.")
6404
+ .argument("<body...>", "What you want to say. Quote it, or let the shell pass it as words.")
6405
+ .requiredOption("--on <kind:id>", COLLAB_SUBJECT_REF_HELP)
6406
+ .option("--path <path>", COLLAB_SUBJECT_PATH_HELP)
6407
+ .option("--reply <id>", "Answer inside an existing thread. Pass a comment id to nest under it, or a thread id to reply at that thread's top level.")
6408
+ .option("--mention <email>", "Notify a workspace member. Repeatable.", collectRepeatable, [])
6409
+ .option("--title <text>", "Title for a NEW thread. Ignored when replying.")
6410
+ .option("--json", "Print a JSON envelope.")
6411
+ .action(async (body, options) => {
6412
+ await handleCommentsAddAction(body.join(" "), options);
6413
+ }))
6414
+ .addCommand(new Command("list")
6415
+ .description("Every comment thread on one object, newest first, with its comments inline. Defaults to open threads only.")
6416
+ .requiredOption("--on <kind:id>", COLLAB_SUBJECT_REF_HELP)
6417
+ .option("--path <path>", `${COLLAB_SUBJECT_PATH_HELP} Omit to see every thread on the object.`)
6418
+ .option("--status <status>", "open (default), resolved, or all.")
6419
+ .option("--limit <n>", "Maximum threads to return.")
6420
+ .option("--json", "Print a JSON envelope.")
6421
+ .action(async (options) => {
6422
+ await handleCollabAction("comments list", options, () => {
6423
+ const subject = requireCollabSubject(options.on);
6424
+ const path = readCollabPath(options.path);
6425
+ const params = new URLSearchParams({ subject: `${subject.kind}:${subject.id}` });
6426
+ if (path)
6427
+ params.set("path", path);
6428
+ const status = readOption(options.status);
6429
+ if (status)
6430
+ params.set("status", status);
6431
+ const limit = readPositiveInt(options.limit);
6432
+ if (limit !== undefined)
6433
+ params.set("limit", String(limit));
6434
+ return requestOxygen(`/api/cli/collab/threads?${params.toString()}`);
6435
+ }, formatCollabThreads);
6436
+ }))
6437
+ .addCommand(new Command("show")
6438
+ .description("One thread by its own id, with every comment. This is what to run when all you have is the id out of a notification email or a `/collab/threads/<id>` link — `list` needs the object the thread hangs off, which that link does not tell you.")
6439
+ .argument("<threadId>", "Thread id (from a notification email, a deep-link, or `oxygen comments list`).")
6440
+ .option("--json", "Print a JSON envelope.")
6441
+ .action(async (threadId, options) => {
6442
+ await handleCollabAction("comments show", options, () => requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(threadId)}`),
6443
+ // Same payload shape `comments list` returns, one thread long, so the
6444
+ // renderer is the same one.
6445
+ formatCollabThreads);
6446
+ }))
6447
+ .addCommand(new Command("resolve")
6448
+ .description("Mark a thread settled, or reopen it with --reopen. Idempotent both ways: re-resolving keeps the original resolver and timestamp. This is conversation state only — it never unblocks an approval gate.")
6449
+ .argument("<threadId>", "Thread id (from `oxygen comments list`).")
6450
+ .option("--reopen", "Reopen a resolved thread instead of resolving it.")
6451
+ .option("--json", "Print a JSON envelope.")
6452
+ .action(async (threadId, options) => {
6453
+ await handleCollabAction("comments resolve", options, () => requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(threadId)}/resolve`, {
6454
+ method: "POST",
6455
+ body: { resolved: !options.reopen },
6456
+ }), formatCollabWriteResult);
6457
+ }))
6458
+ .addCommand(new Command("edit")
6459
+ .description("Rewrite one of your own comments. AUTHOR ONLY. The edit stamps edited_at — a thread that decided a launch is evidence, and evidence that changes without a trace is not evidence.")
6460
+ .argument("<commentId>", "Comment id (from `oxygen comments list`).")
6461
+ .argument("<body...>", "The replacement text.")
6462
+ .option("--json", "Print a JSON envelope.")
6463
+ .action(async (commentId, body, options) => {
6464
+ await handleCollabAction("comments edit", options, () => requestOxygen(`/api/cli/collab/comments/${encodeURIComponent(commentId)}`, {
6465
+ method: "PATCH",
6466
+ body: { body: body.join(" ") },
6467
+ }), formatCollabWriteResult);
6468
+ }))
6469
+ .addCommand(new Command("delete")
6470
+ .description("Retract one of your own comments. AUTHOR ONLY, idempotent. The row stays so the thread remains a complete audit trail; the text is redacted on every read path.")
6471
+ .argument("<commentId>", "Comment id (from `oxygen comments list`).")
6472
+ .option("--json", "Print a JSON envelope.")
6473
+ .action(async (commentId, options) => {
6474
+ await handleCollabAction("comments delete", options, () => requestOxygen(`/api/cli/collab/comments/${encodeURIComponent(commentId)}`, {
6475
+ method: "DELETE",
6476
+ }), formatCollabWriteResult);
6477
+ }));
6478
+ program
6479
+ .command("approvals")
6480
+ .description(`Second-party approvals: ask a named person to decide, and optionally make that decision a hard gate on the live action. Gates: ${COLLAB_GATE_GLOSS}. Asking never blocks anything by itself — a gate is separate, admin-set workspace policy (\`approvals gate set\`), and the owning primitive honors it (a gated sequence still previews and dry-runs; only the live start is refused). Only \`--approve\` opens a gate: \`--changes-requested\` is a real answer that asks for edits and leaves the gate shut. \`approvals client grant <email>\` hands a client a restricted login that can read the work, comment, and decide what is assigned to them — nothing else. ${COLLAB_BETA_NOTE}`)
6481
+ .addCommand(new Command("request")
6482
+ .description("Ask a named second party to decide. Assignees are workspace member EMAILS, and never your own: assigning yourself is refused, because a request you can answer yourself is not an approval. An address that is not a member is refused by name rather than silently dropped, because a request nobody can decide is only discovered the next morning.")
6483
+ .requiredOption("--on <kind:id>", COLLAB_SUBJECT_REF_HELP)
6484
+ .requiredOption("--gate <gate>", COLLAB_GATE_FLAG_HELP)
6485
+ // requiredOption keeps `required: true` in the machine-readable manifest,
6486
+ // but commander's mandatory check is satisfied by the collector's []
6487
+ // default — so the action asserts non-empty itself (requireCollabAssignees).
6488
+ .requiredOption("--assignee <email>", "Who decides — someone other than you. Repeatable: any one of them can answer. Required.", collectRepeatable, [])
6489
+ .option("--path <path>", COLLAB_SUBJECT_PATH_HELP)
6490
+ .option("--title <text>", "Short headline for the ask.")
6491
+ .option("--body <text>", "What you want them to check.")
6492
+ .option("--expires-hours <n>", "Deadline for the ANSWER: an undecided request lapses to expired after N hours, and an expired request never opens the gate. It does not put a clock on a yes — an approval given before the deadline keeps the gate open until the approved content changes.")
6493
+ .option("--json", "Print a JSON envelope.")
6494
+ .action(async (options) => {
6495
+ await handleCollabAction("approvals request", options, () => {
6496
+ const subject = requireCollabSubject(options.on);
6497
+ const gateKind = requireCollabGate(options.gate);
6498
+ const assignees = requireCollabAssignees(options.assignee);
6499
+ const path = readCollabPath(options.path);
6500
+ const title = readOption(options.title);
6501
+ const body = readOption(options.body);
6502
+ const expiresInHours = readPositiveInt(options.expiresHours);
6503
+ return requestOxygen("/api/cli/collab/requests", {
6504
+ method: "POST",
6505
+ body: {
6506
+ subject_kind: subject.kind,
6507
+ subject_id: subject.id,
6508
+ ...(path ? { subject_path: path } : {}),
6509
+ gate_kind: gateKind,
6510
+ ...(title ? { title } : {}),
6511
+ ...(body ? { body } : {}),
6512
+ assignees,
6513
+ ...(expiresInHours !== undefined ? { expires_in_hours: expiresInHours } : {}),
6514
+ },
6515
+ });
6516
+ }, formatCollabRequestWrite);
6517
+ }))
6518
+ .addCommand(new Command("list")
6519
+ .description("Approval requests in this workspace, newest first. Use --mine for the ones assigned to you, or `approvals inbox` for the daily view.")
6520
+ .option("--status <status>", `Filter by status: ${["pending", "approved", "changes_requested", "rejected", "cancelled", "expired"].join(", ")}.`)
6521
+ .option("--mine", "Only requests assigned to you.")
6522
+ .option("--on <kind:id>", `Only requests on one subject. ${COLLAB_SUBJECT_REF_HELP}`)
6523
+ .option("--limit <n>", "Maximum requests to return.")
6524
+ .option("--json", "Print a JSON envelope.")
6525
+ .action(async (options) => {
6526
+ await handleCollabAction("approvals list", options, () => {
6527
+ const params = new URLSearchParams();
6528
+ const status = readOption(options.status);
6529
+ if (status)
6530
+ params.set("status", status);
6531
+ if (options.mine)
6532
+ params.set("assigned_to_me", "true");
6533
+ const on = readOption(options.on);
6534
+ if (on) {
6535
+ const subject = requireCollabSubject(on);
6536
+ params.set("subject", `${subject.kind}:${subject.id}`);
6537
+ }
6538
+ const limit = readPositiveInt(options.limit);
6539
+ if (limit !== undefined)
6540
+ params.set("limit", String(limit));
6541
+ const query = params.toString();
6542
+ return requestOxygen(`/api/cli/collab/requests${query ? `?${query}` : ""}`);
6543
+ }, formatCollabRequestList);
6544
+ }))
6545
+ .addCommand(new Command("inbox")
6546
+ .description("What is waiting on YOU: pending approvals assigned to you plus open threads that name you, each with the command that answers it. This is the surface a client logs in to.")
6547
+ .option("--limit <n>", "Maximum items per section.")
6548
+ .option("--json", "Print a JSON envelope.")
6549
+ .action(async (options) => {
6550
+ await handleCollabAction("approvals inbox", options, () => {
6551
+ const limit = readPositiveInt(options.limit);
6552
+ return requestOxygen(`/api/cli/collab/inbox${limit !== undefined ? `?limit=${limit}` : ""}`);
6553
+ }, formatCollabInbox);
6554
+ }))
6555
+ .addCommand(new Command("show")
6556
+ .description("One approval request by its own id: what is being asked, on what, by whom, who must decide, and whether it can still be decided at all. Run this on the id in a 'you have been asked to approve' email or a `/collab/approvals/<id>` link — `list` cannot find an id you have no subject for. An expired request is reported as such, with the command that clears it.")
6557
+ .argument("<requestId>", "Request id (from a notification email, a deep-link, or `oxygen approvals inbox`).")
6558
+ .option("--json", "Print a JSON envelope.")
6559
+ .action(async (requestId, options) => {
6560
+ await handleCollabAction("approvals show", options, () => requestOxygen(`/api/cli/collab/requests/${encodeURIComponent(requestId)}`), formatCollabRequestDetail);
6561
+ }))
6562
+ .addCommand(new Command("decide")
6563
+ .description("Answer a pending request. Exactly one of --approve / --reject / --changes-requested. Assignees decide; a workspace admin may decide for the workspace; the requester may NOT decide their own request (that is the whole point) — withdraw it with `approvals cancel` instead. ONLY --approve opens a gate.")
6564
+ .argument("<requestId>", "Request id (from `oxygen approvals inbox`).")
6565
+ .option("--approve", "Approve it. This is the only decision that opens a gate.")
6566
+ .option("--reject", "Refuse it. The gate stays shut.")
6567
+ .option("--changes-requested", "Ask for edits. A real answer that does NOT open the gate — the requester revises and asks again.")
6568
+ .option("--note <text>", "Why. Stored on the request and quoted back to whoever is blocked by it.")
6569
+ .option("--json", "Print a JSON envelope.")
6570
+ .action(async (requestId, options) => {
6571
+ await handleCollabAction("approvals decide", options, () => {
6572
+ const decision = requireCollabDecision(options);
6573
+ const note = readOption(options.note);
6574
+ return requestOxygen(`/api/cli/collab/requests/${encodeURIComponent(requestId)}/decide`, {
6575
+ method: "POST",
6576
+ body: { decision, ...(note ? { note } : {}) },
6577
+ });
6578
+ }, formatCollabDecision);
6579
+ }))
6580
+ .addCommand(new Command("cancel")
6581
+ .description("Withdraw a pending request you filed (or, as an admin, clear a stale one). Replaying a cancel is a success flagged `already`; cancelling something already approved or rejected is refused, because a silent no-op would leave you believing a live action is still gated when it is not.")
6582
+ .argument("<requestId>", "Request id.")
6583
+ .option("--note <text>", "Why you are withdrawing it.")
6584
+ .option("--json", "Print a JSON envelope.")
6585
+ .action(async (requestId, options) => {
6586
+ await handleCollabAction("approvals cancel", options, () => {
6587
+ const note = readOption(options.note);
6588
+ return requestOxygen(`/api/cli/collab/requests/${encodeURIComponent(requestId)}/cancel`, {
6589
+ method: "POST",
6590
+ body: note ? { note } : {},
6591
+ });
6592
+ }, formatCollabWriteResult);
6593
+ }))
6594
+ .addCommand(new Command("gate")
6595
+ .description("The opt-in hard gate. Off everywhere until an admin turns it on, so shipping this changed nobody's workflow.")
6596
+ .addCommand(new Command("set")
6597
+ .description(`Require (or stop requiring) an approval before the live action. ADMIN ONLY — a member who can switch the gate off at will is not a gate. Scope it to one object with --on, or to every object of a kind with --default. ${COLLAB_GATE_GLOSS}.`)
6598
+ .option("--on <kind:id>", `Gate one object. ${COLLAB_SUBJECT_REF_HELP}`)
6599
+ .option("--default <kind>", `Gate every object of a kind (the workspace default). One of: ${COLLAB_SUBJECT_KINDS.join(", ")}.`)
6600
+ .requiredOption("--gate <gate>", COLLAB_GATE_FLAG_HELP)
6601
+ .option("--required", "Turn the gate ON.")
6602
+ .option("--not-required", "Turn the gate OFF.")
6603
+ .option("--json", "Print a JSON envelope.")
6604
+ .action(async (options) => {
6605
+ await handleCollabAction("approvals gate set", options, () => requestOxygen("/api/cli/collab/gates", {
6606
+ method: "POST",
6607
+ body: collabGateSetBody(options),
6608
+ }), formatCollabGateSet);
6609
+ }))
6610
+ .addCommand(new Command("list")
6611
+ .description("Which gates this workspace requires. Pass --on to also get the EFFECTIVE answer for one object — its own policy, then the workspace default for its kind, then 'not required' — plus whether it is blocked right now.")
6612
+ .option("--on <kind:id>", `Also resolve the effective gates for one object. ${COLLAB_SUBJECT_REF_HELP}`)
6613
+ .option("--json", "Print a JSON envelope.")
6614
+ .action(async (options) => {
6615
+ await handleCollabAction("approvals gate list", options, () => {
6616
+ const on = readOption(options.on);
6617
+ if (!on)
6618
+ return requestOxygen("/api/cli/collab/gates");
6619
+ const subject = requireCollabSubject(on);
6620
+ return requestOxygen(`/api/cli/collab/gates?subject=${encodeURIComponent(`${subject.kind}:${subject.id}`)}`);
6621
+ }, formatCollabGateList);
6622
+ })))
6623
+ .addCommand(new Command("client")
6624
+ .description("The restricted login an agency hands its client: read the work, comment on it, decide the approvals assigned to them, nothing else. ADMIN ONLY. Invite them to the workspace first — this grants the overlay on an existing membership.")
6625
+ .addCommand(new Command("grant")
6626
+ .description("Restrict a member to the client role.")
6627
+ .argument("<email>", "Workspace member's email.")
6628
+ .option("--json", "Print a JSON envelope.")
6629
+ .action(async (email, options) => {
6630
+ await handleCollabAction("approvals client", options, () => requestOxygen("/api/cli/collab/members/role", {
6631
+ method: "POST",
6632
+ body: { email, role: "client" },
6633
+ }), formatCollabWriteResult);
6634
+ }))
6635
+ .addCommand(new Command("revoke")
6636
+ .description("Clear the client role and restore full workspace access.")
6637
+ .argument("<email>", "Workspace member's email.")
6638
+ .option("--json", "Print a JSON envelope.")
6639
+ .action(async (email, options) => {
6640
+ await handleCollabAction("approvals client", options, () => requestOxygen("/api/cli/collab/members/role", {
6641
+ method: "POST",
6642
+ body: { email, role: null },
6643
+ }), formatCollabWriteResult);
6644
+ }))
6645
+ .addCommand(new Command("list")
6646
+ .description("Every member of this workspace and what they may do — the workspace role plus the Oxygen client overlay. Run it after a grant to see the change: this is the same read the /collab/members page renders.")
6647
+ .option("--json", "Print a JSON envelope.")
6648
+ .action(async (options) => {
6649
+ await handleCollabAction("approvals client list", options, () => requestOxygen("/api/cli/collab/members"), formatCollabMembers);
6650
+ })));
6200
6651
  program
6201
6652
  .command("blueprints")
6202
6653
  .description("Scaffolding bundles: a workflow + tables + columns + prompts as shareable JSON. For guided GTM plays see `oxygen recipes`.")
@@ -6918,6 +7369,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6918
7369
  // doesn't mention visibility leaves it untouched.
6919
7370
  .option("--always-show", "Keep this column visible even when it holds no values.")
6920
7371
  .option("--no-always-show", "Stop pinning this column, so it collapses again while empty.")
7372
+ .option("--hidden-by-default", "Leave this column out of the grid on first open. It stays readable everywhere else, and anyone can reveal it from the column menu.")
7373
+ .option("--no-hidden-by-default", "Show this column in the grid on first open. Read the current opening set with `oxygen tables describe <table>` — every column reports its hiddenByDefault state.")
6921
7374
  .option("--dry-run", "Return the would-be merged definition without writing.")
6922
7375
  .option("--json", "Print a JSON envelope.")
6923
7376
  .action(async (table, column, options) => {
@@ -6963,6 +7416,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6963
7416
  ...(definitionUnset.length > 0 ? { definition_unset: definitionUnset } : {}),
6964
7417
  ...(readOption(options.dataType) ? { data_type: readOption(options.dataType) } : {}),
6965
7418
  ...(typeof options.alwaysShow === "boolean" ? { always_show: options.alwaysShow } : {}),
7419
+ ...(typeof options.hiddenByDefault === "boolean"
7420
+ ? { hidden_by_default: options.hiddenByDefault }
7421
+ : {}),
6966
7422
  ...(options.dryRun ? { dry_run: true } : {}),
6967
7423
  },
6968
7424
  });
@@ -8176,14 +8632,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8176
8632
  program
8177
8633
  .command("directory")
8178
8634
  .description("Manage this organization's public OXYGEN agency directory listing.")
8635
+ .addHelpText("after", `\nDocs: ${directoryDocsUrl}\n`)
8179
8636
  .addCommand(new Command("get")
8180
- .description("Read the listing, its publish state, and its public/settings deep-links.")
8637
+ .description("Read-only, 0 credits. Read the listing, publish state, and public/settings deep-links.")
8181
8638
  .option("--json", "Print a JSON envelope.")
8182
8639
  .action(async (options) => {
8183
8640
  await handleAsyncAction("directory get", options, () => requestOxygen("/api/cli/directory/listing"));
8184
8641
  }))
8185
8642
  .addCommand(new Command("update")
8186
- .description("Update listing profile fields. Only invited organizations can edit.")
8643
+ .description("Update listing fields. Changes to a published listing are public immediately. Only invited organizations can edit.")
8187
8644
  .option("--name <text>", "Agency display name.")
8188
8645
  .option("--slug <slug>", "Public URL slug (lowercase letters, digits, dashes).")
8189
8646
  .option("--logo-url <url>", "Logo image URL.")
@@ -8214,7 +8671,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8214
8671
  });
8215
8672
  }))
8216
8673
  .addCommand(new Command("publish")
8217
- .description("Publish the listing to the public directory at https://oxygen-agent.com/agencies.")
8674
+ .description("Publish at https://oxygen-agent.com/agencies. Requires a tagline, description, and at least one contact method (website, booking link, email, or LinkedIn).")
8218
8675
  .option("--json", "Print a JSON envelope.")
8219
8676
  .action(async (options) => {
8220
8677
  await handleAsyncAction("directory publish", options, () => requestOxygen("/api/cli/directory/listing/publish", { method: "POST", body: {} }));
@@ -8501,11 +8958,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8501
8958
  });
8502
8959
  }))
8503
8960
  .addCommand(new Command("approvals")
8504
- .description("List everything waiting for a human decision across workflows, publishing, message reviews, and inbox reply drafts — the cross-primitive approvals inbox. Read-only: each item includes the exact command to resolve it and a web link to decide.")
8961
+ .description("List everything waiting for a human decision across workflows, publishing, message reviews, inbox reply drafts, and collaboration approval requests — the cross-primitive approvals inbox. Read-only: each item includes the exact command to resolve it and a web link to decide.")
8505
8962
  // Keep --source values in sync with OBSERVABILITY_APPROVAL_SOURCES in
8506
8963
  // apps/web/src/lib/observability-console.ts and the MCP tool enum. The API
8507
8964
  // rejects any other value with invalid_request so a typo fails loudly.
8508
- .option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft.")
8965
+ .option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft, collab_request.")
8509
8966
  .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
8510
8967
  .option("--json", "Print a JSON envelope.")
8511
8968
  .action(async (options) => {
@@ -9616,6 +10073,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9616
10073
  const params = new URLSearchParams({ integration_id: integrationId });
9617
10074
  return requestOxygen(`/api/cli/integrations/composio/actions?${params.toString()}`);
9618
10075
  });
10076
+ }))
10077
+ .addCommand(new Command("destinations")
10078
+ .description("List where a connected integration can send — for Microsoft Teams, the teams you belong to, and one team's channels with --team-id. Free: these are read-only lookups, so no approval or credit cap is needed.")
10079
+ .argument("<integration_id>", "Integration id. Supported: 'microsoft_teams'.")
10080
+ .option("--team-id <id>", "List this team's channels as well as your teams.")
10081
+ .option("--connection-id <id>", "Use a specific connection when several are active.")
10082
+ .option("--json", "Print a JSON envelope.")
10083
+ .action(async (integrationId, options) => {
10084
+ await handleAsyncAction("integrations destinations", options, () => {
10085
+ const params = new URLSearchParams({ integration_id: integrationId });
10086
+ const teamId = readOption(options.teamId);
10087
+ if (teamId)
10088
+ params.set("team_id", teamId);
10089
+ const connectionId = readOption(options.connectionId);
10090
+ if (connectionId)
10091
+ params.set("connection_id", connectionId);
10092
+ return requestOxygen(`/api/cli/integrations/destinations?${params.toString()}`);
10093
+ });
9619
10094
  }))
9620
10095
  .addCommand(new Command("run")
9621
10096
  .description("Run an action for a connected integration — Composio toolkits and native providers alike.")
@@ -9819,7 +10294,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9819
10294
  await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
9820
10295
  }))
9821
10296
  .addCommand(new Command("set")
9822
- .description("Adjust per-account daily action limits and the daily-reset timezone. Values are clamped to safe maximums (e.g. max 80 invites/day). Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
10297
+ .description("Adjust per-account daily action limits and the daily-reset timezone. Values are clamped to safe maximums (30 invites/day and 40 messages/day). Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
9823
10298
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
9824
10299
  .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
9825
10300
  .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
@@ -10053,10 +10528,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10053
10528
  });
10054
10529
  })));
10055
10530
  program.addCommand(new Command("posts")
10056
- .description("Read and publish LinkedIn posts through a connected account. `get`, `comments`, and `reactions` are LIVE provider reads: they use 0 Oxygen credits but consume the sender's daily account-read allowance. Do not use them when a task forbids provider calls; use `publishing comments list` for the local cross-post queue. `create` publishes a real post (approval-gated). Every command here addresses ONE post you already have an id for — Oxygen cannot enumerate an account's own LinkedIn history, so analytics only cover posts Oxygen itself published (`oxygen publishing ...`).")
10531
+ .description("Read and publish LinkedIn posts through a connected account. `get`, `comments`, and `reactions` are LIVE provider reads: they use 0 Oxygen credits but consume the sender's daily account-read allowance. Do not use them when a task forbids provider calls; use `publishing comments list` for the local Community queue, which discovers recent posts owned by connected accounts in the background. `create` publishes a real post (approval-gated). The direct commands here address ONE post you already have an id for; aggregate analytics still cover posts Oxygen itself published (`oxygen publishing ...`).")
10057
10532
  .addCommand(new Command("get")
10058
10533
  .description("LIVE provider read of one LinkedIn post. Uses 0 Oxygen credits but consumes the sender's metered account-read allowance; do not run it when the task forbids provider calls. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). This does not populate or refresh the durable `publishing comments` queue.")
10059
- .requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id. There is no way to LIST your own posts: take the id from the post's LinkedIn URL, or from a post you scheduled (`oxygen publishing posts get <post_id>` → provider_post_id).")
10534
+ .requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id. For a direct live read, take the id from the post's LinkedIn URL or a scheduled post (`oxygen publishing posts get <post_id>` → provider_post_id); Community discovers recent owned posts separately in the background.")
10060
10535
  .option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
10061
10536
  .option("--json", "Print a JSON envelope.")
10062
10537
  .action(async (options) => {
@@ -10154,7 +10629,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10154
10629
  });
10155
10630
  })));
10156
10631
  program.addCommand(new Command("engagement")
10157
- .description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`) or from your connected account's viewers, followers, and connections. For recurring competitor-profile monitoring that discovers future posts, start with `oxygen recipes list competitor --json`. Harvests run as a slow, durable drip under a conservative read budget.")
10632
+ .description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`), and react or comment on it. Recurring capture — a post's engagers on a cadence, 'who viewed my profile', new followers, new connections — is a live table: arm it with `oxygen linkedin intent setup` and operate it with `oxygen feeds list|pause|resume|run`. For recurring competitor-profile monitoring that discovers future posts, start with `oxygen recipes list competitor --json`. Harvests run as a slow, durable drip under a conservative read budget.")
10158
10633
  .addCommand(new Command("harvest")
10159
10634
  .description("Start (or re-arm) a harvest of a post's engagers into a workspace table you can enroll into a sequence. Engagers drip into the table over many ticks; poll `engagement status` to watch it fill. No messages are sent. Cookieless harvests spend Oxygen credits per scraper page and require --max-credits.")
10160
10635
  .requiredOption("--post <social_id_or_url>", "Composite post social_id from `oxygen posts get` (NOT the activity URN), or the public LinkedIn post URL for cookieless.")
@@ -10267,112 +10742,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10267
10742
  },
10268
10743
  });
10269
10744
  });
10270
- }))
10271
- .addCommand(new Command("watch")
10272
- .description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers, 'who viewed my profile', or the sender's inbound network (new followers / new connections) that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
10273
- .addHelpText("after", "\nScope: this watches one known post or one connected-account signal. For daily discovery across one or more public profiles' recent posts, use `oxygen blueprints describe linkedin-profile-engager-monitor --json`.\n")
10274
- .addCommand(new Command("create")
10275
- .description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile); `--kind followers` / `--kind connections` watch the sender's inbound network — people NEW to the org's orbit stream into the watch table as they follow/connect (source unipile, no credits, reads metered against the account's daily ingest budget). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day — the inbound-led-outbound loop. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
10276
- .requiredOption("--kind <kind>", "post | profile_viewers | followers | connections.")
10277
- .requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
10278
- .option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
10279
- .option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
10280
- .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post; profile_viewers/followers/connections are always unipile.")
10281
- .option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
10282
- .option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
10283
- .option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
10284
- .option("--max-credits-per-cycle <credits>", "Standing per-cycle managed-spend cap (required for --auto-enroll and cookieless).")
10285
- .option("--max-enrolls-per-day <n>", "Cap auto-enrollments per day.")
10286
- .option("--recurrence <recurrence>", "once | every_6h | daily | weekly (default once).")
10287
- .option("--json", "Print a JSON envelope.")
10288
- .action(async (options) => {
10289
- await handleAsyncAction("engagement watch create", options, () => {
10290
- const kind = readOption(options.kind);
10291
- const account = readOption(options.account);
10292
- const post = readOption(options.post);
10293
- const postUrl = readOption(options.postUrl);
10294
- const source = readOption(options.source);
10295
- const table = readOption(options.table);
10296
- const sequence = readOption(options.sequence);
10297
- const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
10298
- const maxEnrollsPerDay = readPositiveNumber(options.maxEnrollsPerDay);
10299
- const recurrence = readOption(options.recurrence);
10300
- return requestOxygen("/api/cli/linkedin/engagement/watches", {
10301
- method: "POST",
10302
- body: {
10303
- ...(kind ? { kind } : {}),
10304
- ...(account ? { account } : {}),
10305
- ...(post ? { post } : {}),
10306
- ...(postUrl ? { post_url: postUrl } : {}),
10307
- ...(source ? { source } : {}),
10308
- ...(table ? { table } : {}),
10309
- ...(sequence ? { sequence } : {}),
10310
- ...(options.autoEnroll ? { auto_enroll: true } : {}),
10311
- ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
10312
- ...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
10313
- ...(recurrence ? { recurrence } : {}),
10314
- },
10315
- });
10316
- });
10317
- }))
10318
- .addCommand(new Command("list")
10319
- .description("List the org's engagement watches (status, kind, sender, sequence, per-day enroll count) with a table deep-link.")
10320
- .option("--status <status>", "Filter by status: active | paused | exhausted | completed.")
10321
- .option("--kind <kind>", "Filter by kind.")
10322
- .option("--json", "Print a JSON envelope.")
10323
- .action(async (options) => {
10324
- await handleAsyncAction("engagement watch list", options, () => {
10325
- const status = readOption(options.status);
10326
- const kind = readOption(options.kind);
10327
- const params = new URLSearchParams();
10328
- if (status)
10329
- params.set("status", status);
10330
- if (kind)
10331
- params.set("kind", kind);
10332
- const suffix = params.toString();
10333
- return requestOxygen(`/api/cli/linkedin/engagement/watches${suffix ? `?${suffix}` : ""}`);
10334
- });
10335
- }))
10336
- .addCommand(new Command("pause")
10337
- .description("Pause a watch (stop materializing its harvest + auto-enrolling).")
10338
- .argument("<watchId>", "Watch id.")
10339
- .option("--json", "Print a JSON envelope.")
10340
- .action(async (watchId, options) => {
10341
- await handleAsyncAction("engagement watch pause", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10342
- method: "PATCH",
10343
- body: { action: "pause" },
10344
- }));
10345
- }))
10346
- .addCommand(new Command("resume")
10347
- .description("Resume a paused watch.")
10348
- .argument("<watchId>", "Watch id.")
10349
- .option("--json", "Print a JSON envelope.")
10350
- .action(async (watchId, options) => {
10351
- await handleAsyncAction("engagement watch resume", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10352
- method: "PATCH",
10353
- body: { action: "resume" },
10354
- }));
10355
- }))
10356
- .addCommand(new Command("tag")
10357
- .description("Replace a watch's workspace tags (whole set; `--tags \"\"` clears) — groups a standing watch into the same campaign vocabulary as the sequence it feeds. See `oxygen tags list`.")
10358
- .argument("<watchId>", "Watch id.")
10359
- .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
10360
- .option("--json", "Print a JSON envelope.")
10361
- .action(async (watchId, options) => {
10362
- await handleAsyncAction("engagement watch tag", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}/tags`, {
10363
- method: "POST",
10364
- body: { tags: splitCommaList(options.tags) },
10365
- }));
10366
- }))
10367
- .addCommand(new Command("delete")
10368
- .description("Delete a watch (its harvested table + rows are kept).")
10369
- .argument("<watchId>", "Watch id.")
10370
- .option("--json", "Print a JSON envelope.")
10371
- .action(async (watchId, options) => {
10372
- await handleAsyncAction("engagement watch delete", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10373
- method: "DELETE",
10374
- }));
10375
- }))));
10745
+ })));
10376
10746
  program.addCommand(new Command("linkedin")
10377
10747
  .description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
10378
10748
  .addCommand(new Command("intent")
@@ -10399,7 +10769,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10399
10769
  });
10400
10770
  }))
10401
10771
  .addCommand(new Command("status")
10402
- .description("Show the canonical intent tables, their linked-table deep-links, and the capture watches feeding them.")
10772
+ .description("Show the canonical intent tables, their linked-table deep-links, and the capture feeds filling them.")
10403
10773
  .option("--json", "Print a JSON envelope.")
10404
10774
  .action(async (options) => {
10405
10775
  await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
@@ -10711,13 +11081,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10711
11081
  });
10712
11082
  })));
10713
11083
  program.addCommand(new Command("inbox")
10714
- .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (--channel all), or a single channel. Public comments on OXYGEN-published posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
11084
+ .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (--channel all), or a single channel. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
10715
11085
  .addCommand(new Command("list")
10716
11086
  .description("List conversations newest first. --channel all merges email + LinkedIn + WhatsApp into one stream (narrow it with --channels); --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
10717
11087
  .option("--channel <channel>", "Inbox channel: all (merged), linkedin (default), whatsapp, or email.")
10718
11088
  .option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
10719
11089
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
10720
11090
  .option("--unread", "Only show conversations with unread messages.")
11091
+ .option("--unanswered", "Only conversations awaiting YOUR reply — the last message in the thread is inbound. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.)")
10721
11092
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
10722
11093
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
10723
11094
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -10747,6 +11118,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10747
11118
  params.set("account", account);
10748
11119
  if (options.unread)
10749
11120
  params.set("unread", "true");
11121
+ if (options.unanswered)
11122
+ params.set("unanswered", "true");
10750
11123
  if (options.responsesOnly)
10751
11124
  params.set("responses_only", "true");
10752
11125
  for (const [flag, key] of [
@@ -10859,6 +11232,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10859
11232
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
10860
11233
  .option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
10861
11234
  .option("--include-archived", "Also mark archived conversations read.")
11235
+ .option("--unanswered", "Only sweep conversations awaiting your reply (the last message is inbound) — the same filter as `inbox list --unanswered`, so the scope is exactly that list.")
10862
11236
  .option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
10863
11237
  .option("--json", "Print a JSON envelope.")
10864
11238
  .action(async (options) => {
@@ -10891,6 +11265,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10891
11265
  }
10892
11266
  if (options.includeArchived)
10893
11267
  params.set("include_archived", "true");
11268
+ // Booleans need their own line: the tuple loop above only walks
11269
+ // the string-valued options.
11270
+ if (options.unanswered)
11271
+ params.set("unanswered", "true");
10894
11272
  return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
10895
11273
  method: "POST",
10896
11274
  // Approval rides in the body: a bodyless POST reads as
@@ -11122,6 +11500,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11122
11500
  .option("--json", "Print a JSON envelope.")
11123
11501
  .action(async (key, options) => {
11124
11502
  await handleAsyncAction("inbox label archive", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, { method: "DELETE" }));
11503
+ }))
11504
+ .addCommand(new Command("restore")
11505
+ .description("Restore an archived custom status label under its original key.")
11506
+ .argument("<key>", "The archived custom label key.")
11507
+ .option("--json", "Print a JSON envelope.")
11508
+ .action(async (key, options) => {
11509
+ await handleAsyncAction("inbox label restore", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
11510
+ method: "PATCH",
11511
+ body: { archived: false },
11512
+ }));
11125
11513
  })))
11126
11514
  .addCommand(new Command("drafts")
11127
11515
  .description("The AI reply-agent draft queue (the approve-before-send review queue).")
@@ -12596,7 +12984,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12596
12984
  });
12597
12985
  })));
12598
12986
  program.addCommand(new Command("mailboxes")
12599
- .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed TrulyInbox warmup with explicit plans and credit caps. 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).")
12987
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed SendKit warmup with explicit plans and credit caps. 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).")
12600
12988
  .addCommand(new Command("list")
12601
12989
  .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).")
12602
12990
  .option("--status <status>", "Filter by status: active, paused, or disabled.")
@@ -12612,7 +13000,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12612
13000
  });
12613
13001
  }))
12614
13002
  .addCommand(new Command("get")
12615
- .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 TrulyInbox warmup or EmailGuard monitoring. <mailbox> accepts a mailbox id or email address.")
13003
+ .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.")
12616
13004
  .argument("<mailbox>", "Mailbox id or email address.")
12617
13005
  .option("--json", "Print a JSON envelope.")
12618
13006
  .action(async (mailbox, options) => {
@@ -12625,7 +13013,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12625
13013
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
12626
13014
  }))
12627
13015
  .addCommand(new Command("compatibility")
12628
- .description("Read-only compatibility report for every selected mailbox: origin vendor, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, TrulyInbox 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 complete current 18-pair 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.")
13016
+ .description("Read-only compatibility report for every selected mailbox: origin vendor, 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 complete current 18-pair 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.")
12629
13017
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
12630
13018
  .option("--catalog-only", "Return only the bounded import-method, field, auth-boundary, and 18-pair provider catalogs; do not read or return workspace mailbox rows.")
12631
13019
  .option("--json", "Print a JSON envelope.")
@@ -12647,7 +13035,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12647
13035
  });
12648
13036
  }))
12649
13037
  .addCommand(new Command("import")
12650
- .description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: Microsoft warmup requires Entra tenant-admin consent and EmailGuard remains vendor_blocked for Microsoft. Google app passwords are sent only in the request body, encrypted server-side, and never returned.")
13038
+ .description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: Microsoft warmup requires one Outlook OAuth authorization per mailbox (`mailboxes warmup microsoft`) and EmailGuard remains vendor_blocked for Microsoft. Google app passwords are sent only in the request body, encrypted server-side, and never returned.")
12651
13039
  .addHelpText("after", [
12652
13040
  "",
12653
13041
  "Ordinary identity file contract (CSV / JSON / JSONL / XLSX):",
@@ -12975,7 +13363,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12975
13363
  });
12976
13364
  })))
12977
13365
  .addCommand(new Command("delegation")
12978
- .description("Google sending-domain delegation only — not Microsoft OAuth and not a Hypertide handoff to TrulyInbox 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.")
13366
+ .description("Google sending-domain delegation only — not Microsoft OAuth and not a Hypertide 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.")
12979
13367
  .option("--domain <domain>", "Only report this sending domain.")
12980
13368
  .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.")
12981
13369
  .option("--json", "Print a JSON envelope.")
@@ -12992,9 +13380,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12992
13380
  });
12993
13381
  }))
12994
13382
  .addCommand(new Command("warmup")
12995
- .description("Mailbox warmup for the sending pool. Oxygen's native warmup runs on ONE rail: TrulyInbox, managed and credit-billed per warming-inbox-month. Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. (Instantly and Warmforge remain BYOK integrations elsewhere in Oxygen; they are no longer warmup rails.)")
13383
+ .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). Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. Google and Microsoft both warm here; a Microsoft inbox first needs one Outlook authorization of its own (`mailboxes warmup microsoft`). 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.")
12996
13384
  .addCommand(new Command("enable")
12997
- .description("Enable warmup for sending mailboxes and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is 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.")
13385
+ .description("Enroll sending mailboxes in Oxygen's managed SendKit warmup and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is 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. Microsoft inboxes must finish `mailboxes warmup microsoft` (one Outlook authorization each) before they can enroll. New enrollments only ever land on SendKit — the retired TrulyInbox rail is refused here and only accepts teardown.")
12998
13386
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
12999
13387
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
13000
13388
  .option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
@@ -13038,13 +13426,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13038
13426
  });
13039
13427
  }))
13040
13428
  .addCommand(new Command("microsoft")
13041
- .description("Set up Microsoft/Entra mailboxes for TrulyInbox through tenant-admin consent — never through a mailbox password. Preview returns the consent URL and exact 0-credit sync plan. If it is not executable yet, open consent and re-preview with --tenant <directory-guid>; approve only that fresh hash with --max-credits 0. Warmup remains OFF until a separately approved `warmup enable`.")
13429
+ .description("Authorize Microsoft/Outlook mailboxes for SendKit warmup — ONE browser consent PER MAILBOX, never a password or app password. 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 mailboxes and how many consent links are still outstanding, at 0 credits. Approve that fresh hash with --max-credits 0 to mint the links, open every returned consent_url, then re-run with --status to see which mailboxes came back authorized. Warmup itself stays OFF until a separately approved `warmup enable`.")
13042
13430
  .option("--mailboxes <list>", "Comma-separated Microsoft mailbox ids or addresses. Omit to select every Microsoft mailbox in the pool.")
13043
- .option("--tenant <id>", "Entra directory/tenant GUID. Usually inferred from mailbox metadata or the consent response.")
13044
- .option("--approved", "Register/sync the exact previewed tenant scope after the admin opened the consent URL.")
13431
+ .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.")
13432
+ .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.")
13045
13433
  .option("--plan <hash>", "Fresh setup preview hash (required with --approved).")
13046
13434
  .option("--max-credits <n>", "Hard Oxygen credit cap; this setup requires exactly 0 (required with --approved).")
13047
- .option("--status", "Poll the persisted TrulyInbox workspace sync instead of creating a new setup preview.")
13435
+ .option("--status", "Poll the per-mailbox consents instead of creating a new setup preview: reports which mailboxes are authorized and which are still waiting for someone to open their link.")
13048
13436
  .option("--json", "Print a JSON envelope.")
13049
13437
  .action(async (options) => {
13050
13438
  await handleAsyncAction("mailboxes warmup microsoft", options, () => {
@@ -13125,7 +13513,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13125
13513
  });
13126
13514
  }))
13127
13515
  .addCommand(new Command("pause")
13128
- .description("Pause warmup for TrulyInbox-enrolled mailboxes. Rows left on a retired rail report unsupported rather than being retargeted. Targets the whole pool unless --mailboxes is given.")
13516
+ .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.")
13129
13517
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13130
13518
  .option("--json", "Print a JSON envelope.")
13131
13519
  .action(async (options) => {
@@ -13138,7 +13526,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13138
13526
  });
13139
13527
  }))
13140
13528
  .addCommand(new Command("resume")
13141
- .description("Resume paused warmup at each mailbox's recorded provider. Targets the whole pool unless --mailboxes is given.")
13529
+ .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.")
13142
13530
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13143
13531
  .option("--json", "Print a JSON envelope.")
13144
13532
  .action(async (options) => {
@@ -13151,7 +13539,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13151
13539
  });
13152
13540
  }))
13153
13541
  .addCommand(new Command("disable")
13154
- .description("Disable warmup and unenroll each mailbox from its recorded provider. On the managed trulyinbox rail this also cancels the warmup subscription — billing stops with the vendor removal. Targets the whole pool unless --mailboxes is given.")
13542
+ .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.")
13155
13543
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13156
13544
  .option("--json", "Print a JSON envelope.")
13157
13545
  .action(async (options) => {
@@ -13164,9 +13552,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13164
13552
  });
13165
13553
  }))
13166
13554
  .addCommand(new Command("status")
13167
- .description("Sync TrulyInbox warmup analytics into the pool, updating each mailbox's state. Targets the whole pool unless --mailboxes is given.")
13555
+ .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.")
13168
13556
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to sync. Omit to sync the whole pool.")
13169
- .option("--provider <name>", "Optional; trulyinbox is the only native warmup rail.")
13557
+ .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.")
13170
13558
  .option("--dry-run", "Skip the provider call (mailboxes marked pending).")
13171
13559
  .option("--json", "Print a JSON envelope.")
13172
13560
  .action(async (options) => {
@@ -13189,28 +13577,6 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13189
13577
  const suffix = params.toString();
13190
13578
  return requestOxygen(`/api/cli/mailboxes/warmup/status${suffix ? `?${suffix}` : ""}`);
13191
13579
  });
13192
- }))
13193
- .addCommand(new Command("provision")
13194
- .description("Legacy Zapmail-to-Instantly export for fleets already provisioned on that retired rail. It is not required for native TrulyInbox warmup; new Hypertide/Zapmail/InboxKit mailboxes use `mailboxes warmup enable`.")
13195
- .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to provision. Omit for the whole pool.")
13196
- .option("--connection <id>", "Zapmail connection id. Defaults to the org's active Zapmail connection (or the managed wallet).")
13197
- .option("--force", "Re-export mailboxes already exporting/warming/active (default skips them).")
13198
- .option("--dry-run", "Simulate without calling Zapmail (reports the mailboxes that would be exported).")
13199
- .option("--json", "Print a JSON envelope.")
13200
- .action(async (options) => {
13201
- await handleAsyncAction("mailboxes warmup provision", options, () => {
13202
- const mailboxes = readCsvOption(options.mailboxes);
13203
- const connection = readOption(options.connection);
13204
- return requestOxygen("/api/cli/mailboxes/warmup/provision", {
13205
- method: "POST",
13206
- body: {
13207
- ...(mailboxes.length > 0 ? { mailboxes } : {}),
13208
- ...(connection ? { connection_id: connection } : {}),
13209
- ...(options.force ? { force: true } : {}),
13210
- ...(options.dryRun ? { dry_run: true } : {}),
13211
- },
13212
- });
13213
- });
13214
13580
  })))
13215
13581
  .addCommand(new Command("order")
13216
13582
  .description("RETIRED — managed mailboxes are now purchased as monthly subscriptions via `oxygen managed-inboxes subscribe` (InboxKit-backed). This legacy Zapmail order path was never enabled anywhere and now always fails with code `gone`.")
@@ -13234,7 +13600,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13234
13600
  .addCommand(new Command("run")
13235
13601
  .description("Create a placement test for one sending mailbox. Without --approved this returns a cost PREVIEW. EmailGuard creation returns exact seeds + phrase but sends nothing; next run `placement-test send <id>` to preview and approve that external email. Zapmail owns its probe delivery and completes async (2-24h).")
13236
13602
  .argument("<mailbox>", "Sending mailbox address to test (e.g. ada@send.acme.com).")
13237
- .option("--provider <provider>", "Health provider: emailguard or zapmail. Omit to auto-resolve (Zapmail-hosted mailboxes route to zapmail, else emailguard).")
13603
+ .option("--provider <provider>", "Health provider: emailguard or zapmail. Omit to auto-resolve — EmailGuard is the default for every mailbox; Zapmail is used only for Zapmail-hosted mailboxes when EmailGuard is not connected.")
13238
13604
  .option("--subject <subject>", "Optional subject for the seed message (zapmail ignores it beyond labeling the test).")
13239
13605
  .option("--body <text>", "Optional plain-text body for the seed message (ignored by zapmail).")
13240
13606
  .option("--approved", "Actually run the test (otherwise a preview is returned).")
@@ -16041,11 +16407,24 @@ async function requestColumnsRun(body, table, options) {
16041
16407
  // Inline (formula) results carry no action_run_id and pass through as-is.
16042
16408
  if (!options.background && typeof body.row_id === "string" && isRecord(result)) {
16043
16409
  const actionRunId = readRecordString(result, "action_run_id");
16044
- if (actionRunId)
16410
+ if (actionRunId) {
16411
+ writeSingleRowColumnRunReceipt(result, actionRunId);
16045
16412
  return resolveSingleRowColumnRun(result, actionRunId);
16413
+ }
16046
16414
  }
16047
16415
  return result;
16048
16416
  }
16417
+ // The durable run exists before the convenience auto-wait begins. Emit its
16418
+ // receipt on stderr immediately so strict JSON stdout remains one envelope while
16419
+ // a shell/agent timeout can still recover the charged run instead of reporting
16420
+ // a silent failure with no inspectable id.
16421
+ function writeSingleRowColumnRunReceipt(envelope, actionRunId) {
16422
+ const webUrl = readRecordString(envelope, "web_url")
16423
+ ?? readRecordString(envelope, "run_web_url");
16424
+ const inspection = webUrl ? ` (${webUrl})` : "";
16425
+ process.stderr.write(`queued: column run ${actionRunId}${inspection}\n`);
16426
+ process.stderr.write(`hint: if this process stops, continue with oxygen table-runs wait ${actionRunId}\n`);
16427
+ }
16049
16428
  /**
16050
16429
  * Wait for an auto-backgrounded single-row column run to finish and return the
16051
16430
  * terminal run merged with its item output (the cell value). On wait timeout
@@ -18204,6 +18583,72 @@ async function handleOrgUseAction(organization, options, command) {
18204
18583
  emitCliFailure(command, error);
18205
18584
  }
18206
18585
  }
18586
+ // Why each organization cannot pay for another workspace, in the user's words.
18587
+ // The codes are the server's; the sentences exist so a blocked `billing-link`
18588
+ // says what to fix instead of printing an enum.
18589
+ const BILLING_OWNER_INELIGIBLE_REASONS = {
18590
+ not_admin: "You are not an admin of that organization.",
18591
+ no_plan: "It has no active paid plan.",
18592
+ trialing: "It is on a trial, and a trial only covers the workspace it started in.",
18593
+ already_linked: "Its own billing is already covered by another organization.",
18594
+ };
18595
+ // `--owner` is optional because the case that matters is the plainest one: the
18596
+ // user pays for exactly one organization and wants this workspace on it. The
18597
+ // eligibility rules (admin seat, active paid plan, no trial) stay on the server
18598
+ // — the CLI only picks when the answer is unambiguous, and turns "which one?"
18599
+ // into a typed failure rather than a guess.
18600
+ async function resolveSoleBillingOwnerRef() {
18601
+ const candidates = readBillingOwnerCandidates(await requestOxygen("/api/cli/orgs/billing-owners"));
18602
+ const eligible = candidates.filter((candidate) => candidate.eligible);
18603
+ if (eligible.length === 1)
18604
+ return eligible[0].id;
18605
+ if (eligible.length > 1) {
18606
+ throw new OxygenError("missing_billing_owner", `${eligible.length} of your organizations can pay for this workspace, so Oxygen will not choose for you. Re-run with --owner <organization>.`, {
18607
+ details: {
18608
+ eligible_billing_owners: eligible.map((candidate) => ({
18609
+ organization_id: candidate.id,
18610
+ name: candidate.name,
18611
+ })),
18612
+ next_step: `oxygen orgs billing-link --owner ${eligible[0].id}`,
18613
+ },
18614
+ });
18615
+ }
18616
+ throw new OxygenError("no_eligible_billing_owner", candidates.length === 0
18617
+ ? "You do not belong to another organization, so there is no existing plan to put this workspace on. Start one at https://oxygen-agent.com/billing."
18618
+ : "None of your other organizations can pay for this workspace. Only an organization you administer that is on an active paid plan can cover another workspace — a trial cannot.", {
18619
+ details: {
18620
+ candidates: candidates.map((candidate) => ({
18621
+ organization_id: candidate.id,
18622
+ name: candidate.name,
18623
+ ineligible_reason: candidate.ineligible_reason,
18624
+ reason: candidate.ineligible_reason
18625
+ ? BILLING_OWNER_INELIGIBLE_REASONS[candidate.ineligible_reason] ?? null
18626
+ : null,
18627
+ })),
18628
+ next_step: "Start a plan on the organization that should pay at https://oxygen-agent.com/billing, then re-run `oxygen orgs billing-link`.",
18629
+ },
18630
+ });
18631
+ }
18632
+ // Both spellings of the reason field are read, the same way the billing-link
18633
+ // route accepts either spelling of its body keys: the rows come straight out of
18634
+ // the billing-ownership library, and a casing change there must not silently
18635
+ // downgrade "we picked your plan" into "you have no eligible plan".
18636
+ function readBillingOwnerCandidates(payload) {
18637
+ const rows = isRecord(payload) && Array.isArray(payload.candidates) ? payload.candidates : [];
18638
+ const candidates = [];
18639
+ for (const row of rows) {
18640
+ if (!isRecord(row) || typeof row.id !== "string" || !row.id)
18641
+ continue;
18642
+ const reason = row.ineligible_reason ?? row.ineligibleReason;
18643
+ candidates.push({
18644
+ id: row.id,
18645
+ name: typeof row.name === "string" ? row.name : row.id,
18646
+ eligible: row.eligible === true,
18647
+ ineligible_reason: typeof reason === "string" ? reason : null,
18648
+ });
18649
+ }
18650
+ return candidates;
18651
+ }
18207
18652
  async function handleProfilesListAction(options) {
18208
18653
  try {
18209
18654
  const state = await listCredentialProfiles();
@@ -19168,7 +19613,8 @@ function formatReplyTypes(value) {
19168
19613
  .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
19169
19614
  return entries.length > 0 ? entries.map(([key, count]) => `${key}:${count}`).join(", ") : "—";
19170
19615
  }
19171
- function renderVariantTable(headers, rows) {
19616
+ /** Column-aligned plain-text table, two-space indented. Shared by the sequence-variant lenses and the collaboration lists. */
19617
+ function renderTextTable(headers, rows) {
19172
19618
  const widths = headers.map((header, columnIndex) => {
19173
19619
  let max = header.length;
19174
19620
  for (const row of rows) {
@@ -19203,7 +19649,7 @@ function formatSequenceVariants(data) {
19203
19649
  formatVariantCell(row.bounced),
19204
19650
  formatVariantCell(row.credits_used),
19205
19651
  ]);
19206
- lines.push(...renderVariantTable(headers, rows));
19652
+ lines.push(...renderTextTable(headers, rows));
19207
19653
  }
19208
19654
  lines.push("", styles.bold("By mailbox"));
19209
19655
  if (byMailbox.length === 0) {
@@ -19222,7 +19668,7 @@ function formatSequenceVariants(data) {
19222
19668
  formatReplyTypes(row.reply_types ?? row.replyTypes),
19223
19669
  formatVariantCell(row.failed),
19224
19670
  ]);
19225
- lines.push(...renderVariantTable(headers, rows));
19671
+ lines.push(...renderTextTable(headers, rows));
19226
19672
  }
19227
19673
  lines.push("", styles.bold("By sending domain"));
19228
19674
  if (byDomain.length === 0) {
@@ -19241,14 +19687,14 @@ function formatSequenceVariants(data) {
19241
19687
  formatRatePercent(row.bounce_rate ?? row.bounceRate),
19242
19688
  formatReplyTypes(row.reply_types ?? row.replyTypes),
19243
19689
  ]);
19244
- lines.push(...renderVariantTable(headers, rows));
19690
+ lines.push(...renderTextTable(headers, rows));
19245
19691
  }
19246
19692
  const hasUnattributed = unattributed
19247
19693
  && [unattributed.sent, unattributed.replied, unattributed.bounced, unattributed.failed]
19248
19694
  .some((value) => typeof value === "number" && value > 0);
19249
19695
  if (hasUnattributed && unattributed) {
19250
19696
  lines.push("", styles.bold("Unattributed sending identity"));
19251
- lines.push(...renderVariantTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19697
+ lines.push(...renderTextTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19252
19698
  formatVariantCell(unattributed.sent),
19253
19699
  formatVariantCell(unattributed.replied),
19254
19700
  formatVariantCell(unattributed.positive_replies ?? unattributed.positiveReplies),
@@ -19261,6 +19707,471 @@ function formatSequenceVariants(data) {
19261
19707
  lines.push("");
19262
19708
  return lines.join("\n");
19263
19709
  }
19710
+ // --- Collaboration output ----------------------------------------------------
19711
+ //
19712
+ // Comments and approvals are read by two very different audiences: the agency's
19713
+ // operator in a terminal, and an agent parsing --json. So these commands render
19714
+ // a compact human view by default and the stable envelope under --json — the
19715
+ // same split `sequences variants` uses. Every renderer ends with the deep-link
19716
+ // the API returned, because the client who has to decide is usually the one
19717
+ // person NOT in the terminal.
19718
+ async function handleCollabAction(command, options, action, render) {
19719
+ try {
19720
+ const data = await action();
19721
+ if (options.json) {
19722
+ writeJson(success(command, data));
19723
+ return;
19724
+ }
19725
+ process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
19726
+ }
19727
+ catch (error) {
19728
+ emitCliFailure(command, error);
19729
+ }
19730
+ }
19731
+ /**
19732
+ * `comments add`. Without --reply this is one POST. With --reply the CLI first
19733
+ * resolves which thread owns that id, because the reply endpoint is keyed by
19734
+ * THREAD while the id a user has in hand (from `comments list`, or from the
19735
+ * `next_step` the API prints) is a COMMENT. Passing a thread id works too and
19736
+ * replies at its top level — both ids sit side by side in the list output, and
19737
+ * refusing the wrong one would be pedantry, not safety.
19738
+ */
19739
+ async function handleCommentsAddAction(body, options) {
19740
+ await handleCollabAction("comments add", options, async () => {
19741
+ const subject = requireCollabSubject(options.on);
19742
+ const path = readCollabPath(options.path);
19743
+ const mentions = options.mention.map((value) => value.trim()).filter(Boolean);
19744
+ const replyTo = readOption(options.reply);
19745
+ if (!replyTo) {
19746
+ const title = readOption(options.title);
19747
+ return requestOxygen("/api/cli/collab/threads", {
19748
+ method: "POST",
19749
+ body: {
19750
+ subject_kind: subject.kind,
19751
+ subject_id: subject.id,
19752
+ ...(path ? { subject_path: path } : {}),
19753
+ ...(title ? { title } : {}),
19754
+ body,
19755
+ ...(mentions.length > 0 ? { mentions } : {}),
19756
+ },
19757
+ });
19758
+ }
19759
+ const target = await resolveCollabReplyTarget(subject, replyTo);
19760
+ return requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(target.threadId)}/comments`, {
19761
+ method: "POST",
19762
+ body: {
19763
+ body,
19764
+ ...(target.parentCommentId ? { parent_comment_id: target.parentCommentId } : {}),
19765
+ ...(mentions.length > 0 ? { mentions } : {}),
19766
+ },
19767
+ });
19768
+ }, formatCollabWriteResult);
19769
+ }
19770
+ async function resolveCollabReplyTarget(subject, replyTo) {
19771
+ const params = new URLSearchParams({
19772
+ subject: `${subject.kind}:${subject.id}`,
19773
+ // `all`, not `open`: replying to a resolved thread is how a client reopens
19774
+ // a conversation, and scoping the lookup to open threads would report the
19775
+ // id as missing instead.
19776
+ status: "all",
19777
+ });
19778
+ const listed = await requestOxygen(`/api/cli/collab/threads?${params.toString()}`);
19779
+ const threads = Array.isArray(listed.threads) ? listed.threads.filter(isRecord) : [];
19780
+ for (const thread of threads) {
19781
+ if (thread.id === replyTo)
19782
+ return { threadId: replyTo, parentCommentId: null };
19783
+ const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
19784
+ if (comments.some((comment) => comment.id === replyTo) && typeof thread.id === "string") {
19785
+ return { threadId: thread.id, parentCommentId: replyTo };
19786
+ }
19787
+ }
19788
+ throw new OxygenError("comment_not_found", `No comment or thread "${replyTo}" on ${subject.kind}:${subject.id}. List them with \`oxygen comments list --on ${subject.kind}:${subject.id} --status all\`.`, { details: { reply: replyTo, subject: `${subject.kind}:${subject.id}` }, exitCode: 4 });
19789
+ }
19790
+ /** Exactly one scope (--on or --default) and exactly one requirement flag. */
19791
+ function collabGateSetBody(options) {
19792
+ const on = readOption(options.on);
19793
+ const asDefault = readOption(options.default);
19794
+ if (Boolean(on) === Boolean(asDefault)) {
19795
+ throw new OxygenError("invalid_request", "Pass exactly one scope: --on <kind>:<id> for one object, or --default <kind> for every object of that kind.", { details: { on, default: asDefault }, exitCode: 2 });
19796
+ }
19797
+ if (Boolean(options.required) === Boolean(options.notRequired)) {
19798
+ throw new OxygenError("invalid_request", "Pass exactly one of --required or --not-required.", { exitCode: 2 });
19799
+ }
19800
+ const gateKind = requireCollabGate(options.gate);
19801
+ const subject = on ? requireCollabSubject(on) : null;
19802
+ const subjectKind = subject ? subject.kind : requireCollabSubjectKind(asDefault ?? undefined);
19803
+ if (!GATE_KIND_SUBJECTS[gateKind].includes(subjectKind)) {
19804
+ throw new OxygenError("invalid_request", `The ${gateKind} gate applies to ${GATE_KIND_SUBJECTS[gateKind].map((kind) => COLLAB_SUBJECT_LABELS[kind].many).join(" or ")}, not ${COLLAB_SUBJECT_LABELS[subjectKind].many}.`, { details: { gate_kind: gateKind, subject_kind: subjectKind, applies_to: [...GATE_KIND_SUBJECTS[gateKind]] }, exitCode: 2 });
19805
+ }
19806
+ return {
19807
+ subject_kind: subjectKind,
19808
+ // null is meaningful here: it is what makes the policy the workspace
19809
+ // default for the kind rather than a row about one object.
19810
+ subject_id: subject ? subject.id : null,
19811
+ gate_kind: gateKind,
19812
+ required: Boolean(options.required),
19813
+ };
19814
+ }
19815
+ function collabStyles() {
19816
+ return ansi(output.isTTY === true && !process.env.NO_COLOR);
19817
+ }
19818
+ function collabText(value, fallback = "—") {
19819
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
19820
+ }
19821
+ function collabLink(data) {
19822
+ if (typeof data.web_url === "string")
19823
+ return data.web_url;
19824
+ return typeof data.deepLink === "string" ? data.deepLink : null;
19825
+ }
19826
+ /** Coarse age for a list column: seconds, minutes, hours, then days. */
19827
+ function formatCollabAge(value) {
19828
+ if (typeof value !== "string")
19829
+ return "—";
19830
+ const at = Date.parse(value);
19831
+ if (!Number.isFinite(at))
19832
+ return "—";
19833
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
19834
+ if (seconds < 60)
19835
+ return `${seconds}s`;
19836
+ const minutes = Math.floor(seconds / 60);
19837
+ if (minutes < 60)
19838
+ return `${minutes}m`;
19839
+ const hours = Math.floor(minutes / 60);
19840
+ if (hours < 48)
19841
+ return `${hours}h`;
19842
+ return `${Math.floor(hours / 24)}d`;
19843
+ }
19844
+ /** First line of a body, clipped — a thread's subject line in one cell. */
19845
+ function collabSnippet(value, width = 56) {
19846
+ if (typeof value !== "string")
19847
+ return "";
19848
+ const firstLine = value.split("\n").find((line) => line.trim()) ?? "";
19849
+ return clipCell(firstLine.trim(), width);
19850
+ }
19851
+ /** Trailer every collab render shares: what to do next, then the deep-link. */
19852
+ function collabTrailer(data) {
19853
+ const styles = collabStyles();
19854
+ const lines = [];
19855
+ const nextStep = typeof data.next_step === "string" ? data.next_step.trim() : "";
19856
+ if (nextStep)
19857
+ lines.push("", ` ${styles.dim("Next")} ${nextStep}`);
19858
+ const link = collabLink(data);
19859
+ if (link)
19860
+ lines.push(` ${styles.dim("Link")} ${link}`);
19861
+ return lines;
19862
+ }
19863
+ /**
19864
+ * Generic confirmation for a write whose payload is one object. The API already
19865
+ * writes a `next_step` sentence for each of these, so the renderer's only job is
19866
+ * to say what changed and keep the deep-link visible.
19867
+ */
19868
+ function formatCollabWriteResult(data) {
19869
+ const styles = collabStyles();
19870
+ const lines = [""];
19871
+ const headline = collabWriteHeadline(data);
19872
+ lines.push(` ${styles.green("[OK]")} ${headline}`);
19873
+ if (data.already === true) {
19874
+ lines.push(` ${styles.dim("Already in that state; nothing changed.")}`);
19875
+ }
19876
+ const notified = Array.isArray(data.notified_emails)
19877
+ ? data.notified_emails.filter((value) => typeof value === "string")
19878
+ : [];
19879
+ if (notified.length > 0)
19880
+ lines.push(` ${styles.dim("Notified")} ${notified.join(", ")}`);
19881
+ const unresolved = Array.isArray(data.unresolved_mentions)
19882
+ ? data.unresolved_mentions.filter((value) => typeof value === "string")
19883
+ : [];
19884
+ if (unresolved.length > 0) {
19885
+ lines.push(` ${styles.yellow("Not a workspace member, so not notified:")} ${unresolved.join(", ")}`);
19886
+ }
19887
+ const note = collabText(data.note, "");
19888
+ if (note)
19889
+ lines.push(` ${styles.dim(note)}`);
19890
+ lines.push(...collabTrailer(data));
19891
+ lines.push("");
19892
+ return lines.join("\n");
19893
+ }
19894
+ /** One line naming what changed, matched to each write endpoint's payload shape. */
19895
+ function collabWriteHeadline(data) {
19896
+ // A thread write (`comments add`, `comments resolve`) serializes the thread
19897
+ // itself at the top level.
19898
+ if (typeof data.status === "string" && typeof data.subject === "string") {
19899
+ return `${collabText(data.subject)} — thread ${collabText(data.id)} is ${data.status}.`;
19900
+ }
19901
+ if (isRecord(data.comment)) {
19902
+ return data.comment.deleted === true ? "Comment retracted." : "Comment updated.";
19903
+ }
19904
+ if (isRecord(data.request)) {
19905
+ return `Approval request ${collabText(data.request.id)} is ${collabText(data.request.status)}.`;
19906
+ }
19907
+ // The member-role write returns the membership fields flat, not nested.
19908
+ if (typeof data.email === "string") {
19909
+ return data.granted === true
19910
+ ? `${data.email} is now a client-role member of this workspace.`
19911
+ : `${data.email} is back to full workspace access.`;
19912
+ }
19913
+ return "Done.";
19914
+ }
19915
+ function formatCollabThreads(data) {
19916
+ const styles = collabStyles();
19917
+ const threads = Array.isArray(data.threads) ? data.threads.filter(isRecord) : [];
19918
+ const subject = collabText(data.subject, "this object");
19919
+ const lines = ["", styles.bold(`Comments on ${subject}`)];
19920
+ if (threads.length === 0) {
19921
+ lines.push(` ${styles.dim(collabText(data.hint, "No comment threads here yet."))}`);
19922
+ }
19923
+ for (const thread of threads) {
19924
+ const status = collabText(thread.status, "open");
19925
+ const badge = status === "resolved" ? styles.dim("[resolved]") : styles.green("[open]");
19926
+ const title = collabText(thread.title, "");
19927
+ lines.push("", ` ${badge} ${collabText(thread.id)} ${styles.dim(`${thread.comment_count ?? 0} comment(s) · ${formatCollabAge(thread.created_at)} old`)}`);
19928
+ if (title)
19929
+ lines.push(` ${styles.bold(title)}`);
19930
+ if (typeof thread.subject_path === "string" && thread.subject_path) {
19931
+ lines.push(` ${styles.dim(`on ${thread.subject_path}`)}`);
19932
+ }
19933
+ const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
19934
+ for (const comment of comments) {
19935
+ const author = collabText(comment.author_email, "someone");
19936
+ const marker = comment.parent_comment_id ? " ↳" : " •";
19937
+ const edited = comment.edited_at ? styles.dim(" (edited)") : "";
19938
+ const text = comment.deleted === true
19939
+ ? styles.dim("(retracted)")
19940
+ : collabSnippet(comment.body, 72);
19941
+ lines.push(` ${marker} ${styles.dim(`${formatCollabAge(comment.created_at)} ${author}`)} ${text}${edited}`);
19942
+ lines.push(` ${styles.dim(collabText(comment.id))}`);
19943
+ }
19944
+ const threadLink = collabLink(thread);
19945
+ if (threadLink)
19946
+ lines.push(` ${styles.dim(threadLink)}`);
19947
+ }
19948
+ lines.push(...collabTrailer(data));
19949
+ lines.push("");
19950
+ return lines.join("\n");
19951
+ }
19952
+ function collabRequestRow(request) {
19953
+ return [
19954
+ collabText(request.id),
19955
+ collabText(request.subject),
19956
+ collabText(request.gate_kind),
19957
+ collabText(request.status),
19958
+ collabText(request.requested_by_email, "—"),
19959
+ formatCollabAge(request.created_at),
19960
+ ];
19961
+ }
19962
+ const COLLAB_REQUEST_HEADERS = ["ID", "SUBJECT", "GATE", "STATUS", "REQUESTED BY", "AGE"];
19963
+ function formatCollabRequestList(data) {
19964
+ const styles = collabStyles();
19965
+ const requests = Array.isArray(data.requests) ? data.requests.filter(isRecord) : [];
19966
+ const lines = ["", styles.bold(`Approval requests (${requests.length})`)];
19967
+ if (requests.length === 0) {
19968
+ lines.push(` ${styles.dim(collabText(data.hint, "No approval requests match those filters."))}`);
19969
+ }
19970
+ else {
19971
+ lines.push(...renderTextTable(COLLAB_REQUEST_HEADERS, requests.map(collabRequestRow)));
19972
+ }
19973
+ lines.push(...collabTrailer(data));
19974
+ lines.push("");
19975
+ return lines.join("\n");
19976
+ }
19977
+ /**
19978
+ * The daily surface. Two sections and nothing else: what needs a decision from
19979
+ * this reader, and where they were named. An item the reader cannot act on has
19980
+ * no business being here, so there is no "everything else" section.
19981
+ */
19982
+ function formatCollabInbox(data) {
19983
+ const styles = collabStyles();
19984
+ const pending = Array.isArray(data.pending_approvals) ? data.pending_approvals.filter(isRecord) : [];
19985
+ const mentions = Array.isArray(data.mentions) ? data.mentions.filter(isRecord) : [];
19986
+ const lines = ["", styles.bold(`Waiting on you (${pending.length})`)];
19987
+ if (pending.length === 0) {
19988
+ lines.push(` ${styles.dim("No approvals are assigned to you.")}`);
19989
+ }
19990
+ else {
19991
+ lines.push(...renderTextTable(COLLAB_REQUEST_HEADERS, pending.map(collabRequestRow)));
19992
+ }
19993
+ lines.push("", styles.bold(`Threads that name you (${mentions.length})`));
19994
+ if (mentions.length === 0) {
19995
+ lines.push(` ${styles.dim("Nobody has mentioned you in an open thread.")}`);
19996
+ }
19997
+ else {
19998
+ lines.push(...renderTextTable(["THREAD", "SUBJECT", "LAST COMMENT", "AGE"], mentions.map((thread) => {
19999
+ const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
20000
+ const last = comments[comments.length - 1];
20001
+ return [
20002
+ collabText(thread.id),
20003
+ collabText(thread.subject),
20004
+ collabSnippet(last?.body, 44) || collabText(thread.title, "—"),
20005
+ formatCollabAge(thread.updated_at ?? thread.created_at),
20006
+ ];
20007
+ })));
20008
+ }
20009
+ lines.push(...collabTrailer(data));
20010
+ lines.push("");
20011
+ return lines.join("\n");
20012
+ }
20013
+ /**
20014
+ * `approvals show`. The reader here usually arrived from a link in an email and
20015
+ * knows nothing but the id, so this answers the four questions a table cell
20016
+ * cannot: what is being asked, on what, who owes the answer, and whether the
20017
+ * request can still be decided. An expired request says so in the headline —
20018
+ * `approvals decide` on it is refused by the API.
20019
+ */
20020
+ function formatCollabRequestDetail(data) {
20021
+ const styles = collabStyles();
20022
+ const request = isRecord(data.request) ? data.request : data;
20023
+ const status = collabText(request.status);
20024
+ const lapsed = request.lapsed === true;
20025
+ const badge = lapsed
20026
+ ? styles.yellow("[EXPIRED]")
20027
+ : request.opens_gate === true
20028
+ ? styles.green("[APPROVED]")
20029
+ : status === "pending"
20030
+ ? styles.dim("[PENDING]")
20031
+ : styles.yellow(`[${status.toUpperCase()}]`);
20032
+ const assignees = Array.isArray(request.assignee_emails)
20033
+ ? request.assignee_emails.filter((value) => typeof value === "string")
20034
+ : [];
20035
+ const lines = [
20036
+ "",
20037
+ ` ${badge} ${collabText(request.title, "Approval request")}`,
20038
+ ` ${styles.dim("Request")} ${collabText(request.id)}`,
20039
+ ` ${styles.dim("Subject")} ${collabText(request.subject)}`,
20040
+ ` ${styles.dim("Gate")} ${collabText(request.gate_kind)} — ${collabText(request.gate_meaning, "")}`,
20041
+ ` ${styles.dim("Asked by")} ${collabText(request.requested_by_email, "someone")}`,
20042
+ ` ${styles.dim("Decides")} ${assignees.length > 0 ? assignees.join(", ") : "—"}`,
20043
+ ];
20044
+ const body = collabSnippet(request.body, 72);
20045
+ if (body)
20046
+ lines.push(` ${styles.dim("Asking")} ${body}`);
20047
+ if (typeof request.expires_at === "string") {
20048
+ lines.push(` ${styles.dim("Expires")} ${request.expires_at}${lapsed ? styles.yellow(" — already passed") : ""}`);
20049
+ }
20050
+ const note = collabText(request.decision_note, "");
20051
+ if (note)
20052
+ lines.push(` ${styles.dim("Note")} ${note}`);
20053
+ lines.push(...collabTrailer(data));
20054
+ lines.push("");
20055
+ return lines.join("\n");
20056
+ }
20057
+ /**
20058
+ * `approvals client list`. The column that matters is ACCESS: a blank role cell
20059
+ * means FULL access, not a restricted one, and reading it the other way is how
20060
+ * an agency convinces itself a client login is limited when it is not.
20061
+ */
20062
+ function formatCollabMembers(data) {
20063
+ const styles = collabStyles();
20064
+ const members = Array.isArray(data.members) ? data.members.filter(isRecord) : [];
20065
+ const lines = ["", styles.bold(`Workspace members (${members.length})`)];
20066
+ if (members.length === 0) {
20067
+ lines.push(` ${styles.dim("No members resolved for this workspace.")}`);
20068
+ }
20069
+ else {
20070
+ lines.push(...renderTextTable(["EMAIL", "WORKSPACE ROLE", "OXYGEN ROLE", "ACCESS"], members.map((member) => [
20071
+ collabText(member.email, collabText(member.user_id)),
20072
+ collabText(member.workspace_role),
20073
+ member.oxygen_role === "client" ? "client" : "—",
20074
+ collabText(member.access),
20075
+ ])));
20076
+ }
20077
+ lines.push(...collabTrailer(data));
20078
+ lines.push("");
20079
+ return lines.join("\n");
20080
+ }
20081
+ function formatCollabRequestWrite(data) {
20082
+ const styles = collabStyles();
20083
+ // The request is nested; the resolved assignee emails are not.
20084
+ const request = isRecord(data.request) ? data.request : data;
20085
+ const assignees = Array.isArray(data.assignee_emails)
20086
+ ? data.assignee_emails.filter((value) => typeof value === "string")
20087
+ : [];
20088
+ const lines = [
20089
+ "",
20090
+ ` ${styles.green("[OK]")} Asked ${assignees.length > 0 ? assignees.join(", ") : "the assignees"} to decide.`,
20091
+ ` ${styles.dim("Request")} ${collabText(request.id)}`,
20092
+ ` ${styles.dim("Subject")} ${collabText(request.subject)}`,
20093
+ ` ${styles.dim("Gate")} ${collabText(request.gate_kind)} — ${collabText(request.gate_meaning, "")}`,
20094
+ ];
20095
+ if (typeof request.expires_at === "string") {
20096
+ lines.push(` ${styles.dim("Expires")} ${request.expires_at} ${styles.dim("(an expired request does not open the gate)")}`);
20097
+ }
20098
+ lines.push(...collabTrailer(data));
20099
+ lines.push("");
20100
+ return lines.join("\n");
20101
+ }
20102
+ /**
20103
+ * A decision's headline is `opens_gate`, never the status alone: "the client
20104
+ * replied" and "the client said yes" are the two states an agency must never
20105
+ * confuse, and `changes_requested` is the one that reads like the second and
20106
+ * behaves like the first.
20107
+ */
20108
+ function formatCollabDecision(data) {
20109
+ const styles = collabStyles();
20110
+ const request = isRecord(data.request) ? data.request : data;
20111
+ const status = collabText(request.status);
20112
+ const opensGate = request.opens_gate === true || data.gate_opened === true;
20113
+ const lines = [
20114
+ "",
20115
+ ` ${opensGate ? styles.green("[APPROVED]") : styles.yellow(`[${status.toUpperCase()}]`)} ${collabText(request.subject)} — request ${collabText(request.id)}`,
20116
+ ` ${styles.dim("Gate")} ${opensGate
20117
+ ? `open — the ${collabText(request.gate_kind)} gate no longer blocks this.`
20118
+ : `still shut — a ${collabText(request.gate_kind)} gate only opens on --approve.`}`,
20119
+ ];
20120
+ const note = collabText(request.decision_note, "");
20121
+ if (note)
20122
+ lines.push(` ${styles.dim("Note")} ${note}`);
20123
+ if (data.already === true)
20124
+ lines.push(` ${styles.dim("Replay of the same decision; nothing changed.")}`);
20125
+ lines.push(...collabTrailer(data));
20126
+ lines.push("");
20127
+ return lines.join("\n");
20128
+ }
20129
+ function formatCollabGateSet(data) {
20130
+ const styles = collabStyles();
20131
+ const policy = isRecord(data.policy) ? data.policy : {};
20132
+ const required = policy.required === true;
20133
+ const scope = collabText(data.scope, "subject") === "workspace_default"
20134
+ ? `every ${collabText(policy.subject_kind)}`
20135
+ : `${collabText(policy.subject_kind)}:${collabText(policy.subject_id)}`;
20136
+ const lines = [
20137
+ "",
20138
+ ` ${required ? styles.green("[GATE ON]") : styles.dim("[GATE OFF]")} ${collabText(policy.gate_kind)} on ${scope}`,
20139
+ ` ${styles.dim("Means")} ${collabText(policy.gate_meaning, "")}`,
20140
+ ];
20141
+ lines.push(...collabTrailer(data));
20142
+ lines.push("");
20143
+ return lines.join("\n");
20144
+ }
20145
+ function formatCollabGateList(data) {
20146
+ const styles = collabStyles();
20147
+ const policies = Array.isArray(data.policies) ? data.policies.filter(isRecord) : [];
20148
+ const effective = Array.isArray(data.effective) ? data.effective.filter(isRecord) : [];
20149
+ const lines = ["", styles.bold(`Gate policies (${policies.length})`)];
20150
+ if (policies.length === 0) {
20151
+ lines.push(` ${styles.dim("No gate is required anywhere in this workspace.")}`);
20152
+ }
20153
+ else {
20154
+ lines.push(...renderTextTable(["GATE", "SCOPE", "APPLIES TO", "REQUIRED"], policies.map((policy) => [
20155
+ collabText(policy.gate_kind),
20156
+ collabText(policy.scope),
20157
+ collabText(policy.subject_id, `every ${collabText(policy.subject_kind)}`),
20158
+ policy.required === true ? "yes" : "no",
20159
+ ])));
20160
+ }
20161
+ if (effective.length > 0) {
20162
+ lines.push("", styles.bold(`Effective for ${collabText(data.subject)}`));
20163
+ lines.push(...renderTextTable(["GATE", "REQUIRED", "SOURCE", "BLOCKED NOW", "PENDING REQUEST"], effective.map((entry) => [
20164
+ collabText(entry.gate_kind),
20165
+ entry.required === true ? "yes" : "no",
20166
+ collabText(entry.source),
20167
+ entry.blocked === true ? "yes" : "no",
20168
+ collabText(entry.pending_request_id, "—"),
20169
+ ])));
20170
+ }
20171
+ lines.push(...collabTrailer(data));
20172
+ lines.push("");
20173
+ return lines.join("\n");
20174
+ }
19264
20175
  // `columns reorder` accepts either an absolute --position or a relative
19265
20176
  // --before/--after naming a sibling column. The relative form is resolved
19266
20177
  // client-side into an absolute index against the table's current column order