@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/README.md +1 -1
- package/dist/command-manifest.js +5 -2
- package/dist/index.js +956 -124
- package/node_modules/@oxygen/shared/dist/axiom-field-budget.d.ts +91 -0
- package/node_modules/@oxygen/shared/dist/axiom-field-budget.js +262 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +55 -0
- package/node_modules/@oxygen/shared/dist/collab.d.ts +228 -0
- package/node_modules/@oxygen/shared/dist/collab.js +410 -0
- package/node_modules/@oxygen/shared/dist/column-types.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/column-types.js +26 -1
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +30 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +7 -6
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +69 -8
- package/node_modules/@oxygen/shared/dist/tags.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/tags.js +2 -5
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/package.json +1 -1
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(...
|
|
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(...
|
|
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(...
|
|
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(...
|
|
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
|