@oxygen-agent/cli 1.638.1 → 1.677.10
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 +1033 -135
- 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 +61 -1
- package/node_modules/@oxygen/shared/dist/collab.d.ts +229 -0
- package/node_modules/@oxygen/shared/dist/collab.js +417 -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/credit-guidance.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +90 -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 +8 -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({
|
|
@@ -6133,7 +6311,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6133
6311
|
}));
|
|
6134
6312
|
program
|
|
6135
6313
|
.command("tags")
|
|
6136
|
-
.description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters — nothing is slugified, so "Q3 Outbound" becomes "q3 outbound"). ATTACH tags on the owning surface — each of those groups has its own \`tag\` subcommand (e.g. \`oxygen inbox tag\`, \`oxygen tables tag\`, \`oxygen domains tag\`) — then \`tags get <tag>\` returns every carrier with deep-links. CURATE the vocabulary here: \`tags create\` declares a tag (with a description, color, and pin) before anything carries it, \`tags update\` re-annotates one, and \`tags rename\`/\`merge\`/\`delete\` reshape it everywhere at once.`)
|
|
6314
|
+
.description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters — nothing is slugified, so "Q3 Outbound" becomes "q3 outbound"). ATTACH tags on the owning surface — each of those groups has its own \`tag\` subcommand (e.g. \`oxygen inbox tag\`, \`oxygen tables tag\`, \`oxygen domains tag\`) — then \`tags get <tag>\` returns every carrier with deep-links. CURATE the vocabulary here: \`tags create\` declares a tag (with a description, color, and pin) before anything carries it, \`tags update\` re-annotates one, and \`tags rename\`/\`merge\`/\`delete\` reshape it everywhere at once. Tags are free, inert metadata: attaching or removing one costs 0 credits, makes no provider call, and changes no sending, routing, or warm-up behaviour.`)
|
|
6137
6315
|
.addCommand(new Command("list")
|
|
6138
6316
|
.description("List every workspace tag with per-primitive counts, most-used first.")
|
|
6139
6317
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -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
|
});
|
|
@@ -8106,6 +8541,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8106
8541
|
// appearing in a command's flag strings, so an approval-shaped name
|
|
8107
8542
|
// here would mislabel every command that carries it.
|
|
8108
8543
|
.option("--move-existing", "Also re-home the mailboxes you already have onto the new dedicated IP. Off by default because moving an established mailbox throws away the sending warmup it accumulated on its current IP.")
|
|
8544
|
+
.option("--country <code>", "ISO 3166-1 alpha-2 country the dedicated IP should sit in (e.g. US, DE). Match it to where your mailboxes' owners plausibly sign in from — the IP's job is making the sign-in look ordinary, and an account that suddenly authenticates from another country is what gets it challenged. Omitted uses the default region. An unserviceable or out-of-stock country fails before anything is ordered or charged.")
|
|
8109
8545
|
.option("--json", "Print a JSON envelope.")
|
|
8110
8546
|
.action(async (options) => {
|
|
8111
8547
|
await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", {
|
|
@@ -8113,6 +8549,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8113
8549
|
body: {
|
|
8114
8550
|
...(options.approved ? { approve: true } : {}),
|
|
8115
8551
|
...(options.moveExisting ? { move_existing: true } : {}),
|
|
8552
|
+
...(readOption(options.country) ? { country: readOption(options.country) } : {}),
|
|
8116
8553
|
},
|
|
8117
8554
|
}));
|
|
8118
8555
|
}))
|
|
@@ -8523,11 +8960,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8523
8960
|
});
|
|
8524
8961
|
}))
|
|
8525
8962
|
.addCommand(new Command("approvals")
|
|
8526
|
-
.description("List everything waiting for a human decision across workflows, publishing, message reviews,
|
|
8963
|
+
.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
8964
|
// Keep --source values in sync with OBSERVABILITY_APPROVAL_SOURCES in
|
|
8528
8965
|
// apps/web/src/lib/observability-console.ts and the MCP tool enum. The API
|
|
8529
8966
|
// 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.")
|
|
8967
|
+
.option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft, collab_request.")
|
|
8531
8968
|
.option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
|
|
8532
8969
|
.option("--json", "Print a JSON envelope.")
|
|
8533
8970
|
.action(async (options) => {
|
|
@@ -9638,6 +10075,28 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9638
10075
|
const params = new URLSearchParams({ integration_id: integrationId });
|
|
9639
10076
|
return requestOxygen(`/api/cli/integrations/composio/actions?${params.toString()}`);
|
|
9640
10077
|
});
|
|
10078
|
+
}))
|
|
10079
|
+
.addCommand(new Command("destinations")
|
|
10080
|
+
.description("List where a connected integration can send — for Microsoft Teams, your teams and your chats, plus one team's channels with --team-id. Free: these are read-only lookups, so no approval or credit cap is needed.")
|
|
10081
|
+
.argument("<integration_id>", "Integration id. Supported: 'microsoft_teams'.")
|
|
10082
|
+
.option("--team-id <id>", "List this team's channels as well.")
|
|
10083
|
+
.option("--kind <kind>", "Narrow to one of: teams, channels, chats. Default lists teams and chats.")
|
|
10084
|
+
.option("--connection-id <id>", "Use a specific connection when several are active.")
|
|
10085
|
+
.option("--json", "Print a JSON envelope.")
|
|
10086
|
+
.action(async (integrationId, options) => {
|
|
10087
|
+
await handleAsyncAction("integrations destinations", options, () => {
|
|
10088
|
+
const params = new URLSearchParams({ integration_id: integrationId });
|
|
10089
|
+
const teamId = readOption(options.teamId);
|
|
10090
|
+
if (teamId)
|
|
10091
|
+
params.set("team_id", teamId);
|
|
10092
|
+
const kind = readOption(options.kind);
|
|
10093
|
+
if (kind)
|
|
10094
|
+
params.set("kind", kind);
|
|
10095
|
+
const connectionId = readOption(options.connectionId);
|
|
10096
|
+
if (connectionId)
|
|
10097
|
+
params.set("connection_id", connectionId);
|
|
10098
|
+
return requestOxygen(`/api/cli/integrations/destinations?${params.toString()}`);
|
|
10099
|
+
});
|
|
9641
10100
|
}))
|
|
9642
10101
|
.addCommand(new Command("run")
|
|
9643
10102
|
.description("Run an action for a connected integration — Composio toolkits and native providers alike.")
|
|
@@ -9676,6 +10135,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9676
10135
|
.description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
|
|
9677
10136
|
.option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
|
|
9678
10137
|
.option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
|
|
10138
|
+
.option("--tag <tags>", "Comma-separated workspace tags — matches sender accounts carrying ANY of these tags (see `oxygen tags list`).")
|
|
9679
10139
|
.option("--json", "Print a JSON envelope.")
|
|
9680
10140
|
.action(async (options) => {
|
|
9681
10141
|
await handleAsyncAction("senders list", options, () => {
|
|
@@ -9685,6 +10145,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9685
10145
|
const status = readOption(options.status);
|
|
9686
10146
|
if (status)
|
|
9687
10147
|
params.set("status", status);
|
|
10148
|
+
const tags = splitCommaList(options.tag);
|
|
10149
|
+
if (tags.length > 0)
|
|
10150
|
+
params.set("tag", tags.join(","));
|
|
9688
10151
|
const suffix = params.toString();
|
|
9689
10152
|
return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
|
|
9690
10153
|
});
|
|
@@ -9860,6 +10323,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9860
10323
|
.option("--total-reads-per-day <n>", "Daily cap across all read units.")
|
|
9861
10324
|
.option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
|
|
9862
10325
|
.option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
|
|
10326
|
+
.option("--interactive-min-spacing-seconds <n>", "Minimum seconds between human-sent Unibox actions.")
|
|
10327
|
+
.option("--interactive-spacing-jitter-seconds <n>", "Random jitter seconds added to human-sent Unibox action spacing.")
|
|
10328
|
+
.option("--interactive-sends-per-hour <n>", "Hourly cap on human-sent Unibox actions.")
|
|
9863
10329
|
.option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
|
|
9864
10330
|
.option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
|
|
9865
10331
|
.option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
|
|
@@ -9882,12 +10348,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9882
10348
|
.addCommand(new Command("list")
|
|
9883
10349
|
.description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
|
|
9884
10350
|
.option("--status <status>", "Filter by status: active, paused, or archived.")
|
|
10351
|
+
.option("--tag <tags>", "Comma-separated workspace tags — matches sender profiles carrying ANY of these tags (see `oxygen tags list`).")
|
|
9885
10352
|
.option("--json", "Print a JSON envelope.")
|
|
9886
10353
|
.action(async (options) => {
|
|
9887
10354
|
await handleAsyncAction("senders profiles list", options, () => {
|
|
10355
|
+
const params = new URLSearchParams();
|
|
9888
10356
|
const status = readOption(options.status);
|
|
9889
|
-
|
|
10357
|
+
if (status)
|
|
10358
|
+
params.set("status", status);
|
|
10359
|
+
const tags = splitCommaList(options.tag);
|
|
10360
|
+
if (tags.length > 0)
|
|
10361
|
+
params.set("tag", tags.join(","));
|
|
10362
|
+
const suffix = params.toString();
|
|
10363
|
+
return requestOxygen(`/api/cli/senders/profiles${suffix ? `?${suffix}` : ""}`);
|
|
9890
10364
|
});
|
|
10365
|
+
}))
|
|
10366
|
+
.addCommand(new Command("tag")
|
|
10367
|
+
.description("Replace a sender profile's workspace tags (whole set; `--tags \"\"` clears) — one edit pools the whole sending identity behind a campaign tag. Member accounts keep their own tags. See `oxygen tags list` for the vocabulary.")
|
|
10368
|
+
.argument("<id>", "Sender profile id.")
|
|
10369
|
+
.requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
|
|
10370
|
+
.option("--json", "Print a JSON envelope.")
|
|
10371
|
+
.action(async (id, options) => {
|
|
10372
|
+
await handleAsyncAction("senders profiles tag", options, () => requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}/tags`, {
|
|
10373
|
+
method: "POST",
|
|
10374
|
+
body: { tags: splitCommaList(options.tags) },
|
|
10375
|
+
}));
|
|
9891
10376
|
}))
|
|
9892
10377
|
.addCommand(new Command("get")
|
|
9893
10378
|
.description("Get one sender profile with the LinkedIn/WhatsApp senders and inboxes attached to it.")
|
|
@@ -10176,7 +10661,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10176
10661
|
});
|
|
10177
10662
|
})));
|
|
10178
10663
|
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
|
|
10664
|
+
.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
10665
|
.addCommand(new Command("harvest")
|
|
10181
10666
|
.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
10667
|
.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 +10774,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10289
10774
|
},
|
|
10290
10775
|
});
|
|
10291
10776
|
});
|
|
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
|
-
}))));
|
|
10777
|
+
})));
|
|
10398
10778
|
program.addCommand(new Command("linkedin")
|
|
10399
10779
|
.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
10780
|
.addCommand(new Command("intent")
|
|
@@ -10421,7 +10801,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10421
10801
|
});
|
|
10422
10802
|
}))
|
|
10423
10803
|
.addCommand(new Command("status")
|
|
10424
|
-
.description("Show the canonical intent tables, their linked-table deep-links, and the capture
|
|
10804
|
+
.description("Show the canonical intent tables, their linked-table deep-links, and the capture feeds filling them.")
|
|
10425
10805
|
.option("--json", "Print a JSON envelope.")
|
|
10426
10806
|
.action(async (options) => {
|
|
10427
10807
|
await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
|
|
@@ -10516,6 +10896,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10516
10896
|
.description("List connected WhatsApp accounts with health status, warm-up ramp state, limits, and today's usage.")
|
|
10517
10897
|
.option("--status <status>", "Filter by account status: active, paused, disconnected, restricted, or credentials_required.")
|
|
10518
10898
|
.option("--no-usage", "Skip today's per-account usage counts.")
|
|
10899
|
+
.option("--tag <tags>", "Comma-separated workspace tags — matches WhatsApp accounts carrying ANY of these tags (see `oxygen tags list`).")
|
|
10519
10900
|
.option("--json", "Print a JSON envelope.")
|
|
10520
10901
|
.action(async (options) => {
|
|
10521
10902
|
await handleAsyncAction("whatsapp accounts", options, () => {
|
|
@@ -10525,6 +10906,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10525
10906
|
const status = readOption(options.status);
|
|
10526
10907
|
if (status)
|
|
10527
10908
|
params.set("status", status);
|
|
10909
|
+
const tags = splitCommaList(options.tag);
|
|
10910
|
+
if (tags.length > 0)
|
|
10911
|
+
params.set("tag", tags.join(","));
|
|
10528
10912
|
const suffix = params.toString();
|
|
10529
10913
|
return requestOxygen(`/api/cli/whatsapp/accounts${suffix ? `?${suffix}` : ""}`);
|
|
10530
10914
|
});
|
|
@@ -10740,6 +11124,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10740
11124
|
.option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
|
|
10741
11125
|
.option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
|
|
10742
11126
|
.option("--unread", "Only show conversations with unread messages.")
|
|
11127
|
+
.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
11128
|
.option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
|
|
10744
11129
|
.option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
|
|
10745
11130
|
.option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
|
|
@@ -10769,6 +11154,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10769
11154
|
params.set("account", account);
|
|
10770
11155
|
if (options.unread)
|
|
10771
11156
|
params.set("unread", "true");
|
|
11157
|
+
if (options.unanswered)
|
|
11158
|
+
params.set("unanswered", "true");
|
|
10772
11159
|
if (options.responsesOnly)
|
|
10773
11160
|
params.set("responses_only", "true");
|
|
10774
11161
|
for (const [flag, key] of [
|
|
@@ -10881,6 +11268,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10881
11268
|
.option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
|
|
10882
11269
|
.option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
|
|
10883
11270
|
.option("--include-archived", "Also mark archived conversations read.")
|
|
11271
|
+
.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
11272
|
.option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
|
|
10885
11273
|
.option("--json", "Print a JSON envelope.")
|
|
10886
11274
|
.action(async (options) => {
|
|
@@ -10913,6 +11301,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10913
11301
|
}
|
|
10914
11302
|
if (options.includeArchived)
|
|
10915
11303
|
params.set("include_archived", "true");
|
|
11304
|
+
// Booleans need their own line: the tuple loop above only walks
|
|
11305
|
+
// the string-valued options.
|
|
11306
|
+
if (options.unanswered)
|
|
11307
|
+
params.set("unanswered", "true");
|
|
10916
11308
|
return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
|
|
10917
11309
|
method: "POST",
|
|
10918
11310
|
// Approval rides in the body: a bodyless POST reads as
|
|
@@ -11144,6 +11536,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11144
11536
|
.option("--json", "Print a JSON envelope.")
|
|
11145
11537
|
.action(async (key, options) => {
|
|
11146
11538
|
await handleAsyncAction("inbox label archive", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, { method: "DELETE" }));
|
|
11539
|
+
}))
|
|
11540
|
+
.addCommand(new Command("restore")
|
|
11541
|
+
.description("Restore an archived custom status label under its original key.")
|
|
11542
|
+
.argument("<key>", "The archived custom label key.")
|
|
11543
|
+
.option("--json", "Print a JSON envelope.")
|
|
11544
|
+
.action(async (key, options) => {
|
|
11545
|
+
await handleAsyncAction("inbox label restore", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
|
|
11546
|
+
method: "PATCH",
|
|
11547
|
+
body: { archived: false },
|
|
11548
|
+
}));
|
|
11147
11549
|
})))
|
|
11148
11550
|
.addCommand(new Command("drafts")
|
|
11149
11551
|
.description("The AI reply-agent draft queue (the approve-before-send review queue).")
|
|
@@ -12142,12 +12544,31 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12142
12544
|
});
|
|
12143
12545
|
})))
|
|
12144
12546
|
.addCommand(new Command("numbers")
|
|
12145
|
-
.description("The org's dialing pool: list | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
|
|
12547
|
+
.description("The org's dialing pool: list | tag | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
|
|
12146
12548
|
.addCommand(new Command("list")
|
|
12147
|
-
.description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month.")
|
|
12549
|
+
.description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month. Optional --tag narrows to one campaign's numbers.")
|
|
12550
|
+
.option("--tag <tags>", "Comma-separated workspace tags — matches phone numbers carrying ANY of these tags (see `oxygen tags list`).")
|
|
12148
12551
|
.option("--json", "Print a JSON envelope.")
|
|
12149
12552
|
.action(async (options) => {
|
|
12150
|
-
await handleAsyncAction("voice numbers list", options, () =>
|
|
12553
|
+
await handleAsyncAction("voice numbers list", options, () => {
|
|
12554
|
+
const params = new URLSearchParams();
|
|
12555
|
+
const tags = splitCommaList(options.tag);
|
|
12556
|
+
if (tags.length > 0)
|
|
12557
|
+
params.set("tag", tags.join(","));
|
|
12558
|
+
const suffix = params.toString();
|
|
12559
|
+
return requestOxygen(`/api/cli/voice/numbers${suffix ? `?${suffix}` : ""}`);
|
|
12560
|
+
});
|
|
12561
|
+
}))
|
|
12562
|
+
.addCommand(new Command("tag")
|
|
12563
|
+
.description("Replace a phone number's workspace tags (whole set; `--tags \"\"` clears) — e.g. pool the numbers dialing one campaign behind its tag. See `oxygen tags list` for the vocabulary.")
|
|
12564
|
+
.argument("<number>", "Phone number in E.164 (e.g. +14155550142) or its id.")
|
|
12565
|
+
.requiredOption("--tags <tags>", "Comma-separated workspace tags (replaces the whole set; empty clears).")
|
|
12566
|
+
.option("--json", "Print a JSON envelope.")
|
|
12567
|
+
.action(async (number, options) => {
|
|
12568
|
+
await handleAsyncAction("voice numbers tag", options, () => requestOxygen(`/api/cli/voice/numbers/${encodeURIComponent(number)}/tags`, {
|
|
12569
|
+
method: "POST",
|
|
12570
|
+
body: { tags: splitCommaList(options.tags) },
|
|
12571
|
+
}));
|
|
12151
12572
|
}))
|
|
12152
12573
|
.addCommand(new Command("release")
|
|
12153
12574
|
.description("Give a number back to the carrier and stop its monthly charge. IRREVERSIBLE — the number returns to the carrier's pool and can be taken by someone else within minutes; you cannot get it back. Previews by default; pass --approved to actually release.")
|
|
@@ -12618,10 +13039,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12618
13039
|
});
|
|
12619
13040
|
})));
|
|
12620
13041
|
program.addCommand(new Command("mailboxes")
|
|
12621
|
-
.description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed SendKit warmup with explicit plans and credit caps. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
|
|
13042
|
+
.description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed SendKit warmup with explicit plans and credit caps. Managed InboxKit Google and Microsoft mailboxes use InboxKit's native Sequencer export during the approved warmup action; SendKit warms them but never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
|
|
12622
13043
|
.addCommand(new Command("list")
|
|
12623
13044
|
.description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), and a pool overview (including counts by source).")
|
|
12624
13045
|
.option("--status <status>", "Filter by status: active, paused, or disabled.")
|
|
13046
|
+
.option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
|
|
12625
13047
|
.option("--json", "Print a JSON envelope.")
|
|
12626
13048
|
.action(async (options) => {
|
|
12627
13049
|
await handleAsyncAction("mailboxes list", options, () => {
|
|
@@ -12629,6 +13051,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12629
13051
|
const status = readOption(options.status);
|
|
12630
13052
|
if (status)
|
|
12631
13053
|
params.set("status", status);
|
|
13054
|
+
const tags = splitCommaList(options.tag);
|
|
13055
|
+
if (tags.length > 0)
|
|
13056
|
+
params.set("tag", tags.join(","));
|
|
12632
13057
|
const suffix = params.toString();
|
|
12633
13058
|
return requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`);
|
|
12634
13059
|
});
|
|
@@ -12669,7 +13094,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12669
13094
|
});
|
|
12670
13095
|
}))
|
|
12671
13096
|
.addCommand(new Command("import")
|
|
12672
|
-
.description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: Microsoft warmup
|
|
13097
|
+
.description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: managed InboxKit Google/Microsoft warmup uses native InboxKit Sequencer export during `warmup enable`; eligible non-InboxKit Microsoft warmup uses the separate Outlook OAuth fallback (`mailboxes warmup microsoft`); EmailGuard remains vendor_blocked for Microsoft. Google app passwords are sent only in the request body, encrypted server-side, and never returned.")
|
|
12673
13098
|
.addHelpText("after", [
|
|
12674
13099
|
"",
|
|
12675
13100
|
"Ordinary identity file contract (CSV / JSON / JSONL / XLSX):",
|
|
@@ -13014,9 +13439,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13014
13439
|
});
|
|
13015
13440
|
}))
|
|
13016
13441
|
.addCommand(new Command("warmup")
|
|
13017
|
-
.description("Mailbox warmup for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. Oxygen's native warmup runs on ONE rail: SendKit, managed and credit-billed at 3,000 credits per warming inbox per month ($3). Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. Google and Microsoft
|
|
13442
|
+
.description("Mailbox warmup for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. Oxygen's native warmup runs on ONE rail: SendKit, managed and credit-billed at 3,000 credits per warming inbox per month ($3). Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. Managed InboxKit Google and Microsoft mailboxes are exported through InboxKit's native Sequencer integration inside that approved action; InboxKit auto-export stays off, and any non-cancelled InboxKit warmup blocks the handoff so one mailbox cannot warm twice. SendKit is warmup-only: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down here with `warmup disable`; they are never silently moved onto SendKit.")
|
|
13018
13443
|
.addCommand(new Command("enable")
|
|
13019
|
-
.description("Enroll sending mailboxes in Oxygen's managed SendKit warmup and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is enrolled or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Microsoft
|
|
13444
|
+
.description("Enroll sending mailboxes in Oxygen's managed SendKit warmup and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is exported, enrolled, or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Managed InboxKit Google and Microsoft mailboxes use InboxKit's native Sequencer export here by default; auto-export remains off, and any non-cancelled InboxKit warmup must be cancelled before export (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. SendKit supplies warmup only — OXYGEN Sequences retain campaign enrollment and dispatch. New enrollments only ever land on SendKit; the retired TrulyInbox rail is refused here and only accepts teardown.")
|
|
13020
13445
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
|
|
13021
13446
|
.option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
|
|
13022
13447
|
.option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
|
|
@@ -13060,8 +13485,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13060
13485
|
});
|
|
13061
13486
|
}))
|
|
13062
13487
|
.addCommand(new Command("microsoft")
|
|
13063
|
-
.description("
|
|
13064
|
-
.option("--mailboxes <list>", "Comma-separated Microsoft mailbox ids or addresses. Omit to select
|
|
13488
|
+
.description("NON-INBOXKIT FALLBACK ONLY: authorize eligible Microsoft/Outlook mailboxes for SendKit warmup with ONE browser consent PER MAILBOX, never a password or app password. Do not run this for managed InboxKit mailboxes: both Google and Microsoft use InboxKit's native Sequencer export during the separately approved `warmup enable`. For the fallback there is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact fallback mailboxes and outstanding links at 0 credits. Approve that fresh hash with --max-credits 0, open every returned consent_url, then re-run with --status. Warmup itself stays OFF until `warmup enable`.")
|
|
13489
|
+
.option("--mailboxes <list>", "Comma-separated eligible non-InboxKit Microsoft mailbox ids or addresses. Omit to select the pool; the command refuses a scope containing managed InboxKit rows and points to warmup enable.")
|
|
13065
13490
|
.option("--tenant <id>", "Deprecated and ignored: SendKit consent is per mailbox, so there is no tenant-wide scope to bind. Still accepted so older scripts keep running.")
|
|
13066
13491
|
.option("--approved", "Mint the per-mailbox consent links for the exact previewed mailbox scope. Still 0 credits — a human then has to open each link.")
|
|
13067
13492
|
.option("--plan <hash>", "Fresh setup preview hash (required with --approved).")
|
|
@@ -13234,7 +13659,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13234
13659
|
.addCommand(new Command("run")
|
|
13235
13660
|
.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
13661
|
.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
|
|
13662
|
+
.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
13663
|
.option("--subject <subject>", "Optional subject for the seed message (zapmail ignores it beyond labeling the test).")
|
|
13239
13664
|
.option("--body <text>", "Optional plain-text body for the seed message (ignored by zapmail).")
|
|
13240
13665
|
.option("--approved", "Actually run the test (otherwise a preview is returned).")
|
|
@@ -13312,6 +13737,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13312
13737
|
.option("--expiring", "Only non-deleted domains expiring within 60 days.")
|
|
13313
13738
|
.option("--has-mailboxes <bool>", "true → only domains with sending mailboxes; false → only domains without.")
|
|
13314
13739
|
.option("--include-archived", "Include archived domains (hidden by default).")
|
|
13740
|
+
.option("--tag <tags>", "Comma-separated workspace tags — matches domains carrying ANY of these tags (see `oxygen tags list`).")
|
|
13315
13741
|
.option("--json", "Print a JSON envelope.")
|
|
13316
13742
|
.action(async (options) => {
|
|
13317
13743
|
await handleAsyncAction("domains list", options, () => {
|
|
@@ -13335,6 +13761,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13335
13761
|
params.set("has_mailboxes", hasMailboxes);
|
|
13336
13762
|
if (options.includeArchived)
|
|
13337
13763
|
params.set("include_archived", "true");
|
|
13764
|
+
const tags = splitCommaList(options.tag);
|
|
13765
|
+
if (tags.length > 0)
|
|
13766
|
+
params.set("tag", tags.join(","));
|
|
13338
13767
|
const suffix = params.toString();
|
|
13339
13768
|
return requestOxygen(`/api/cli/domains${suffix ? `?${suffix}` : ""}`);
|
|
13340
13769
|
});
|
|
@@ -19247,7 +19676,8 @@ function formatReplyTypes(value) {
|
|
|
19247
19676
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
19248
19677
|
return entries.length > 0 ? entries.map(([key, count]) => `${key}:${count}`).join(", ") : "—";
|
|
19249
19678
|
}
|
|
19250
|
-
|
|
19679
|
+
/** Column-aligned plain-text table, two-space indented. Shared by the sequence-variant lenses and the collaboration lists. */
|
|
19680
|
+
function renderTextTable(headers, rows) {
|
|
19251
19681
|
const widths = headers.map((header, columnIndex) => {
|
|
19252
19682
|
let max = header.length;
|
|
19253
19683
|
for (const row of rows) {
|
|
@@ -19282,7 +19712,7 @@ function formatSequenceVariants(data) {
|
|
|
19282
19712
|
formatVariantCell(row.bounced),
|
|
19283
19713
|
formatVariantCell(row.credits_used),
|
|
19284
19714
|
]);
|
|
19285
|
-
lines.push(...
|
|
19715
|
+
lines.push(...renderTextTable(headers, rows));
|
|
19286
19716
|
}
|
|
19287
19717
|
lines.push("", styles.bold("By mailbox"));
|
|
19288
19718
|
if (byMailbox.length === 0) {
|
|
@@ -19301,7 +19731,7 @@ function formatSequenceVariants(data) {
|
|
|
19301
19731
|
formatReplyTypes(row.reply_types ?? row.replyTypes),
|
|
19302
19732
|
formatVariantCell(row.failed),
|
|
19303
19733
|
]);
|
|
19304
|
-
lines.push(...
|
|
19734
|
+
lines.push(...renderTextTable(headers, rows));
|
|
19305
19735
|
}
|
|
19306
19736
|
lines.push("", styles.bold("By sending domain"));
|
|
19307
19737
|
if (byDomain.length === 0) {
|
|
@@ -19320,14 +19750,14 @@ function formatSequenceVariants(data) {
|
|
|
19320
19750
|
formatRatePercent(row.bounce_rate ?? row.bounceRate),
|
|
19321
19751
|
formatReplyTypes(row.reply_types ?? row.replyTypes),
|
|
19322
19752
|
]);
|
|
19323
|
-
lines.push(...
|
|
19753
|
+
lines.push(...renderTextTable(headers, rows));
|
|
19324
19754
|
}
|
|
19325
19755
|
const hasUnattributed = unattributed
|
|
19326
19756
|
&& [unattributed.sent, unattributed.replied, unattributed.bounced, unattributed.failed]
|
|
19327
19757
|
.some((value) => typeof value === "number" && value > 0);
|
|
19328
19758
|
if (hasUnattributed && unattributed) {
|
|
19329
19759
|
lines.push("", styles.bold("Unattributed sending identity"));
|
|
19330
|
-
lines.push(...
|
|
19760
|
+
lines.push(...renderTextTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
|
|
19331
19761
|
formatVariantCell(unattributed.sent),
|
|
19332
19762
|
formatVariantCell(unattributed.replied),
|
|
19333
19763
|
formatVariantCell(unattributed.positive_replies ?? unattributed.positiveReplies),
|
|
@@ -19340,6 +19770,471 @@ function formatSequenceVariants(data) {
|
|
|
19340
19770
|
lines.push("");
|
|
19341
19771
|
return lines.join("\n");
|
|
19342
19772
|
}
|
|
19773
|
+
// --- Collaboration output ----------------------------------------------------
|
|
19774
|
+
//
|
|
19775
|
+
// Comments and approvals are read by two very different audiences: the agency's
|
|
19776
|
+
// operator in a terminal, and an agent parsing --json. So these commands render
|
|
19777
|
+
// a compact human view by default and the stable envelope under --json — the
|
|
19778
|
+
// same split `sequences variants` uses. Every renderer ends with the deep-link
|
|
19779
|
+
// the API returned, because the client who has to decide is usually the one
|
|
19780
|
+
// person NOT in the terminal.
|
|
19781
|
+
async function handleCollabAction(command, options, action, render) {
|
|
19782
|
+
try {
|
|
19783
|
+
const data = await action();
|
|
19784
|
+
if (options.json) {
|
|
19785
|
+
writeJson(success(command, data));
|
|
19786
|
+
return;
|
|
19787
|
+
}
|
|
19788
|
+
process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
|
|
19789
|
+
}
|
|
19790
|
+
catch (error) {
|
|
19791
|
+
emitCliFailure(command, error);
|
|
19792
|
+
}
|
|
19793
|
+
}
|
|
19794
|
+
/**
|
|
19795
|
+
* `comments add`. Without --reply this is one POST. With --reply the CLI first
|
|
19796
|
+
* resolves which thread owns that id, because the reply endpoint is keyed by
|
|
19797
|
+
* THREAD while the id a user has in hand (from `comments list`, or from the
|
|
19798
|
+
* `next_step` the API prints) is a COMMENT. Passing a thread id works too and
|
|
19799
|
+
* replies at its top level — both ids sit side by side in the list output, and
|
|
19800
|
+
* refusing the wrong one would be pedantry, not safety.
|
|
19801
|
+
*/
|
|
19802
|
+
async function handleCommentsAddAction(body, options) {
|
|
19803
|
+
await handleCollabAction("comments add", options, async () => {
|
|
19804
|
+
const subject = requireCollabSubject(options.on);
|
|
19805
|
+
const path = readCollabPath(options.path);
|
|
19806
|
+
const mentions = options.mention.map((value) => value.trim()).filter(Boolean);
|
|
19807
|
+
const replyTo = readOption(options.reply);
|
|
19808
|
+
if (!replyTo) {
|
|
19809
|
+
const title = readOption(options.title);
|
|
19810
|
+
return requestOxygen("/api/cli/collab/threads", {
|
|
19811
|
+
method: "POST",
|
|
19812
|
+
body: {
|
|
19813
|
+
subject_kind: subject.kind,
|
|
19814
|
+
subject_id: subject.id,
|
|
19815
|
+
...(path ? { subject_path: path } : {}),
|
|
19816
|
+
...(title ? { title } : {}),
|
|
19817
|
+
body,
|
|
19818
|
+
...(mentions.length > 0 ? { mentions } : {}),
|
|
19819
|
+
},
|
|
19820
|
+
});
|
|
19821
|
+
}
|
|
19822
|
+
const target = await resolveCollabReplyTarget(subject, replyTo);
|
|
19823
|
+
return requestOxygen(`/api/cli/collab/threads/${encodeURIComponent(target.threadId)}/comments`, {
|
|
19824
|
+
method: "POST",
|
|
19825
|
+
body: {
|
|
19826
|
+
body,
|
|
19827
|
+
...(target.parentCommentId ? { parent_comment_id: target.parentCommentId } : {}),
|
|
19828
|
+
...(mentions.length > 0 ? { mentions } : {}),
|
|
19829
|
+
},
|
|
19830
|
+
});
|
|
19831
|
+
}, formatCollabWriteResult);
|
|
19832
|
+
}
|
|
19833
|
+
async function resolveCollabReplyTarget(subject, replyTo) {
|
|
19834
|
+
const params = new URLSearchParams({
|
|
19835
|
+
subject: `${subject.kind}:${subject.id}`,
|
|
19836
|
+
// `all`, not `open`: replying to a resolved thread is how a client reopens
|
|
19837
|
+
// a conversation, and scoping the lookup to open threads would report the
|
|
19838
|
+
// id as missing instead.
|
|
19839
|
+
status: "all",
|
|
19840
|
+
});
|
|
19841
|
+
const listed = await requestOxygen(`/api/cli/collab/threads?${params.toString()}`);
|
|
19842
|
+
const threads = Array.isArray(listed.threads) ? listed.threads.filter(isRecord) : [];
|
|
19843
|
+
for (const thread of threads) {
|
|
19844
|
+
if (thread.id === replyTo)
|
|
19845
|
+
return { threadId: replyTo, parentCommentId: null };
|
|
19846
|
+
const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
|
|
19847
|
+
if (comments.some((comment) => comment.id === replyTo) && typeof thread.id === "string") {
|
|
19848
|
+
return { threadId: thread.id, parentCommentId: replyTo };
|
|
19849
|
+
}
|
|
19850
|
+
}
|
|
19851
|
+
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 });
|
|
19852
|
+
}
|
|
19853
|
+
/** Exactly one scope (--on or --default) and exactly one requirement flag. */
|
|
19854
|
+
function collabGateSetBody(options) {
|
|
19855
|
+
const on = readOption(options.on);
|
|
19856
|
+
const asDefault = readOption(options.default);
|
|
19857
|
+
if (Boolean(on) === Boolean(asDefault)) {
|
|
19858
|
+
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 });
|
|
19859
|
+
}
|
|
19860
|
+
if (Boolean(options.required) === Boolean(options.notRequired)) {
|
|
19861
|
+
throw new OxygenError("invalid_request", "Pass exactly one of --required or --not-required.", { exitCode: 2 });
|
|
19862
|
+
}
|
|
19863
|
+
const gateKind = requireCollabGate(options.gate);
|
|
19864
|
+
const subject = on ? requireCollabSubject(on) : null;
|
|
19865
|
+
const subjectKind = subject ? subject.kind : requireCollabSubjectKind(asDefault ?? undefined);
|
|
19866
|
+
if (!GATE_KIND_SUBJECTS[gateKind].includes(subjectKind)) {
|
|
19867
|
+
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 });
|
|
19868
|
+
}
|
|
19869
|
+
return {
|
|
19870
|
+
subject_kind: subjectKind,
|
|
19871
|
+
// null is meaningful here: it is what makes the policy the workspace
|
|
19872
|
+
// default for the kind rather than a row about one object.
|
|
19873
|
+
subject_id: subject ? subject.id : null,
|
|
19874
|
+
gate_kind: gateKind,
|
|
19875
|
+
required: Boolean(options.required),
|
|
19876
|
+
};
|
|
19877
|
+
}
|
|
19878
|
+
function collabStyles() {
|
|
19879
|
+
return ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
19880
|
+
}
|
|
19881
|
+
function collabText(value, fallback = "—") {
|
|
19882
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
19883
|
+
}
|
|
19884
|
+
function collabLink(data) {
|
|
19885
|
+
if (typeof data.web_url === "string")
|
|
19886
|
+
return data.web_url;
|
|
19887
|
+
return typeof data.deepLink === "string" ? data.deepLink : null;
|
|
19888
|
+
}
|
|
19889
|
+
/** Coarse age for a list column: seconds, minutes, hours, then days. */
|
|
19890
|
+
function formatCollabAge(value) {
|
|
19891
|
+
if (typeof value !== "string")
|
|
19892
|
+
return "—";
|
|
19893
|
+
const at = Date.parse(value);
|
|
19894
|
+
if (!Number.isFinite(at))
|
|
19895
|
+
return "—";
|
|
19896
|
+
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
19897
|
+
if (seconds < 60)
|
|
19898
|
+
return `${seconds}s`;
|
|
19899
|
+
const minutes = Math.floor(seconds / 60);
|
|
19900
|
+
if (minutes < 60)
|
|
19901
|
+
return `${minutes}m`;
|
|
19902
|
+
const hours = Math.floor(minutes / 60);
|
|
19903
|
+
if (hours < 48)
|
|
19904
|
+
return `${hours}h`;
|
|
19905
|
+
return `${Math.floor(hours / 24)}d`;
|
|
19906
|
+
}
|
|
19907
|
+
/** First line of a body, clipped — a thread's subject line in one cell. */
|
|
19908
|
+
function collabSnippet(value, width = 56) {
|
|
19909
|
+
if (typeof value !== "string")
|
|
19910
|
+
return "";
|
|
19911
|
+
const firstLine = value.split("\n").find((line) => line.trim()) ?? "";
|
|
19912
|
+
return clipCell(firstLine.trim(), width);
|
|
19913
|
+
}
|
|
19914
|
+
/** Trailer every collab render shares: what to do next, then the deep-link. */
|
|
19915
|
+
function collabTrailer(data) {
|
|
19916
|
+
const styles = collabStyles();
|
|
19917
|
+
const lines = [];
|
|
19918
|
+
const nextStep = typeof data.next_step === "string" ? data.next_step.trim() : "";
|
|
19919
|
+
if (nextStep)
|
|
19920
|
+
lines.push("", ` ${styles.dim("Next")} ${nextStep}`);
|
|
19921
|
+
const link = collabLink(data);
|
|
19922
|
+
if (link)
|
|
19923
|
+
lines.push(` ${styles.dim("Link")} ${link}`);
|
|
19924
|
+
return lines;
|
|
19925
|
+
}
|
|
19926
|
+
/**
|
|
19927
|
+
* Generic confirmation for a write whose payload is one object. The API already
|
|
19928
|
+
* writes a `next_step` sentence for each of these, so the renderer's only job is
|
|
19929
|
+
* to say what changed and keep the deep-link visible.
|
|
19930
|
+
*/
|
|
19931
|
+
function formatCollabWriteResult(data) {
|
|
19932
|
+
const styles = collabStyles();
|
|
19933
|
+
const lines = [""];
|
|
19934
|
+
const headline = collabWriteHeadline(data);
|
|
19935
|
+
lines.push(` ${styles.green("[OK]")} ${headline}`);
|
|
19936
|
+
if (data.already === true) {
|
|
19937
|
+
lines.push(` ${styles.dim("Already in that state; nothing changed.")}`);
|
|
19938
|
+
}
|
|
19939
|
+
const notified = Array.isArray(data.notified_emails)
|
|
19940
|
+
? data.notified_emails.filter((value) => typeof value === "string")
|
|
19941
|
+
: [];
|
|
19942
|
+
if (notified.length > 0)
|
|
19943
|
+
lines.push(` ${styles.dim("Notified")} ${notified.join(", ")}`);
|
|
19944
|
+
const unresolved = Array.isArray(data.unresolved_mentions)
|
|
19945
|
+
? data.unresolved_mentions.filter((value) => typeof value === "string")
|
|
19946
|
+
: [];
|
|
19947
|
+
if (unresolved.length > 0) {
|
|
19948
|
+
lines.push(` ${styles.yellow("Not a workspace member, so not notified:")} ${unresolved.join(", ")}`);
|
|
19949
|
+
}
|
|
19950
|
+
const note = collabText(data.note, "");
|
|
19951
|
+
if (note)
|
|
19952
|
+
lines.push(` ${styles.dim(note)}`);
|
|
19953
|
+
lines.push(...collabTrailer(data));
|
|
19954
|
+
lines.push("");
|
|
19955
|
+
return lines.join("\n");
|
|
19956
|
+
}
|
|
19957
|
+
/** One line naming what changed, matched to each write endpoint's payload shape. */
|
|
19958
|
+
function collabWriteHeadline(data) {
|
|
19959
|
+
// A thread write (`comments add`, `comments resolve`) serializes the thread
|
|
19960
|
+
// itself at the top level.
|
|
19961
|
+
if (typeof data.status === "string" && typeof data.subject === "string") {
|
|
19962
|
+
return `${collabText(data.subject)} — thread ${collabText(data.id)} is ${data.status}.`;
|
|
19963
|
+
}
|
|
19964
|
+
if (isRecord(data.comment)) {
|
|
19965
|
+
return data.comment.deleted === true ? "Comment retracted." : "Comment updated.";
|
|
19966
|
+
}
|
|
19967
|
+
if (isRecord(data.request)) {
|
|
19968
|
+
return `Approval request ${collabText(data.request.id)} is ${collabText(data.request.status)}.`;
|
|
19969
|
+
}
|
|
19970
|
+
// The member-role write returns the membership fields flat, not nested.
|
|
19971
|
+
if (typeof data.email === "string") {
|
|
19972
|
+
return data.granted === true
|
|
19973
|
+
? `${data.email} is now a client-role member of this workspace.`
|
|
19974
|
+
: `${data.email} is back to full workspace access.`;
|
|
19975
|
+
}
|
|
19976
|
+
return "Done.";
|
|
19977
|
+
}
|
|
19978
|
+
function formatCollabThreads(data) {
|
|
19979
|
+
const styles = collabStyles();
|
|
19980
|
+
const threads = Array.isArray(data.threads) ? data.threads.filter(isRecord) : [];
|
|
19981
|
+
const subject = collabText(data.subject, "this object");
|
|
19982
|
+
const lines = ["", styles.bold(`Comments on ${subject}`)];
|
|
19983
|
+
if (threads.length === 0) {
|
|
19984
|
+
lines.push(` ${styles.dim(collabText(data.hint, "No comment threads here yet."))}`);
|
|
19985
|
+
}
|
|
19986
|
+
for (const thread of threads) {
|
|
19987
|
+
const status = collabText(thread.status, "open");
|
|
19988
|
+
const badge = status === "resolved" ? styles.dim("[resolved]") : styles.green("[open]");
|
|
19989
|
+
const title = collabText(thread.title, "");
|
|
19990
|
+
lines.push("", ` ${badge} ${collabText(thread.id)} ${styles.dim(`${thread.comment_count ?? 0} comment(s) · ${formatCollabAge(thread.created_at)} old`)}`);
|
|
19991
|
+
if (title)
|
|
19992
|
+
lines.push(` ${styles.bold(title)}`);
|
|
19993
|
+
if (typeof thread.subject_path === "string" && thread.subject_path) {
|
|
19994
|
+
lines.push(` ${styles.dim(`on ${thread.subject_path}`)}`);
|
|
19995
|
+
}
|
|
19996
|
+
const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
|
|
19997
|
+
for (const comment of comments) {
|
|
19998
|
+
const author = collabText(comment.author_email, "someone");
|
|
19999
|
+
const marker = comment.parent_comment_id ? " ↳" : " •";
|
|
20000
|
+
const edited = comment.edited_at ? styles.dim(" (edited)") : "";
|
|
20001
|
+
const text = comment.deleted === true
|
|
20002
|
+
? styles.dim("(retracted)")
|
|
20003
|
+
: collabSnippet(comment.body, 72);
|
|
20004
|
+
lines.push(` ${marker} ${styles.dim(`${formatCollabAge(comment.created_at)} ${author}`)} ${text}${edited}`);
|
|
20005
|
+
lines.push(` ${styles.dim(collabText(comment.id))}`);
|
|
20006
|
+
}
|
|
20007
|
+
const threadLink = collabLink(thread);
|
|
20008
|
+
if (threadLink)
|
|
20009
|
+
lines.push(` ${styles.dim(threadLink)}`);
|
|
20010
|
+
}
|
|
20011
|
+
lines.push(...collabTrailer(data));
|
|
20012
|
+
lines.push("");
|
|
20013
|
+
return lines.join("\n");
|
|
20014
|
+
}
|
|
20015
|
+
function collabRequestRow(request) {
|
|
20016
|
+
return [
|
|
20017
|
+
collabText(request.id),
|
|
20018
|
+
collabText(request.subject),
|
|
20019
|
+
collabText(request.gate_kind),
|
|
20020
|
+
collabText(request.status),
|
|
20021
|
+
collabText(request.requested_by_email, "—"),
|
|
20022
|
+
formatCollabAge(request.created_at),
|
|
20023
|
+
];
|
|
20024
|
+
}
|
|
20025
|
+
const COLLAB_REQUEST_HEADERS = ["ID", "SUBJECT", "GATE", "STATUS", "REQUESTED BY", "AGE"];
|
|
20026
|
+
function formatCollabRequestList(data) {
|
|
20027
|
+
const styles = collabStyles();
|
|
20028
|
+
const requests = Array.isArray(data.requests) ? data.requests.filter(isRecord) : [];
|
|
20029
|
+
const lines = ["", styles.bold(`Approval requests (${requests.length})`)];
|
|
20030
|
+
if (requests.length === 0) {
|
|
20031
|
+
lines.push(` ${styles.dim(collabText(data.hint, "No approval requests match those filters."))}`);
|
|
20032
|
+
}
|
|
20033
|
+
else {
|
|
20034
|
+
lines.push(...renderTextTable(COLLAB_REQUEST_HEADERS, requests.map(collabRequestRow)));
|
|
20035
|
+
}
|
|
20036
|
+
lines.push(...collabTrailer(data));
|
|
20037
|
+
lines.push("");
|
|
20038
|
+
return lines.join("\n");
|
|
20039
|
+
}
|
|
20040
|
+
/**
|
|
20041
|
+
* The daily surface. Two sections and nothing else: what needs a decision from
|
|
20042
|
+
* this reader, and where they were named. An item the reader cannot act on has
|
|
20043
|
+
* no business being here, so there is no "everything else" section.
|
|
20044
|
+
*/
|
|
20045
|
+
function formatCollabInbox(data) {
|
|
20046
|
+
const styles = collabStyles();
|
|
20047
|
+
const pending = Array.isArray(data.pending_approvals) ? data.pending_approvals.filter(isRecord) : [];
|
|
20048
|
+
const mentions = Array.isArray(data.mentions) ? data.mentions.filter(isRecord) : [];
|
|
20049
|
+
const lines = ["", styles.bold(`Waiting on you (${pending.length})`)];
|
|
20050
|
+
if (pending.length === 0) {
|
|
20051
|
+
lines.push(` ${styles.dim("No approvals are assigned to you.")}`);
|
|
20052
|
+
}
|
|
20053
|
+
else {
|
|
20054
|
+
lines.push(...renderTextTable(COLLAB_REQUEST_HEADERS, pending.map(collabRequestRow)));
|
|
20055
|
+
}
|
|
20056
|
+
lines.push("", styles.bold(`Threads that name you (${mentions.length})`));
|
|
20057
|
+
if (mentions.length === 0) {
|
|
20058
|
+
lines.push(` ${styles.dim("Nobody has mentioned you in an open thread.")}`);
|
|
20059
|
+
}
|
|
20060
|
+
else {
|
|
20061
|
+
lines.push(...renderTextTable(["THREAD", "SUBJECT", "LAST COMMENT", "AGE"], mentions.map((thread) => {
|
|
20062
|
+
const comments = Array.isArray(thread.comments) ? thread.comments.filter(isRecord) : [];
|
|
20063
|
+
const last = comments[comments.length - 1];
|
|
20064
|
+
return [
|
|
20065
|
+
collabText(thread.id),
|
|
20066
|
+
collabText(thread.subject),
|
|
20067
|
+
collabSnippet(last?.body, 44) || collabText(thread.title, "—"),
|
|
20068
|
+
formatCollabAge(thread.updated_at ?? thread.created_at),
|
|
20069
|
+
];
|
|
20070
|
+
})));
|
|
20071
|
+
}
|
|
20072
|
+
lines.push(...collabTrailer(data));
|
|
20073
|
+
lines.push("");
|
|
20074
|
+
return lines.join("\n");
|
|
20075
|
+
}
|
|
20076
|
+
/**
|
|
20077
|
+
* `approvals show`. The reader here usually arrived from a link in an email and
|
|
20078
|
+
* knows nothing but the id, so this answers the four questions a table cell
|
|
20079
|
+
* cannot: what is being asked, on what, who owes the answer, and whether the
|
|
20080
|
+
* request can still be decided. An expired request says so in the headline —
|
|
20081
|
+
* `approvals decide` on it is refused by the API.
|
|
20082
|
+
*/
|
|
20083
|
+
function formatCollabRequestDetail(data) {
|
|
20084
|
+
const styles = collabStyles();
|
|
20085
|
+
const request = isRecord(data.request) ? data.request : data;
|
|
20086
|
+
const status = collabText(request.status);
|
|
20087
|
+
const lapsed = request.lapsed === true;
|
|
20088
|
+
const badge = lapsed
|
|
20089
|
+
? styles.yellow("[EXPIRED]")
|
|
20090
|
+
: request.opens_gate === true
|
|
20091
|
+
? styles.green("[APPROVED]")
|
|
20092
|
+
: status === "pending"
|
|
20093
|
+
? styles.dim("[PENDING]")
|
|
20094
|
+
: styles.yellow(`[${status.toUpperCase()}]`);
|
|
20095
|
+
const assignees = Array.isArray(request.assignee_emails)
|
|
20096
|
+
? request.assignee_emails.filter((value) => typeof value === "string")
|
|
20097
|
+
: [];
|
|
20098
|
+
const lines = [
|
|
20099
|
+
"",
|
|
20100
|
+
` ${badge} ${collabText(request.title, "Approval request")}`,
|
|
20101
|
+
` ${styles.dim("Request")} ${collabText(request.id)}`,
|
|
20102
|
+
` ${styles.dim("Subject")} ${collabText(request.subject)}`,
|
|
20103
|
+
` ${styles.dim("Gate")} ${collabText(request.gate_kind)} — ${collabText(request.gate_meaning, "")}`,
|
|
20104
|
+
` ${styles.dim("Asked by")} ${collabText(request.requested_by_email, "someone")}`,
|
|
20105
|
+
` ${styles.dim("Decides")} ${assignees.length > 0 ? assignees.join(", ") : "—"}`,
|
|
20106
|
+
];
|
|
20107
|
+
const body = collabSnippet(request.body, 72);
|
|
20108
|
+
if (body)
|
|
20109
|
+
lines.push(` ${styles.dim("Asking")} ${body}`);
|
|
20110
|
+
if (typeof request.expires_at === "string") {
|
|
20111
|
+
lines.push(` ${styles.dim("Expires")} ${request.expires_at}${lapsed ? styles.yellow(" — already passed") : ""}`);
|
|
20112
|
+
}
|
|
20113
|
+
const note = collabText(request.decision_note, "");
|
|
20114
|
+
if (note)
|
|
20115
|
+
lines.push(` ${styles.dim("Note")} ${note}`);
|
|
20116
|
+
lines.push(...collabTrailer(data));
|
|
20117
|
+
lines.push("");
|
|
20118
|
+
return lines.join("\n");
|
|
20119
|
+
}
|
|
20120
|
+
/**
|
|
20121
|
+
* `approvals client list`. The column that matters is ACCESS: a blank role cell
|
|
20122
|
+
* means FULL access, not a restricted one, and reading it the other way is how
|
|
20123
|
+
* an agency convinces itself a client login is limited when it is not.
|
|
20124
|
+
*/
|
|
20125
|
+
function formatCollabMembers(data) {
|
|
20126
|
+
const styles = collabStyles();
|
|
20127
|
+
const members = Array.isArray(data.members) ? data.members.filter(isRecord) : [];
|
|
20128
|
+
const lines = ["", styles.bold(`Workspace members (${members.length})`)];
|
|
20129
|
+
if (members.length === 0) {
|
|
20130
|
+
lines.push(` ${styles.dim("No members resolved for this workspace.")}`);
|
|
20131
|
+
}
|
|
20132
|
+
else {
|
|
20133
|
+
lines.push(...renderTextTable(["EMAIL", "WORKSPACE ROLE", "OXYGEN ROLE", "ACCESS"], members.map((member) => [
|
|
20134
|
+
collabText(member.email, collabText(member.user_id)),
|
|
20135
|
+
collabText(member.workspace_role),
|
|
20136
|
+
member.oxygen_role === "client" ? "client" : "—",
|
|
20137
|
+
collabText(member.access),
|
|
20138
|
+
])));
|
|
20139
|
+
}
|
|
20140
|
+
lines.push(...collabTrailer(data));
|
|
20141
|
+
lines.push("");
|
|
20142
|
+
return lines.join("\n");
|
|
20143
|
+
}
|
|
20144
|
+
function formatCollabRequestWrite(data) {
|
|
20145
|
+
const styles = collabStyles();
|
|
20146
|
+
// The request is nested; the resolved assignee emails are not.
|
|
20147
|
+
const request = isRecord(data.request) ? data.request : data;
|
|
20148
|
+
const assignees = Array.isArray(data.assignee_emails)
|
|
20149
|
+
? data.assignee_emails.filter((value) => typeof value === "string")
|
|
20150
|
+
: [];
|
|
20151
|
+
const lines = [
|
|
20152
|
+
"",
|
|
20153
|
+
` ${styles.green("[OK]")} Asked ${assignees.length > 0 ? assignees.join(", ") : "the assignees"} to decide.`,
|
|
20154
|
+
` ${styles.dim("Request")} ${collabText(request.id)}`,
|
|
20155
|
+
` ${styles.dim("Subject")} ${collabText(request.subject)}`,
|
|
20156
|
+
` ${styles.dim("Gate")} ${collabText(request.gate_kind)} — ${collabText(request.gate_meaning, "")}`,
|
|
20157
|
+
];
|
|
20158
|
+
if (typeof request.expires_at === "string") {
|
|
20159
|
+
lines.push(` ${styles.dim("Expires")} ${request.expires_at} ${styles.dim("(an expired request does not open the gate)")}`);
|
|
20160
|
+
}
|
|
20161
|
+
lines.push(...collabTrailer(data));
|
|
20162
|
+
lines.push("");
|
|
20163
|
+
return lines.join("\n");
|
|
20164
|
+
}
|
|
20165
|
+
/**
|
|
20166
|
+
* A decision's headline is `opens_gate`, never the status alone: "the client
|
|
20167
|
+
* replied" and "the client said yes" are the two states an agency must never
|
|
20168
|
+
* confuse, and `changes_requested` is the one that reads like the second and
|
|
20169
|
+
* behaves like the first.
|
|
20170
|
+
*/
|
|
20171
|
+
function formatCollabDecision(data) {
|
|
20172
|
+
const styles = collabStyles();
|
|
20173
|
+
const request = isRecord(data.request) ? data.request : data;
|
|
20174
|
+
const status = collabText(request.status);
|
|
20175
|
+
const opensGate = request.opens_gate === true || data.gate_opened === true;
|
|
20176
|
+
const lines = [
|
|
20177
|
+
"",
|
|
20178
|
+
` ${opensGate ? styles.green("[APPROVED]") : styles.yellow(`[${status.toUpperCase()}]`)} ${collabText(request.subject)} — request ${collabText(request.id)}`,
|
|
20179
|
+
` ${styles.dim("Gate")} ${opensGate
|
|
20180
|
+
? `open — the ${collabText(request.gate_kind)} gate no longer blocks this.`
|
|
20181
|
+
: `still shut — a ${collabText(request.gate_kind)} gate only opens on --approve.`}`,
|
|
20182
|
+
];
|
|
20183
|
+
const note = collabText(request.decision_note, "");
|
|
20184
|
+
if (note)
|
|
20185
|
+
lines.push(` ${styles.dim("Note")} ${note}`);
|
|
20186
|
+
if (data.already === true)
|
|
20187
|
+
lines.push(` ${styles.dim("Replay of the same decision; nothing changed.")}`);
|
|
20188
|
+
lines.push(...collabTrailer(data));
|
|
20189
|
+
lines.push("");
|
|
20190
|
+
return lines.join("\n");
|
|
20191
|
+
}
|
|
20192
|
+
function formatCollabGateSet(data) {
|
|
20193
|
+
const styles = collabStyles();
|
|
20194
|
+
const policy = isRecord(data.policy) ? data.policy : {};
|
|
20195
|
+
const required = policy.required === true;
|
|
20196
|
+
const scope = collabText(data.scope, "subject") === "workspace_default"
|
|
20197
|
+
? `every ${collabText(policy.subject_kind)}`
|
|
20198
|
+
: `${collabText(policy.subject_kind)}:${collabText(policy.subject_id)}`;
|
|
20199
|
+
const lines = [
|
|
20200
|
+
"",
|
|
20201
|
+
` ${required ? styles.green("[GATE ON]") : styles.dim("[GATE OFF]")} ${collabText(policy.gate_kind)} on ${scope}`,
|
|
20202
|
+
` ${styles.dim("Means")} ${collabText(policy.gate_meaning, "")}`,
|
|
20203
|
+
];
|
|
20204
|
+
lines.push(...collabTrailer(data));
|
|
20205
|
+
lines.push("");
|
|
20206
|
+
return lines.join("\n");
|
|
20207
|
+
}
|
|
20208
|
+
function formatCollabGateList(data) {
|
|
20209
|
+
const styles = collabStyles();
|
|
20210
|
+
const policies = Array.isArray(data.policies) ? data.policies.filter(isRecord) : [];
|
|
20211
|
+
const effective = Array.isArray(data.effective) ? data.effective.filter(isRecord) : [];
|
|
20212
|
+
const lines = ["", styles.bold(`Gate policies (${policies.length})`)];
|
|
20213
|
+
if (policies.length === 0) {
|
|
20214
|
+
lines.push(` ${styles.dim("No gate is required anywhere in this workspace.")}`);
|
|
20215
|
+
}
|
|
20216
|
+
else {
|
|
20217
|
+
lines.push(...renderTextTable(["GATE", "SCOPE", "APPLIES TO", "REQUIRED"], policies.map((policy) => [
|
|
20218
|
+
collabText(policy.gate_kind),
|
|
20219
|
+
collabText(policy.scope),
|
|
20220
|
+
collabText(policy.subject_id, `every ${collabText(policy.subject_kind)}`),
|
|
20221
|
+
policy.required === true ? "yes" : "no",
|
|
20222
|
+
])));
|
|
20223
|
+
}
|
|
20224
|
+
if (effective.length > 0) {
|
|
20225
|
+
lines.push("", styles.bold(`Effective for ${collabText(data.subject)}`));
|
|
20226
|
+
lines.push(...renderTextTable(["GATE", "REQUIRED", "SOURCE", "BLOCKED NOW", "PENDING REQUEST"], effective.map((entry) => [
|
|
20227
|
+
collabText(entry.gate_kind),
|
|
20228
|
+
entry.required === true ? "yes" : "no",
|
|
20229
|
+
collabText(entry.source),
|
|
20230
|
+
entry.blocked === true ? "yes" : "no",
|
|
20231
|
+
collabText(entry.pending_request_id, "—"),
|
|
20232
|
+
])));
|
|
20233
|
+
}
|
|
20234
|
+
lines.push(...collabTrailer(data));
|
|
20235
|
+
lines.push("");
|
|
20236
|
+
return lines.join("\n");
|
|
20237
|
+
}
|
|
19343
20238
|
// `columns reorder` accepts either an absolute --position or a relative
|
|
19344
20239
|
// --before/--after naming a sibling column. The relative form is resolved
|
|
19345
20240
|
// client-side into an absolute index against the table's current column order
|
|
@@ -20899,6 +21794,9 @@ options) {
|
|
|
20899
21794
|
setLimit("total_reads_per_day", options.totalReadsPerDay);
|
|
20900
21795
|
setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
|
|
20901
21796
|
setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
|
|
21797
|
+
setLimit("interactive_min_spacing_seconds", options.interactiveMinSpacingSeconds);
|
|
21798
|
+
setLimit("interactive_spacing_jitter_seconds", options.interactiveSpacingJitterSeconds);
|
|
21799
|
+
setLimit("interactive_sends_per_hour", options.interactiveSendsPerHour);
|
|
20902
21800
|
// Send windows are campaign-scoped; the only per-account time setting is the
|
|
20903
21801
|
// timezone the daily counters reset in.
|
|
20904
21802
|
const workingHours = {};
|