@oxygen-agent/cli 1.638.1 → 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 {
@@ -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;
@@ -3762,9 +3910,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3762
3910
  }));
3763
3911
  })))
3764
3912
  .addCommand(new Command("analytics")
3765
- .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.")
3766
3914
  .addCommand(new Command("summary")
3767
- .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.")
3768
3917
  .option("--range <range>", "Publication window: 7d, 30d (default), 90d, or 365d.")
3769
3918
  .option("--from <date>", "Custom inclusive UTC start date.")
3770
3919
  .option("--to <date>", "Custom inclusive UTC end date.")
@@ -3772,6 +3921,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3772
3921
  .option("--json", "Print a JSON envelope.")
3773
3922
  .action(async (options) => {
3774
3923
  await handleAsyncAction("publishing analytics summary", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
3924
+ channel: readOption(options.channel),
3775
3925
  range: readOption(options.range),
3776
3926
  from: readOption(options.from),
3777
3927
  to: readOption(options.to),
@@ -3779,7 +3929,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3779
3929
  })));
3780
3930
  }))
3781
3931
  .addCommand(new Command("post")
3782
- .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.")
3783
3933
  .argument("<post_id>", "Scheduled post id.")
3784
3934
  .option("--since <date>", "ISO date to start the series from.")
3785
3935
  .option("--json", "Print a JSON envelope.")
@@ -3806,7 +3956,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3806
3956
  method: "POST",
3807
3957
  body: { cpm: parseJsonObject(options.cpmJson ?? "") },
3808
3958
  }));
3809
- })))
3959
+ }))
3960
+ .addHelpText("after", "\nExample (stored X snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel x --range 30d --json\n"))
3810
3961
  .addCommand(new Command("import")
3811
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.")
3812
3963
  .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
@@ -3998,6 +4149,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3998
4149
  method: "POST",
3999
4150
  body: buildCrmPhotosBody(options),
4000
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
+ }));
4001
4166
  }))
4002
4167
  .addCommand(
4003
4168
  // `crm objects` lists (parent action runs when no subcommand matches);
@@ -5268,7 +5433,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5268
5433
  return requestOxygen(`/api/cli/tables/webhooks?${params.toString()}`);
5269
5434
  })))
5270
5435
  .addCommand(new Command("create")
5271
- .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).")
5272
5437
  .argument("<table>", "Table id or slug.")
5273
5438
  .option("--name <name>", "Display name for the webhook.")
5274
5439
  .option("--mode <mode>", "insert or upsert. Defaults to upsert when --upsert-key is set, otherwise insert.")
@@ -5278,6 +5443,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5278
5443
  .option("--event-id-path <path>", "Dot path to the event id. Defaults to event_id, eventId, id, or payload hash.")
5279
5444
  .option("--event-type-path <path>", "Dot path to the event type. Defaults to type or event.")
5280
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.")
5281
5450
  .option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
5282
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.")
5283
5452
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
@@ -5287,6 +5456,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5287
5456
  .option("--json", "Print a JSON envelope.")
5288
5457
  .action((table, options) => {
5289
5458
  const autoRun = readTableWebhookAutoRunOptions(options);
5459
+ const unipile = readOption(options.source) === "unipile";
5290
5460
  return handleAsyncAction("tables webhook create", options, () => requestOxygen("/api/cli/tables/webhooks", {
5291
5461
  method: "POST",
5292
5462
  body: {
@@ -5299,6 +5469,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5299
5469
  ...(readOption(options.eventIdPath) ? { event_id_path: readOption(options.eventIdPath) } : {}),
5300
5470
  ...(readOption(options.eventTypePath) ? { event_type_path: readOption(options.eventTypePath) } : {}),
5301
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
+ : {}),
5302
5480
  ...(autoRun ? { auto_run: autoRun } : {}),
5303
5481
  },
5304
5482
  }));
@@ -5340,7 +5518,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5340
5518
  .description("List direct table webhook deliveries and auto-run enqueue status.")
5341
5519
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
5342
5520
  .option("--table <table>", "Filter by table id or slug.")
5343
- .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).")
5344
5522
  .option("--auto-run-status <status>", "Filter by not_configured, queued, skipped, or failed_to_enqueue.")
5345
5523
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
5346
5524
  .option("--json", "Print a JSON envelope.")
@@ -5623,7 +5801,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5623
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.")
5624
5802
  .argument("[endpoint_id]", "Optional webhook endpoint id, such as tw_...")
5625
5803
  .option("--table <table>", "Table id or slug whose ledger to read.")
5626
- .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).")
5627
5805
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
5628
5806
  .option("--json", "Print a JSON envelope.")
5629
5807
  .action((endpointId, options) => handleAsyncAction("feeds deliveries", options, () => requestOxygen(tableWebhookDeliveriesPath({
@@ -6218,6 +6396,258 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6218
6396
  body: { action: "delete", tags, ...(options.apply ? { apply: true } : {}) },
6219
6397
  }));
6220
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
+ })));
6221
6651
  program
6222
6652
  .command("blueprints")
6223
6653
  .description("Scaffolding bundles: a workflow + tables + columns + prompts as shareable JSON. For guided GTM plays see `oxygen recipes`.")
@@ -6939,6 +7369,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6939
7369
  // doesn't mention visibility leaves it untouched.
6940
7370
  .option("--always-show", "Keep this column visible even when it holds no values.")
6941
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.")
6942
7374
  .option("--dry-run", "Return the would-be merged definition without writing.")
6943
7375
  .option("--json", "Print a JSON envelope.")
6944
7376
  .action(async (table, column, options) => {
@@ -6984,6 +7416,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6984
7416
  ...(definitionUnset.length > 0 ? { definition_unset: definitionUnset } : {}),
6985
7417
  ...(readOption(options.dataType) ? { data_type: readOption(options.dataType) } : {}),
6986
7418
  ...(typeof options.alwaysShow === "boolean" ? { always_show: options.alwaysShow } : {}),
7419
+ ...(typeof options.hiddenByDefault === "boolean"
7420
+ ? { hidden_by_default: options.hiddenByDefault }
7421
+ : {}),
6987
7422
  ...(options.dryRun ? { dry_run: true } : {}),
6988
7423
  },
6989
7424
  });
@@ -8523,11 +8958,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8523
8958
  });
8524
8959
  }))
8525
8960
  .addCommand(new Command("approvals")
8526
- .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.")
8527
8962
  // Keep --source values in sync with OBSERVABILITY_APPROVAL_SOURCES in
8528
8963
  // apps/web/src/lib/observability-console.ts and the MCP tool enum. The API
8529
8964
  // rejects any other value with invalid_request so a typo fails loudly.
8530
- .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.")
8531
8966
  .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
8532
8967
  .option("--json", "Print a JSON envelope.")
8533
8968
  .action(async (options) => {
@@ -9638,6 +10073,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9638
10073
  const params = new URLSearchParams({ integration_id: integrationId });
9639
10074
  return requestOxygen(`/api/cli/integrations/composio/actions?${params.toString()}`);
9640
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
+ });
9641
10094
  }))
9642
10095
  .addCommand(new Command("run")
9643
10096
  .description("Run an action for a connected integration — Composio toolkits and native providers alike.")
@@ -10176,7 +10629,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10176
10629
  });
10177
10630
  })));
10178
10631
  program.addCommand(new Command("engagement")
10179
- .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.")
10180
10633
  .addCommand(new Command("harvest")
10181
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.")
10182
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.")
@@ -10289,112 +10742,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10289
10742
  },
10290
10743
  });
10291
10744
  });
10292
- }))
10293
- .addCommand(new Command("watch")
10294
- .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.")
10295
- .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")
10296
- .addCommand(new Command("create")
10297
- .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.")
10298
- .requiredOption("--kind <kind>", "post | profile_viewers | followers | connections.")
10299
- .requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
10300
- .option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
10301
- .option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
10302
- .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post; profile_viewers/followers/connections are always unipile.")
10303
- .option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
10304
- .option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
10305
- .option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
10306
- .option("--max-credits-per-cycle <credits>", "Standing per-cycle managed-spend cap (required for --auto-enroll and cookieless).")
10307
- .option("--max-enrolls-per-day <n>", "Cap auto-enrollments per day.")
10308
- .option("--recurrence <recurrence>", "once | every_6h | daily | weekly (default once).")
10309
- .option("--json", "Print a JSON envelope.")
10310
- .action(async (options) => {
10311
- await handleAsyncAction("engagement watch create", options, () => {
10312
- const kind = readOption(options.kind);
10313
- const account = readOption(options.account);
10314
- const post = readOption(options.post);
10315
- const postUrl = readOption(options.postUrl);
10316
- const source = readOption(options.source);
10317
- const table = readOption(options.table);
10318
- const sequence = readOption(options.sequence);
10319
- const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
10320
- const maxEnrollsPerDay = readPositiveNumber(options.maxEnrollsPerDay);
10321
- const recurrence = readOption(options.recurrence);
10322
- return requestOxygen("/api/cli/linkedin/engagement/watches", {
10323
- method: "POST",
10324
- body: {
10325
- ...(kind ? { kind } : {}),
10326
- ...(account ? { account } : {}),
10327
- ...(post ? { post } : {}),
10328
- ...(postUrl ? { post_url: postUrl } : {}),
10329
- ...(source ? { source } : {}),
10330
- ...(table ? { table } : {}),
10331
- ...(sequence ? { sequence } : {}),
10332
- ...(options.autoEnroll ? { auto_enroll: true } : {}),
10333
- ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
10334
- ...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
10335
- ...(recurrence ? { recurrence } : {}),
10336
- },
10337
- });
10338
- });
10339
- }))
10340
- .addCommand(new Command("list")
10341
- .description("List the org's engagement watches (status, kind, sender, sequence, per-day enroll count) with a table deep-link.")
10342
- .option("--status <status>", "Filter by status: active | paused | exhausted | completed.")
10343
- .option("--kind <kind>", "Filter by kind.")
10344
- .option("--json", "Print a JSON envelope.")
10345
- .action(async (options) => {
10346
- await handleAsyncAction("engagement watch list", options, () => {
10347
- const status = readOption(options.status);
10348
- const kind = readOption(options.kind);
10349
- const params = new URLSearchParams();
10350
- if (status)
10351
- params.set("status", status);
10352
- if (kind)
10353
- params.set("kind", kind);
10354
- const suffix = params.toString();
10355
- return requestOxygen(`/api/cli/linkedin/engagement/watches${suffix ? `?${suffix}` : ""}`);
10356
- });
10357
- }))
10358
- .addCommand(new Command("pause")
10359
- .description("Pause a watch (stop materializing its harvest + auto-enrolling).")
10360
- .argument("<watchId>", "Watch id.")
10361
- .option("--json", "Print a JSON envelope.")
10362
- .action(async (watchId, options) => {
10363
- await handleAsyncAction("engagement watch pause", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10364
- method: "PATCH",
10365
- body: { action: "pause" },
10366
- }));
10367
- }))
10368
- .addCommand(new Command("resume")
10369
- .description("Resume a paused watch.")
10370
- .argument("<watchId>", "Watch id.")
10371
- .option("--json", "Print a JSON envelope.")
10372
- .action(async (watchId, options) => {
10373
- await handleAsyncAction("engagement watch resume", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10374
- method: "PATCH",
10375
- body: { action: "resume" },
10376
- }));
10377
- }))
10378
- .addCommand(new Command("tag")
10379
- .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`.")
10380
- .argument("<watchId>", "Watch id.")
10381
- .requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
10382
- .option("--json", "Print a JSON envelope.")
10383
- .action(async (watchId, options) => {
10384
- await handleAsyncAction("engagement watch tag", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}/tags`, {
10385
- method: "POST",
10386
- body: { tags: splitCommaList(options.tags) },
10387
- }));
10388
- }))
10389
- .addCommand(new Command("delete")
10390
- .description("Delete a watch (its harvested table + rows are kept).")
10391
- .argument("<watchId>", "Watch id.")
10392
- .option("--json", "Print a JSON envelope.")
10393
- .action(async (watchId, options) => {
10394
- await handleAsyncAction("engagement watch delete", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
10395
- method: "DELETE",
10396
- }));
10397
- }))));
10745
+ })));
10398
10746
  program.addCommand(new Command("linkedin")
10399
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.")
10400
10748
  .addCommand(new Command("intent")
@@ -10421,7 +10769,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10421
10769
  });
10422
10770
  }))
10423
10771
  .addCommand(new Command("status")
10424
- .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.")
10425
10773
  .option("--json", "Print a JSON envelope.")
10426
10774
  .action(async (options) => {
10427
10775
  await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
@@ -10740,6 +11088,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10740
11088
  .option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
10741
11089
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
10742
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.)")
10743
11092
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
10744
11093
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
10745
11094
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -10769,6 +11118,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10769
11118
  params.set("account", account);
10770
11119
  if (options.unread)
10771
11120
  params.set("unread", "true");
11121
+ if (options.unanswered)
11122
+ params.set("unanswered", "true");
10772
11123
  if (options.responsesOnly)
10773
11124
  params.set("responses_only", "true");
10774
11125
  for (const [flag, key] of [
@@ -10881,6 +11232,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10881
11232
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
10882
11233
  .option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
10883
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.")
10884
11236
  .option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
10885
11237
  .option("--json", "Print a JSON envelope.")
10886
11238
  .action(async (options) => {
@@ -10913,6 +11265,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10913
11265
  }
10914
11266
  if (options.includeArchived)
10915
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");
10916
11272
  return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
10917
11273
  method: "POST",
10918
11274
  // Approval rides in the body: a bodyless POST reads as
@@ -11144,6 +11500,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11144
11500
  .option("--json", "Print a JSON envelope.")
11145
11501
  .action(async (key, options) => {
11146
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
+ }));
11147
11513
  })))
11148
11514
  .addCommand(new Command("drafts")
11149
11515
  .description("The AI reply-agent draft queue (the approve-before-send review queue).")
@@ -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).")
@@ -19247,7 +19613,8 @@ function formatReplyTypes(value) {
19247
19613
  .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
19248
19614
  return entries.length > 0 ? entries.map(([key, count]) => `${key}:${count}`).join(", ") : "—";
19249
19615
  }
19250
- 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) {
19251
19618
  const widths = headers.map((header, columnIndex) => {
19252
19619
  let max = header.length;
19253
19620
  for (const row of rows) {
@@ -19282,7 +19649,7 @@ function formatSequenceVariants(data) {
19282
19649
  formatVariantCell(row.bounced),
19283
19650
  formatVariantCell(row.credits_used),
19284
19651
  ]);
19285
- lines.push(...renderVariantTable(headers, rows));
19652
+ lines.push(...renderTextTable(headers, rows));
19286
19653
  }
19287
19654
  lines.push("", styles.bold("By mailbox"));
19288
19655
  if (byMailbox.length === 0) {
@@ -19301,7 +19668,7 @@ function formatSequenceVariants(data) {
19301
19668
  formatReplyTypes(row.reply_types ?? row.replyTypes),
19302
19669
  formatVariantCell(row.failed),
19303
19670
  ]);
19304
- lines.push(...renderVariantTable(headers, rows));
19671
+ lines.push(...renderTextTable(headers, rows));
19305
19672
  }
19306
19673
  lines.push("", styles.bold("By sending domain"));
19307
19674
  if (byDomain.length === 0) {
@@ -19320,14 +19687,14 @@ function formatSequenceVariants(data) {
19320
19687
  formatRatePercent(row.bounce_rate ?? row.bounceRate),
19321
19688
  formatReplyTypes(row.reply_types ?? row.replyTypes),
19322
19689
  ]);
19323
- lines.push(...renderVariantTable(headers, rows));
19690
+ lines.push(...renderTextTable(headers, rows));
19324
19691
  }
19325
19692
  const hasUnattributed = unattributed
19326
19693
  && [unattributed.sent, unattributed.replied, unattributed.bounced, unattributed.failed]
19327
19694
  .some((value) => typeof value === "number" && value > 0);
19328
19695
  if (hasUnattributed && unattributed) {
19329
19696
  lines.push("", styles.bold("Unattributed sending identity"));
19330
- lines.push(...renderVariantTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19697
+ lines.push(...renderTextTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
19331
19698
  formatVariantCell(unattributed.sent),
19332
19699
  formatVariantCell(unattributed.replied),
19333
19700
  formatVariantCell(unattributed.positive_replies ?? unattributed.positiveReplies),
@@ -19340,6 +19707,471 @@ function formatSequenceVariants(data) {
19340
19707
  lines.push("");
19341
19708
  return lines.join("\n");
19342
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
+ }
19343
20175
  // `columns reorder` accepts either an absolute --position or a relative
19344
20176
  // --before/--after naming a sibling column. The relative form is resolved
19345
20177
  // client-side into an absolute index against the table's current column order