@oxygen-agent/cli 1.1003.12 → 1.1010.644
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/column-decision-options.d.ts +20 -0
- package/dist/column-decision-options.js +54 -0
- package/dist/command-manifest.js +15 -2
- package/dist/functions-commands.js +11 -11
- package/dist/index.js +1222 -159
- package/dist/search-ai-filter-notice.d.ts +17 -0
- package/dist/search-ai-filter-notice.js +38 -0
- package/dist/skills.js +34 -10
- package/dist/util.d.ts +9 -0
- package/dist/util.js +14 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/billing.js +45 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
- package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
- package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
- 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/langfuse.d.ts +44 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
- package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
- package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
- package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.js +8 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
- package/node_modules/@oxygen/shared/package.json +9 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -15,8 +15,10 @@ import { registerFunctionsCommands } from "./functions-commands.js";
|
|
|
15
15
|
import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
|
|
16
16
|
import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
|
|
17
17
|
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, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
|
|
18
|
-
import { PURCHASABLE_PLAN_KEYS } from "@oxygen/shared/billing";
|
|
18
|
+
import { PURCHASABLE_PLAN_KEYS, resolveBasePricingPlan } from "@oxygen/shared/billing";
|
|
19
19
|
import { TAG_COLORS } from "@oxygen/shared/select-options";
|
|
20
|
+
import { readColumnDecisionFlags } from "./column-decision-options.js";
|
|
21
|
+
import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
|
|
20
22
|
import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
|
|
21
23
|
import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
|
|
22
24
|
import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
|
|
@@ -29,12 +31,13 @@ import { waitForCliRun } from "./run-wait.js";
|
|
|
29
31
|
import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readNonNegativeInt, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
|
|
30
32
|
import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
|
|
31
33
|
import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
|
|
34
|
+
import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
|
|
32
35
|
import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
|
|
33
36
|
import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
|
|
34
37
|
import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
|
|
35
38
|
import { resolveCliBinaryName } from "./runtime.js";
|
|
36
39
|
import { updateCli } from "./update.js";
|
|
37
|
-
import { isRecord, readErrorMessage, readOption } from "./util.js";
|
|
40
|
+
import { isRecord, readClearableOption, readErrorMessage, readOption } from "./util.js";
|
|
38
41
|
const AGENT_MODEL_POLICY_HELP = "Model policy JSON: level low (Fast), medium (Balanced, default), or high (Max); credential_mode managed or organization_byok.";
|
|
39
42
|
const AGENT_TOOL_POLICY_HELP = "Tool policy JSON. Effects: read, internal_write, paid, external_write. Ask-before-changes preset: {\"mode\":\"scoped\",\"rules\":[{\"decision\":\"allow\",\"effect\":\"read\"},{\"decision\":\"ask\",\"effect\":\"internal_write\"},{\"decision\":\"ask\",\"effect\":\"paid\"},{\"decision\":\"ask\",\"effect\":\"external_write\"}]}. Full mode requires --confirm-full-access.";
|
|
40
43
|
/**
|
|
@@ -813,6 +816,7 @@ async function handleAsyncAction(command, options, action) {
|
|
|
813
816
|
writeManagedProviderAvailabilityNotice(data);
|
|
814
817
|
writeAvatarWarning(data);
|
|
815
818
|
writeCreditsReceipt(data);
|
|
819
|
+
writeSearchAiFilterTotalsNotice(data);
|
|
816
820
|
}
|
|
817
821
|
catch (error) {
|
|
818
822
|
emitCliFailure(command, error);
|
|
@@ -1052,6 +1056,15 @@ function writeCreditsReceipt(data) {
|
|
|
1052
1056
|
process.stderr.write(byok
|
|
1053
1057
|
? `estimated 0 Oxygen credits for a live run on your own key (${byok})${remaining !== null ? `, ${remaining} available` : ""}\n`
|
|
1054
1058
|
: `estimated ${block.estimated_credits.toLocaleString("en-US")} credits for a live run${remaining !== null ? `, ${remaining} available` : ""}\n`);
|
|
1059
|
+
// Last, where an agent reading the tail of the output sees it: the
|
|
1060
|
+
// estimate above is a ceiling, and only kept posts are charged
|
|
1061
|
+
// (blind acceptance, 2026-09-25: agents read `| tail` and missed it).
|
|
1062
|
+
// The same for what an existing table's saved auto-run adds to this run.
|
|
1063
|
+
for (const key of ["ai_filter_reasons", "standing_auto_run"]) {
|
|
1064
|
+
const summary = asPayloadRecord(data[key])?.summary;
|
|
1065
|
+
if (typeof summary === "string")
|
|
1066
|
+
process.stderr.write(`${summary}\n`);
|
|
1067
|
+
}
|
|
1055
1068
|
}
|
|
1056
1069
|
}
|
|
1057
1070
|
// A disabled workflow is a customer's automation at zero, and `status:
|
|
@@ -1084,6 +1097,11 @@ export function formatDisabledWorkflowNotices(data) {
|
|
|
1084
1097
|
}
|
|
1085
1098
|
return notices;
|
|
1086
1099
|
}
|
|
1100
|
+
function writeSearchAiFilterTotalsNotice(data) {
|
|
1101
|
+
const notice = formatSearchAiFilterTotalsNotice(data);
|
|
1102
|
+
if (notice)
|
|
1103
|
+
process.stderr.write(`${notice}\n`);
|
|
1104
|
+
}
|
|
1087
1105
|
function writeAiPromptPreviewNotice(data) {
|
|
1088
1106
|
const notice = formatAiPromptPreviewNotice(data);
|
|
1089
1107
|
if (notice)
|
|
@@ -1514,6 +1532,11 @@ function writeMaxCreditsHint(error) {
|
|
|
1514
1532
|
// --yes and spend nothing — the spend-flag hint would name flags the
|
|
1515
1533
|
// command does not take. The server message is the signal: it spells out
|
|
1516
1534
|
// "(CLI: --yes)" on those gates.
|
|
1535
|
+
// Amplification grants name their own three flags and have no dry run.
|
|
1536
|
+
if (error.message.includes("--acknowledge-risk")) {
|
|
1537
|
+
process.stderr.write("hint: re-run with --approved --max-credits <n> --acknowledge-risk\n");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1517
1540
|
if (error.message.includes("--yes")) {
|
|
1518
1541
|
process.stderr.write("hint: inspect the preview, then re-run with --yes to approve the permanent deletion\n");
|
|
1519
1542
|
return;
|
|
@@ -1817,6 +1840,14 @@ function parseEnvFile(content) {
|
|
|
1817
1840
|
return entries;
|
|
1818
1841
|
}
|
|
1819
1842
|
function readFileIfPresent(value) {
|
|
1843
|
+
// Help text across these options promises "JSON or @file/path"; a leading
|
|
1844
|
+
// `@` names a file too (2026-09-25 blind eval: `--filters-json @f.json` was
|
|
1845
|
+
// parsed as JSON). An `@…` value that names no file stays literal text.
|
|
1846
|
+
if (value.startsWith("@") && value.length > 1) {
|
|
1847
|
+
const fromAt = readFileIfPresent(value.slice(1));
|
|
1848
|
+
if (fromAt !== value.slice(1))
|
|
1849
|
+
return fromAt;
|
|
1850
|
+
}
|
|
1820
1851
|
const candidate = resolve(value);
|
|
1821
1852
|
try {
|
|
1822
1853
|
return readFileSync(candidate, "utf8");
|
|
@@ -1949,6 +1980,27 @@ function buildCrmObjectAddAttrBody(options) {
|
|
|
1949
1980
|
...(options.asIdentityJson ? { identity: parseJsonObject(options.asIdentityJson) } : {}),
|
|
1950
1981
|
};
|
|
1951
1982
|
}
|
|
1983
|
+
function buildCrmObjectUpdateAttrBody(options) {
|
|
1984
|
+
const name = readOption(options.name);
|
|
1985
|
+
const optionsJson = readOption(options.optionsJson);
|
|
1986
|
+
if (!name && options.required === undefined && !optionsJson) {
|
|
1987
|
+
throw new OxygenError("missing_attribute_change", "Nothing to change. Pass --name <label>, --required / --no-required, and/or --options-json <json>.", { exitCode: 1 });
|
|
1988
|
+
}
|
|
1989
|
+
let choices;
|
|
1990
|
+
if (optionsJson) {
|
|
1991
|
+
const parsed = parseJsonValue(optionsJson, "--options-json");
|
|
1992
|
+
if (!Array.isArray(parsed)) {
|
|
1993
|
+
throw new OxygenError("invalid_options_json", "--options-json must be a JSON array of {value,label,color?}.", { exitCode: 1 });
|
|
1994
|
+
}
|
|
1995
|
+
choices = parsed;
|
|
1996
|
+
}
|
|
1997
|
+
return {
|
|
1998
|
+
...(name ? { display_name: name } : {}),
|
|
1999
|
+
...(options.required !== undefined ? { is_required: options.required } : {}),
|
|
2000
|
+
...(choices !== undefined ? { options: choices } : {}),
|
|
2001
|
+
mode: resolveLiveDryRunMode(options),
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
1952
2004
|
function buildCrmAssertBody(object, options) {
|
|
1953
2005
|
return {
|
|
1954
2006
|
object,
|
|
@@ -2136,6 +2188,7 @@ function buildPublishingPostsListPath(options) {
|
|
|
2136
2188
|
sender_account_id: options.sender,
|
|
2137
2189
|
cursor: options.cursor,
|
|
2138
2190
|
limit: options.limit,
|
|
2191
|
+
order: options.order,
|
|
2139
2192
|
};
|
|
2140
2193
|
for (const [key, value] of Object.entries(filters)) {
|
|
2141
2194
|
const read = readOption(value);
|
|
@@ -2213,10 +2266,84 @@ function buildPublishingPostCreateBody(options) {
|
|
|
2213
2266
|
}
|
|
2214
2267
|
body.ugc_participation_id = ugcParticipationId;
|
|
2215
2268
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2269
|
+
const xContent = buildPublishingXContent(options, { forMerge: false });
|
|
2270
|
+
if (content || xContent)
|
|
2271
|
+
body.content = { ...(content ?? {}), ...(xContent ?? {}) };
|
|
2218
2272
|
return body;
|
|
2219
2273
|
}
|
|
2274
|
+
/**
|
|
2275
|
+
* The X flags as content keys (see the API's X content contract). On update they
|
|
2276
|
+
* become a `content_merge` so the rest of the stored content is kept; there
|
|
2277
|
+
* `--reply-settings everyone` removes the key.
|
|
2278
|
+
*/
|
|
2279
|
+
function buildPublishingXContent(options, mode) {
|
|
2280
|
+
const content = {};
|
|
2281
|
+
const threadPosts = options.threadPost ?? [];
|
|
2282
|
+
const threadMedia = parseKeyedPairs(options.threadMedia ?? [], "--thread-media", "position=media_id");
|
|
2283
|
+
if (threadMedia.length > 0 && threadPosts.length === 0) {
|
|
2284
|
+
throw new OxygenError("invalid_request", "--thread-media needs the thread's posts: add --thread-post for each post after the first.", { exitCode: 2 });
|
|
2285
|
+
}
|
|
2286
|
+
if (threadPosts.length > 0) {
|
|
2287
|
+
const thread = threadPosts.map((text) => ({ text }));
|
|
2288
|
+
for (const [position, mediaId] of threadMedia) {
|
|
2289
|
+
const index = Number(position) - 2;
|
|
2290
|
+
if (!Number.isInteger(index) || index < 0 || index >= thread.length) {
|
|
2291
|
+
throw new OxygenError("invalid_request", `--thread-media ${position}=... names no thread post; positions run 2 to ${thread.length + 1}.`, { exitCode: 2 });
|
|
2292
|
+
}
|
|
2293
|
+
const entry = thread[index];
|
|
2294
|
+
entry.media_asset_ids = [...(entry.media_asset_ids ?? []), mediaId];
|
|
2295
|
+
}
|
|
2296
|
+
content.thread = thread;
|
|
2297
|
+
}
|
|
2298
|
+
const pollOptions = options.pollOption ?? [];
|
|
2299
|
+
const pollDuration = readOption(options.pollDuration);
|
|
2300
|
+
if (pollDuration && pollOptions.length === 0) {
|
|
2301
|
+
throw new OxygenError("invalid_request", "--poll-duration needs the poll's options: add --poll-option 2-4 times.", { exitCode: 2 });
|
|
2302
|
+
}
|
|
2303
|
+
if (pollOptions.length > 0) {
|
|
2304
|
+
content.poll = { options: pollOptions, duration_minutes: pollDuration ? parsePollDurationMinutes(pollDuration) : 1440 };
|
|
2305
|
+
}
|
|
2306
|
+
const replySettings = readOption(options.replySettings);
|
|
2307
|
+
if (replySettings) {
|
|
2308
|
+
const map = { everyone: null, following: "following", mentioned: "mentionedUsers", subscribers: "subscribers" };
|
|
2309
|
+
if (!(replySettings in map)) {
|
|
2310
|
+
throw new OxygenError("invalid_request", "--reply-settings must be everyone, following, mentioned, or subscribers.", { exitCode: 2 });
|
|
2311
|
+
}
|
|
2312
|
+
const value = map[replySettings];
|
|
2313
|
+
if (value)
|
|
2314
|
+
content.reply_settings = value;
|
|
2315
|
+
else if (mode.forMerge)
|
|
2316
|
+
content.reply_settings = null;
|
|
2317
|
+
}
|
|
2318
|
+
const altText = parseKeyedPairs(options.altText ?? [], "--alt-text", "media_id=text");
|
|
2319
|
+
if (altText.length > 0)
|
|
2320
|
+
content.media_alt_text = Object.fromEntries(altText);
|
|
2321
|
+
const photoTags = (options.photoTag ?? []).map((name) => name.trim().replace(/^@/, "")).filter(Boolean);
|
|
2322
|
+
if (photoTags.length > 0)
|
|
2323
|
+
content.media_tagged_usernames = photoTags;
|
|
2324
|
+
return Object.keys(content).length > 0 ? content : null;
|
|
2325
|
+
}
|
|
2326
|
+
/** `key=value` pairs from a repeatable flag; the value may itself contain `=`. */
|
|
2327
|
+
function parseKeyedPairs(entries, flag, shape) {
|
|
2328
|
+
return entries.map((entry) => {
|
|
2329
|
+
const at = entry.indexOf("=");
|
|
2330
|
+
const key = at > 0 ? entry.slice(0, at).trim() : "";
|
|
2331
|
+
const value = at > 0 ? entry.slice(at + 1) : "";
|
|
2332
|
+
if (!key || !value.trim()) {
|
|
2333
|
+
throw new OxygenError("invalid_request", `${flag} takes ${shape}.`, { exitCode: 2 });
|
|
2334
|
+
}
|
|
2335
|
+
return [key, value];
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
/** `30m`, `6h`, `2d`, or a bare number of minutes. */
|
|
2339
|
+
function parsePollDurationMinutes(value) {
|
|
2340
|
+
const match = /^(\d+)\s*([mhd]?)$/i.exec(value.trim());
|
|
2341
|
+
if (!match) {
|
|
2342
|
+
throw new OxygenError("invalid_request", "--poll-duration takes a number of minutes or 30m, 6h, 2d.", { exitCode: 2 });
|
|
2343
|
+
}
|
|
2344
|
+
const unit = match[2].toLowerCase();
|
|
2345
|
+
return Number(match[1]) * (unit === "d" ? 1440 : unit === "h" ? 60 : 1);
|
|
2346
|
+
}
|
|
2220
2347
|
function buildPublishingPostUpdateBody(options) {
|
|
2221
2348
|
const body = {};
|
|
2222
2349
|
const sender = readOption(options.sender);
|
|
@@ -2249,8 +2376,89 @@ function buildPublishingPostUpdateBody(options) {
|
|
|
2249
2376
|
body.status = status;
|
|
2250
2377
|
if (content)
|
|
2251
2378
|
body.content = content;
|
|
2379
|
+
const merge = readOption(options.contentMergeJson);
|
|
2380
|
+
const xContent = buildPublishingXContent(options, { forMerge: true });
|
|
2381
|
+
if (merge || xContent)
|
|
2382
|
+
body.content_merge = { ...(merge ? parseJsonObject(merge) : {}), ...(xContent ?? {}) };
|
|
2383
|
+
if (options.confirmNotPublished)
|
|
2384
|
+
body.confirm_not_published = true;
|
|
2252
2385
|
return body;
|
|
2253
2386
|
}
|
|
2387
|
+
/**
|
|
2388
|
+
* The LinkedIn video cover flags: a media id, a local file uploaded first, or
|
|
2389
|
+
* (update only) a clear. Sent as the top-level `video_thumbnail_media_asset_id`,
|
|
2390
|
+
* which the server merges into content, so the post's attachments are untouched.
|
|
2391
|
+
*/
|
|
2392
|
+
async function withPublishingVideoThumbnailOption(body, options) {
|
|
2393
|
+
const mediaId = readOption(options.videoThumbnail);
|
|
2394
|
+
const filePath = readOption(options.videoThumbnailFile);
|
|
2395
|
+
const chosen = [mediaId, filePath, options.clearVideoThumbnail ? "clear" : undefined].filter(Boolean);
|
|
2396
|
+
if (chosen.length > 1) {
|
|
2397
|
+
throw new OxygenError("invalid_request", "Choose one of --video-thumbnail, --video-thumbnail-file, or --clear-video-thumbnail.", { exitCode: 2 });
|
|
2398
|
+
}
|
|
2399
|
+
if (options.clearVideoThumbnail)
|
|
2400
|
+
return { ...body, video_thumbnail_media_asset_id: null };
|
|
2401
|
+
if (mediaId)
|
|
2402
|
+
return { ...body, video_thumbnail_media_asset_id: mediaId };
|
|
2403
|
+
if (!filePath)
|
|
2404
|
+
return body;
|
|
2405
|
+
const contentType = publishingMediaContentTypeForPath(filePath);
|
|
2406
|
+
if (contentType !== "image/jpeg" && contentType !== "image/png") {
|
|
2407
|
+
throw new OxygenError("invalid_request", "--video-thumbnail-file must be a .jpg, .jpeg, or .png image.", { exitCode: 2 });
|
|
2408
|
+
}
|
|
2409
|
+
const uploaded = await uploadPublishingMediaFile(filePath, { contentType, scheduledPostId: null });
|
|
2410
|
+
const media = uploaded.media && typeof uploaded.media === "object" ? uploaded.media : {};
|
|
2411
|
+
const id = readRecordString(media, "id");
|
|
2412
|
+
if (!id)
|
|
2413
|
+
throw new OxygenError("publishing_media_upload_failed", "The cover image uploaded but no media id came back.", { exitCode: 1 });
|
|
2414
|
+
return { ...body, video_thumbnail_media_asset_id: id };
|
|
2415
|
+
}
|
|
2416
|
+
/** Extension → MIME for the types Publishing accepts; anything else needs --content-type. */
|
|
2417
|
+
function publishingMediaContentTypeForPath(path) {
|
|
2418
|
+
const extension = path.toLowerCase().split(".").pop() ?? "";
|
|
2419
|
+
const types = {
|
|
2420
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp",
|
|
2421
|
+
mp4: "video/mp4", m4v: "video/x-m4v", mov: "video/quicktime", webm: "video/webm",
|
|
2422
|
+
pdf: "application/pdf",
|
|
2423
|
+
};
|
|
2424
|
+
return types[extension] ?? null;
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Presign, PUT the file straight to object storage, then have the server verify
|
|
2428
|
+
* it — the three hops the web composer makes. The body is a file-backed Blob, so
|
|
2429
|
+
* a large video streams from disk instead of being read into memory.
|
|
2430
|
+
*/
|
|
2431
|
+
async function uploadPublishingMediaFile(filePath, options) {
|
|
2432
|
+
const { openAsBlob } = await import("node:fs");
|
|
2433
|
+
const { basename } = await import("node:path");
|
|
2434
|
+
const contentType = options.contentType ?? publishingMediaContentTypeForPath(filePath);
|
|
2435
|
+
if (!contentType) {
|
|
2436
|
+
throw new OxygenError("invalid_request", "Could not tell the file type from its extension. Pass --content-type, such as video/mp4 or image/png.", { details: { path: filePath }, exitCode: 2 });
|
|
2437
|
+
}
|
|
2438
|
+
const file = await openAsBlob(filePath, { type: contentType });
|
|
2439
|
+
const ticket = await requestOxygen("/api/cli/publishing/media/upload-url", {
|
|
2440
|
+
method: "POST",
|
|
2441
|
+
body: {
|
|
2442
|
+
file_name: basename(filePath),
|
|
2443
|
+
content_type: contentType,
|
|
2444
|
+
byte_length: file.size,
|
|
2445
|
+
...(options.scheduledPostId ? { scheduled_post_id: options.scheduledPostId } : {}),
|
|
2446
|
+
},
|
|
2447
|
+
});
|
|
2448
|
+
const uploadUrl = readRecordString(ticket, "upload_url");
|
|
2449
|
+
const completeUrl = readRecordString(ticket, "complete_url");
|
|
2450
|
+
if (!uploadUrl || !completeUrl) {
|
|
2451
|
+
throw new OxygenError("publishing_media_upload_failed", "The server did not return an upload URL for that file.", { exitCode: 1 });
|
|
2452
|
+
}
|
|
2453
|
+
const headers = ticket.upload_headers && typeof ticket.upload_headers === "object"
|
|
2454
|
+
? Object.fromEntries(Object.entries(ticket.upload_headers).map(([key, value]) => [key, String(value)]))
|
|
2455
|
+
: { "content-type": contentType };
|
|
2456
|
+
const put = await fetch(uploadUrl, { method: "PUT", headers, body: file, signal: AbortSignal.timeout(15 * 60_000) });
|
|
2457
|
+
if (!put.ok) {
|
|
2458
|
+
throw new OxygenError("publishing_media_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
|
|
2459
|
+
}
|
|
2460
|
+
return await requestOxygen(completeUrl, { method: "POST", body: {} });
|
|
2461
|
+
}
|
|
2254
2462
|
function buildPublishingMediaUploadUrlBody(options) {
|
|
2255
2463
|
const fileName = readOption(options.fileName);
|
|
2256
2464
|
const contentType = readOption(options.contentType);
|
|
@@ -2565,15 +2773,42 @@ function splitCommaList(value) {
|
|
|
2565
2773
|
return [];
|
|
2566
2774
|
return raw.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
2567
2775
|
}
|
|
2776
|
+
// A list value goes out comma-separated (`accounts=a,b`), which every list param
|
|
2777
|
+
// in the publishing routes reads.
|
|
2568
2778
|
function buildPublishingAnalyticsPath(base, params) {
|
|
2569
2779
|
const query = new URLSearchParams();
|
|
2780
|
+
const lists = [];
|
|
2570
2781
|
for (const [key, value] of Object.entries(params)) {
|
|
2571
|
-
if (value)
|
|
2572
|
-
|
|
2782
|
+
if (typeof value === "string") {
|
|
2783
|
+
if (value)
|
|
2784
|
+
query.set(key, value);
|
|
2785
|
+
}
|
|
2786
|
+
else if (value && value.length > 0) {
|
|
2787
|
+
lists.push(`${encodeURIComponent(key)}=${value.map(encodeURIComponent).join(",")}`);
|
|
2788
|
+
}
|
|
2573
2789
|
}
|
|
2574
|
-
const suffix = query.toString();
|
|
2790
|
+
const suffix = [query.toString(), ...lists].filter(Boolean).join("&");
|
|
2575
2791
|
return suffix ? `${base}?${suffix}` : base;
|
|
2576
2792
|
}
|
|
2793
|
+
const PUBLISHING_ANALYTICS_ACCOUNT_HELP = "An account key from the summary's accounts list, or a LinkedIn sender id from `oxygen senders list`. Repeatable.";
|
|
2794
|
+
/** Repeated and/or comma-separated `--account` values, trimmed and de-duplicated. */
|
|
2795
|
+
function readPublishingAnalyticsAccounts(values) {
|
|
2796
|
+
return [...new Set((values ?? []).flatMap((value) => splitCommaList(value)))];
|
|
2797
|
+
}
|
|
2798
|
+
// Content analytics sums every posting profile unless --account names some. When
|
|
2799
|
+
// more than one posted, the last line a human reads says how to see one of them;
|
|
2800
|
+
// stderr, so stdout stays the payload (the writeDryRunNotice split).
|
|
2801
|
+
function writePublishingAnalyticsAccountHint(data) {
|
|
2802
|
+
const payload = asPayloadRecord(data);
|
|
2803
|
+
const accounts = Array.isArray(payload?.accounts) ? payload.accounts : [];
|
|
2804
|
+
const first = asPayloadRecord(accounts[0]);
|
|
2805
|
+
if (accounts.length < 2 || typeof first?.key !== "string")
|
|
2806
|
+
return;
|
|
2807
|
+
const channel = asPayloadRecord(payload?.scope)?.channel;
|
|
2808
|
+
const channelFlag = typeof channel === "string" && channel !== "linkedin" ? ` --channel ${channel}` : "";
|
|
2809
|
+
const example = typeof first.name === "string" ? `, e.g. ${first.name}` : "";
|
|
2810
|
+
process.stderr.write(`Totals combine ${accounts.length} accounts. Scope to one${example}: oxygen publishing analytics summary${channelFlag} --account ${first.key}\n`);
|
|
2811
|
+
}
|
|
2577
2812
|
function buildPublishingCommentsListPath(options) {
|
|
2578
2813
|
const view = readOption(options.view);
|
|
2579
2814
|
const status = readOption(options.status);
|
|
@@ -2655,8 +2890,15 @@ function requirePublishingApprovalAndCap(approved, maxCredits, action, details)
|
|
|
2655
2890
|
}
|
|
2656
2891
|
return cap;
|
|
2657
2892
|
}
|
|
2893
|
+
/** The LinkedIn-restriction acknowledgement every arming amplification call needs. */
|
|
2894
|
+
function requireAmplificationRiskAcknowledged(acknowledged, action, details) {
|
|
2895
|
+
if (acknowledged === true)
|
|
2896
|
+
return;
|
|
2897
|
+
throw new OxygenError("amplification_risk_acknowledgement_required", `${action} runs automated engagement from connected LinkedIn accounts, which LinkedIn may warn, restrict or close. Pacing reduces that risk but does not remove it. Re-run with --acknowledge-risk.`, { details: { ...details, risk_acknowledged: false }, exitCode: 7 });
|
|
2898
|
+
}
|
|
2658
2899
|
function buildPublishingAmplificationCreateBody(options) {
|
|
2659
2900
|
const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Creating an amplification policy", { name: options.name, scope: options.scope });
|
|
2901
|
+
requireAmplificationRiskAcknowledged(options.acknowledgeRisk, "Creating an amplification policy", { name: options.name, scope: options.scope });
|
|
2660
2902
|
const senders = splitCommaList(options.senders);
|
|
2661
2903
|
const actions = splitCommaList(options.actions);
|
|
2662
2904
|
if (senders.length === 0)
|
|
@@ -2670,6 +2912,7 @@ function buildPublishingAmplificationCreateBody(options) {
|
|
|
2670
2912
|
actions,
|
|
2671
2913
|
max_credits_per_cycle: maxCredits,
|
|
2672
2914
|
approved: true,
|
|
2915
|
+
risk_acknowledged: true,
|
|
2673
2916
|
...publishingAmplificationScope(options),
|
|
2674
2917
|
...publishingAmplificationTunables(options),
|
|
2675
2918
|
};
|
|
@@ -2732,11 +2975,15 @@ function buildPublishingAmplificationUpdateBody(options) {
|
|
|
2732
2975
|
...(senders.length > 0 ? { participant_sender_ids: senders } : {}),
|
|
2733
2976
|
...(actions.length > 0 ? { actions } : {}),
|
|
2734
2977
|
...(maxCredits !== undefined ? { max_credits_per_cycle: maxCredits } : {}),
|
|
2978
|
+
...(options.acknowledgeRisk === true ? { risk_acknowledged: true } : {}),
|
|
2735
2979
|
...publishingAmplificationTunables(options),
|
|
2736
2980
|
};
|
|
2737
2981
|
}
|
|
2738
2982
|
function buildPublishingBoostBody(postId, options) {
|
|
2739
2983
|
const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Boosting a post", { post_id: postId });
|
|
2984
|
+
requireAmplificationRiskAcknowledged(options.acknowledgeRisk, "Boosting a post", {
|
|
2985
|
+
post_id: postId,
|
|
2986
|
+
});
|
|
2740
2987
|
const senders = splitCommaList(options.senders);
|
|
2741
2988
|
const actions = splitCommaList(options.actions);
|
|
2742
2989
|
if (senders.length === 0)
|
|
@@ -2750,6 +2997,7 @@ function buildPublishingBoostBody(postId, options) {
|
|
|
2750
2997
|
actions,
|
|
2751
2998
|
max_credits: maxCredits,
|
|
2752
2999
|
approved: true,
|
|
3000
|
+
risk_acknowledged: true,
|
|
2753
3001
|
...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
|
|
2754
3002
|
...(commentPool ? { comment_pool: commentPool } : {}),
|
|
2755
3003
|
};
|
|
@@ -2837,16 +3085,35 @@ function buildCrmNotesPath(object, rowId, options) {
|
|
|
2837
3085
|
const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/notes`;
|
|
2838
3086
|
return query ? `${base}?${query}` : base;
|
|
2839
3087
|
}
|
|
3088
|
+
/**
|
|
3089
|
+
* One record's tasks, or — with neither argument — every task in the workspace
|
|
3090
|
+
* (Philipp, 2026-09-22). The record scope is BOTH arguments or neither: one
|
|
3091
|
+
* alone is an unfinished command, and guessing the other half would silently
|
|
3092
|
+
* read a different set than the operator asked for.
|
|
3093
|
+
*/
|
|
2840
3094
|
function buildCrmTasksPath(object, rowId, options) {
|
|
3095
|
+
if (Boolean(object) !== Boolean(rowId)) {
|
|
3096
|
+
throw new OxygenError("invalid_request", "Name both the object and the record, or neither: `oxygen crm tasks list companies acme.com` reads one record, `oxygen crm tasks list` reads the whole workspace.", { exitCode: 1 });
|
|
3097
|
+
}
|
|
2841
3098
|
const params = new URLSearchParams();
|
|
2842
|
-
|
|
2843
|
-
|
|
3099
|
+
// The workspace read is "what do I owe anyone": without --status it lists
|
|
3100
|
+
// OPEN work, as its description promises — the blind acceptance run got
|
|
3101
|
+
// cancelled rows back and had to confirm with a second call. A record's own
|
|
3102
|
+
// list keeps every status, the way its tab shows Done and Cancelled groups.
|
|
3103
|
+
// `--status all` asks the workspace read for everything.
|
|
3104
|
+
const status = options.status ?? (object && rowId ? undefined : "open");
|
|
3105
|
+
if (status && status !== "all")
|
|
3106
|
+
params.set("status", status);
|
|
3107
|
+
if (options.assignee)
|
|
3108
|
+
params.set("assignee", options.assignee);
|
|
2844
3109
|
if (options.limit)
|
|
2845
3110
|
params.set("limit", options.limit);
|
|
2846
3111
|
if (options.cursor)
|
|
2847
3112
|
params.set("cursor", options.cursor);
|
|
2848
3113
|
const query = params.toString();
|
|
2849
|
-
const base =
|
|
3114
|
+
const base = object && rowId
|
|
3115
|
+
? `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tasks`
|
|
3116
|
+
: "/api/cli/crm/tasks";
|
|
2850
3117
|
return query ? `${base}?${query}` : base;
|
|
2851
3118
|
}
|
|
2852
3119
|
function buildCrmPipelinePath(options) {
|
|
@@ -4059,13 +4326,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4059
4326
|
throw new OxygenError("confirmation_required", "Refusing to create upsert-key indexes without --confirm.", { exitCode: 1 });
|
|
4060
4327
|
}
|
|
4061
4328
|
const limit = readPositiveInt(options.limit);
|
|
4329
|
+
// `--org` is also a root option; Commander hands it to the root when
|
|
4330
|
+
// both define it, so this subcommand's value arrives empty. Read both
|
|
4331
|
+
// scopes (as backfill-table-order-indexes does) or the filter is lost
|
|
4332
|
+
// and the backfill silently covers every tenant.
|
|
4333
|
+
const organizationId = readOption(options.org) ?? readOption(program.opts().org);
|
|
4062
4334
|
return requestOxygen("/api/cli/db/backfill-upsert-indexes", {
|
|
4063
4335
|
method: "POST",
|
|
4064
4336
|
body: {
|
|
4065
4337
|
apply: Boolean(options.apply),
|
|
4066
4338
|
dry_run: !options.apply,
|
|
4067
4339
|
confirm: Boolean(options.confirm),
|
|
4068
|
-
...(
|
|
4340
|
+
...(organizationId ? { organization_id: organizationId } : {}),
|
|
4069
4341
|
...(limit !== undefined ? { limit } : {}),
|
|
4070
4342
|
},
|
|
4071
4343
|
});
|
|
@@ -4278,7 +4550,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4278
4550
|
}));
|
|
4279
4551
|
program
|
|
4280
4552
|
.command("publishing")
|
|
4281
|
-
.description("Social publishing, performance, and public-comment operations. Start Community triage with `oxygen publishing comments list --view unanswered --json`. Docs: https://oxygen-agent.com/docs/execution/publishing")
|
|
4553
|
+
.description("Social publishing, performance, and public-comment operations. Start Community triage with `oxygen publishing comments list --view unanswered --json`. Teammates engaging on the workspace's own posts is `oxygen publishing amplification --help` (created disabled; enabling needs --approved and --acknowledge-risk). Docs: https://oxygen-agent.com/docs/execution/publishing")
|
|
4282
4554
|
.addCommand(new Command("mentions")
|
|
4283
4555
|
.description("Resolve LinkedIn identities for a publish-faithful post preview.")
|
|
4284
4556
|
.addCommand(new Command("resolve")
|
|
@@ -4302,13 +4574,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4302
4574
|
.addCommand(new Command("posts")
|
|
4303
4575
|
.description("Manage scheduled Publishing posts.")
|
|
4304
4576
|
.addCommand(new Command("list")
|
|
4305
|
-
.description("List scheduled posts.")
|
|
4577
|
+
.description("List scheduled posts by publish time, oldest first; pass --order newest for the latest first. Follow next_cursor for more than one page.")
|
|
4306
4578
|
.option("--status <status>", "Filter by draft, scheduled, queued, publishing, published, failed, or canceled.")
|
|
4307
4579
|
.option("--approval-status <status>", "Filter by draft, needs_approval, approved, or rejected.")
|
|
4308
4580
|
.option("--tag <tag>", "Only posts carrying this workspace tag.")
|
|
4309
4581
|
.option("--provider <provider>", "Filter by provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
|
|
4310
4582
|
.option("--sender <sender_account_id>", "Only posts belonging to this connected sender in the active workspace.")
|
|
4311
|
-
.option("--
|
|
4583
|
+
.option("--order <order>", "oldest (default) or newest publish time first.")
|
|
4584
|
+
.option("--cursor <cursor>", "Continue from next_cursor with the same filters and order.")
|
|
4312
4585
|
.option("--limit <n>", "Maximum posts to return.")
|
|
4313
4586
|
.option("--json", "Print a JSON envelope.")
|
|
4314
4587
|
.action(async (options) => {
|
|
@@ -4322,24 +4595,33 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4322
4595
|
.option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
|
|
4323
4596
|
.option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers. Find it with `integrations list` (each integration's connection); create one with `integrations connect <provider>` (an OAuth redirect URL, or Settings > Connections on the web).")
|
|
4324
4597
|
.option("--title <title>", "Internal title for the queue.")
|
|
4325
|
-
.option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly
|
|
4598
|
+
.option("--text <text>", "Post text. For LinkedIn mentions, use a person's @<public-identifier> directly; company pages cannot be tagged, so write the company name without the @.")
|
|
4326
4599
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
4327
|
-
.option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text.
|
|
4600
|
+
.option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. X options have their own flags (--thread-post, --poll-option, --reply-settings, --alt-text, --photo-tag); raw X API keys can still ride in composio.arguments.")
|
|
4328
4601
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
4329
4602
|
.option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to the LinkedIn sender's own timezone when --sender is set, else UTC.")
|
|
4330
4603
|
.option("--status <status>", "draft or scheduled. Defaults to scheduled. A draft sits outside the queue until `publishing posts approve` schedules and arms it in one step; scheduled is queued for its publish time and still waits for approval.")
|
|
4331
4604
|
.option("--draft", "Create as a draft instead of scheduled.")
|
|
4332
4605
|
.option("--ugc-participation-id <id>", "Also enroll this personal post in one active creator participation. It is always saved as an unapproved draft; omit --approved.")
|
|
4333
4606
|
.option("--approved", "Mark an ordinary post approved for the scheduler. Invalid with --ugc-participation-id.")
|
|
4334
|
-
.option("--
|
|
4335
|
-
.
|
|
4336
|
-
|
|
4607
|
+
.option("--thread-post <text>", "X: text of the next post in a thread (posts 2, 3, ... in order). Repeatable.", collectRepeatable, [])
|
|
4608
|
+
.option("--thread-media <position=media_id>", "X: attach an uploaded media id to thread post N (2 or later), e.g. 2=<media_id>. Repeatable.", collectRepeatable, [])
|
|
4609
|
+
.option("--poll-option <text>", "X: a poll option on the first post (2-4 options, 25 characters each). Repeatable.", collectRepeatable, [])
|
|
4610
|
+
.option("--poll-duration <duration>", "X: how long the poll runs, e.g. 30m, 6h, 2d (5 minutes to 7 days; default 1d).")
|
|
4611
|
+
.option("--reply-settings <who>", "X: who can reply: everyone, following, mentioned, or subscribers.")
|
|
4612
|
+
.option("--alt-text <media_id=text>", "X: image or GIF description for screen readers (up to 1,000 characters). Repeatable.", collectRepeatable, [])
|
|
4613
|
+
.option("--photo-tag <username>", "X: tag an account in the first post's images (up to 10). Repeatable.", collectRepeatable, [])
|
|
4614
|
+
.option("--video-thumbnail <media_id>", "LinkedIn video cover: an uploaded JPEG or PNG media id (`publishing media upload`). Needs a video in media_asset_ids.")
|
|
4615
|
+
.option("--video-thumbnail-file <path>", "Upload a local JPEG or PNG and use it as the LinkedIn video cover.")
|
|
4616
|
+
.option("--json", "Print a JSON envelope.")
|
|
4617
|
+
.action(async (options) => {
|
|
4618
|
+
await handleAsyncAction("publishing posts create", options, async () => requestOxygen("/api/cli/publishing/posts", {
|
|
4337
4619
|
method: "POST",
|
|
4338
|
-
body: buildPublishingPostCreateBody(options),
|
|
4620
|
+
body: await withPublishingVideoThumbnailOption(buildPublishingPostCreateBody(options), options),
|
|
4339
4621
|
}));
|
|
4340
4622
|
}))
|
|
4341
4623
|
.addCommand(new Command("get")
|
|
4342
|
-
.description("Get one scheduled post with resolved media previews and attempt history.")
|
|
4624
|
+
.description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free). Open web_url to preview the post as it will look on LinkedIn or X.")
|
|
4343
4625
|
.argument("<post_id>", "Scheduled post id.")
|
|
4344
4626
|
.option("--json", "Print a JSON envelope.")
|
|
4345
4627
|
.action(async (postId, options) => {
|
|
@@ -4353,18 +4635,30 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4353
4635
|
.option("--sender <sender_account_id>", "LinkedIn sender account id.")
|
|
4354
4636
|
.option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
|
|
4355
4637
|
.option("--title <title>", "Internal title for the queue.")
|
|
4356
|
-
.option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly
|
|
4638
|
+
.option("--text <text>", "Post text. For LinkedIn mentions, use a person's @<public-identifier> directly; company pages cannot be tagged, so write the company name without the @.")
|
|
4357
4639
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
4358
|
-
.option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. This replaces the existing content object.")
|
|
4640
|
+
.option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. This replaces the existing content object; use --content-merge-json or the X flags to change single keys.")
|
|
4359
4641
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
4360
4642
|
.option("--publish-at <iso>", "ISO date-time when the post should publish.")
|
|
4361
4643
|
.option("--timezone <tz>", "Display timezone for the scheduled date.")
|
|
4362
4644
|
.option("--status <status>", "draft or scheduled.")
|
|
4645
|
+
.option("--thread-post <text>", "X: text of the next post in a thread (posts 2, 3, ... in order). Repeatable.", collectRepeatable, [])
|
|
4646
|
+
.option("--thread-media <position=media_id>", "X: attach an uploaded media id to thread post N (2 or later), e.g. 2=<media_id>. Repeatable.", collectRepeatable, [])
|
|
4647
|
+
.option("--poll-option <text>", "X: a poll option on the first post (2-4 options, 25 characters each). Repeatable.", collectRepeatable, [])
|
|
4648
|
+
.option("--poll-duration <duration>", "X: how long the poll runs, e.g. 30m, 6h, 2d (5 minutes to 7 days; default 1d).")
|
|
4649
|
+
.option("--reply-settings <who>", "X: who can reply: everyone, following, mentioned, or subscribers.")
|
|
4650
|
+
.option("--alt-text <media_id=text>", "X: image or GIF description for screen readers (up to 1,000 characters). Repeatable.", collectRepeatable, [])
|
|
4651
|
+
.option("--photo-tag <username>", "X: tag an account in the first post's images (up to 10). Repeatable.", collectRepeatable, [])
|
|
4652
|
+
.option("--content-merge-json <json>", "Merge these keys into the stored content instead of replacing it; a null value removes a key, e.g. '{\"poll\":null}'.")
|
|
4653
|
+
.option("--video-thumbnail <media_id>", "Set the LinkedIn video cover to an uploaded JPEG or PNG media id (`publishing media upload`). The post needs a video attached.")
|
|
4654
|
+
.option("--video-thumbnail-file <path>", "Upload a local JPEG or PNG and set it as the LinkedIn video cover. To pick a frame from the video instead, open the post's web_url and use Cover.")
|
|
4655
|
+
.option("--clear-video-thumbnail", "Remove the LinkedIn video cover.")
|
|
4656
|
+
.option("--confirm-not-published", "Only after you checked the account: the post OXYGEN could not confirm is NOT there. Unlocks a post that failed with publishing_effect_outcome_uncertain; if the post is live, do not use it or it publishes twice.")
|
|
4363
4657
|
.option("--json", "Print a JSON envelope.")
|
|
4364
4658
|
.action(async (postId, options) => {
|
|
4365
|
-
await handleAsyncAction("publishing posts update", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`, {
|
|
4659
|
+
await handleAsyncAction("publishing posts update", options, async () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`, {
|
|
4366
4660
|
method: "PATCH",
|
|
4367
|
-
body: buildPublishingPostUpdateBody(options),
|
|
4661
|
+
body: await withPublishingVideoThumbnailOption(buildPublishingPostUpdateBody(options), options),
|
|
4368
4662
|
}));
|
|
4369
4663
|
}))
|
|
4370
4664
|
.addCommand(new Command("approve")
|
|
@@ -4386,12 +4680,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4386
4680
|
}));
|
|
4387
4681
|
}))
|
|
4388
4682
|
.addCommand(new Command("retry")
|
|
4389
|
-
.description("Move a failed post back to scheduled for another worker attempt.")
|
|
4683
|
+
.description("Move a failed post back to scheduled for another worker attempt. A post that failed with publishing_effect_outcome_uncertain stays locked until you check the account and pass --confirm-not-published.")
|
|
4390
4684
|
.argument("<post_id>", "Scheduled post id.")
|
|
4685
|
+
.option("--confirm-not-published", "Only after you checked the account: the post OXYGEN could not confirm is NOT there. Unlocks a post that failed with publishing_effect_outcome_uncertain; if the post is live, do not use it or it publishes twice.")
|
|
4391
4686
|
.option("--json", "Print a JSON envelope.")
|
|
4392
4687
|
.action(async (postId, options) => {
|
|
4393
4688
|
await handleAsyncAction("publishing posts retry", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/retry`, {
|
|
4394
4689
|
method: "POST",
|
|
4690
|
+
...(options.confirmNotPublished ? { body: { confirm_not_published: true } } : {}),
|
|
4395
4691
|
}));
|
|
4396
4692
|
}))
|
|
4397
4693
|
.addCommand(new Command("uncancel")
|
|
@@ -4442,15 +4738,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4442
4738
|
}));
|
|
4443
4739
|
}))
|
|
4444
4740
|
.addCommand(new Command("boost")
|
|
4445
|
-
.description("Boost ONE post: your teammates' connected accounts react to and comment on it, once. These are REAL public LinkedIn writes from their accounts and they SPEND CREDITS — refused (exit 7) without --approved and --
|
|
4741
|
+
.description("Boost ONE post: your teammates' connected accounts react to and comment on it, once. These are REAL public LinkedIn writes from their accounts and they SPEND CREDITS — refused (exit 7) without --approved, --max-credits and --acknowledge-risk. Creates a post-scoped policy that is already enabled and plans its actions immediately. For a standing rule across many posts, use `publishing amplification create`.")
|
|
4446
4742
|
.argument("<post_id>", "Scheduled post id.")
|
|
4447
4743
|
.requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
|
|
4448
4744
|
.requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
|
|
4449
|
-
.option("--max-actions-per-post <n>", "Hard cap on how many actions are planned.")
|
|
4745
|
+
.option("--max-actions-per-post <n>", "Hard cap on how many actions are planned (at most 12).")
|
|
4450
4746
|
.option("--max-credits <n>", "Credit ceiling for the boost (required — this is a paid action).")
|
|
4451
4747
|
.option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
|
|
4452
4748
|
.option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
|
|
4453
4749
|
.option("--approved", "Confirm the real public engagement. Without it the command refuses and nothing is planned.")
|
|
4750
|
+
.option("--acknowledge-risk", "Acknowledge that LinkedIn may warn, restrict or close accounts used for automated engagement. Required.")
|
|
4454
4751
|
.option("--json", "Print a JSON envelope.")
|
|
4455
4752
|
.action(async (postId, options) => {
|
|
4456
4753
|
await handleAsyncAction("publishing posts boost", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/boost`, {
|
|
@@ -4569,6 +4866,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4569
4866
|
})))
|
|
4570
4867
|
.addCommand(new Command("media")
|
|
4571
4868
|
.description("Manage Publishing media uploads.")
|
|
4869
|
+
.addCommand(new Command("upload")
|
|
4870
|
+
.description("Upload a local image, video, or PDF in one step and print its media id. Attach it with --content-json '{\"media_asset_ids\":[...]}', or use a JPEG/PNG as a LinkedIn video cover with `publishing posts update --video-thumbnail`.")
|
|
4871
|
+
.argument("<path>", "Local file path.")
|
|
4872
|
+
.option("--content-type <type>", "MIME type when the file extension does not say it, such as video/mp4.")
|
|
4873
|
+
.option("--scheduled-post <post_id>", "Optional provenance association; it does not attach the file to the post.")
|
|
4874
|
+
.option("--json", "Print a JSON envelope.")
|
|
4875
|
+
.action(async (filePath, options) => {
|
|
4876
|
+
await handleAsyncAction("publishing media upload", options, () => uploadPublishingMediaFile(filePath, {
|
|
4877
|
+
contentType: readOption(options.contentType) ?? null,
|
|
4878
|
+
scheduledPostId: readOption(options.scheduledPost) ?? null,
|
|
4879
|
+
}));
|
|
4880
|
+
}))
|
|
4572
4881
|
.addCommand(new Command("upload-url")
|
|
4573
4882
|
.description("Create a Publishing media asset and presigned upload URL with required upload headers.")
|
|
4574
4883
|
.requiredOption("--file-name <name>", "Original media filename.")
|
|
@@ -4715,18 +5024,48 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4715
5024
|
.option("--from <date>", "Custom inclusive UTC start date.")
|
|
4716
5025
|
.option("--to <date>", "Custom inclusive UTC end date.")
|
|
4717
5026
|
.option("--limit <n>", "Top posts to return. Defaults to 10, maximum 50.")
|
|
5027
|
+
.option("--sort <metric>", "Rank top posts by engagements (default; reactions + comments + reposts), impressions, reactions, comments, or reposts. `leader` reports the top post's lead over the runner-up and the median.")
|
|
5028
|
+
.option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
|
|
5029
|
+
.option("--json", "Print a JSON envelope.")
|
|
5030
|
+
.action(async (options) => {
|
|
5031
|
+
const accounts = readPublishingAnalyticsAccounts(options.account);
|
|
5032
|
+
let data;
|
|
5033
|
+
await handleAsyncAction("publishing analytics summary", options, async () => {
|
|
5034
|
+
data = await requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
|
|
5035
|
+
channel: readOption(options.channel),
|
|
5036
|
+
range: readOption(options.range),
|
|
5037
|
+
from: readOption(options.from),
|
|
5038
|
+
to: readOption(options.to),
|
|
5039
|
+
limit: readOption(options.limit),
|
|
5040
|
+
sort: readOption(options.sort),
|
|
5041
|
+
accounts,
|
|
5042
|
+
}));
|
|
5043
|
+
return data;
|
|
5044
|
+
});
|
|
5045
|
+
if (!options.json && accounts.length === 0)
|
|
5046
|
+
writePublishingAnalyticsAccountHint(data);
|
|
5047
|
+
}))
|
|
5048
|
+
.addCommand(new Command("timeseries")
|
|
5049
|
+
.description("Impressions and engagements EARNED per day across all tracked posts (not only posts published in the range), with reactions, comments and reposts also reported on their own, whether each is growing or shrinking (against the previous period, or when tracking began inside the range, the second half of its tracked days against the first; `trend.basis` says which and `trend.compared` shows both values), and follower change per LinkedIn account. Days without a tracked post are null, never zero; `estimated` days spread a gain across an unread gap of up to three days. Read-only: no provider call, 0 credits.")
|
|
5050
|
+
.option("--channel <channel>", "Channel: linkedin (default) or x.")
|
|
5051
|
+
.option("--range <range>", "Window: 7d, 30d (default), 90d, or 365d.")
|
|
5052
|
+
.option("--from <date>", "Custom inclusive start date.")
|
|
5053
|
+
.option("--to <date>", "Custom inclusive end date.")
|
|
5054
|
+
.option("--tz <zone>", "IANA time zone for day boundaries, e.g. Europe/Berlin. Defaults to UTC.")
|
|
5055
|
+
.option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
|
|
4718
5056
|
.option("--json", "Print a JSON envelope.")
|
|
4719
5057
|
.action(async (options) => {
|
|
4720
|
-
await handleAsyncAction("publishing analytics
|
|
5058
|
+
await handleAsyncAction("publishing analytics timeseries", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/timeseries", {
|
|
4721
5059
|
channel: readOption(options.channel),
|
|
4722
5060
|
range: readOption(options.range),
|
|
4723
5061
|
from: readOption(options.from),
|
|
4724
5062
|
to: readOption(options.to),
|
|
4725
|
-
|
|
5063
|
+
tz: readOption(options.tz),
|
|
5064
|
+
accounts: readPublishingAnalyticsAccounts(options.account),
|
|
4726
5065
|
})));
|
|
4727
5066
|
}))
|
|
4728
5067
|
.addCommand(new Command("post")
|
|
4729
|
-
.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.")
|
|
5068
|
+
.description("One post's stored daily metric series, totals, growth curve, 24h/7d benchmarks against the workspace median, 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.")
|
|
4730
5069
|
.argument("<post_id>", "Scheduled post id.")
|
|
4731
5070
|
.option("--since <date>", "ISO date to start the series from.")
|
|
4732
5071
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -4734,14 +5073,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4734
5073
|
await handleAsyncAction("publishing analytics post", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) })));
|
|
4735
5074
|
}))
|
|
4736
5075
|
.addCommand(new Command("account")
|
|
4737
|
-
.description("Stored per-account metric series for connected publishing accounts. Read-only: no provider call, 0 credits.")
|
|
4738
|
-
.option("--provider <provider>", "
|
|
5076
|
+
.description("Stored per-account metric series (followers) for connected LinkedIn and X publishing accounts, read once a day in the background. Read-only: no provider call, 0 credits.")
|
|
5077
|
+
.option("--provider <provider>", "Channel to read: linkedin or x. Omit for every connected channel.")
|
|
4739
5078
|
.option("--since <date>", "ISO date to start the series from.")
|
|
5079
|
+
.option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
|
|
4740
5080
|
.option("--json", "Print a JSON envelope.")
|
|
4741
5081
|
.action(async (options) => {
|
|
4742
5082
|
await handleAsyncAction("publishing analytics account", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/account", {
|
|
4743
5083
|
provider: readOption(options.provider),
|
|
4744
5084
|
since: readOption(options.since),
|
|
5085
|
+
accounts: readPublishingAnalyticsAccounts(options.account),
|
|
4745
5086
|
})));
|
|
4746
5087
|
}))
|
|
4747
5088
|
.addCommand(new Command("emv")
|
|
@@ -4754,7 +5095,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4754
5095
|
body: { cpm: parseJsonObject(options.cpmJson ?? "") },
|
|
4755
5096
|
}));
|
|
4756
5097
|
}))
|
|
4757
|
-
.addHelpText("after", "\
|
|
5098
|
+
.addHelpText("after", "\nExamples (stored snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel linkedin --range 30d --sort impressions --json\n oxygen publishing analytics timeseries --channel linkedin --range 30d --tz Europe/Berlin --json\n\nTotals, leader and trends combine every posting account; `accounts` lists them and `--account <key>` counts only the ones you name. `top_posts[].account` names each post's author.\nWhy a metric is null: every null carries a reason in `null_reasons`:\n not_exposed_by_provider the rail OXYGEN currently reads through never returns it (`capabilities.metrics.<metric>.reason` names the limit)\n not_synced_yet no background snapshot has landed yet\n not_returned_by_provider snapshots exist but the provider left it out (LinkedIn shows impressions only to the post's author)\nSee also: the oxygen-linkedin-marketing product skill (`oxygen skills install`) for reading LinkedIn performance.\n"))
|
|
4758
5099
|
.addCommand(new Command("import")
|
|
4759
5100
|
.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.")
|
|
4760
5101
|
.requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
|
|
@@ -4777,14 +5118,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4777
5118
|
await handleAsyncAction("publishing amplification list", options, () => requestOxygen("/api/cli/publishing/amplification"));
|
|
4778
5119
|
}))
|
|
4779
5120
|
.addCommand(new Command("create")
|
|
4780
|
-
.description("Create an amplification policy. It arms REAL public engagement from other people's connected accounts and
|
|
5121
|
+
.description("Create an amplification policy. It arms REAL public engagement from other people's connected accounts and, once enabled, draws credits each cycle (creating it spends nothing), so it is refused (exit 7) without --approved, --max-credits and --acknowledge-risk — that grant is recorded with who/when/what scope. The policy is created DISABLED: run `amplification enable` when you actually want it to run.")
|
|
4781
5122
|
.requiredOption("--name <name>", "Policy name.")
|
|
4782
5123
|
.requiredOption("--scope <kind>", "What it amplifies: all, tag, or post.")
|
|
4783
5124
|
.option("--tag <tag>", "Workspace tag to scope to (with --scope tag).")
|
|
4784
5125
|
.option("--post <post_id>", "Post to scope to (with --scope post).")
|
|
4785
5126
|
.requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
|
|
4786
5127
|
.requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
|
|
4787
|
-
.option("--max-actions-per-post <n>", "Hard cap on actions per post.")
|
|
5128
|
+
.option("--max-actions-per-post <n>", "Hard cap on actions per post (at most 12).")
|
|
4788
5129
|
.option("--max-credits <n>", "Hard credit cap per cycle (required — this policy spends credits).")
|
|
4789
5130
|
.option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
|
|
4790
5131
|
.option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
|
|
@@ -4793,6 +5134,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4793
5134
|
.option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
|
|
4794
5135
|
.option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
|
|
4795
5136
|
.option("--approved", "Grant the standing permission. Without it the command refuses and creates nothing.")
|
|
5137
|
+
.option("--acknowledge-risk", "Acknowledge that LinkedIn may warn, restrict or close accounts used for automated engagement. Required.")
|
|
4796
5138
|
.option("--json", "Print a JSON envelope.")
|
|
4797
5139
|
.action(async (options) => {
|
|
4798
5140
|
await handleAsyncAction("publishing amplification create", options, () => requestOxygen("/api/cli/publishing/amplification", {
|
|
@@ -4813,7 +5155,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4813
5155
|
.option("--name <name>", "Policy name.")
|
|
4814
5156
|
.option("--senders <ids>", "Comma-separated connected sender account ids.")
|
|
4815
5157
|
.option("--actions <list>", "Comma-separated actions: reaction, comment.")
|
|
4816
|
-
.option("--max-actions-per-post <n>", "Hard cap on actions per post.")
|
|
5158
|
+
.option("--max-actions-per-post <n>", "Hard cap on actions per post (at most 12).")
|
|
4817
5159
|
.option("--max-credits <n>", "Hard credit cap per cycle.")
|
|
4818
5160
|
.option("--comment-pool <text>", "A comment to draw from. Repeatable.", collectRepeatable, [])
|
|
4819
5161
|
.option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
|
|
@@ -4821,6 +5163,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4821
5163
|
.option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
|
|
4822
5164
|
.option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
|
|
4823
5165
|
.option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
|
|
5166
|
+
.option("--acknowledge-risk", "Record a fresh LinkedIn-restriction risk acknowledgement on the policy.")
|
|
4824
5167
|
.option("--json", "Print a JSON envelope.")
|
|
4825
5168
|
.action(async (policyId, options) => {
|
|
4826
5169
|
await handleAsyncAction("publishing amplification update", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
|
|
@@ -4829,16 +5172,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4829
5172
|
}));
|
|
4830
5173
|
}))
|
|
4831
5174
|
.addCommand(new Command("enable")
|
|
4832
|
-
.description("Arm a policy. From here the worker engages on every post in scope from the participants' real accounts, within the caps — so this refuses (exit 7) without --approved. Nothing else in the policy changes.")
|
|
5175
|
+
.description("Arm a policy. From here the worker engages on every post in scope from the participants' real accounts, within the caps — so this refuses (exit 7) without --approved and --acknowledge-risk. Nothing else in the policy changes.")
|
|
4833
5176
|
.argument("<policy_id>", "Amplification policy id.")
|
|
4834
5177
|
.option("--approved", "Confirm arming the standing public engagement. Without it nothing is armed.")
|
|
5178
|
+
.option("--acknowledge-risk", "Acknowledge that LinkedIn may warn, restrict or close accounts used for automated engagement. Required.")
|
|
4835
5179
|
.option("--json", "Print a JSON envelope.")
|
|
4836
5180
|
.action(async (policyId, options) => {
|
|
4837
5181
|
await handleAsyncAction("publishing amplification enable", options, () => {
|
|
4838
5182
|
if (options.approved !== true) {
|
|
4839
5183
|
throw new OxygenError("approval_required", "Enabling a policy starts real public engagement from the participants' connected accounts. Re-run with --approved.", { details: { policy_id: policyId }, exitCode: 7 });
|
|
4840
5184
|
}
|
|
4841
|
-
|
|
5185
|
+
requireAmplificationRiskAcknowledged(options.acknowledgeRisk, "Enabling a policy", {
|
|
5186
|
+
policy_id: policyId,
|
|
5187
|
+
});
|
|
5188
|
+
return requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/enable`, { method: "POST", body: { risk_acknowledged: true } });
|
|
4842
5189
|
});
|
|
4843
5190
|
}))
|
|
4844
5191
|
.addCommand(new Command("disable")
|
|
@@ -5100,7 +5447,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5100
5447
|
})));
|
|
5101
5448
|
program
|
|
5102
5449
|
.command("crm")
|
|
5103
|
-
.description("Agent-native CRM object setup, records, automatic enrichment, and metadata commands.")
|
|
5450
|
+
.description("Agent-native CRM object setup, records, automatic enrichment, and metadata commands. Who to contact first today: crm signals leads-today lists people ranked by their strongest intent signal in the last 7 days (website visit > profile view > post reaction > new follower), most recent first within a type.")
|
|
5104
5451
|
.addCommand(new Command("setup")
|
|
5105
5452
|
.description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
|
|
5106
5453
|
.option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
|
|
@@ -5220,6 +5567,30 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5220
5567
|
method: "POST",
|
|
5221
5568
|
body: buildCrmObjectAddAttrBody(options),
|
|
5222
5569
|
}));
|
|
5570
|
+
}))
|
|
5571
|
+
.addCommand(new Command("update-attr")
|
|
5572
|
+
.description("Change one field on a CRM object: rename it, make it required or optional, or replace the choices of a select/status field. The field's key never changes, so prompts and formulas that read it keep working. An Oxygen-shipped field on companies/people/deals can be renamed and its choices changed (its key, type and required flag stay); a field you added is fully yours. Defaults to dry-run.")
|
|
5573
|
+
.argument("<object>", "CRM object slug, such as companies, people, deals, or a custom object like projects.")
|
|
5574
|
+
.argument("<attribute>", "The field's key as `crm describe` lists it, e.g. renewal_date or pipeline_stage.")
|
|
5575
|
+
.option("--name <label>", "New display name for the field.")
|
|
5576
|
+
.option("--required", "Make the field required.")
|
|
5577
|
+
.option("--no-required", "Make the field optional.")
|
|
5578
|
+
.option("--options-json <json>", "Replacement choice list for a select/status field: a JSON array of {value,label,color?}. Keep an existing value to keep the records that hold it.")
|
|
5579
|
+
.option("--dry-run", "Validate the change without applying it.")
|
|
5580
|
+
.option("--live", "Apply the change. Default is dry-run.")
|
|
5581
|
+
.option("--json", "Print a JSON envelope.")
|
|
5582
|
+
.action(async (object, attribute, options, command) => {
|
|
5583
|
+
await handleAsyncAction("crm object update-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "PATCH", body: buildCrmObjectUpdateAttrBody(options) }));
|
|
5584
|
+
}))
|
|
5585
|
+
.addCommand(new Command("remove-attr")
|
|
5586
|
+
.description("Remove a field you added to a CRM object. The column is archived (restorable with `columns restore <table> <key>`, which brings the field back; no cell destroyed), the object stops listing it, and the key is free for a new field at once. Oxygen-shipped fields, the title field, identity fields and relations cannot be removed. Defaults to dry-run.")
|
|
5587
|
+
.argument("<object>", "CRM object slug, such as companies, people, deals, or a custom object like projects.")
|
|
5588
|
+
.argument("<attribute>", "The field's key as `crm describe` lists it.")
|
|
5589
|
+
.option("--dry-run", "Show what would be removed without applying it.")
|
|
5590
|
+
.option("--live", "Remove the field. Default is dry-run.")
|
|
5591
|
+
.option("--json", "Print a JSON envelope.")
|
|
5592
|
+
.action(async (object, attribute, options, command) => {
|
|
5593
|
+
await handleAsyncAction("crm object remove-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "DELETE", body: { mode: resolveLiveDryRunMode(options) } }));
|
|
5223
5594
|
})))
|
|
5224
5595
|
.addCommand(new Command("search")
|
|
5225
5596
|
.description("Search CRM records by identity or record label. Takes ONE query; narrow the objects with --object, not a second argument.")
|
|
@@ -5297,15 +5668,48 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5297
5668
|
await handleAsyncAction("crm files attach", options, async () => {
|
|
5298
5669
|
const { readFile } = await import("node:fs/promises");
|
|
5299
5670
|
const { basename } = await import("node:path");
|
|
5671
|
+
const { createHash } = await import("node:crypto");
|
|
5300
5672
|
const bytes = await readFile(filePath);
|
|
5301
|
-
|
|
5673
|
+
// Presign, PUT straight to object storage, then have the server
|
|
5674
|
+
// verify and attach — the same three hops the web drop zone and
|
|
5675
|
+
// `copilot attach` make. Bytes never ride in the JSON body, whose
|
|
5676
|
+
// 2,000,000-byte ceiling held attachments to ~1.5 MB against a
|
|
5677
|
+
// store that allows 100 MiB.
|
|
5678
|
+
const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`;
|
|
5679
|
+
const ticket = await requestOxygen(`${base}/upload-url`, {
|
|
5302
5680
|
method: "POST",
|
|
5303
5681
|
body: {
|
|
5304
5682
|
name: basename(filePath),
|
|
5305
|
-
|
|
5306
|
-
|
|
5683
|
+
byte_length: bytes.byteLength,
|
|
5684
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
5307
5685
|
},
|
|
5308
5686
|
});
|
|
5687
|
+
const uploadUrl = readRecordString(ticket, "upload_url");
|
|
5688
|
+
const completeUrl = readRecordString(ticket, "complete_url");
|
|
5689
|
+
if (!uploadUrl || !completeUrl) {
|
|
5690
|
+
throw new OxygenError("attachment_upload_failed", "The server did not return an upload URL for that file.", { exitCode: 1 });
|
|
5691
|
+
}
|
|
5692
|
+
const controller = new AbortController();
|
|
5693
|
+
const timer = setTimeout(() => controller.abort(), 300_000);
|
|
5694
|
+
let put;
|
|
5695
|
+
try {
|
|
5696
|
+
put = await fetch(uploadUrl, {
|
|
5697
|
+
method: "PUT",
|
|
5698
|
+
headers: { "content-type": readRecordString(ticket, "upload_content_type") ?? "application/octet-stream" },
|
|
5699
|
+
body: new Uint8Array(bytes),
|
|
5700
|
+
signal: controller.signal,
|
|
5701
|
+
});
|
|
5702
|
+
}
|
|
5703
|
+
finally {
|
|
5704
|
+
clearTimeout(timer);
|
|
5705
|
+
}
|
|
5706
|
+
if (!put.ok) {
|
|
5707
|
+
throw new OxygenError("attachment_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
|
|
5708
|
+
}
|
|
5709
|
+
return requestOxygen(completeUrl, {
|
|
5710
|
+
method: "POST",
|
|
5711
|
+
body: readOption(options.label) ? { label: readOption(options.label) } : {},
|
|
5712
|
+
});
|
|
5309
5713
|
});
|
|
5310
5714
|
}))
|
|
5311
5715
|
.addCommand(new Command("detach")
|
|
@@ -5318,27 +5722,28 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5318
5722
|
}));
|
|
5319
5723
|
})))
|
|
5320
5724
|
.addCommand(new Command("tasks")
|
|
5321
|
-
.description("
|
|
5725
|
+
.description("Reminders and to-dos on CRM records: file a task on a record, list the open work across the whole workspace or one record's, complete it. Distinct from `oxygen voice tasks`, which is the phone queue a rep dials from.")
|
|
5322
5726
|
.addCommand(new Command("list")
|
|
5323
|
-
.description("List one record's
|
|
5324
|
-
.argument("
|
|
5325
|
-
.argument("
|
|
5326
|
-
.option("--status <status>", "
|
|
5727
|
+
.description("List open reminders across the whole workspace, or one record's with <object> <record>. Open work first, then by deadline; --assignee me for yours, --status completed for the done ones. An undated task sorts last inside its group rather than first.")
|
|
5728
|
+
.argument("[object]", "CRM object slug, such as companies or people. Leave both arguments off to read every record's tasks.")
|
|
5729
|
+
.argument("[record]", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
|
|
5730
|
+
.option("--status <status>", "open, completed, cancelled or all. The workspace read defaults to open; a record's own list shows every status.")
|
|
5731
|
+
.option("--assignee <email|me>", "Only this member's tasks. `me` is whoever this key authenticates as.")
|
|
5327
5732
|
.option("--limit <n>", "Maximum tasks to return. Defaults to 50.")
|
|
5328
5733
|
.option("--cursor <cursor>", "Pagination cursor from a previous page.")
|
|
5329
5734
|
.option("--json", "Print a JSON envelope.")
|
|
5330
5735
|
.action(async (object, rowId, options) => {
|
|
5331
|
-
await
|
|
5736
|
+
await handleReadActionWithLens("crm tasks list", options, () => requestOxygen(buildCrmTasksPath(object, rowId, options)), formatCrmTasks);
|
|
5332
5737
|
}))
|
|
5333
5738
|
.addCommand(new Command("add")
|
|
5334
|
-
.description("Create a task on a CRM record. Free and internal, so it writes immediately rather than previewing first.")
|
|
5739
|
+
.description("Create a task on a CRM record. Free and internal, so it writes immediately rather than previewing first. The returned web_url opens THIS task on the record (?tab=tasks&task=<id>) — hand that one to the user, not the bare record link.")
|
|
5335
5740
|
.argument("<object>", "CRM object slug, such as companies or people.")
|
|
5336
5741
|
.argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
|
|
5337
5742
|
.argument("<title>", "What needs doing.")
|
|
5338
5743
|
.option("--body <body>", "Longer description.")
|
|
5339
5744
|
.option("--priority <priority>", "urgent, high, medium or low.")
|
|
5340
5745
|
.option("--due <iso>", "ISO-8601 due timestamp. An unparseable value is rejected, never silently dropped.")
|
|
5341
|
-
.option("--assignee <
|
|
5746
|
+
.option("--assignee <email>", "Workspace member to assign it to, by email (a user id also works). Unassigned means the team, not nobody.")
|
|
5342
5747
|
.option("--json", "Print a JSON envelope.")
|
|
5343
5748
|
.action(async (object, rowId, title, options) => {
|
|
5344
5749
|
await handleAsyncAction("crm tasks add", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tasks`, {
|
|
@@ -5360,25 +5765,25 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5360
5765
|
.option("--status <status>", "open, completed or cancelled.")
|
|
5361
5766
|
.option("--priority <priority>", "urgent, high, medium, low, or an empty string to clear it.")
|
|
5362
5767
|
.option("--due <iso>", "ISO-8601 due timestamp, or an empty string to clear the deadline.")
|
|
5363
|
-
.option("--assignee <
|
|
5768
|
+
.option("--assignee <email>", "Workspace member by email (a user id also works), or an empty string to unassign.")
|
|
5364
5769
|
.option("--json", "Print a JSON envelope.")
|
|
5365
5770
|
.action(async (taskId, options) => {
|
|
5366
5771
|
await handleAsyncAction("crm tasks update", options, () => {
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
const
|
|
5370
|
-
const
|
|
5772
|
+
// Absent flag: leave the field alone. Empty string: clear it.
|
|
5773
|
+
// The two must stay distinguishable all the way to the route.
|
|
5774
|
+
const body = readClearableOption(options.body);
|
|
5775
|
+
const priority = readClearableOption(options.priority);
|
|
5776
|
+
const due = readClearableOption(options.due);
|
|
5777
|
+
const assignee = readClearableOption(options.assignee);
|
|
5371
5778
|
return requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
|
|
5372
5779
|
method: "PATCH",
|
|
5373
5780
|
body: {
|
|
5374
5781
|
...(readOption(options.title) ? { title: readOption(options.title) } : {}),
|
|
5375
5782
|
...(readOption(options.status) ? { status: readOption(options.status) } : {}),
|
|
5376
|
-
...(body !== undefined ? { body
|
|
5377
|
-
...(priority !== undefined ? { priority
|
|
5378
|
-
...(due !== undefined ? { due_at: due
|
|
5379
|
-
...(assignee !== undefined
|
|
5380
|
-
? { assignee_actor_id: assignee === "" ? null : assignee }
|
|
5381
|
-
: {}),
|
|
5783
|
+
...(body !== undefined ? { body } : {}),
|
|
5784
|
+
...(priority !== undefined ? { priority } : {}),
|
|
5785
|
+
...(due !== undefined ? { due_at: due } : {}),
|
|
5786
|
+
...(assignee !== undefined ? { assignee_actor_id: assignee } : {}),
|
|
5382
5787
|
},
|
|
5383
5788
|
});
|
|
5384
5789
|
});
|
|
@@ -5416,7 +5821,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5416
5821
|
await handleAsyncAction("crm notes list", options, () => requestOxygen(buildCrmNotesPath(object, rowId, options)));
|
|
5417
5822
|
}))
|
|
5418
5823
|
.addCommand(new Command("add")
|
|
5419
|
-
.description("Write a note on a CRM record. Free, internal, and editable afterwards, so it writes immediately rather than previewing first.")
|
|
5824
|
+
.description("Write a note on a CRM record. Free, internal, and editable afterwards, so it writes immediately rather than previewing first. The returned web_url opens THIS note on the record (?tab=notes¬e=<id>) — hand that one to the user, not the bare record link.")
|
|
5420
5825
|
.argument("<object>", "CRM object slug, such as companies or people.")
|
|
5421
5826
|
.argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
|
|
5422
5827
|
.argument("<body>", "Note text. Markdown.")
|
|
@@ -5443,7 +5848,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5443
5848
|
.option("--json", "Print a JSON envelope.")
|
|
5444
5849
|
.action(async (noteId, options) => {
|
|
5445
5850
|
await handleAsyncAction("crm notes update", options, () => {
|
|
5446
|
-
|
|
5851
|
+
// Absent --title leaves it; --title "" clears it.
|
|
5852
|
+
const title = readClearableOption(options.title);
|
|
5447
5853
|
return requestOxygen(`/api/cli/crm/notes/${encodeURIComponent(noteId)}`, {
|
|
5448
5854
|
method: "PATCH",
|
|
5449
5855
|
body: {
|
|
@@ -5591,11 +5997,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5591
5997
|
await handleAsyncAction("crm activity timeline", options, () => requestOxygen(buildCrmTimelinePath(object, rowId, options)));
|
|
5592
5998
|
})))
|
|
5593
5999
|
.addCommand(new Command("describe")
|
|
5594
|
-
.description("Describe one configured CRM object.")
|
|
6000
|
+
.description("Describe one configured CRM object: its fields, identities and relationships. Pass --field <key> to read just one field (its name, type, choices, flags) instead of the whole object.")
|
|
5595
6001
|
.argument("<object>", "CRM object slug, such as companies or people.")
|
|
6002
|
+
.option("--field <key>", "Narrow the answer to one field by its key, e.g. --field pipeline_stage. Also accepted as --attribute.")
|
|
6003
|
+
.option("--attribute <key>", "Alias for --field.")
|
|
5596
6004
|
.option("--json", "Print a JSON envelope.")
|
|
5597
6005
|
.action(async (object, options) => {
|
|
5598
|
-
|
|
6006
|
+
const field = readOption(options.field) ?? readOption(options.attribute);
|
|
6007
|
+
await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}${field ? `?attribute=${encodeURIComponent(field)}` : ""}`));
|
|
5599
6008
|
}))
|
|
5600
6009
|
.addCommand(new Command("duplicates")
|
|
5601
6010
|
.description("List ranked duplicate-candidate record pairs for one CRM object (identity-overlap + fuzzy-label). Read-only.")
|
|
@@ -5652,7 +6061,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5652
6061
|
}));
|
|
5653
6062
|
}))
|
|
5654
6063
|
.addCommand(new Command("leads-today")
|
|
5655
|
-
.description("List people to contact today, ranked by their most recent GTM intent signal. Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
|
|
6064
|
+
.description("List people to contact today, ranked by their most recent GTM intent signal. When the output has rankedBy, equal signals are ordered by the company's ICP fit (see each lead's fit). Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
|
|
5656
6065
|
.option("--limit <limit>", "Maximum leads to return. Defaults to 25, max 100.")
|
|
5657
6066
|
.option("--within-days <days>", "Signal look-back window in days. Defaults to 7, max 30.")
|
|
5658
6067
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -5693,11 +6102,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5693
6102
|
await handleAsyncAction("crm automation audit", options, () => requestOxygen(buildCrmAutomationAuditPath(options)));
|
|
5694
6103
|
})))
|
|
5695
6104
|
.addCommand(new Command("sync")
|
|
5696
|
-
.description("
|
|
6105
|
+
.description("Sync or import a connected CRM with Oxygen CRM objects: import records (HubSpot/Attio/Pipedrive), configure a standing two-way sync (HubSpot/Attio), run a reconciliation cycle, and inspect runs and provider links.")
|
|
5697
6106
|
.addCommand(new Command("import")
|
|
5698
|
-
.description("Import provider records into an Oxygen CRM object as a durable workflow run. Defaults to dry-run.")
|
|
5699
|
-
.argument("<provider>", "Connected CRM to import from: hubspot or
|
|
5700
|
-
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies,
|
|
6107
|
+
.description("Import provider records into an Oxygen CRM object (canonical CRM truth) as a durable workflow run. Defaults to dry-run. This pulls the WHOLE object and has no list/segment filter; to import one HubSpot saved list or one Pipedrive saved filter into a new table you can filter and enrich, use `oxygen tables hubspot import` or `oxygen tables pipedrive import` instead.")
|
|
6108
|
+
.argument("<provider>", "Connected CRM to import from: hubspot, attio, or pipedrive.")
|
|
6109
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, attio people|companies, or pipedrive persons|organizations. Pipedrive deals have no natural identity key and are not importable yet.")
|
|
5701
6110
|
.option("--into <object>", "Oxygen CRM object slug to import into. Defaults to the natural mapping.")
|
|
5702
6111
|
.option("--limit <n>", "Max records to pull this run.")
|
|
5703
6112
|
.option("--max-credits <n>", "Volume + external-quota cap (BYOK reads cost 0 Oxygen credits but use your CRM API quota).")
|
|
@@ -5868,7 +6277,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5868
6277
|
})));
|
|
5869
6278
|
program
|
|
5870
6279
|
.command("signals")
|
|
5871
|
-
.description("The Signals primitive: the typed GTM intent-event stream — record signals, list the raw event feed, inspect the type registry, and read the ranked leads-to-contact-today queue.")
|
|
6280
|
+
.description("The Signals primitive: the typed GTM intent-event stream — record signals, list the raw event feed, inspect the type registry, and read the ranked leads-to-contact-today queue. Who to contact first today: signals leads-today.")
|
|
5872
6281
|
.addCommand(new Command("list")
|
|
5873
6282
|
.description("List the raw GTM signal-event feed newest-first (website_visit / profile_view / post_reaction / new_follower), each resolved onto the person it attached to. Read-only.")
|
|
5874
6283
|
.option("--types <types>", "Comma-separated signal types to include. Defaults to all four.")
|
|
@@ -5905,7 +6314,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5905
6314
|
}));
|
|
5906
6315
|
}))
|
|
5907
6316
|
.addCommand(new Command("leads-today")
|
|
5908
|
-
.description("List people to contact today, ranked by their most recent GTM intent signal. Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
|
|
6317
|
+
.description("List people to contact today, ranked by their most recent GTM intent signal. When the output has rankedBy, equal signals are ordered by the company's ICP fit (see each lead's fit). Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
|
|
5909
6318
|
.option("--limit <limit>", "Maximum leads to return. Defaults to 25, max 100.")
|
|
5910
6319
|
.option("--within-days <days>", "Signal look-back window in days. Defaults to 7, max 30.")
|
|
5911
6320
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -5913,15 +6322,21 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5913
6322
|
await handleAsyncAction("signals leads-today", options, () => requestOxygen(buildCrmLeadsTodayPath(options)));
|
|
5914
6323
|
}))
|
|
5915
6324
|
.addCommand(new Command("search")
|
|
5916
|
-
.description("Source EXTERNAL market signals into a table: hiring, technology adoption, funding, acquisitions, and
|
|
6325
|
+
.description("Source EXTERNAL market signals into a table: hiring, technology adoption, funding, acquisitions, news, and public LinkedIn posts by keyword (linkedin_posts). This is the sourcing half of Signals — `signals list` reads the events already captured for your workspace.")
|
|
5917
6326
|
.addCommand(new Command("plan")
|
|
5918
6327
|
.description("Compile a signal-sourcing request into ordered provider routes without provider calls: the chain, per-route applied/dropped filters, credit estimate, table blueprint, and whether the route can be kept LIVE on a cadence. Free.")
|
|
5919
6328
|
.argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
|
|
5920
6329
|
.option("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file.")
|
|
5921
|
-
.requiredOption("--family <family>", "Signal family: hiring, tech, funding, acquisition, news, or
|
|
6330
|
+
.requiredOption("--family <family>", "Signal family: hiring, tech, funding, acquisition, news, job_change, or linkedin_posts (public LinkedIn posts matching keywords).")
|
|
5922
6331
|
.option("--scope <scope>", "market (discover new companies) or watch_list (track companies you name via --domains). Defaults per family.")
|
|
5923
|
-
.option("--target-count <n>", "Desired row count for routing and estimates.")
|
|
5924
|
-
.option("--keywords <csv>", "Comma-separated keywords, e.g. job titles for hiring or technology names for tech.")
|
|
6332
|
+
.option("--target-count <n>", "Desired row count for routing and estimates. linkedin_posts: posts fetched per keyword before the AI filter (default 100), which also sets the Why it matches credit ceiling (1.5 per fetched post).")
|
|
6333
|
+
.option("--keywords <csv>", "Comma-separated keywords, e.g. job titles for hiring or technology names for tech. For linkedin_posts: a post must mention AT LEAST ONE of these (each is its own search, at most 5).")
|
|
6334
|
+
.option("--all-keywords <csv>", "linkedin_posts: a post must mention ALL of these.")
|
|
6335
|
+
.option("--exclude-keywords <csv>", "linkedin_posts: drop posts that mention any of these, e.g. hiring.")
|
|
6336
|
+
.option("--sort <order>", "linkedin_posts: recent (latest first), relevance (top match) or top (most reactions first).")
|
|
6337
|
+
.option("--min-reactions <n>", "linkedin_posts: keep only posts with at least this many reactions.")
|
|
6338
|
+
.option("--posted-by <url>", "linkedin_posts: only posts by this LinkedIn profile or company page URL.")
|
|
6339
|
+
.option("--ai-filter <text>", "linkedin_posts: keep only posts an AI classifier judges to match this description (at most 500 characters). Free, unless the plan returns an ai_filter block: that block names the family it covers and its credits_per_item.")
|
|
5925
6340
|
.option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes.")
|
|
5926
6341
|
.option("--location <text>", "Country or state NAME for providers that filter on a location string.")
|
|
5927
6342
|
.option("--last-days <n>", "Only signals from the last N days (1-3650).")
|
|
@@ -5936,13 +6351,37 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5936
6351
|
method: "POST",
|
|
5937
6352
|
body: readSignalsSearchPlanBody(options, promptArg),
|
|
5938
6353
|
}));
|
|
6354
|
+
}))
|
|
6355
|
+
.addCommand(new Command("preview")
|
|
6356
|
+
.description("Preview public LinkedIn posts for free (family linkedin_posts): up to 25 per keyword, with author, engagement and the --ai-filter applied. Nothing is created or charged. Each --keywords entry is one search against a small hourly search budget shared across OXYGEN, and keywords match their exact words: use a few short terms and let --ai-filter judge meaning. Then signals search run --family linkedin_posts with the same flags creates the table: free, except that with --ai-filter its Why it matches column fills itself for every kept post, quoted by the dry run.")
|
|
6357
|
+
.option("--family <family>", "linkedin_posts, the only family with a free preview.", "linkedin_posts")
|
|
6358
|
+
.option("--keywords <csv>", "A post must mention AT LEAST ONE of these (each is its own search, at most 5).")
|
|
6359
|
+
.option("--all-keywords <csv>", "A post must mention ALL of these.")
|
|
6360
|
+
.option("--exclude-keywords <csv>", "Drop posts that mention any of these, e.g. hiring.")
|
|
6361
|
+
.option("--last-days <n>", "Only posts from the last N days: 1 = 24 hours, up to 7 = week, up to 30 = month.")
|
|
6362
|
+
.option("--sort <order>", "recent (latest first), relevance (top match) or top (most reactions first).")
|
|
6363
|
+
.option("--min-reactions <n>", "Keep only posts with at least this many reactions.")
|
|
6364
|
+
.option("--posted-by <url>", "Only posts by this LinkedIn profile or company page URL.")
|
|
6365
|
+
.option("--ai-filter <text>", "Keep only posts an AI classifier judges to match this description (at most 500 characters). Free, and each kept post shows why it matches (ai_filter_reason).")
|
|
6366
|
+
.option("--filters-json <json-or-file>", "Filters JSON inline or a path to a JSON file; wins over individual flags per top-level filter path.")
|
|
6367
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen's key. user_api_key: your own Up2Data key saved in Connections, which spends your Up2Data searches instead of the shared hourly budget.")
|
|
6368
|
+
.option("--json", "Print a JSON envelope.")
|
|
6369
|
+
.action(async (options) => {
|
|
6370
|
+
await handleAsyncAction("signals search preview", options, () => requestOxygen("/api/cli/signals/search/preview", {
|
|
6371
|
+
method: "POST",
|
|
6372
|
+
body: {
|
|
6373
|
+
family: options.family,
|
|
6374
|
+
filters: readSignalSearchFilters(options) ?? {},
|
|
6375
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
6376
|
+
},
|
|
6377
|
+
}));
|
|
5939
6378
|
}))
|
|
5940
6379
|
.addCommand(new Command("run")
|
|
5941
|
-
.description("Return a dry-run request or queue a live signal-search ingestion run. Live requires --approved and --max-credits. Add --bind-feed --every to also bind a pull feed to the same table in the same call, so the table keeps refilling on a cadence.")
|
|
6380
|
+
.description("Return a dry-run request or queue a live signal-search ingestion run. Live requires --approved and --max-credits (linkedin_posts is free, --approved only, unless --ai-filter is set: its Why it matches reasons cost credits per kept post, quoted by the dry run, or none on the workspace's own OpenRouter key, and a dry-run ai_filter quote prices the filter itself). Add --bind-feed --every to also bind a pull feed to the same table in the same call, so the table keeps refilling on a cadence.")
|
|
5942
6381
|
.argument("[prompt]", "Prompt text or @file (same as --prompt); requires --family. The --prompt flag wins if both are given.")
|
|
5943
6382
|
.option("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file. Requires --family.")
|
|
5944
6383
|
.option("--plan-json <json-or-file>", "Plan JSON returned by signals search plan, or a path to a JSON file.")
|
|
5945
|
-
.option("--family <family>", "Signal family when planning from --prompt: hiring, tech, funding, acquisition, news, or
|
|
6384
|
+
.option("--family <family>", "Signal family when planning from --prompt: hiring, tech, funding, acquisition, news, job_change, or linkedin_posts.")
|
|
5946
6385
|
.option("--scope <scope>", "market or watch_list when planning from --prompt.")
|
|
5947
6386
|
.option("--route-id <id>", "Route id from the plan to execute.")
|
|
5948
6387
|
.option("--tool-id <tool>", "Tool id from the plan to execute.")
|
|
@@ -5951,9 +6390,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5951
6390
|
.option("--upsert-key <key>", "Column key used for live upsert. Defaults to the plan upsert key.")
|
|
5952
6391
|
.option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
|
|
5953
6392
|
.option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to 1.")
|
|
5954
|
-
.option("--max-credits <n>", "
|
|
5955
|
-
.option("--target-count <n>", "Desired row count when planning
|
|
5956
|
-
.option("--keywords <csv>", "Comma-separated keywords when planning from --prompt.")
|
|
6393
|
+
.option("--max-credits <n>", "Credit ceiling for live runs. linkedin_posts needs it only when the dry run quotes credits for the --ai-filter reasons or the filter itself.")
|
|
6394
|
+
.option("--target-count <n>", "Desired row count when planning. linkedin_posts: posts fetched per keyword before the AI filter (default 100); with --ai-filter the Why it matches ceiling is 1.5 credits per fetched post, but only kept posts are charged.")
|
|
6395
|
+
.option("--keywords <csv>", "Comma-separated keywords when planning from --prompt. For linkedin_posts: a post must mention at least one.")
|
|
6396
|
+
.option("--all-keywords <csv>", "linkedin_posts: a post must mention ALL of these, when planning from --prompt.")
|
|
6397
|
+
.option("--exclude-keywords <csv>", "linkedin_posts: drop posts that mention any of these, when planning from --prompt.")
|
|
6398
|
+
.option("--sort <order>", "linkedin_posts: recent, relevance or top, when planning from --prompt.")
|
|
6399
|
+
.option("--min-reactions <n>", "linkedin_posts: at least this many reactions, when planning from --prompt.")
|
|
6400
|
+
.option("--posted-by <url>", "linkedin_posts: only posts by this profile or company page, when planning from --prompt.")
|
|
6401
|
+
.option("--ai-filter <text>", "linkedin_posts: keep only posts an AI classifier judges to match this description (at most 500 characters), when planning from --prompt. Other families only where the plan returns an ai_filter block.")
|
|
5957
6402
|
.option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes when planning from --prompt.")
|
|
5958
6403
|
.option("--location <text>", "Country or state NAME when planning from --prompt.")
|
|
5959
6404
|
.option("--last-days <n>", "Only signals from the last N days when planning from --prompt.")
|
|
@@ -5966,6 +6411,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5966
6411
|
.option("--every <sugar>", CADENCE_FLAG_DESCRIPTION)
|
|
5967
6412
|
.option("--max-credits-per-cycle <n>", "Credit ceiling PER sync cycle for the bound feed.")
|
|
5968
6413
|
.option("--max-rows-per-cycle <n>", "Advisory row ceiling per sync cycle for the bound feed.")
|
|
6414
|
+
.option("--credential-mode <mode>", "linkedin_posts: managed (default) or user_api_key to search on your own Up2Data key saved in Connections, billed by Up2Data. The table's bound feed keeps using it.")
|
|
5969
6415
|
.option("--json", "Print a JSON envelope.")
|
|
5970
6416
|
.action(async (promptArg, options) => {
|
|
5971
6417
|
await handleAsyncAction("signals search run", options, () => requestOxygen("/api/cli/signals/search/run", {
|
|
@@ -5975,10 +6421,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5975
6421
|
})));
|
|
5976
6422
|
const tablesCommand = program
|
|
5977
6423
|
.command("tables")
|
|
5978
|
-
.description("Tenant workspace table
|
|
6424
|
+
.description("Tenant workspace tables: create, import rows (CSV, a link, a HubSpot saved list, a Pipedrive saved filter, Supabase), enrich with columns, and run them. `tables sources` lists every way a table can start.")
|
|
5979
6425
|
.addCommand(new Command("sources")
|
|
5980
6426
|
.description("List every way a table can start — imports, company/people/signal sources, webhooks, functions — with the provider count and per-row credit estimate behind each. Free; nothing is called or written. The same catalog the web New table picker shows.")
|
|
5981
|
-
.option("--group <group>", "Only one group:
|
|
6427
|
+
.option("--group <group>", "Only one group: Companies, People, \"Professional Network\" (or linkedin), Imports, CRM, Signals, or Tables.")
|
|
5982
6428
|
.option("--json", "Print a JSON envelope.")
|
|
5983
6429
|
.action(async (options) => {
|
|
5984
6430
|
await handleAsyncAction("tables sources", options, () => {
|
|
@@ -5990,6 +6436,220 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5990
6436
|
return requestOxygen(`/api/cli/tables/sources${qs}`);
|
|
5991
6437
|
});
|
|
5992
6438
|
}))
|
|
6439
|
+
.addCommand(new Command("hubspot")
|
|
6440
|
+
.description("Import HubSpot contacts or companies into a new table or Records, optionally from a saved list and with do-not-contact suppression.")
|
|
6441
|
+
.addCommand(new Command("lists")
|
|
6442
|
+
.description("List the connected portal's saved contact/company segments and readable identity properties. Reads definitions only; no memberships, rows, or writes. Free — 0 Oxygen credits, uses your HubSpot API quota.")
|
|
6443
|
+
.option("--query <text>", "Optional list-name search.")
|
|
6444
|
+
.option("--connection <id>", "HubSpot connection id; defaults to the first connected account.")
|
|
6445
|
+
.option("--json", "Print a JSON envelope.")
|
|
6446
|
+
.action(async (options) => {
|
|
6447
|
+
await handleAsyncAction("tables hubspot lists", options, () => {
|
|
6448
|
+
const params = new URLSearchParams();
|
|
6449
|
+
const query = readOption(options.query);
|
|
6450
|
+
if (query)
|
|
6451
|
+
params.set("query", query);
|
|
6452
|
+
const connection = readOption(options.connection);
|
|
6453
|
+
if (connection)
|
|
6454
|
+
params.set("connection_id", connection);
|
|
6455
|
+
const suffix = params.toString();
|
|
6456
|
+
return requestOxygen(`/api/cli/tables/hubspot-import${suffix ? `?${suffix}` : ""}`);
|
|
6457
|
+
});
|
|
6458
|
+
}))
|
|
6459
|
+
.addCommand(new Command("import")
|
|
6460
|
+
.description("Preview or import HubSpot contacts/companies into a new table or Records. Named do-not-contact lists must synchronize before any import. Nothing is enrolled or sent. Live requires the exact dry-run fingerprint and approval.")
|
|
6461
|
+
.option("--list <id>", "HubSpot saved-list id. Omit with --object to import all records; discover ids with `oxygen tables hubspot lists`.")
|
|
6462
|
+
.option("--object <object>", "HubSpot object: contacts or companies. Required without --list; defaults to contacts for a saved list.")
|
|
6463
|
+
.option("--destination <destination>", "Import into table (default) or records.")
|
|
6464
|
+
.option("--connection <id>", "HubSpot connection id; defaults to the first connected account.")
|
|
6465
|
+
.option("--sample", "Read up to 50 real records during the dry-run preview; uses HubSpot API quota and makes no writes.")
|
|
6466
|
+
.option("--dnc-list <id>", "HubSpot contact-list id to suppress into do-not-contact before importing.")
|
|
6467
|
+
.option("--company-dnc-list <id>", "Optional HubSpot company-list id to suppress organization-wide.")
|
|
6468
|
+
.option("--no-dnc", "Import without suppressing anything. Required if you pass no --dnc-list, so an unsuppressed import is never accidental.")
|
|
6469
|
+
.option("--name <table>", "Display name for the new table.")
|
|
6470
|
+
.option("--project <project>", "Project id or slug. Defaults to General.")
|
|
6471
|
+
.option("--contact-linkedin-property <name>", "Optional contact property holding a public LinkedIn /in/ URL.")
|
|
6472
|
+
.option("--contact-company-domain-property <name>", "Optional contact property copied to company_domain.")
|
|
6473
|
+
.option("--contact-company-linkedin-property <name>", "Optional contact property copied to company_linkedin_url.")
|
|
6474
|
+
.option("--company-domain-property <name>", "Company domain property (default domain).", "domain")
|
|
6475
|
+
.option("--company-linkedin-property <name>", "Optional company LinkedIn URL property.")
|
|
6476
|
+
.option("--contact-properties <list>", "Comma-separated extra HubSpot contact properties to hydrate (max 50).")
|
|
6477
|
+
.option("--max-records-per-list <n>", "Optional record limit; omit to import all selected records.")
|
|
6478
|
+
.option("--max-credits <n>", "Hard credits cap per delivery.", "10")
|
|
6479
|
+
.option("--once", "Import once instead of refreshing on a schedule.")
|
|
6480
|
+
.option("--cron <expression>", "Refresh schedule; default every 15 minutes.")
|
|
6481
|
+
.option("--timezone <iana>", "Schedule timezone; default UTC.")
|
|
6482
|
+
.option("--reviewed-fingerprint <hash>", "Exact dry-run fingerprint, required for live.")
|
|
6483
|
+
.option("--idempotency-key <key>", "Optional first-run idempotency key.")
|
|
6484
|
+
.option("--live", "Create the import and queue the first durable run.")
|
|
6485
|
+
.option("--approved", "Approve the reviewed reads, DNC writes, and destination upserts.")
|
|
6486
|
+
.option("--json", "Print a JSON envelope.")
|
|
6487
|
+
.action(async (options) => {
|
|
6488
|
+
await handleAsyncAction("tables hubspot import", options, () => {
|
|
6489
|
+
const list = readOption(options.list);
|
|
6490
|
+
const object = readOption(options.object);
|
|
6491
|
+
const destination = readOption(options.destination);
|
|
6492
|
+
if (!list && !object)
|
|
6493
|
+
throw new OxygenError("invalid_request", "Pass --list or --object contacts|companies to select what to import.", { exitCode: 1 });
|
|
6494
|
+
if (object && object !== "contacts" && object !== "companies")
|
|
6495
|
+
throw new OxygenError("invalid_request", "--object must be contacts or companies.", { exitCode: 1 });
|
|
6496
|
+
if (destination && destination !== "table" && destination !== "records")
|
|
6497
|
+
throw new OxygenError("invalid_request", "--destination must be table or records.", { exitCode: 1 });
|
|
6498
|
+
const dncList = readOption(options.dncList);
|
|
6499
|
+
const companyDncList = readOption(options.companyDncList);
|
|
6500
|
+
// Commander maps `--no-dnc` to `dnc: false`; the default is
|
|
6501
|
+
// `true`, so an unset flag is indistinguishable from an opt-in.
|
|
6502
|
+
// Requiring one of the two means an import that suppresses
|
|
6503
|
+
// nothing is always something the operator typed, which is the
|
|
6504
|
+
// same guarantee the web lane gets from its preview fingerprint.
|
|
6505
|
+
const optedOut = options.dnc === false;
|
|
6506
|
+
if (!dncList && !companyDncList && !optedOut) {
|
|
6507
|
+
// invalid_request, not a bare Error: handleAsyncAction reports
|
|
6508
|
+
// an uncoded throw as `unexpected_error`, which tells a calling
|
|
6509
|
+
// agent that OXYGEN broke when the caller simply omitted a flag.
|
|
6510
|
+
throw new OxygenError("invalid_request", "Pass --dnc-list (and/or --company-dnc-list) to suppress a HubSpot list, or --no-dnc to import without suppressing anything. Run `oxygen tables hubspot lists` to see the ids your portal exposes.", { exitCode: 1 });
|
|
6511
|
+
}
|
|
6512
|
+
if (optedOut && (dncList || companyDncList)) {
|
|
6513
|
+
throw new OxygenError("invalid_request", "--no-dnc cannot be combined with --dnc-list or --company-dnc-list.", { exitCode: 1 });
|
|
6514
|
+
}
|
|
6515
|
+
const maxRecords = readPositiveInt(options.maxRecordsPerList);
|
|
6516
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
6517
|
+
if (options.maxRecordsPerList !== undefined && (maxRecords === undefined || !Number.isSafeInteger(maxRecords)))
|
|
6518
|
+
throw new OxygenError("invalid_request", "--max-records-per-list must be a positive integer.", { exitCode: 1 });
|
|
6519
|
+
if (maxCredits === undefined)
|
|
6520
|
+
throw new OxygenError("spend_cap_required", "--max-credits must be positive.", { exitCode: 1 });
|
|
6521
|
+
const fingerprint = readOption(options.reviewedFingerprint);
|
|
6522
|
+
if (options.live && !fingerprint)
|
|
6523
|
+
throw new OxygenError("approval_required", "Live import requires --reviewed-fingerprint from the exact dry-run. Run the same command without --live first.", { exitCode: 1 });
|
|
6524
|
+
const contactProperties = readOption(options.contactProperties)
|
|
6525
|
+
?.split(",")
|
|
6526
|
+
.map((entry) => entry.trim())
|
|
6527
|
+
.filter(Boolean) ?? [];
|
|
6528
|
+
return requestOxygen("/api/cli/tables/hubspot-import", {
|
|
6529
|
+
method: "POST",
|
|
6530
|
+
body: {
|
|
6531
|
+
lead_list_id: list ?? null,
|
|
6532
|
+
...(object ? { object } : {}),
|
|
6533
|
+
...(destination ? { destination } : {}),
|
|
6534
|
+
...(readOption(options.connection) ? { connection_id: readOption(options.connection) } : {}),
|
|
6535
|
+
...(options.sample ? { sample: true } : {}),
|
|
6536
|
+
dnc_list_id: dncList ?? null,
|
|
6537
|
+
company_dnc_list_id: companyDncList ?? null,
|
|
6538
|
+
table_name: readOption(options.name) ?? null,
|
|
6539
|
+
project: readOption(options.project) ?? null,
|
|
6540
|
+
contact_linkedin_property: readOption(options.contactLinkedinProperty) ?? null,
|
|
6541
|
+
contact_company_domain_property: readOption(options.contactCompanyDomainProperty) ?? null,
|
|
6542
|
+
contact_company_linkedin_property: readOption(options.contactCompanyLinkedinProperty) ?? null,
|
|
6543
|
+
company_domain_property: readOption(options.companyDomainProperty) ?? "domain",
|
|
6544
|
+
company_linkedin_property: readOption(options.companyLinkedinProperty) ?? null,
|
|
6545
|
+
contact_properties: contactProperties,
|
|
6546
|
+
max_records_per_list: maxRecords ?? null,
|
|
6547
|
+
refresh: options.once ? "once" : "scheduled",
|
|
6548
|
+
cron: readOption(options.cron) ?? undefined,
|
|
6549
|
+
timezone: readOption(options.timezone) ?? undefined,
|
|
6550
|
+
max_credits: maxCredits,
|
|
6551
|
+
mode: options.live ? "live" : "dry_run",
|
|
6552
|
+
...(options.approved ? { approved: true } : {}),
|
|
6553
|
+
...(fingerprint ? { reviewed_fingerprint: fingerprint } : {}),
|
|
6554
|
+
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
6555
|
+
},
|
|
6556
|
+
});
|
|
6557
|
+
});
|
|
6558
|
+
})))
|
|
6559
|
+
.addCommand(new Command("pipedrive")
|
|
6560
|
+
.description("Import a Pipedrive saved people filter into a new Oxygen table, optionally suppressing a Pipedrive do-not-contact filter first. Connect Pipedrive with an API token first: `oxygen integrations connect pipedrive --api-key <token>`.")
|
|
6561
|
+
.addCommand(new Command("filters")
|
|
6562
|
+
.description("List the connected Pipedrive account's saved people/organization filters and its person/organization fields (custom-field keys included). Reads definitions only; no persons, rows, or writes. Free — 0 Oxygen credits, uses your Pipedrive API quota.")
|
|
6563
|
+
.option("--query <text>", "Optional filter-name search.")
|
|
6564
|
+
.option("--json", "Print a JSON envelope.")
|
|
6565
|
+
.action(async (options) => {
|
|
6566
|
+
await handleAsyncAction("tables pipedrive filters", options, () => {
|
|
6567
|
+
const params = new URLSearchParams();
|
|
6568
|
+
const query = readOption(options.query);
|
|
6569
|
+
if (query)
|
|
6570
|
+
params.set("query", query);
|
|
6571
|
+
const suffix = params.toString();
|
|
6572
|
+
return requestOxygen(`/api/cli/tables/pipedrive-import${suffix ? `?${suffix}` : ""}`);
|
|
6573
|
+
});
|
|
6574
|
+
}))
|
|
6575
|
+
.addCommand(new Command("import")
|
|
6576
|
+
.description("Preview or run a Pipedrive saved people filter into a NEW table. Any do-not-contact filter you name is synchronized first and no row lands unless it fully succeeds. Nothing is ever enrolled or sent. Live requires the exact dry-run fingerprint and approval.")
|
|
6577
|
+
.requiredOption("--filter <id>", "Pipedrive people-filter id whose persons become rows. Run `oxygen tables pipedrive filters` to see the ids your account exposes.")
|
|
6578
|
+
.option("--dnc-filter <id>", "Pipedrive people-filter id to suppress into do-not-contact before importing (a filter on Marketing status = Unsubscribed is the usual choice).")
|
|
6579
|
+
.option("--org-dnc-filter <id>", "Optional Pipedrive organization-filter id to suppress organization-wide.")
|
|
6580
|
+
.option("--no-dnc", "Import without suppressing anything. Required if you pass no --dnc-filter, so an unsuppressed import is never accidental.")
|
|
6581
|
+
.option("--name <table>", "Display name for the new table.")
|
|
6582
|
+
.option("--project <project>", "Project id or slug. Defaults to General.")
|
|
6583
|
+
.option("--person-linkedin-field <key>", "Optional person field key holding a public LinkedIn /in/ URL (custom keys are 40-character hex; see `tables pipedrive filters`).")
|
|
6584
|
+
.option("--person-title-field <key>", "Optional person field key copied to title. Pipedrive has no native job-title field.")
|
|
6585
|
+
.option("--org-domain-field <key>", "Optional organization field key holding the website or domain. Pipedrive has no native domain field; never inferred from email.")
|
|
6586
|
+
.option("--org-linkedin-field <key>", "Optional organization field key holding its LinkedIn company page.")
|
|
6587
|
+
.option("--person-fields <list>", "Comma-separated extra Pipedrive person field keys to hydrate into pipedrive_fields (max 15).")
|
|
6588
|
+
.option("--no-hydrate-organizations", "Skip reading each person's organization; company columns stay empty.")
|
|
6589
|
+
.option("--max-records-per-filter <n>", "Complete-snapshot cap (1-5000), enforced while the filter is read.", "5000")
|
|
6590
|
+
.option("--max-credits <n>", "Hard credits cap per delivery.", "10")
|
|
6591
|
+
.option("--once", "Import once instead of refreshing on a schedule.")
|
|
6592
|
+
.option("--cron <expression>", "Refresh schedule; default every 15 minutes.")
|
|
6593
|
+
.option("--timezone <iana>", "Schedule timezone; default UTC.")
|
|
6594
|
+
.option("--reviewed-fingerprint <hash>", "Exact dry-run fingerprint, required for live.")
|
|
6595
|
+
.option("--idempotency-key <key>", "Optional first-run idempotency key.")
|
|
6596
|
+
.option("--live", "Create the table and queue the first durable run.")
|
|
6597
|
+
.option("--approved", "Approve the reviewed bounded reads, DNC writes, and table upserts.")
|
|
6598
|
+
.option("--json", "Print a JSON envelope.")
|
|
6599
|
+
.action(async (options) => {
|
|
6600
|
+
await handleAsyncAction("tables pipedrive import", options, () => {
|
|
6601
|
+
const dncFilter = readOption(options.dncFilter);
|
|
6602
|
+
const orgDncFilter = readOption(options.orgDncFilter);
|
|
6603
|
+
// Same affirmative contract as `tables hubspot import`: an import
|
|
6604
|
+
// that suppresses nothing is always something the operator typed.
|
|
6605
|
+
const optedOut = options.dnc === false;
|
|
6606
|
+
if (!dncFilter && !orgDncFilter && !optedOut) {
|
|
6607
|
+
throw new OxygenError("invalid_request", "Pass --dnc-filter (and/or --org-dnc-filter) to suppress a Pipedrive filter, or --no-dnc to import without suppressing anything. Run `oxygen tables pipedrive filters` to see the ids your account exposes.", { exitCode: 1 });
|
|
6608
|
+
}
|
|
6609
|
+
if (optedOut && (dncFilter || orgDncFilter)) {
|
|
6610
|
+
throw new OxygenError("invalid_request", "--no-dnc cannot be combined with --dnc-filter or --org-dnc-filter.", { exitCode: 1 });
|
|
6611
|
+
}
|
|
6612
|
+
const maxRecords = readPositiveInt(options.maxRecordsPerFilter);
|
|
6613
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
6614
|
+
if (maxRecords === undefined || maxRecords > 5000)
|
|
6615
|
+
throw new OxygenError("invalid_request", "--max-records-per-filter must be between 1 and 5000.", { exitCode: 1 });
|
|
6616
|
+
if (maxCredits === undefined)
|
|
6617
|
+
throw new OxygenError("spend_cap_required", "--max-credits must be positive.", { exitCode: 1 });
|
|
6618
|
+
const fingerprint = readOption(options.reviewedFingerprint);
|
|
6619
|
+
if (options.live && !fingerprint)
|
|
6620
|
+
throw new OxygenError("approval_required", "Live import requires --reviewed-fingerprint from the exact dry-run. Run the same command without --live first.", { exitCode: 1 });
|
|
6621
|
+
const personFields = readOption(options.personFields)
|
|
6622
|
+
?.split(",")
|
|
6623
|
+
.map((entry) => entry.trim())
|
|
6624
|
+
.filter(Boolean) ?? [];
|
|
6625
|
+
const hydrateOrganizations = options.hydrateOrganizations !== false;
|
|
6626
|
+
return requestOxygen("/api/cli/tables/pipedrive-import", {
|
|
6627
|
+
method: "POST",
|
|
6628
|
+
body: {
|
|
6629
|
+
filter_id: readOption(options.filter),
|
|
6630
|
+
dnc_filter_id: dncFilter ?? null,
|
|
6631
|
+
org_dnc_filter_id: orgDncFilter ?? null,
|
|
6632
|
+
table_name: readOption(options.name) ?? null,
|
|
6633
|
+
project: readOption(options.project) ?? null,
|
|
6634
|
+
person_linkedin_field: readOption(options.personLinkedinField) ?? null,
|
|
6635
|
+
person_title_field: readOption(options.personTitleField) ?? null,
|
|
6636
|
+
org_domain_field: readOption(options.orgDomainField) ?? null,
|
|
6637
|
+
org_linkedin_field: readOption(options.orgLinkedinField) ?? null,
|
|
6638
|
+
person_fields: personFields,
|
|
6639
|
+
hydrate_organizations: hydrateOrganizations,
|
|
6640
|
+
max_records_per_filter: maxRecords,
|
|
6641
|
+
refresh: options.once ? "once" : "scheduled",
|
|
6642
|
+
cron: readOption(options.cron) ?? undefined,
|
|
6643
|
+
timezone: readOption(options.timezone) ?? undefined,
|
|
6644
|
+
max_credits: maxCredits,
|
|
6645
|
+
mode: options.live ? "live" : "dry_run",
|
|
6646
|
+
...(options.approved ? { approved: true } : {}),
|
|
6647
|
+
...(fingerprint ? { reviewed_fingerprint: fingerprint } : {}),
|
|
6648
|
+
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
6649
|
+
},
|
|
6650
|
+
});
|
|
6651
|
+
});
|
|
6652
|
+
})))
|
|
5993
6653
|
.addCommand(new Command("create")
|
|
5994
6654
|
.description("Create a real Postgres-backed workspace table. Free — 0 Oxygen credits. To create a table and import a file in one step, use `tables import --create <name>`.")
|
|
5995
6655
|
.argument("<name>", "Display name for the table.")
|
|
@@ -6410,9 +7070,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6410
7070
|
});
|
|
6411
7071
|
}));
|
|
6412
7072
|
const watcherCommand = tablesCommand.command("watcher")
|
|
6413
|
-
.description("
|
|
7073
|
+
.description("Professional Network Post Engagers: one editable table of daily engagers, source attribution, and current employers from posts in the past 7 days. Start with preview (free); create/resume activate paid daily monitoring after exact approval.");
|
|
6414
7074
|
watcherCommand.command("get")
|
|
6415
|
-
.description("Read a table's
|
|
7075
|
+
.description("Read a table's Professional Network Post Engagers configuration and status. Free.")
|
|
6416
7076
|
.argument("<table>", "Watcher table id or slug.")
|
|
6417
7077
|
.option("--json", "Print a JSON envelope.")
|
|
6418
7078
|
.action(async (table, options) => {
|
|
@@ -6422,7 +7082,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6422
7082
|
const command = watcherCommand.command(action)
|
|
6423
7083
|
.description({
|
|
6424
7084
|
preview: "Free configuration and credit preview: no provider calls, writes, or scheduling. Omit --max-credits for a recommendation and preview hash. Ask for real profile URLs; never invent them.",
|
|
6425
|
-
create: "Create and activate a
|
|
7085
|
+
create: "Create and activate a Professional Network Post Engagers table after approval of its exact preview. Starts collection now and daily at 07:00 UTC under the approved per-cycle cap.",
|
|
6426
7086
|
update: "Edit watched profiles or the per-cycle cap from the table. Preview the changes first; approval binds the exact new configuration.",
|
|
6427
7087
|
pause: "Pause daily monitoring while retaining the table and collected rows.",
|
|
6428
7088
|
resume: "Resume paid daily monitoring after reviewing a fresh preview and approving its per-cycle credit cap.",
|
|
@@ -6434,7 +7094,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6434
7094
|
.option("--name <name>", "Watcher table display name.")
|
|
6435
7095
|
.option("--project <project>", "Project id or slug; defaults to General.")
|
|
6436
7096
|
.option("--profiles-json <json>", "JSON array of 1–10 real public LinkedIn profile URLs; replaces the watched list.")
|
|
6437
|
-
.option("--max-credits <credits>", "Hard credit ceiling for each daily cycle; never a monthly ceiling.")
|
|
7097
|
+
.option("--max-credits <credits>", "Hard credit ceiling for each daily cycle; never a monthly ceiling.")
|
|
7098
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen credits per cycle. user_api_key: every LinkedIn call runs on your own Up2Data key saved in Connections, billed by Up2Data, no Oxygen credits.");
|
|
6438
7099
|
if (action !== "preview")
|
|
6439
7100
|
command
|
|
6440
7101
|
.option("--preview-hash <hash>", "Exact configuration hash returned by preview; re-preview after any change.")
|
|
@@ -6461,11 +7122,60 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6461
7122
|
...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
|
|
6462
7123
|
...(readOption(options.previewHash) ? { preview_hash: readOption(options.previewHash) } : {}),
|
|
6463
7124
|
...(options.approved ? { approved: true } : {}),
|
|
7125
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
6464
7126
|
},
|
|
6465
7127
|
});
|
|
6466
7128
|
});
|
|
6467
7129
|
});
|
|
6468
7130
|
}
|
|
7131
|
+
const followersCommand = tablesCommand.command("followers")
|
|
7132
|
+
.description("Order a LinkedIn company page's followers as a table of leads — a competitor's audience. `check` is free and quotes the exact cost; `order` places it. The order is fulfilled by ScrapeLi, so the table is created immediately and its rows land later; there is no published delivery time.");
|
|
7133
|
+
followersCommand.command("check")
|
|
7134
|
+
.description("Free: read the page's follower count and quote the exact credits an order would reserve. No spend, no table; `orderable: false` means no order can be placed yet.")
|
|
7135
|
+
.requiredOption("--company <url>", "LinkedIn company page URL, e.g. https://www.linkedin.com/company/hubspot.")
|
|
7136
|
+
.option("--records <n>", "How many followers to price. Minimum 100; defaults to 1000.")
|
|
7137
|
+
.option("--package <package>", "Basic (default) or Advanced.")
|
|
7138
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
|
|
7139
|
+
.option("--json", "Print a JSON envelope.")
|
|
7140
|
+
.action(async (options) => {
|
|
7141
|
+
await handleAsyncAction("tables followers check", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
|
|
7142
|
+
method: "POST",
|
|
7143
|
+
body: {
|
|
7144
|
+
action: "preview",
|
|
7145
|
+
company_url: readOption(options.company),
|
|
7146
|
+
...(options.records !== undefined ? { records: readPositiveNumber(options.records) } : {}),
|
|
7147
|
+
...(readOption(options.package) ? { package: readOption(options.package) } : {}),
|
|
7148
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
7149
|
+
},
|
|
7150
|
+
}));
|
|
7151
|
+
});
|
|
7152
|
+
followersCommand.command("order")
|
|
7153
|
+
.description("Place the follower order after checking it. With managed credits it requires --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
|
|
7154
|
+
.requiredOption("--company <url>", "LinkedIn company page URL whose followers to order.")
|
|
7155
|
+
.option("--max-credits <credits>", "Hard credit ceiling for this order. Must be at least the amount `tables followers check` quoted. Not needed with --credential-mode user_api_key.")
|
|
7156
|
+
.requiredOption("--request-id <uuid>", "One stable UUID for this order. Reuse it after a timeout; a new key would buy the same audience twice.")
|
|
7157
|
+
.option("--records <n>", "How many followers to buy. Minimum 100; defaults to 1000.")
|
|
7158
|
+
.option("--package <package>", "Basic (default) or Advanced.")
|
|
7159
|
+
.option("--name <name>", "Table name. Defaults to \"<Company> Followers\".")
|
|
7160
|
+
.option("--project <project>", "Project id or slug; defaults to General.")
|
|
7161
|
+
.option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
|
|
7162
|
+
.option("--json", "Print a JSON envelope.")
|
|
7163
|
+
.action(async (options) => {
|
|
7164
|
+
await handleAsyncAction("tables followers order", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
|
|
7165
|
+
method: "POST",
|
|
7166
|
+
body: {
|
|
7167
|
+
action: "create",
|
|
7168
|
+
company_url: readOption(options.company),
|
|
7169
|
+
...(options.records !== undefined ? { records: readPositiveNumber(options.records) } : {}),
|
|
7170
|
+
...(readOption(options.package) ? { package: readOption(options.package) } : {}),
|
|
7171
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
7172
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
7173
|
+
...(options.maxCredits !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
7174
|
+
...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
|
|
7175
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
7176
|
+
},
|
|
7177
|
+
}));
|
|
7178
|
+
});
|
|
6469
7179
|
tablesCommand.addCommand(new Command("relate")
|
|
6470
7180
|
.description("Relate two tables: define empty Tables-owned relation columns on the source and target. Then use `oxygen tables link` to populate row-to-row edges. Works on any workspace table; plain tables stay plain and are never registered as CRM objects. Defaults to dry-run.")
|
|
6471
7181
|
.argument("<table>", "Source table id or slug.")
|
|
@@ -6938,7 +7648,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6938
7648
|
return requestOxygen(`/api/cli/tables/auto-run?${params.toString()}`);
|
|
6939
7649
|
})))
|
|
6940
7650
|
.addCommand(new Command("set")
|
|
6941
|
-
.description("Enable a standing auto-run: automatically queue the given columns for rows written to this table (imports, inserts, upserts). Default off.")
|
|
7651
|
+
.description("Enable a standing auto-run: automatically queue the given columns for rows written to this table (imports, inserts, upserts). A queued cell that already has a value is skipped unless --force, so an upsert that touches an existing, filled row is not charged again. Rows a LinkedIn capture writes are subscribed separately and only when newly inserted — for profile viewers, `viewers enrichment --on`. Default off.")
|
|
6942
7652
|
.argument("<table>", "Table id or slug.")
|
|
6943
7653
|
.requiredOption("--columns <csv>", "Comma-separated column keys to auto-run on newly written rows (tool, AI, formula, enrichment, bind, or lookup columns).")
|
|
6944
7654
|
.option("--sources <csv>", "Which write sources trigger the auto-run: any of import, insert, upsert, send. Defaults to all when omitted.")
|
|
@@ -8556,10 +9266,14 @@ Examples:
|
|
|
8556
9266
|
.addCommand(new Command("add")
|
|
8557
9267
|
.description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-gtm skill's enriching-and-researching.md. A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately.")
|
|
8558
9268
|
.argument("<table>", "Table id or slug.")
|
|
8559
|
-
.option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup, then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free
|
|
8560
|
-
.option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input
|
|
8561
|
-
|
|
8562
|
-
|
|
9269
|
+
.option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, the LinkedIn Scraper only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, the LinkedIn Scraper only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
|
|
9270
|
+
.option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input domain=website --input linkedin_url=company_li with --preset. Repeatable. For a preset it is only needed when the automatic match is wrong or missing; for a template it names the column each declared input reads.", collectRepeatable, [])
|
|
9271
|
+
// No preset takes a field list any more (founder, 2026-09-24). Hidden,
|
|
9272
|
+
// not removed: an old script passing them gets the server's refusal,
|
|
9273
|
+
// which names `find company --fields` and `--preset company_tech_stack`,
|
|
9274
|
+
// instead of an unknown-option error that explains nothing.
|
|
9275
|
+
.addOption(new Option("--preset-fields <fields>", "Retired: presets take no field list.").hideHelp())
|
|
9276
|
+
.addOption(new Option("--check-technologies <names>", "Retired: presets take no field list.").hideHelp())
|
|
8563
9277
|
.option("--capability <capability>", "Seed a ready-to-run enrichment column WITHOUT running it (0 credits): verify_email grades the address a row already holds \u2014 MillionVerifier first, catch-all domains escalate to BounceBan; work_email, personal_email, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall (personal_email is the non-work mailbox, graded by MillionVerifier before it is written). Sets kind=enrichment and jsonb; label and key default from the capability. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
|
|
8564
9278
|
.option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
|
|
8565
9279
|
.option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
|
|
@@ -8573,7 +9287,7 @@ Examples:
|
|
|
8573
9287
|
.option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
|
|
8574
9288
|
.option("--research-mode <mode>", "Research columns: how tightly the answer is bound to the sources. Default: summarize and combine what they say. strict: answer only from what a source states verbatim (for figures and identifiers). estimate: reason to a figure from the evidence (for revenue or headcount).")
|
|
8575
9289
|
.option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25, default 5). More evidence costs no extra search, but a larger prompt.")
|
|
8576
|
-
.option("--research-engine <engine>", "Research columns: pin the search provider (
|
|
9290
|
+
.option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa), or with --research-url the page-fetch provider (firecrawl or exa). Defaults to serper for search and firecrawl for fetch, falling back automatically. Most users should not set this.")
|
|
8577
9291
|
.option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
|
|
8578
9292
|
.option("--prompt-key <key>", "Add a column template from `oxygen columns catalog` (e.g. pricing_plan_summary_v1 reads a pricing-page URL column and returns plan count, price range and enterprise plan; company_founders_v1 reads a domain column and returns the founders with sources; person_skill_set_v1 reads the person_enrich payload column with --input profile=person_enrich). Materializes the template's prompt, output schema and grounding and sets its kind (ai, research, formula, or search — a managed web search whose cell is a page URL). Bind each declared input with --input <name>=<column_key>.")
|
|
8579
9293
|
.option("--input-mapping <json>", "JSON alternative to --input for --prompt-key: an object mapping template input names to columns, e.g. '{\"url\":{\"column\":\"pricing_url\"}}' (a bare column-key string or {\"literal\": ...} also works). Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
|
|
@@ -8585,6 +9299,16 @@ Examples:
|
|
|
8585
9299
|
.option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
|
|
8586
9300
|
.option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
|
|
8587
9301
|
.option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
|
|
9302
|
+
// Classification columns stay out of --help until OXYGEN_SYSTEM_ONE_COLUMN_MODE
|
|
9303
|
+
// is on in production: a help screen the npm CLI prints everywhere must not
|
|
9304
|
+
// offer what production answers with classification_not_enabled. They still
|
|
9305
|
+
// parse where the surface is on (dev), and the flag-aware `tools search`
|
|
9306
|
+
// catalog advertises them there. Unhide at the production flip (ADR 0027).
|
|
9307
|
+
.addOption(new Option("--classify <labels>", "Classification column: label every row with one of these comma-separated labels (2-20), answering --prompt. Each cell holds the label, a 0-1 confidence and uncertain:true below --min-confidence (the best guess is kept). Flat ~0.075 credits/row; see `columns run --dry-run`.").hideHelp())
|
|
9308
|
+
.addOption(new Option("--yes-no <question>", "Classification column: answer this yes/no question per row (reference columns as {{column_key}}). Filter on the answer with `tables query --filter-tree-json` and a path leaf, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"<column>\",\"path\":\"answer\",\"operator\":\"is\",\"value\":true}]}'.").hideHelp())
|
|
9309
|
+
.addOption(new Option("--score <levels>", "Classification column: rate each row on these comma-separated levels, lowest first (2-10), answering --prompt.").hideHelp())
|
|
9310
|
+
.addOption(new Option("--min-confidence <0-1>", "Classification columns: below this the answer is marked uncertain. Default 0.85 (0.7 for --yes-no).").hideHelp())
|
|
9311
|
+
.addOption(new Option("--grounding <mode>", "Classification columns: none (default) judges only the row and your question; workspace also sends your workspace knowledge (ICP, positioning), which can pull answers toward your ICP.").hideHelp())
|
|
8588
9312
|
.option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
|
|
8589
9313
|
.option("--bind-map <pairs>", "Bind identity mapping as identity=column pairs, e.g. domain=website,linkedin_url=li.")
|
|
8590
9314
|
.option("--bind-create", "Bind columns: assert a new CRM record for unmatched rows (onNoMatch=create; a truth write, needs --approved on run).")
|
|
@@ -8635,6 +9359,11 @@ Examples:
|
|
|
8635
9359
|
if (options.label || options.promptKey || options.definitionJson) {
|
|
8636
9360
|
throw new OxygenError("invalid_request", "--preset builds its own column set. Drop --label/--prompt-key/--definition-json, or add the column by hand without --preset.", { exitCode: 1 });
|
|
8637
9361
|
}
|
|
9362
|
+
// Named only when passed: the classification flags are hidden from help.
|
|
9363
|
+
const classificationFlag = options.classify ? "--classify" : options.yesNo ? "--yes-no" : options.score ? "--score" : null;
|
|
9364
|
+
if (classificationFlag) {
|
|
9365
|
+
throw new OxygenError("invalid_request", `--preset builds its own column set. Drop ${classificationFlag}, or add the classification column without --preset.`, { exitCode: 1 });
|
|
9366
|
+
}
|
|
8638
9367
|
const presetBody = { table, preset };
|
|
8639
9368
|
const inputs = parseKeyValuePairs(options.input ?? []);
|
|
8640
9369
|
if (Object.keys(inputs).length > 0)
|
|
@@ -8661,7 +9390,30 @@ Examples:
|
|
|
8661
9390
|
if (options.promptKey && !options.inputMapping && Object.keys(templateInputs).length === 0) {
|
|
8662
9391
|
throw new OxygenError("missing_input_mapping", `--prompt-key ${options.promptKey} reads named inputs; bind each one to a column with --input <name>=<column_key> (e.g. --input url=pricing_url), or pass --input-mapping <json>. See \`oxygen columns catalog\` for the inputs a template reads.`, { exitCode: 1 });
|
|
8663
9392
|
}
|
|
8664
|
-
const
|
|
9393
|
+
const promptOption = readAiPromptOption(options.prompt);
|
|
9394
|
+
const decisionFlags = readColumnDecisionFlags(options);
|
|
9395
|
+
if (decisionFlags) {
|
|
9396
|
+
const conflicting = [
|
|
9397
|
+
capability ? "--capability" : null,
|
|
9398
|
+
readOption(options.promptKey) ? "--prompt-key" : null,
|
|
9399
|
+
readOption(options.bindObject) || readOption(options.bindMap) || options.bindCreate ? "--bind-object" : null,
|
|
9400
|
+
readOption(options.lookupTable) ? "--lookup-table" : null,
|
|
9401
|
+
readOption(options.model) ? "--model" : null,
|
|
9402
|
+
readOption(options.outputSchemaJson) || readOption(options.outputSchemaFile) ? "--output-schema-json" : null,
|
|
9403
|
+
readOption(options.researchQuery) || readOption(options.researchUrl) ? "--research-query/--research-url" : null,
|
|
9404
|
+
readOption(options.kind) && readOption(options.kind).toLowerCase() !== "ai" ? `--kind ${readOption(options.kind)}` : null,
|
|
9405
|
+
].filter((flag) => flag !== null);
|
|
9406
|
+
if (conflicting.length > 0) {
|
|
9407
|
+
throw new OxygenError("invalid_request", `A classification column (--classify, --yes-no or --score) is an AI column answered by the classifier, so it cannot be combined with ${conflicting.join(", ")}.`, { exitCode: 1 });
|
|
9408
|
+
}
|
|
9409
|
+
if (decisionFlags.question !== null && promptOption !== null) {
|
|
9410
|
+
throw new OxygenError("invalid_request", "--yes-no carries the question itself. Drop --prompt, or use --classify \"yes,no\" with --prompt.", { exitCode: 1 });
|
|
9411
|
+
}
|
|
9412
|
+
if (decisionFlags.question === null && promptOption === null) {
|
|
9413
|
+
throw new OxygenError("invalid_request", "--classify and --score need the question each row answers. Add --prompt \"<question>\", referencing columns as {{column_key}} — e.g. --prompt \"What kind of company is {{company_name}}?\".", { exitCode: 1 });
|
|
9414
|
+
}
|
|
9415
|
+
}
|
|
9416
|
+
const prompt = decisionFlags?.question ?? promptOption;
|
|
8665
9417
|
if (prompt !== null && options.promptKey) {
|
|
8666
9418
|
throw new OxygenError("invalid_request", "Pass either --prompt (a freeform prompt) or --prompt-key (a prompt-library entry), not both.", { exitCode: 1 });
|
|
8667
9419
|
}
|
|
@@ -8731,6 +9483,12 @@ Examples:
|
|
|
8731
9483
|
column.definition = isResearch
|
|
8732
9484
|
? applyResearchColumnConfig(applyAiColumnConfig(definition, options), options)
|
|
8733
9485
|
: applyAiColumnConfig(definition, options);
|
|
9486
|
+
if (decisionFlags) {
|
|
9487
|
+
column.definition.decision = decisionFlags.decision;
|
|
9488
|
+
// The cell is {answer, confidence, uncertain, reason}: JSON, so it filters.
|
|
9489
|
+
if (!options.dataType)
|
|
9490
|
+
column.data_type = "jsonb";
|
|
9491
|
+
}
|
|
8734
9492
|
}
|
|
8735
9493
|
if (readOption(options.bindObject) || readOption(options.bindMap) || options.bindCreate) {
|
|
8736
9494
|
// A column is one kind. --prompt already set kind=ai above, and the
|
|
@@ -8803,7 +9561,7 @@ Examples:
|
|
|
8803
9561
|
.option("--all", "Run all rows. Requires --background, except with --dry-run, which previews the background run without queueing it.")
|
|
8804
9562
|
.option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
|
|
8805
9563
|
.option("--formula-values <mode>", "With --filter-json on a formula column, first refresh that selector with `columns run <table> <column> --force` (0 credits), then pass 'materialized'. The run filters the stored snapshot and persists this freshness acknowledgement.")
|
|
8806
|
-
.option("--force", "Run even when the target cell already has a value. Formula columns compute when read, so `tables query` shows their values without a run
|
|
9564
|
+
.option("--force", "Run even when the target cell already has a value. Formula columns compute when read, so `tables query` shows their values without a run. Stored fields (definition `fill: \"empty\"`, such as the person_enrich profile fields) keep their own cell instead: it fills when the column they read from runs. A run skips rows that already store a value (existing_value), and --force refreshes them.")
|
|
8807
9565
|
.option("--connection-id <connection_id>", "Optional provider integration connection id.")
|
|
8808
9566
|
.option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment server runs are always backgrounded.")
|
|
8809
9567
|
.option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
|
|
@@ -8990,7 +9748,7 @@ Examples:
|
|
|
8990
9748
|
.option("--research-exclude-domains <csv>", "Research columns: replace the comma-separated list of domains to exclude.")
|
|
8991
9749
|
.option("--research-mode <mode>", "Research columns: strict (answer only from the sources) or estimate (reason to a figure from them).")
|
|
8992
9750
|
.option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25).")
|
|
8993
|
-
.option("--research-engine <engine>", "Research columns: pin the search provider (
|
|
9751
|
+
.option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa).")
|
|
8994
9752
|
// Declaring BOTH forms leaves the default undefined (Commander only
|
|
8995
9753
|
// defaults to true when --no- is declared alone), so an update that
|
|
8996
9754
|
// doesn't mention visibility leaves it untouched.
|
|
@@ -9443,6 +10201,7 @@ Examples:
|
|
|
9443
10201
|
.option("--upsert-key <key>", "Column key used to upsert instead of inserting.")
|
|
9444
10202
|
.option("--connection-id <connection_id>", "Optional provider integration connection id.")
|
|
9445
10203
|
.option("--max-concurrency <n>", "Maximum concurrent ingestion items for this run. Defaults to 5.")
|
|
10204
|
+
.option("--max-credits <n>", "Maximum credits to reserve for this run; required for paid tools.")
|
|
9446
10205
|
.option("--metadata-json <json>", "Optional metadata object to attach to the run.")
|
|
9447
10206
|
.option("--json", "Print a JSON envelope.")
|
|
9448
10207
|
.action(async (table, options) => {
|
|
@@ -9455,6 +10214,7 @@ Examples:
|
|
|
9455
10214
|
}
|
|
9456
10215
|
const maxPages = readPositiveInt(options.maxPages);
|
|
9457
10216
|
const maxConcurrency = readPositiveInt(options.maxConcurrency);
|
|
10217
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
9458
10218
|
const rowMappingJson = readOption(options.rowMappingJson);
|
|
9459
10219
|
const payload = {
|
|
9460
10220
|
tool_id: toolId,
|
|
@@ -9474,6 +10234,7 @@ Examples:
|
|
|
9474
10234
|
...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
|
|
9475
10235
|
...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
|
|
9476
10236
|
...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
|
|
10237
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
9477
10238
|
metadata: {
|
|
9478
10239
|
command: "table-ingestions create-tool-page",
|
|
9479
10240
|
...(options.metadataJson ? parseJsonObject(options.metadataJson) : {}),
|
|
@@ -9724,15 +10485,15 @@ Examples:
|
|
|
9724
10485
|
.description("Plan, dry-run, or queue provider-backed company search.")
|
|
9725
10486
|
.addCommand(new Command("filters")
|
|
9726
10487
|
.description("List Company Search filter fields and options without a provider call, including every enum field's full accepted-value list. --source hiring lists the Job postings filters instead (job.* and company.*).")
|
|
9727
|
-
.option("--source <source>", "company_search (default) or hiring: live job postings for the roles you name, one row per posting with a link to it.")
|
|
10488
|
+
.option("--source <source>", "company_search (default), google_maps, or hiring: live job postings for the roles you name, one row per posting with a link to it.")
|
|
9728
10489
|
.option("--json", "Print a JSON envelope.")
|
|
9729
10490
|
.action(async (options) => {
|
|
9730
10491
|
await handleAsyncAction("companies search filters", options, () => requestOxygen(`/api/cli/companies/search/preview${readCompanySourceQuery(options.source)}`));
|
|
9731
10492
|
}))
|
|
9732
10493
|
.addCommand(new Command("preview")
|
|
9733
|
-
.description("Preview up to 50 rows free, using native filters from companies search filters.
|
|
9734
|
-
.requiredOption("--filters-json <json-or-file>", '
|
|
9735
|
-
.option("--source <source>", "company_search (default) or
|
|
10494
|
+
.description("Preview up to 50 rows free, using native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter; total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total.")
|
|
10495
|
+
.requiredOption("--filters-json <json-or-file>", 'Native filters as JSON or @file/path. Google Maps requires q, placeId or cid; other sources accept {} for all results.')
|
|
10496
|
+
.option("--source <source>", "company_search (default), hiring or google_maps. Filters for hiring nest under job and company, e.g. {\"job\":{\"title\":{\"include\":[\"Account Executive\"]}}}.")
|
|
9736
10497
|
.option("--json", "Print a JSON envelope.")
|
|
9737
10498
|
.action(async (options) => {
|
|
9738
10499
|
await handleAsyncAction("companies search preview", options, () => requestOxygen("/api/cli/companies/search/preview", {
|
|
@@ -9772,7 +10533,7 @@ Examples:
|
|
|
9772
10533
|
}))
|
|
9773
10534
|
.addCommand(new Command("run")
|
|
9774
10535
|
.description("Return a dry-run request or queue a live company-search ingestion run.")
|
|
9775
|
-
.option("--source <source>", "Use company_search or
|
|
10536
|
+
.option("--source <source>", "Use company_search, hiring or google_maps with --source-filters-json instead of a prompt or plan. hiring sources the live job postings for the roles you name, one row per posting (up to 5,000 per search), each with its posting URL, date and hiring company.")
|
|
9776
10537
|
.option("--source-filters-json <json-or-file>", 'Same nested filters as the free preview, e.g. {"employee_count":{"min":10}} or, with --source hiring, {"job":{"title":{"include":["Account Executive"]}}}.')
|
|
9777
10538
|
.option("--table-name <name>", "Name the table this run creates. Ignored when --table names an existing table, which must already exist.")
|
|
9778
10539
|
.argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
|
|
@@ -9786,7 +10547,7 @@ Examples:
|
|
|
9786
10547
|
.option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
|
|
9787
10548
|
.option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate, and is itself capped by --target-count: asking for more pages than that many rows needs cannot add rows.")
|
|
9788
10549
|
.option("--max-credits <n>", "Required credit ceiling for live runs.")
|
|
9789
|
-
.option("--target-count <n>", "How many rows to source
|
|
10550
|
+
.option("--target-count <n>", "How many rows to source (default 50), independently of the free preview size. Maps previews up to 20; page size varies by source. Total matches may be unavailable. This count bounds pagination: --max-pages cannot exceed the pages it needs, and a run that stops because of it reports stopped_by row_target.")
|
|
9790
10551
|
.option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
|
|
9791
10552
|
.option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
|
|
9792
10553
|
.option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
|
|
@@ -9833,7 +10594,7 @@ Examples:
|
|
|
9833
10594
|
.argument("<table>", "Table id or slug.")
|
|
9834
10595
|
.option("--missing-fields <fields>", "Comma-separated fields to fill.")
|
|
9835
10596
|
.option("--providers <providers>", "Comma-separated provider pool.")
|
|
9836
|
-
.option("--mode <mode>", "dry_run or live. Defaults to live. dry_run returns the same plan as `companies enrich preview` and creates no column; only a live run creates the target columns it fills. To
|
|
10597
|
+
.option("--mode <mode>", "dry_run or live. Defaults to live. dry_run returns the same plan as `companies enrich preview` and creates no column; only a live run creates the target columns it fills. To reuse a published workspace Function, inspect `functions list` and attach it with `functions bind` before running the column.")
|
|
9837
10598
|
.option("--max-credits <n>", "Required credit ceiling for live runs.")
|
|
9838
10599
|
.option("--approved", "Approve this live paid run after inspecting the preview.")
|
|
9839
10600
|
.option("--all", "Run on all rows.")
|
|
@@ -10072,8 +10833,13 @@ Examples:
|
|
|
10072
10833
|
.requiredOption("--to <plan>",
|
|
10073
10834
|
// Derived from the catalog, never retyped: this help text is the only
|
|
10074
10835
|
// place a customer's agent learns which plans exist, and it named
|
|
10075
|
-
// three retired ones for as long as it was a literal.
|
|
10076
|
-
|
|
10836
|
+
// three retired ones for as long as it was a literal. The price sits
|
|
10837
|
+
// beside each key because nothing else on the CLI prints a
|
|
10838
|
+
// plan-to-price table (blind baseline, 2026-09-22).
|
|
10839
|
+
`Target plan: ${PURCHASABLE_PLAN_KEYS.map((key) => {
|
|
10840
|
+
const cents = resolveBasePricingPlan(key)?.monthlyPriceCents;
|
|
10841
|
+
return typeof cents === "number" ? `${key} ($${cents / 100}/mo)` : key;
|
|
10842
|
+
}).join(", ")}.`)
|
|
10077
10843
|
.option("--json", "Print a JSON envelope.")
|
|
10078
10844
|
.action(async (options) => {
|
|
10079
10845
|
await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
|
|
@@ -10138,7 +10904,7 @@ Examples:
|
|
|
10138
10904
|
.option("--provider <provider>", "Filter by provider id. With --meter provider_seats, selects the seat provider: linkedin (default) or whatsapp.")
|
|
10139
10905
|
.option("--source <source_id>", "Filter by source_id/provider operation.")
|
|
10140
10906
|
.option("--source-id <source_id>", "Alias for --source.")
|
|
10141
|
-
.option("--run-id <run_id>", "Filter by run id recorded in ledger metadata.")
|
|
10907
|
+
.option("--run-id <run_id>", "Filter by run id recorded in ledger metadata. A table run's reserve and release events carry it, but its settled charges may not yet: read what a table run billed from `table-runs get <run>` (creditsUsed), or per row from `table-runs items`.")
|
|
10142
10908
|
.option("--nonzero", "Only include events that changed available or reserved credits.")
|
|
10143
10909
|
.option("--json", "Print a JSON envelope.")
|
|
10144
10910
|
.action(async (options) => {
|
|
@@ -10160,7 +10926,7 @@ Examples:
|
|
|
10160
10926
|
.option("--provider <provider>", "Filter by provider id.")
|
|
10161
10927
|
.option("--source <source_id>", "Filter by source_id/provider operation.")
|
|
10162
10928
|
.option("--source-id <source_id>", "Alias for --source.")
|
|
10163
|
-
.option("--run-id <run_id>", "Filter by run id recorded in ledger metadata.")
|
|
10929
|
+
.option("--run-id <run_id>", "Filter by run id recorded in ledger metadata. A table run's reserve and release events carry it, but its settled charges may not yet: read what a table run billed from `table-runs get <run>` (creditsUsed), or per row from `table-runs items`.")
|
|
10164
10930
|
.option("--nonzero", "Only include events that changed available or reserved credits. Default for audit except BYOK filters.")
|
|
10165
10931
|
.option("--json", "Print a JSON envelope.")
|
|
10166
10932
|
.action(async (options) => {
|
|
@@ -10329,7 +11095,7 @@ Examples:
|
|
|
10329
11095
|
program.addCommand(new Command("egress")
|
|
10330
11096
|
.description("Native email egress: status, shared/dedicated IP inventory, the dedicated-IP add-on, policy assignments, and a fail-closed retired rotation shim.")
|
|
10331
11097
|
.addCommand(new Command("status")
|
|
10332
|
-
.description("Show this workspace's actual native egress mode, active IP, health counts, dedicated add-on, and one bounded page of the inspectable mailbox policy ledger. Read-only, 0 credits.")
|
|
11098
|
+
.description("Show this workspace's actual native egress mode, active IP, health counts, dedicated add-on, and one bounded page of the inspectable mailbox policy ledger. Native Gmail/Microsoft sends, reply reads and token refreshes use the active IP; Zapmail-connected (Zapbox) mailboxes send through Zapmail and never use it. Read-only, 0 credits.")
|
|
10333
11099
|
.option("--assignment-offset <number>", "Continue the policy ledger from open_assignments_next_offset.")
|
|
10334
11100
|
.option("--json", "Print a JSON envelope.")
|
|
10335
11101
|
.action(async (options) => {
|
|
@@ -10340,15 +11106,15 @@ Examples:
|
|
|
10340
11106
|
await handleAsyncAction("egress status", options, () => requestOxygen(`/api/cli/egress${query}`));
|
|
10341
11107
|
}))
|
|
10342
11108
|
.addCommand(new Command("ips")
|
|
10343
|
-
.description("List the shared native worker IP and this workspace's dedicated IP history. Read-only, 0 credits.")
|
|
11109
|
+
.description("List the shared native worker IP (it carries the sends, reply reads and token refreshes of every workspace without a dedicated IP) and this workspace's dedicated IP history. Read-only, 0 credits.")
|
|
10344
11110
|
.option("--json", "Print a JSON envelope.")
|
|
10345
11111
|
.action(async (options) => {
|
|
10346
11112
|
await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
|
|
10347
11113
|
}))
|
|
10348
11114
|
.addCommand(new Command("dedicated")
|
|
10349
|
-
.description("Dedicated native egress add-on (
|
|
11115
|
+
.description("Dedicated native egress add-on (2,500 credits per 30-day period): one static IP only this workspace uses, which IT can allowlist; workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check status.")
|
|
10350
11116
|
.addCommand(new Command("request")
|
|
10351
|
-
.description("Preview the dedicated native egress add-on (
|
|
11117
|
+
.description("Preview the dedicated native egress add-on (2,500 credits per 30-day period, $25 face value). With --approved, ORDER it: OXYGEN provisions one isolated send drain plus one static egress IP for the workspace, debits the first period immediately, and debits the next 30 days later — not on the 1st. Every current inbox is assigned to that IP, every future inbox joins it automatically, and after the guarded handoff every send, reply read and token refresh for the workspace's mailboxes uses that one IP, so IT can allowlist it in Microsoft Conditional Access or Google context-aware access. Recipients of Gmail API and Microsoft Graph sends still see Google/Microsoft relay IPs; a Microsoft mailbox on the SMTP transport may show the sending IP, shared or dedicated, in its headers. This is workspace tenant isolation and account safety, not a deliverability or inbox-placement lever. Without --approved nothing is ordered or charged. While native provisioning is unavailable, ordering fails closed and the preview says so.")
|
|
10352
11118
|
// --approved (not --approve): the credit-spending approval flag,
|
|
10353
11119
|
// which is also what derives spends_credits in the self-index.
|
|
10354
11120
|
.option("--approved", "Execute the order (a real recurring credit charge). Omit for a no-side-effect preview.")
|
|
@@ -11173,12 +11939,12 @@ Examples:
|
|
|
11173
11939
|
}));
|
|
11174
11940
|
program
|
|
11175
11941
|
.command("copilot")
|
|
11176
|
-
.description("Workspace Copilot: an attended workspace agent you drive turn by turn from the terminal. Start a session, send it messages and watch the reply stream, approve the actions it proposes, then cancel when done. Inference bills credits at 5x the actual model cost and is bounded by a
|
|
11942
|
+
.description("Workspace Copilot: an attended workspace agent you drive turn by turn from the terminal. Start a session, send it messages and watch the reply stream, approve the actions it proposes, then cancel when done. Inference bills credits at 5x the actual model cost and is bounded by a 3,000-credit internal platform safety backstop; paid or external actions keep their own approval gates. Web: /copilot.")
|
|
11177
11943
|
.addCommand(new Command("start")
|
|
11178
|
-
.description("Start an attended Workspace Copilot session. Starting is free; the next send starts inference. No inference budget is required; inference bills at 5x actual model cost under a
|
|
11944
|
+
.description("Start an attended Workspace Copilot session on Oxygen Auto — Oxygen picks the model, keeps one conversation on one model, and runs its own errands on a cheaper one. Starting is free; the next send starts inference. No inference budget is required; inference bills at 5x actual model cost under a 3,000-credit internal platform safety backstop. Legacy budget and tier flags remain accepted for older clients but do not authorize or cap spend. Paid or external actions require separate approval.")
|
|
11179
11945
|
.option("--budget-credits <number>", "Legacy compatibility only: accepted and clamped, but does not authorize or cap attended Copilot inference.")
|
|
11180
|
-
.option("--tier <low|medium|high>", "
|
|
11181
|
-
.option("--model <id>", "Pin a specific model id instead of
|
|
11946
|
+
.option("--tier <auto|low|medium|high>", "Legacy. Sessions run on Oxygen Auto; Oxygen picks the model. The three older tiers are still accepted so existing scripts keep working.")
|
|
11947
|
+
.option("--model <id>", "Pin a specific priced model id instead of letting Oxygen choose.")
|
|
11182
11948
|
.option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
|
|
11183
11949
|
.option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
|
|
11184
11950
|
.option("--title <text>", "Optional human title for the session.")
|
|
@@ -11617,7 +12383,7 @@ Examples:
|
|
|
11617
12383
|
.description("The priced catalog: every provider operation and every OXYGEN column (enrichment bundles, waterfalls, templates, Functions) with its credit cost per row. Start with `tools search --for-table <table>` to see what a table's own columns can be enriched with and what each costs, before adding anything.")
|
|
11618
12384
|
.addCommand(new Command("search")
|
|
11619
12385
|
.description("Search the priced catalog (0 credits): provider operations in `tools`, and OXYGEN's own columns in `native_columns` (enrichment bundles, waterfalls, templates, Functions), each with `price_label`, `estimated_credits_per_row`, `pricing_kind` and the exact `cli_add` command. With --for-table <table> it reads that table's columns and returns `suggestions`: the enrichments those columns already support, priced per row. Hydrate one provider operation with tools get. A response listing partial_sources means an optional catalog source timed out and totals may be understated — rerun for the complete catalog.")
|
|
11620
|
-
.argument("[query]", "Search text.")
|
|
12386
|
+
.argument("[query]", "Search text, e.g. `web` for the Web search column and its modes (each priced in `modes`), or `email`.")
|
|
11621
12387
|
.option("--verbosity <verbosity>", "minimal, summary, or full. Defaults to minimal; hydrate one result with tools get.")
|
|
11622
12388
|
.option("--terse", "Alias for --verbosity minimal.")
|
|
11623
12389
|
.option("--all", "Return the complete matching catalog — with no query that is every provider operation (7,000+ rows). Explicit because the default is bounded to 10; with --for-table the table-scoped answer is `suggestions`, which --all does not change.")
|
|
@@ -11629,7 +12395,7 @@ Examples:
|
|
|
11629
12395
|
.option("--provider <provider>", "Filter to one exact provider id, such as blitzapi.")
|
|
11630
12396
|
.option("--limit <n>", "Maximum number of tools to return. Capped at 100.")
|
|
11631
12397
|
.option("--providers", "Also return a per-vendor summary (provider id, display name, tool count) alongside the tools.")
|
|
11632
|
-
.option("--for-table <table>", "Table id or slug. Prices Oxygen's own column kinds, presets and waterfalls (native_columns) for that table's columns and lists deterministic suggestions first — what you can enrich here and what each costs per row, before adding anything. `
|
|
12398
|
+
.option("--for-table <table>", "Table id or slug. Prices Oxygen's own column kinds, presets and waterfalls (native_columns) for that table's columns and lists deterministic suggestions first — what you can enrich here and what each costs per row, before adding anything. The envelope opens with `suggested` (each entry's price and exact add command), then `native_columns`; `tools` comes last and lists only provider operations a column can run, so leave --all off unless you want every one of them.")
|
|
11633
12399
|
.option("--favorites", "Only the catalog entries you favourited for this workspace (see tools favorite).")
|
|
11634
12400
|
.option("--table-runnable", "Only tools a table column can run.")
|
|
11635
12401
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -12236,6 +13002,7 @@ Examples:
|
|
|
12236
13002
|
.option("--account-id <id>", "Provider account id for integrations that span multiple accounts (e.g. Cloudflare when the token can access more than one account).")
|
|
12237
13003
|
.option("--country <code>", "ISO 3166-1 alpha-2 country where the LinkedIn account owner normally signs in (required for `connect linkedin`; picks the egress proxy).")
|
|
12238
13004
|
.option("--pairing-phone-number <e164>", "WhatsApp only: pair by code sent to this E.164 number instead of showing a QR.")
|
|
13005
|
+
.option("--acknowledge-risk", "LinkedIn only, required for a new account: LinkedIn restricts automated activity; pacing reduces but does not remove the risk.")
|
|
12239
13006
|
.option("--json", "Print a JSON envelope.")
|
|
12240
13007
|
.action(async (integrationId, options) => {
|
|
12241
13008
|
await handleAsyncAction("integrations connect", options, () => {
|
|
@@ -12253,6 +13020,7 @@ Examples:
|
|
|
12253
13020
|
...(accountId ? { account_id: accountId } : {}),
|
|
12254
13021
|
...(country ? { country } : {}),
|
|
12255
13022
|
...(pairingPhoneNumber ? { pairing_phone_number: pairingPhoneNumber } : {}),
|
|
13023
|
+
...(options.acknowledgeRisk ? { acknowledge_linkedin_risk: true } : {}),
|
|
12256
13024
|
},
|
|
12257
13025
|
});
|
|
12258
13026
|
});
|
|
@@ -12353,7 +13121,7 @@ Examples:
|
|
|
12353
13121
|
program.addCommand(new Command("senders")
|
|
12354
13122
|
.description("Manage the org's connected LinkedIn sender accounts for Sequencer: list, connect, sync, get details, disconnect, and tune rate limits. This group is LinkedIn/WhatsApp accounts only; the cross-channel identity (one person's name and photo) that owns inboxes as well is `oxygen senders profiles`.")
|
|
12355
13123
|
.addCommand(new Command("list")
|
|
12356
|
-
.description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
|
|
13124
|
+
.description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage. Disconnected accounts are hidden unless you pass --status disconnected.")
|
|
12357
13125
|
.option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
|
|
12358
13126
|
.option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
|
|
12359
13127
|
.option("--tag <tags>", "Comma-separated workspace tags — matches sender accounts carrying ANY of these tags (see `oxygen tags list`).")
|
|
@@ -12380,7 +13148,7 @@ Examples:
|
|
|
12380
13148
|
await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
|
|
12381
13149
|
}))
|
|
12382
13150
|
.addCommand(new Command("connect")
|
|
12383
|
-
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account occupies one LinkedIn sending seat — $30/month, billed in dollars on the billing owner's seat subscription — unless the billing owner still has grandfathered LinkedIn capacity; check `oxygen billing seats --json` before connecting, and a workspace linked to another organization's plan draws on that organization's seats. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
|
|
13151
|
+
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account occupies one LinkedIn sending seat — $30/month, billed in dollars on the billing owner's seat subscription — unless the billing owner still has grandfathered LinkedIn capacity; check `oxygen billing seats --json` before connecting, and a workspace linked to another organization's plan draws on that organization's seats. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. LinkedIn restricts automated activity: every connection needs --acknowledge-risk (only a reconnect that already recorded one is exempt), and --count above 1 also needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
|
|
12384
13152
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
12385
13153
|
.option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
|
|
12386
13154
|
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
@@ -12388,6 +13156,9 @@ Examples:
|
|
|
12388
13156
|
.option("--custom-proxy", "Let the account owner enter a custom proxy in Unipile's hosted wizard. OXYGEN never receives the proxy credentials.")
|
|
12389
13157
|
.option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Every link uses the same --country; use separate calls for different login countries. Ignored when reconnecting.")
|
|
12390
13158
|
.option("--inbox-scope <scope>", "LinkedIn inbox privacy for new accounts: oxygen_initiated (default) or all. Reconnect preserves the existing account setting.")
|
|
13159
|
+
.option("--acknowledge-risk", "Required for a new account: LinkedIn restricts automated activity; pacing reduces but does not remove the risk of a restriction.")
|
|
13160
|
+
.option("--owner-consent", "Required with --count above 1: each account owner connects their own account.")
|
|
13161
|
+
.option("--established", "The account is an established profile: skip the ~2-week warm-up a new account gets by default.")
|
|
12391
13162
|
.option("--json", "Print a JSON envelope.")
|
|
12392
13163
|
.action(async (options) => {
|
|
12393
13164
|
await handleAsyncAction("senders connect", options, () => {
|
|
@@ -12405,6 +13176,9 @@ Examples:
|
|
|
12405
13176
|
...(options.customProxy ? { custom_proxy: true } : {}),
|
|
12406
13177
|
...(count ? { count } : {}),
|
|
12407
13178
|
...(inboxScope ? { inbox_sync_scope: inboxScope } : {}),
|
|
13179
|
+
...(options.acknowledgeRisk ? { acknowledge_linkedin_risk: true } : {}),
|
|
13180
|
+
...(options.ownerConsent ? { owner_consent: true } : {}),
|
|
13181
|
+
...(options.established ? { established: true } : {}),
|
|
12408
13182
|
},
|
|
12409
13183
|
});
|
|
12410
13184
|
});
|
|
@@ -12487,7 +13261,7 @@ Examples:
|
|
|
12487
13261
|
});
|
|
12488
13262
|
}))
|
|
12489
13263
|
.addCommand(new Command("disconnect")
|
|
12490
|
-
.description("Disconnect a LinkedIn sender account so it stops sending. <id> accepts a sender account id, connection id, or Unipile account id.")
|
|
13264
|
+
.description("Disconnect a LinkedIn sender account so it stops sending and frees its sending seat for another account. Works on an account that is already disconnected. <id> accepts a sender account id, connection id, or Unipile account id.")
|
|
12491
13265
|
.argument("<id>", "Sender account id, connection id, or Unipile account id.")
|
|
12492
13266
|
.option("--json", "Print a JSON envelope.")
|
|
12493
13267
|
.action(async (id, options) => {
|
|
@@ -12529,7 +13303,7 @@ Examples:
|
|
|
12529
13303
|
await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
|
|
12530
13304
|
}))
|
|
12531
13305
|
.addCommand(new Command("set")
|
|
12532
|
-
.description("Adjust per-account daily action limits and the daily-reset timezone. Values are clamped to
|
|
13306
|
+
.description("Adjust per-account daily action limits and the daily-reset timezone. Defaults: 20 invites/day, 100 invites/week, 40 messages/day, 5 comments/day. Values are clamped to hard maximums (30 invites/day, 150 invites/week, 40 messages/day, 20 comments/day, 300 human Unibox sends/hour); higher caps raise the risk of a LinkedIn restriction. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
|
|
12533
13307
|
.argument("<id>", "Sender account id, connection id, or Unipile account id.")
|
|
12534
13308
|
.option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
|
|
12535
13309
|
.option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
|
|
@@ -12551,13 +13325,17 @@ Examples:
|
|
|
12551
13325
|
.option("--interactive-min-spacing-seconds <n>", "Minimum seconds between human-sent Unibox actions.")
|
|
12552
13326
|
.option("--interactive-spacing-jitter-seconds <n>", "Random jitter seconds added to human-sent Unibox action spacing.")
|
|
12553
13327
|
.option("--interactive-sends-per-hour <n>", "Hourly cap on human-sent Unibox actions.")
|
|
12554
|
-
.option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g.
|
|
13328
|
+
.option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. Europe/Berlin. Defaults to the zone of the country chosen at connect.")
|
|
12555
13329
|
.option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
|
|
12556
13330
|
.option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
|
|
13331
|
+
.option("--read-warmup-disable", "Turn off the READ warm-up ramp for this account. Network capture (`oxygen linkedin network setup`) eases a newly armed account in at 5 \u2192 10 \u2192 20 reads a day over two weeks; this runs it at the full read budget immediately. Separate from --warmup-disable, which governs invites and messages.")
|
|
13332
|
+
.option("--read-warmup-restart", "Restart the READ warm-up ramp from today, so network capture eases back in from 5 reads a day. Also clears --read-warmup-disable.")
|
|
12557
13333
|
.option("--warmup-start-date <date>", "Set the warm-up start date (ISO, e.g. 2026-01-31). A past date credits prior warming and advances the ramp.")
|
|
12558
13334
|
.option("--warmup-preset <preset>", "Warm-up ramp curve: conservative (~3 weeks), standard (~2 weeks, default), or fast (~1 week for an aged account).")
|
|
12559
|
-
.option("--randomize-caps", "Randomise this account's daily send caps to 80-100% of configured so volume
|
|
13335
|
+
.option("--randomize-caps", "Randomise this account's daily send caps to 80-100% of configured so daily volume varies instead of hitting the same cap every day.")
|
|
12560
13336
|
.option("--no-randomize-caps", "Turn off randomised daily caps (use the exact configured caps every day).")
|
|
13337
|
+
.option("--weekend-full-volume", "Keep full daily send caps on Saturday and Sunday (by default they run at half).")
|
|
13338
|
+
.option("--no-weekend-full-volume", "Run daily send caps at half on Saturday and Sunday again (the default).")
|
|
12561
13339
|
.option("--json", "Print a JSON envelope.")
|
|
12562
13340
|
.action(async (id, options) => {
|
|
12563
13341
|
await handleAsyncAction("senders limits set", options, () => {
|
|
@@ -12770,7 +13548,7 @@ Examples:
|
|
|
12770
13548
|
});
|
|
12771
13549
|
})));
|
|
12772
13550
|
program.addCommand(new Command("followers")
|
|
12773
|
-
.description("Read
|
|
13551
|
+
.description("Read the followers of YOUR OWN connected LinkedIn account into a Clay-like workspace table you can enrich, qualify, and enroll from. Imports run as a slow, durable background drip under a dedicated conservative read budget — they never burst and never starve the sequencer's own reads. For the followers of a company page — a competitor's audience — use `oxygen tables followers check` instead.")
|
|
12774
13552
|
.addCommand(new Command("import")
|
|
12775
13553
|
.description("Start (or re-arm) a followers import. Followers drip into a workspace table + the deduped mirror over many ticks; poll `followers status` to watch them accrue. The import keeps itself fresh by periodically re-walking followers in the background (new followers dedupe in). No messages are sent and no Oxygen credits are charged.")
|
|
12776
13554
|
.option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
|
|
@@ -12804,11 +13582,13 @@ Examples:
|
|
|
12804
13582
|
});
|
|
12805
13583
|
})));
|
|
12806
13584
|
program.addCommand(new Command("viewers")
|
|
12807
|
-
.description("Read the named LinkedIn 'who viewed my profile' (WVMP) viewers into a Clay-like workspace table.
|
|
13585
|
+
.description("Read the named LinkedIn 'who viewed my profile' (WVMP) viewers into a Clay-like workspace table that leads with each person's photo, name and `viewed_account` (\"Viewed profile of\": every one of your connected accounts that person viewed). `import` arms (or resumes) a slow, durable background drip per account under a dedicated conservative read budget; `pause` switches an account off without touching rows or the table; `enrichment` switches standing paid full-profile enrichment of new viewers on/off. LinkedIn hides anonymous/private viewers, so the table is always a partial sample. `status` reports two counts with two denominators: `viewers_count` is named viewers captured from LinkedIn into the per-account mirror (a person who viewed two of your accounts counts twice) and `table_row_count` is rows in the table, one per viewer — they are not the same number and neither is a page or read counter.")
|
|
12808
13586
|
.addCommand(new Command("import")
|
|
12809
|
-
.description("Start (or re-arm) a durable profile-viewers (WVMP) import. Named viewers drip into a workspace table
|
|
12810
|
-
.option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
|
|
13587
|
+
.description("Start (or re-arm, or resume a paused account) a durable profile-viewers (WVMP) import. Named viewers drip into a workspace table (one row per viewer, upserted on provider_id) from the deduped per-account mirror over many ticks; poll `viewers status` to watch them accrue. Two columns are coarser than they look: `view_count` counts the capture walks a viewer re-appeared in (about one every 6 hours), not profile views, and `last_viewed_at` is approximate — LinkedIn only reports 'Viewed 3w ago'-style recency, stored as the earliest day it can mean. Do not run this for an account whose viewers already land through a LinkedIn capture feed (`viewers status` says `landing_via: feed`): it would arm a second writer into a different table. The import itself sends no messages and charges no Oxygen credits; `--enrich`/`--no-enrich` switch standing per-viewer paid enrichment on or off in the same call (omit to leave it as-is) — see `viewers enrichment`.")
|
|
13588
|
+
.option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id); also switches a paused account back on. Omit to import every active LinkedIn account.")
|
|
12811
13589
|
.option("--table <ref>", "Existing workspace table (id or slug) to import into. Omit to create or reuse a 'LinkedIn Profile Viewers' table.")
|
|
13590
|
+
.option("--enrich", "Also switch on standing full-profile enrichment for new viewers (see `viewers enrichment`). Omit to leave enrichment as-is.")
|
|
13591
|
+
.option("--no-enrich", "Also switch off standing full-profile enrichment. Omit to leave enrichment as-is.")
|
|
12812
13592
|
.option("--json", "Print a JSON envelope.")
|
|
12813
13593
|
.action(async (options) => {
|
|
12814
13594
|
await handleAsyncAction("viewers import", options, () => {
|
|
@@ -12819,12 +13599,13 @@ Examples:
|
|
|
12819
13599
|
body: {
|
|
12820
13600
|
...(account ? { account } : {}),
|
|
12821
13601
|
...(table ? { table } : {}),
|
|
13602
|
+
...(options.enrich === undefined ? {} : { enrich: options.enrich }),
|
|
12822
13603
|
},
|
|
12823
13604
|
});
|
|
12824
13605
|
});
|
|
12825
13606
|
}))
|
|
12826
13607
|
.addCommand(new Command("status")
|
|
12827
|
-
.description("Show durable profile-viewers (WVMP) import progress
|
|
13608
|
+
.description("Show durable profile-viewers (WVMP) import progress: `accounts[]` lists every connected LinkedIn account with its display_name, headline, picture_url, per-account `on` (true when switched on, whatever its health) and state (off|collecting|waiting|paused|attention) with state_note, last_read_at/next_read_at, and whether it is toggleable — plus a preview of the viewers table, `enrichment` (on, credits_per_person, columns — see `viewers enrichment`) and a deep-link to open it. Read job/org `health` before `last_error`, worst state wins: `landing` — every import lands somewhere (its own table, or the LinkedIn capture feed named in `landing_via`/`jobs[].landing`) and is due; `waiting` — it lands, but the last tick parked on the account's daily read quota until `next_tick_at` (a wait, not a fault); `paused` — an account was switched off with `viewers pause`; `viewers import --account <ref>` switches it back on; `stalled` — the landing feed is stopped or paused, or the import spent its retries; `health_note` carries the exact repair (`oxygen feeds resume <id>` or `viewers import`); `unbound` — armed with no table and no feed, reading LinkedIn every tick and writing nothing, without ever recording an error (repair: `viewers import`); `none` — nothing armed. Two counts, two denominators: `viewers_count` is named viewers captured from LinkedIn into the per-account mirror (`viewers_count_scope` says workspace or one account; a person who viewed two of your accounts counts twice), `table_row_count` is rows in the table (one per viewer). They diverge while landing lags capture.")
|
|
12828
13609
|
.option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
|
|
12829
13610
|
.option("--json", "Print a JSON envelope.")
|
|
12830
13611
|
.action(async (options) => {
|
|
@@ -12836,9 +13617,38 @@ Examples:
|
|
|
12836
13617
|
const suffix = params.toString();
|
|
12837
13618
|
return requestOxygen(`/api/cli/linkedin/viewers/status${suffix ? `?${suffix}` : ""}`);
|
|
12838
13619
|
});
|
|
13620
|
+
}))
|
|
13621
|
+
.addCommand(new Command("pause")
|
|
13622
|
+
.description("Switch the profile-viewers import off for one account or all: rows and the table stay exactly as they are, and `viewers import --account <ref>` switches it back on. Imports landed by a LinkedIn capture feed are not touched here — pause the feed instead with `oxygen feeds pause <id>`. Free.")
|
|
13623
|
+
.option("--account <ref>", "Sender account to pause (sender id, connection id, or Unipile account id). Omit to pause every account.")
|
|
13624
|
+
.option("--json", "Print a JSON envelope.")
|
|
13625
|
+
.action(async (options) => {
|
|
13626
|
+
await handleAsyncAction("viewers pause", options, () => {
|
|
13627
|
+
const account = readOption(options.account);
|
|
13628
|
+
return requestOxygen("/api/cli/linkedin/viewers/pause", {
|
|
13629
|
+
method: "POST",
|
|
13630
|
+
body: { ...(account ? { account } : {}) },
|
|
13631
|
+
});
|
|
13632
|
+
});
|
|
13633
|
+
}))
|
|
13634
|
+
.addCommand(new Command("enrichment")
|
|
13635
|
+
.description("Turn automatic full-profile enrichment of NEW profile viewers on or off for the viewers table (" + MANAGED_DATA_SUPPLIERS.scraper + " profile: about, current role and company, experience, education, location). This is STANDING credit spend, charged credits_per_person credits per enriched viewer, applied only to viewers first seen after `enrichment.enabled_at`, each delivery bounded by `enrichment.max_credits_per_delivery` (the plan default). Switching on adds the table's Person profile columns (a paid lookup plus its extractors); they stay, with their data, when switched off. Existing rows are never charged by it — enrich them on demand by running the Person profile column. `viewers status` shows `enrichment` (on, credits_per_person, enabled_at, max_credits_per_delivery, next_capture_at: when new viewers can next arrive); the runs appear on the table's Runs page. Requires exactly one of --on or --off.")
|
|
13636
|
+
.option("--on", "Switch standing enrichment on for new viewers.")
|
|
13637
|
+
.option("--off", "Switch standing enrichment off.")
|
|
13638
|
+
.option("--json", "Print a JSON envelope.")
|
|
13639
|
+
.action(async (options) => {
|
|
13640
|
+
await handleAsyncAction("viewers enrichment", options, () => {
|
|
13641
|
+
if (options.on === options.off) {
|
|
13642
|
+
throw new Error("Pass exactly one of --on or --off.");
|
|
13643
|
+
}
|
|
13644
|
+
return requestOxygen("/api/cli/linkedin/viewers/enrichment", {
|
|
13645
|
+
method: "POST",
|
|
13646
|
+
body: { enabled: options.on === true },
|
|
13647
|
+
});
|
|
13648
|
+
});
|
|
12839
13649
|
})));
|
|
12840
13650
|
program.addCommand(new Command("posts")
|
|
12841
|
-
.description("Read and publish LinkedIn posts through a connected account. `get`, `comments`, and `reactions` are LIVE provider reads: they use 0 Oxygen credits but consume the sender's daily account-read allowance. Do not use them when a task forbids provider calls; use `publishing comments list` for the local Community queue, which discovers recent posts owned by connected accounts in the background. `create` publishes a real post (approval-gated). The direct commands here address ONE post you already have an id for; aggregate analytics
|
|
13651
|
+
.description("Read and publish LinkedIn posts through a connected account. `get`, `comments`, and `reactions` are LIVE provider reads: they use 0 Oxygen credits but consume the sender's daily account-read allowance. Do not use them when a task forbids provider calls; use `publishing comments list` for the local Community queue, which discovers recent posts owned by connected accounts in the background. `create` publishes a real post (approval-gated). The direct commands here address ONE post you already have an id for; aggregate analytics are `oxygen publishing analytics` (LinkedIn includes owned posts discovered from the provider).")
|
|
12842
13652
|
.addCommand(new Command("get")
|
|
12843
13653
|
.description("LIVE provider read of one LinkedIn post. Uses 0 Oxygen credits but consumes the sender's metered account-read allowance; do not run it when the task forbids provider calls. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). This does not populate or refresh the durable `publishing comments` queue.")
|
|
12844
13654
|
.requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id. For a direct live read, take the id from the post's LinkedIn URL or a scheduled post (`oxygen publishing posts get <post_id>` → provider_post_id); Community discovers recent owned posts separately in the background.")
|
|
@@ -12939,7 +13749,7 @@ Examples:
|
|
|
12939
13749
|
});
|
|
12940
13750
|
})));
|
|
12941
13751
|
program.addCommand(new Command("engagement")
|
|
12942
|
-
.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 daily monitoring across profiles and their recent post engagers, start with `oxygen tables watcher preview --help` (free credit review; one editable table). Harvests run as a slow, durable drip under a conservative read budget.")
|
|
13752
|
+
.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 daily monitoring across profiles and their recent post engagers, start with `oxygen tables watcher preview --help` (free credit review; one editable table). Harvests run as a slow, durable drip under a conservative read budget. To have teammates react to or comment on the workspace's own posts, use `oxygen publishing amplification --help`.")
|
|
12943
13753
|
.addCommand(new Command("engagers")
|
|
12944
13754
|
.description("Read one post's reactors and commenters right now as a de-duplicated people list ready to enroll. Pages both sources up to --max-pages (default 5, max 20) x 100 per page; `truncated: true` in the envelope means a cap stopped a source that still had more — raise --max-pages, or use `oxygen engagement harvest` for a post too big to read in one request. Nothing is sent and no credits are spent, but every page is one LinkedIn read against the sender account. --post is the composite social_id from `oxygen posts get` (NOT the activity URN).")
|
|
12945
13755
|
.requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
|
|
@@ -13093,7 +13903,46 @@ Examples:
|
|
|
13093
13903
|
});
|
|
13094
13904
|
})));
|
|
13095
13905
|
program.addCommand(new Command("linkedin")
|
|
13096
|
-
.description("LinkedIn
|
|
13906
|
+
.description("Put your own LinkedIn network in a table and keep it current. `linkedin network setup` captures everyone you are connected to and everyone who follows you into one self-refreshing 'LinkedIn Network' table (free, read-only on your account, paced so the account is never at risk); `linkedin network status` reports the pace and `linkedin network pause` switches it off. `linkedin invitations setup` does the same for open connection requests — received and sent — in one self-updating table. Also home to `linkedin intent` (engagement capture) and `linkedin ingestion status` (every LinkedIn read drip and each account's remaining read budget).")
|
|
13907
|
+
.addCommand(new Command("network")
|
|
13908
|
+
.description("Capture everyone already in your LinkedIn network \u2014 the people you are connected to and the people who follow you \u2014 into one self-refreshing workspace table you can filter, enrich and enroll from. READ-ONLY on your LinkedIn account: it lists your own connections and followers and nothing else. It sends no messages, no connection requests, and does not visit anyone's profile. Runs as a slow background drip on its OWN per-account read budget, so it cannot slow down or starve anything else the account does: about 2,000 people a day while it first walks your network (40 reads of up to 50 people), then a light check a few times a day for new arrivals. Only inside each account's own 07:00-22:00 working hours, spaced minutes apart, never in a burst; a newly armed account eases in over its first two weeks. LinkedIn does not say how big a network is until the walk has been through it, so a large network takes days \u2014 `status` reports the rate, not a completion date it cannot know. Free \u2014 no Oxygen credits; it uses the LinkedIn seat you already pay for.")
|
|
13909
|
+
.addCommand(new Command("setup")
|
|
13910
|
+
.description("Arm (or re-arm) network capture. Creates or reuses one merged 'LinkedIn Network' table and starts a connections drip and a followers drip per account, both writing into that one table deduped on LinkedIn member id \u2014 somebody who is both shows as 'both' in the Relationship column. This is a STANDING permission: capture keeps running until you switch it off with `linkedin network pause`, and a LinkedIn account you connect later is armed automatically. A newly connected account eases into full read speed over its first two weeks. Nothing is sent and no credits are charged. The response names each account it armed (`senders[].display_name`) \u2014 check it is yours before leaving it running.")
|
|
13911
|
+
.option("--account <ref>", "Account to capture (sender id, connection id, or Unipile account id). Omit to capture every connected LinkedIn account.")
|
|
13912
|
+
.option("--table <ref>", "Existing workspace table (id or slug) to capture into. Omit to create or reuse 'LinkedIn Network'.")
|
|
13913
|
+
.option("--json", "Print a JSON envelope.")
|
|
13914
|
+
.action(async (options) => {
|
|
13915
|
+
await handleAsyncAction("linkedin network setup", options, () => {
|
|
13916
|
+
const account = readOption(options.account);
|
|
13917
|
+
const table = readOption(options.table);
|
|
13918
|
+
return requestOxygen("/api/cli/tables/linkedin-network", {
|
|
13919
|
+
method: "POST",
|
|
13920
|
+
body: {
|
|
13921
|
+
...(account ? { account } : {}),
|
|
13922
|
+
...(table ? { table } : {}),
|
|
13923
|
+
},
|
|
13924
|
+
});
|
|
13925
|
+
});
|
|
13926
|
+
}))
|
|
13927
|
+
.addCommand(new Command("status")
|
|
13928
|
+
.description("Show network capture progress: `accounts[]` lists every connected LinkedIn account with its headline, `on` (switched on or not), and state (off|collecting|waiting|paused|attention); which accounts are armed, what each one may actually read TODAY after its warm-up ramp (`reads_per_day_now` / `people_per_day_now`), and per-drip state \u2014 including whether each half is still doing its first full walk ('backfill') or has switched to the light check ('delta'). `pace` states the rate in people per day and says plainly that no completion date exists until the first walk finishes, rather than inventing one. Also previews the table and deep-links to it. A capture sitting in 'backfill' for days is working as designed, not stuck.")
|
|
13929
|
+
.option("--json", "Print a JSON envelope.")
|
|
13930
|
+
.action(async (options) => {
|
|
13931
|
+
await handleAsyncAction("linkedin network status", options, () => requestOxygen("/api/cli/tables/linkedin-network"));
|
|
13932
|
+
}))
|
|
13933
|
+
.addCommand(new Command("pause")
|
|
13934
|
+
.description("Switch network capture off: the worker stops reading that account's connections and followers until you run `linkedin network setup` again. Pass --account to pause one account (the one you armed by mistake, say); omit it to pause every armed account. The table and its rows stay exactly as they are, and re-arming continues the walk where it stopped rather than starting over. A standalone `connections import` or `followers import` is not touched. Free.")
|
|
13935
|
+
.option("--account <ref>", "Account to pause (sender id, connection id, or Unipile account id). Omit to pause every armed LinkedIn account.")
|
|
13936
|
+
.option("--json", "Print a JSON envelope.")
|
|
13937
|
+
.action(async (options) => {
|
|
13938
|
+
await handleAsyncAction("linkedin network pause", options, () => {
|
|
13939
|
+
const account = readOption(options.account);
|
|
13940
|
+
return requestOxygen("/api/cli/tables/linkedin-network/pause", {
|
|
13941
|
+
method: "POST",
|
|
13942
|
+
body: { ...(account ? { account } : {}) },
|
|
13943
|
+
});
|
|
13944
|
+
});
|
|
13945
|
+
})))
|
|
13097
13946
|
.addCommand(new Command("intent")
|
|
13098
13947
|
.description("Capture organic LinkedIn intent in durable OXYGEN Workflows + linked Tables. People are deduped; every reaction, comment, weekly profile view, new follower, and new connection is retained as a touchpoint. Engagement does not create CRM leads.")
|
|
13099
13948
|
.addCommand(new Command("setup")
|
|
@@ -13189,7 +14038,44 @@ Examples:
|
|
|
13189
14038
|
});
|
|
13190
14039
|
})))
|
|
13191
14040
|
.addCommand(new Command("invitations")
|
|
13192
|
-
.description("
|
|
14041
|
+
.description("Open LinkedIn connection requests on your own accounts. `setup` keeps one self-updating 'LinkedIn Open Connection Requests' table of every pending request — people who asked to connect with you (received) and requests you sent (sent) — and marks each one accepted or no_longer_pending when it disappears; `status` and `pause` manage it. `list` and `withdraw` act on the sent requests directly.")
|
|
14042
|
+
.addCommand(new Command("setup")
|
|
14043
|
+
.description("Switch on the open connection requests table. Creates or reuses one 'LinkedIn Open Connection Requests' table and checks each chosen account about twice a day, inside that account's working hours: every pending request, received and sent, is a row with the person, the account it is on (`account`), its `direction` and its `status`. A request that disappears is marked `accepted` when the person is now a connection, otherwise `no_longer_pending` (LinkedIn does not say whether it was declined, withdrawn or expired); acceptances seen live are marked within minutes. READ-ONLY on LinkedIn: nothing is accepted, declined, withdrawn or sent. Free — no Oxygen credits; the reads count against each account's daily LinkedIn read budget. Accounts connected later stay off until you switch them on.")
|
|
14044
|
+
.option("--account <ref>", "Account to switch on (sender id, connection id, or Unipile account id). Omit to switch on every connected LinkedIn account.")
|
|
14045
|
+
.option("--table <ref>", "Existing workspace table (id or slug) to write into. Omit to create or reuse 'LinkedIn Open Connection Requests'.")
|
|
14046
|
+
.option("--json", "Print a JSON envelope.")
|
|
14047
|
+
.action(async (options) => {
|
|
14048
|
+
await handleAsyncAction("linkedin invitations setup", options, () => {
|
|
14049
|
+
const account = readOption(options.account);
|
|
14050
|
+
const table = readOption(options.table);
|
|
14051
|
+
return requestOxygen("/api/cli/tables/linkedin-open-invites", {
|
|
14052
|
+
method: "POST",
|
|
14053
|
+
body: {
|
|
14054
|
+
...(account ? { account } : {}),
|
|
14055
|
+
...(table ? { table } : {}),
|
|
14056
|
+
},
|
|
14057
|
+
});
|
|
14058
|
+
});
|
|
14059
|
+
}))
|
|
14060
|
+
.addCommand(new Command("status")
|
|
14061
|
+
.description("Show the open connection requests table: `accounts[]` lists every connected LinkedIn account with `on`, its state (off|collecting|waiting|paused|attention), and when it was last and will next be checked. Also previews the table and deep-links to it.")
|
|
14062
|
+
.option("--json", "Print a JSON envelope.")
|
|
14063
|
+
.action(async (options) => {
|
|
14064
|
+
await handleAsyncAction("linkedin invitations status", options, () => requestOxygen("/api/cli/tables/linkedin-open-invites"));
|
|
14065
|
+
}))
|
|
14066
|
+
.addCommand(new Command("pause")
|
|
14067
|
+
.description("Stop checking open connection requests for one account (--account) or every account. The table and its rows stay; `linkedin invitations setup` switches it back on. Free.")
|
|
14068
|
+
.option("--account <ref>", "Account to pause (sender id, connection id, or Unipile account id). Omit to pause every account.")
|
|
14069
|
+
.option("--json", "Print a JSON envelope.")
|
|
14070
|
+
.action(async (options) => {
|
|
14071
|
+
await handleAsyncAction("linkedin invitations pause", options, () => {
|
|
14072
|
+
const account = readOption(options.account);
|
|
14073
|
+
return requestOxygen("/api/cli/tables/linkedin-open-invites/pause", {
|
|
14074
|
+
method: "POST",
|
|
14075
|
+
body: { ...(account ? { account } : {}) },
|
|
14076
|
+
});
|
|
14077
|
+
});
|
|
14078
|
+
}))
|
|
13193
14079
|
.addCommand(new Command("withdraw")
|
|
13194
14080
|
.description("Withdraw pending sent LinkedIn invitations. A real external social action gated behind approval: prints the resolved target list by default and only withdraws when you pass --approved. Select targets with --ids (a comma-separated id list from `linkedin invitations list`) OR --older-than <days> (pending invites older than N days, among the 100 most recent sent) — not both. Idempotent: an invite that is already gone counts as withdrawn.")
|
|
13195
14081
|
.option("--ids <a,b>", "Comma-separated invitation ids to withdraw (from `linkedin invitations list`).")
|
|
@@ -13214,7 +14100,7 @@ Examples:
|
|
|
13214
14100
|
});
|
|
13215
14101
|
}))
|
|
13216
14102
|
.addCommand(new Command("list")
|
|
13217
|
-
.description("List the account's pending sent LinkedIn invitations with their invitation ids — the ids `linkedin invitations withdraw --ids` cancels. Read-only: no provider write, no credits.")
|
|
14103
|
+
.description("List the account's pending sent LinkedIn invitations with their invitation ids — the ids `linkedin invitations withdraw --ids` cancels. Read-only: no provider write, no credits. For received requests too, kept in a table that updates itself, use `linkedin invitations setup`.")
|
|
13218
14104
|
.option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
|
|
13219
14105
|
.option("--limit <n>", "Maximum invitations to return.")
|
|
13220
14106
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -13234,7 +14120,7 @@ Examples:
|
|
|
13234
14120
|
program.addCommand(new Command("whatsapp")
|
|
13235
14121
|
.description("Manage the org's connected WhatsApp accounts for Sequencer: list, connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
|
|
13236
14122
|
.addCommand(new Command("accounts")
|
|
13237
|
-
.description("List connected WhatsApp accounts with health status, warm-up ramp state, limits, and today's usage.")
|
|
14123
|
+
.description("List connected WhatsApp accounts with health status, warm-up ramp state, limits, and today's usage. Disconnected accounts are hidden unless you pass --status disconnected.")
|
|
13238
14124
|
.option("--status <status>", "Filter by account status: active, paused, disconnected, restricted, or credentials_required.")
|
|
13239
14125
|
.option("--no-usage", "Skip today's per-account usage counts.")
|
|
13240
14126
|
.option("--tag <tags>", "Comma-separated workspace tags — matches WhatsApp accounts carrying ANY of these tags (see `oxygen tags list`).")
|
|
@@ -13288,7 +14174,7 @@ Examples:
|
|
|
13288
14174
|
});
|
|
13289
14175
|
}))
|
|
13290
14176
|
.addCommand(new Command("disconnect")
|
|
13291
|
-
.description("Disconnect a WhatsApp account so it stops sending. <id> accepts an account id, connection id, or Unipile account id.")
|
|
14177
|
+
.description("Disconnect a WhatsApp account so it stops sending and frees its sending seat for another account. Works on an account that is already disconnected. <id> accepts an account id, connection id, or Unipile account id.")
|
|
13292
14178
|
.argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
|
|
13293
14179
|
.option("--json", "Print a JSON envelope.")
|
|
13294
14180
|
.action(async (id, options) => {
|
|
@@ -13460,25 +14346,29 @@ Examples:
|
|
|
13460
14346
|
program.addCommand(new Command("inbox")
|
|
13461
14347
|
.description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
|
|
13462
14348
|
.addCommand(new Command("list")
|
|
13463
|
-
.description("List conversations newest first across email, LinkedIn, and WhatsApp — the merged stream is the default, --channels narrows it, and --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
|
|
14349
|
+
.description("List conversations newest first across email, LinkedIn, and WhatsApp — the merged stream is the default, --channels narrows it, and --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation. Background AI reply triage sets each conversation's status, so triage is a filter, not a script: `--status interested` is positive sales intent, `--status not_interested` is who said no, and `--unanswered` only means the last message is theirs. None of them means a thread needs a reply: interested includes thank-yous and booking confirmations, and neutral can hold real questions. A reply that just arrived can read neutral until it is analyzed (`inbox analyze <id>` classifies one now). In --json, `count` is the rows on this page (follow next_cursor for more); on email, `total_conversations` and the `sidebar_counts` facets are mailbox-wide and do not apply your filters, so never read them as the size of a filtered list.")
|
|
13464
14350
|
.option("--channel <channel>", "Inbox channel: all (default, the merged stream), email, linkedin, or whatsapp.")
|
|
13465
14351
|
.option("--channels <list>", "Narrow the merged stream: comma-separated channel groups to include (email,linkedin,whatsapp). Implies --channel all.")
|
|
13466
14352
|
.option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
|
|
13467
14353
|
.option("--unread", "Only show conversations with unread messages.")
|
|
13468
|
-
.option("--unanswered", "Only conversations
|
|
14354
|
+
.option("--unanswered", "Only conversations you have not replied to — the last message in the thread is inbound. Not the same as needing a reply: it keeps thank-yous and automated mail. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.) It includes inbound mail no campaign sent — unsolicited or spam — so for replies to your outreach add --sequence-id; the ids and per-campaign counts are in sidebar_counts.byCampaign of the same --json response.")
|
|
14355
|
+
// Hidden while the AI inbox triage is dev-only (ADR 0027): production
|
|
14356
|
+
// help must not offer a flag its API refuses. Unhide with the flag's
|
|
14357
|
+
// production rollout.
|
|
14358
|
+
.addOption(new Option("--needs-reply", "Narrower than --unanswered: unanswered threads minus those AI inbox triage judged bulk (newsletters, notifications, mass pitches) or needing no answer. Threads not yet triaged stay in. Rows then carry `triage` in --json. Ignored while --search is set. Only where AI inbox triage is enabled; elsewhere the API answers needs_reply_unavailable.").hideHelp())
|
|
13469
14359
|
.option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
|
|
13470
14360
|
.option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
|
|
13471
14361
|
.option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
|
|
13472
|
-
.option("--status <keys>", "Comma-separated status keys
|
|
14362
|
+
.option("--status <keys>", "Comma-separated reply-status keys, set by AI reply triage. System keys: interested, neutral, out_of_office, wrong_person, not_now, not_interested, lost, bounced; your workspace's full list, custom labels included, is `inbox labels list`. Cross-channel — filters email + LinkedIn + WhatsApp by the shared taxonomy.")
|
|
13473
14363
|
.option("--tag <tags>", "Comma-separated campaign tags — matches email conversations whose campaign (sequence) carries any of these tags. DMs have no campaign link, so a tag filter shows email only.")
|
|
13474
14364
|
.option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
|
|
13475
14365
|
.option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
|
|
13476
|
-
.option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs —
|
|
14366
|
+
.option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs — `sequences list` shows each campaign's id). Returns only conversations linked to that campaign; `sidebar_counts.byCampaign` is its size.")
|
|
13477
14367
|
.option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
|
|
13478
14368
|
.option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
|
|
13479
14369
|
.option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
|
|
13480
14370
|
.option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
|
|
13481
|
-
.option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
|
|
14371
|
+
.option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow. For structured filters use --status or --tag, not a text search.")
|
|
13482
14372
|
.option("--include-archived", "Include archived conversations.")
|
|
13483
14373
|
.option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
|
|
13484
14374
|
.option("--cursor <cursor>", "Merged stream only: the previous page's next_cursor — resumes after that row.")
|
|
@@ -13497,6 +14387,8 @@ Examples:
|
|
|
13497
14387
|
params.set("unread", "true");
|
|
13498
14388
|
if (options.unanswered)
|
|
13499
14389
|
params.set("unanswered", "true");
|
|
14390
|
+
if (options.needsReply)
|
|
14391
|
+
params.set("needs_reply", "true");
|
|
13500
14392
|
if (options.responsesOnly)
|
|
13501
14393
|
params.set("responses_only", "true");
|
|
13502
14394
|
for (const [flag, key] of [
|
|
@@ -13578,6 +14470,18 @@ Examples:
|
|
|
13578
14470
|
},
|
|
13579
14471
|
});
|
|
13580
14472
|
});
|
|
14473
|
+
}))
|
|
14474
|
+
.addCommand(new Command("delete-message")
|
|
14475
|
+
.description("Delete your sent LinkedIn message for everyone within 60 minutes. This cannot be undone. Omit --approved for a preview; show it before approving. Other channels and inbound messages are unsupported.")
|
|
14476
|
+
.argument("<conversationId>", "Oxygen conversation id from inbox get.")
|
|
14477
|
+
.argument("<messageId>", "Oxygen message id from inbox get.")
|
|
14478
|
+
.option("--approved", "Approve permanent deletion of this exact message after reviewing the preview.")
|
|
14479
|
+
.option("--json", "Print a JSON envelope.")
|
|
14480
|
+
.action(async (conversationId, messageId, options) => {
|
|
14481
|
+
await handleAsyncAction("inbox delete-message", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/delete`, {
|
|
14482
|
+
method: "POST",
|
|
14483
|
+
body: { approved: options.approved === true },
|
|
14484
|
+
}));
|
|
13581
14485
|
}))
|
|
13582
14486
|
.addCommand(new Command("mark-read")
|
|
13583
14487
|
.description("Mark a conversation and all its messages as read.")
|
|
@@ -13609,7 +14513,9 @@ Examples:
|
|
|
13609
14513
|
.option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
|
|
13610
14514
|
.option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
|
|
13611
14515
|
.option("--include-archived", "Also mark archived conversations read.")
|
|
13612
|
-
.option("--unanswered", "Only sweep conversations
|
|
14516
|
+
.option("--unanswered", "Only sweep conversations you have not replied to (the last message is inbound) — the same filter as `inbox list --unanswered`, so the scope is exactly that list.")
|
|
14517
|
+
// Hidden with `inbox list --needs-reply`; see there.
|
|
14518
|
+
.addOption(new Option("--needs-reply", "Only sweep the `inbox list --needs-reply` set (where AI inbox triage is enabled). Ignored while --search is set.").hideHelp())
|
|
13613
14519
|
.option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
|
|
13614
14520
|
.option("--json", "Print a JSON envelope.")
|
|
13615
14521
|
.action(async (options) => {
|
|
@@ -13646,6 +14552,8 @@ Examples:
|
|
|
13646
14552
|
// the string-valued options.
|
|
13647
14553
|
if (options.unanswered)
|
|
13648
14554
|
params.set("unanswered", "true");
|
|
14555
|
+
if (options.needsReply)
|
|
14556
|
+
params.set("needs_reply", "true");
|
|
13649
14557
|
return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
|
|
13650
14558
|
method: "POST",
|
|
13651
14559
|
// Approval rides in the body: a bodyless POST reads as
|
|
@@ -14359,6 +15267,7 @@ Examples:
|
|
|
14359
15267
|
.option("--from <date>", "Custom start date (YYYY-MM-DD). Windows up to five years are accepted.")
|
|
14360
15268
|
.option("--to <date>", "Custom end date (YYYY-MM-DD).")
|
|
14361
15269
|
.option("--sequence <id-or-slug>", "Limit analytics to one sequence.")
|
|
15270
|
+
.option("--sender <key>", "A sender from `oxygen senders list` or `oxygen senders profiles list`, or a key from this output's senders list. Repeatable.", collectRepeatable, [])
|
|
14362
15271
|
.option("--json", "Print a JSON envelope.")
|
|
14363
15272
|
.action(async (options) => {
|
|
14364
15273
|
await handleReadActionWithLens("sequences analytics", options, () => {
|
|
@@ -14367,6 +15276,7 @@ Examples:
|
|
|
14367
15276
|
const from = readOption(options.from);
|
|
14368
15277
|
const to = readOption(options.to);
|
|
14369
15278
|
const sequence = readOption(options.sequence);
|
|
15279
|
+
const senders = (options.sender ?? []).flatMap((value) => readCsvOption(value));
|
|
14370
15280
|
if (range)
|
|
14371
15281
|
params.set("range", range);
|
|
14372
15282
|
if (from)
|
|
@@ -14375,6 +15285,8 @@ Examples:
|
|
|
14375
15285
|
params.set("to", to);
|
|
14376
15286
|
if (sequence)
|
|
14377
15287
|
params.set("sequence", sequence);
|
|
15288
|
+
if (senders.length > 0)
|
|
15289
|
+
params.set("senders", senders.join(","));
|
|
14378
15290
|
const qs = params.toString();
|
|
14379
15291
|
return requestOxygen(`/api/cli/sequences/analytics${qs ? `?${qs}` : ""}`);
|
|
14380
15292
|
}, formatSequenceAnalyticsHealth);
|
|
@@ -15565,7 +16477,7 @@ Examples:
|
|
|
15565
16477
|
});
|
|
15566
16478
|
}))
|
|
15567
16479
|
.addCommand(new Command("hubspot")
|
|
15568
|
-
.description("Discover and synchronize HubSpot contact/company saved lists into the shared Oxygen DNC endpoint.")
|
|
16480
|
+
.description("Discover and synchronize HubSpot contact/company saved lists into the shared Oxygen DNC endpoint. The same saved lists can be imported as table rows instead — see `oxygen tables hubspot import`, which syncs a do-not-contact list first and then lands the members in a new table.")
|
|
15569
16481
|
.addCommand(new Command("lists")
|
|
15570
16482
|
.description("List contact/company saved lists and readable identity properties. Reads definitions only; no memberships or writes.")
|
|
15571
16483
|
.option("--query <text>", "Optional list-name search.")
|
|
@@ -16034,11 +16946,12 @@ Examples:
|
|
|
16034
16946
|
await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
|
|
16035
16947
|
}))
|
|
16036
16948
|
.addCommand(new Command("delete")
|
|
16037
|
-
.description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused.
|
|
16949
|
+
.description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Without --cascade, a mailbox whose own warm-up or deliverability monitoring is still connected fails closed with its exact teardown steps; with --cascade, those add-ons are disconnected as part of the delete (their recurring credits stop too) and nothing is deleted unless every teardown is confirmed. A live managed DOMAIN subscription always fails closed, because it covers other mailboxes — manage it from its domain row.")
|
|
16038
16950
|
.requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
|
|
16039
16951
|
.option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
|
|
16040
16952
|
.option("--plan-hash <hash>", "Fresh preview plan_hash.")
|
|
16041
16953
|
.option("--confirmation <phrase>", "Exact confirmation_phrase returned by the fresh preview.")
|
|
16954
|
+
.option("--cascade", "Disconnect each mailbox's OWN warm-up and deliverability-monitoring add-ons as part of the delete, instead of failing closed until you tear them down by hand. Stops their recurring credits too. Pass it on the preview as well as the approval: the plan hash is bound to it, so an approval cannot turn it on. A live managed domain subscription still fails closed.")
|
|
16042
16955
|
.option("--json", "Print a JSON envelope.")
|
|
16043
16956
|
.action(async (options) => {
|
|
16044
16957
|
await handleAsyncAction("mailboxes delete", options, () => {
|
|
@@ -16056,6 +16969,7 @@ Examples:
|
|
|
16056
16969
|
body: {
|
|
16057
16970
|
mailboxes,
|
|
16058
16971
|
...(options.approved === true ? { approved: true } : {}),
|
|
16972
|
+
...(options.cascade === true ? { cascade: true } : {}),
|
|
16059
16973
|
...(planHash ? { plan_hash: planHash } : {}),
|
|
16060
16974
|
...(confirmation ? { confirmation } : {}),
|
|
16061
16975
|
},
|
|
@@ -16721,6 +17635,21 @@ Examples:
|
|
|
16721
17635
|
},
|
|
16722
17636
|
});
|
|
16723
17637
|
});
|
|
17638
|
+
}))
|
|
17639
|
+
.addCommand(new Command("reactivate")
|
|
17640
|
+
.description("Make the warm-up provider re-run the connection check it abandoned on an inbox it has DISABLED, without touching your credential, your subscription, or the ramp day. Use this when warmup status reports an inbox stopped by the provider and `warmup reconnect` refuses it — which is what happens to a Microsoft inbox connected by OAuth or tenant consent, because there is no password to re-push. 0 credits: the subscription, its renewal date, and the ramp day all stay exactly as they are, so it is the free alternative to disable + re-enable (which bills another warm-up month AND restarts the ramp at day 1). Only inboxes with a live warm-up subscription are eligible. IMPORTANT: this forces the provider to re-check; a forced pass is NOT proof the connection works. Confirm recovery from a rising send count with `oxygen mailboxes warmup status` after the provider's next batch, which is hours away, not minutes. Targets the whole pool unless --mailboxes is given.")
|
|
17641
|
+
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
17642
|
+
.option("--json", "Print a JSON envelope.")
|
|
17643
|
+
.action(async (options) => {
|
|
17644
|
+
await handleAsyncAction("mailboxes warmup reactivate", options, () => {
|
|
17645
|
+
const mailboxes = readCsvOption(options.mailboxes);
|
|
17646
|
+
return requestOxygen("/api/cli/mailboxes/warmup/reactivate", {
|
|
17647
|
+
method: "POST",
|
|
17648
|
+
body: {
|
|
17649
|
+
...(mailboxes.length > 0 ? { mailboxes } : {}),
|
|
17650
|
+
},
|
|
17651
|
+
});
|
|
17652
|
+
});
|
|
16724
17653
|
}))
|
|
16725
17654
|
.addCommand(new Command("disable")
|
|
16726
17655
|
.description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. NOT the way to fix a rotated app password — use `mailboxes warmup reconnect` for that, which costs 0 credits instead of a new 100-credit month. Targets the whole pool unless --mailboxes is given.")
|
|
@@ -19463,7 +20392,9 @@ function printColumnCatalog(result) {
|
|
|
19463
20392
|
const data = isRecord(result) ? (isRecord(result.data) ? result.data : result) : null;
|
|
19464
20393
|
const entries = data && Array.isArray(data.entries) ? data.entries.filter(isRecord) : [];
|
|
19465
20394
|
const bundles = data && Array.isArray(data.bundles) ? data.bundles.filter(isRecord) : [];
|
|
19466
|
-
|
|
20395
|
+
// Native column kinds (Classify) are sent only by a server that runs them.
|
|
20396
|
+
const native = data && Array.isArray(data.native) ? data.native.filter(isRecord) : [];
|
|
20397
|
+
if (entries.length === 0 && bundles.length === 0 && native.length === 0) {
|
|
19467
20398
|
process.stderr.write("No column templates match. No preset bundle matches either.\n"
|
|
19468
20399
|
+ "A template is one column (`columns add <table> --prompt-key <key>`); a bundle is a preset that creates several at once (`columns add <table> --preset <id>`).\n"
|
|
19469
20400
|
+ "Shorten --search, drop it, or pick a section: `columns catalog --category people`.\n");
|
|
@@ -19477,6 +20408,20 @@ function printColumnCatalog(result) {
|
|
|
19477
20408
|
byFamily.set(family, bucket);
|
|
19478
20409
|
}
|
|
19479
20410
|
const lines = [];
|
|
20411
|
+
for (const kind of native) {
|
|
20412
|
+
const credits = isRecord(kind.credit_estimate) && typeof kind.credit_estimate.label === "string" ? kind.credit_estimate.label : "";
|
|
20413
|
+
lines.push(`${String(kind.label)} [native${credits ? ` · ${credits}` : ""}]`);
|
|
20414
|
+
if (typeof kind.description === "string" && kind.description)
|
|
20415
|
+
lines.push(` ${kind.description}`);
|
|
20416
|
+
for (const mode of Array.isArray(kind.modes) ? kind.modes.filter(isRecord) : []) {
|
|
20417
|
+
if (typeof mode.add_command === "string")
|
|
20418
|
+
lines.push(` ${mode.add_command}`);
|
|
20419
|
+
}
|
|
20420
|
+
if (typeof kind.review_uncertain === "string" && kind.review_uncertain)
|
|
20421
|
+
lines.push(` ${kind.review_uncertain}`);
|
|
20422
|
+
}
|
|
20423
|
+
if (native.length > 0 && byFamily.size > 0)
|
|
20424
|
+
lines.push("");
|
|
19480
20425
|
for (const [family, bucket] of byFamily) {
|
|
19481
20426
|
lines.push(`${family.replaceAll("_", " ")}:`);
|
|
19482
20427
|
for (const entry of bucket) {
|
|
@@ -19559,8 +20504,21 @@ function applyAiColumnConfig(definition, options) {
|
|
|
19559
20504
|
definition.structuredOutput = true;
|
|
19560
20505
|
return definition;
|
|
19561
20506
|
}
|
|
19562
|
-
|
|
19563
|
-
|
|
20507
|
+
// Client-side mirror of the server's managed grounding rosters
|
|
20508
|
+
// (AI_WEB_SEARCH_ENGINES / AI_WEB_FETCH_ENGINES in @oxygen/tenant-db). Kept in
|
|
20509
|
+
// step by hand: if this set is wider than the server's, `--research-engine` takes
|
|
20510
|
+
// a value the server then rejects; if it is narrower, the CLI blocks an engine
|
|
20511
|
+
// that works. D52 took parallel and linkup to BYOK and moved firecrawl off the
|
|
20512
|
+
// search rail onto scraping only.
|
|
20513
|
+
//
|
|
20514
|
+
// Restored 2026-09-21: b152df38b (a Dashboards CLI change) rebased a 295-line
|
|
20515
|
+
// addition over a stale copy of this file and silently reverted both sets to the
|
|
20516
|
+
// pre-D52 values, so dev shipped a CLI that accepted `--research-engine parallel`
|
|
20517
|
+
// and let the server reject it. `research-engine-roster.test.ts` now asserts
|
|
20518
|
+
// these against the tenant-db unions so the next stale rebase fails the gate
|
|
20519
|
+
// instead of reaching a customer.
|
|
20520
|
+
const RESEARCH_ENGINES = new Set(["serper", "exa"]);
|
|
20521
|
+
const RESEARCH_FETCH_ENGINES = new Set(["firecrawl", "exa"]);
|
|
19564
20522
|
const RESEARCH_MODES = new Set(["strict", "estimate"]);
|
|
19565
20523
|
/**
|
|
19566
20524
|
* Fold the `--research-*` flags into `definition.webSearch`.
|
|
@@ -19604,8 +20562,8 @@ function applyResearchColumnConfig(definition, options) {
|
|
|
19604
20562
|
const allowed = researchUrl ? RESEARCH_FETCH_ENGINES : RESEARCH_ENGINES;
|
|
19605
20563
|
if (!allowed.has(engine)) {
|
|
19606
20564
|
throw new OxygenError("invalid_request", researchUrl
|
|
19607
|
-
? `--research-engine with --research-url must be firecrawl
|
|
19608
|
-
: `--research-engine must be
|
|
20565
|
+
? `--research-engine with --research-url must be firecrawl or exa (got ${engine}).`
|
|
20566
|
+
: `--research-engine must be serper or exa (got ${engine}).`, { exitCode: 1 });
|
|
19609
20567
|
}
|
|
19610
20568
|
webSearch.engine = engine;
|
|
19611
20569
|
}
|
|
@@ -20523,8 +21481,14 @@ function readSignalsSearchRunBody(options, promptArg) {
|
|
|
20523
21481
|
const promptSource = options.prompt ?? promptArg;
|
|
20524
21482
|
const prompt = promptSource ? readFileIfPresent(promptSource) : null;
|
|
20525
21483
|
const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
|
|
20526
|
-
|
|
20527
|
-
|
|
21484
|
+
// A post search needs no prompt: its --keywords and filters are the plan,
|
|
21485
|
+
// as they are for the free preview (the server names the table after them).
|
|
21486
|
+
const postSearchFilters = !prompt && !plan && readOption(options.family) === "linkedin_posts"
|
|
21487
|
+
? readSignalSearchFilters(options)
|
|
21488
|
+
: null;
|
|
21489
|
+
const postSearch = Array.isArray(postSearchFilters?.keywords) && postSearchFilters.keywords.length > 0;
|
|
21490
|
+
if (!prompt && !plan && !postSearch) {
|
|
21491
|
+
throw new OxygenError("invalid_request", "Pass --prompt or --plan-json (linkedin_posts needs only --keywords).", { exitCode: 1 });
|
|
20528
21492
|
}
|
|
20529
21493
|
if (prompt && !readOption(options.family)) {
|
|
20530
21494
|
throw new OxygenError("invalid_request", "Pass --family when planning from --prompt.", { exitCode: 1 });
|
|
@@ -20535,8 +21499,15 @@ function readSignalsSearchRunBody(options, promptArg) {
|
|
|
20535
21499
|
const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
|
|
20536
21500
|
const maxRowsPerCycle = readPositiveInt(options.maxRowsPerCycle);
|
|
20537
21501
|
// Filters describe a prompt that is being (re)planned. A saved plan already
|
|
20538
|
-
// froze its own, so re-
|
|
20539
|
-
|
|
21502
|
+
// froze its own, so they are never re-sent with one; typed alongside a saved
|
|
21503
|
+
// plan they are refused, because dropping them would run live unfiltered. A
|
|
21504
|
+
// prompt-less linkedin_posts search (no plan) is planned from its filters.
|
|
21505
|
+
const typedFilters = readSignalSearchFilters(options);
|
|
21506
|
+
if (plan && !prompt && typedFilters) {
|
|
21507
|
+
throw new OxygenError("invalid_request", `A saved plan keeps the filters it was planned with, so ${Object.keys(typedFilters).join(", ")} cannot be added to --plan-json. `
|
|
21508
|
+
+ "Re-plan with oxygen signals search plan --prompt <goal> --family <family> and the filter flags, or pass them to signals search run --prompt.", { exitCode: 1 });
|
|
21509
|
+
}
|
|
21510
|
+
const filters = prompt ? typedFilters : postSearchFilters;
|
|
20540
21511
|
return {
|
|
20541
21512
|
...(prompt ? { prompt } : {}),
|
|
20542
21513
|
...(plan ? { plan } : {}),
|
|
@@ -20557,6 +21528,7 @@ function readSignalsSearchRunBody(options, promptArg) {
|
|
|
20557
21528
|
...(readOption(options.every) ? { every: readOption(options.every) } : {}),
|
|
20558
21529
|
...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
|
|
20559
21530
|
...(maxRowsPerCycle !== undefined ? { max_rows_per_cycle: maxRowsPerCycle } : {}),
|
|
21531
|
+
...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
|
|
20560
21532
|
};
|
|
20561
21533
|
}
|
|
20562
21534
|
// SignalSearchFilters mirror (structural — the server validates every field and
|
|
@@ -20567,6 +21539,24 @@ function readSignalSearchFilters(options) {
|
|
|
20567
21539
|
const keywords = readCsvOption(options.keywords);
|
|
20568
21540
|
if (keywords.length > 0)
|
|
20569
21541
|
filters.keywords = keywords;
|
|
21542
|
+
const allKeywords = readCsvOption(options.allKeywords);
|
|
21543
|
+
if (allKeywords.length > 0)
|
|
21544
|
+
filters.all_keywords = allKeywords;
|
|
21545
|
+
const excludeKeywords = readCsvOption(options.excludeKeywords);
|
|
21546
|
+
if (excludeKeywords.length > 0)
|
|
21547
|
+
filters.exclude_keywords = excludeKeywords;
|
|
21548
|
+
const sort = readOption(options.sort);
|
|
21549
|
+
if (sort)
|
|
21550
|
+
filters.sort_by = sort;
|
|
21551
|
+
const minReactions = readOption(options.minReactions);
|
|
21552
|
+
if (minReactions)
|
|
21553
|
+
filters.min_reactions = minReactions;
|
|
21554
|
+
const postedBy = readOption(options.postedBy);
|
|
21555
|
+
if (postedBy)
|
|
21556
|
+
filters.posted_by = postedBy;
|
|
21557
|
+
const aiFilter = readOption(options.aiFilter);
|
|
21558
|
+
if (aiFilter)
|
|
21559
|
+
filters.ai_filter = aiFilter;
|
|
20570
21560
|
const countries = readCsvOption(options.countries);
|
|
20571
21561
|
if (countries.length > 0)
|
|
20572
21562
|
filters.countries = countries;
|
|
@@ -20614,8 +21604,8 @@ function readCompaniesSearchRunBody(options, promptArg) {
|
|
|
20614
21604
|
const promptSource = options.prompt ?? promptArg;
|
|
20615
21605
|
const prompt = promptSource ? readFileIfPresent(promptSource) : null;
|
|
20616
21606
|
const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
|
|
20617
|
-
if (!prompt && !plan && options.source !== "company_search" && options.source !== "hiring") {
|
|
20618
|
-
throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --source company_search|hiring with --source-filters-json.", { exitCode: 1 });
|
|
21607
|
+
if (!prompt && !plan && options.source !== "company_search" && options.source !== "hiring" && options.source !== "google_maps") {
|
|
21608
|
+
throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --source company_search|hiring|google_maps with --source-filters-json.", { exitCode: 1 });
|
|
20619
21609
|
}
|
|
20620
21610
|
const maxPages = readPositiveInt(options.maxPages);
|
|
20621
21611
|
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
@@ -20792,8 +21782,8 @@ const PEOPLE_SEARCH_MIN_CONNECTIONS_HELP = "Only return people with at least thi
|
|
|
20792
21782
|
/** The company lanes that share Company Search's flow; anything else is a typo, not a provider. */
|
|
20793
21783
|
function readCompanySearchSource(value) {
|
|
20794
21784
|
const source = readOption(value) ?? "company_search";
|
|
20795
|
-
if (source !== "company_search" && source !== "hiring") {
|
|
20796
|
-
throw new OxygenError("invalid_request", "--source must be company_search or
|
|
21785
|
+
if (source !== "company_search" && source !== "hiring" && source !== "google_maps") {
|
|
21786
|
+
throw new OxygenError("invalid_request", "--source must be company_search, hiring or google_maps.", { exitCode: 1 });
|
|
20797
21787
|
}
|
|
20798
21788
|
return source;
|
|
20799
21789
|
}
|
|
@@ -24371,6 +25361,57 @@ function formatCrmObjects(data) {
|
|
|
24371
25361
|
"",
|
|
24372
25362
|
].join("\n");
|
|
24373
25363
|
}
|
|
25364
|
+
/**
|
|
25365
|
+
* A to-do list an operator can read without a JSON parser.
|
|
25366
|
+
*
|
|
25367
|
+
* The record column is the point of the workspace read — a task title alone
|
|
25368
|
+
* ("Send the deck") says nothing about who it is for — so it comes first, from
|
|
25369
|
+
* the same primary link the web row shows. A record-scoped read prints the
|
|
25370
|
+
* same table; the column is simply the record you asked about.
|
|
25371
|
+
*/
|
|
25372
|
+
function formatCrmTasks(data) {
|
|
25373
|
+
const tasks = Array.isArray(data.tasks) ? data.tasks.filter(isRecord) : [];
|
|
25374
|
+
// The workspace read names its scope as `object: null`; the record-scoped
|
|
25375
|
+
// envelope carries no `object` key at all.
|
|
25376
|
+
const scoped = !Object.hasOwn(data, "object");
|
|
25377
|
+
if (tasks.length === 0) {
|
|
25378
|
+
return scoped
|
|
25379
|
+
? "No tasks on this record. Add one with: oxygen crm tasks add <object> <record> \"<title>\"\n"
|
|
25380
|
+
: "No tasks in this workspace. A task is filed on a record: oxygen crm tasks add companies acme.com \"Send the deck\"\n";
|
|
25381
|
+
}
|
|
25382
|
+
const rows = tasks.map((task) => {
|
|
25383
|
+
const links = Array.isArray(task.links) ? task.links.filter(isRecord) : [];
|
|
25384
|
+
const primary = links[0];
|
|
25385
|
+
const assignee = isRecord(task.assignee) ? task.assignee : null;
|
|
25386
|
+
return [
|
|
25387
|
+
// Clipped, so one essay-length title cannot push every other column off
|
|
25388
|
+
// the terminal. `--json` still carries the whole thing.
|
|
25389
|
+
clipCell(String(primary?.label ?? primary?.objectSlug ?? "—"), 28),
|
|
25390
|
+
clipCell(String(task.title ?? "—"), 48),
|
|
25391
|
+
String(task.status ?? "—"),
|
|
25392
|
+
String(task.priority ?? "—"),
|
|
25393
|
+
formatCrmTaskDue(task.dueAt),
|
|
25394
|
+
String(assignee?.name ?? assignee?.email ?? "unassigned"),
|
|
25395
|
+
String(task.id ?? "—"),
|
|
25396
|
+
];
|
|
25397
|
+
});
|
|
25398
|
+
const link = typeof data.web_url === "string" ? data.web_url : null;
|
|
25399
|
+
return [
|
|
25400
|
+
`${tasks.length} task${tasks.length === 1 ? "" : "s"}${data.hasMore === true ? " (more available — pass --cursor)" : ""}`,
|
|
25401
|
+
"",
|
|
25402
|
+
...renderTextTable(["RECORD", "TASK", "STATUS", "PRIORITY", "DUE", "ASSIGNEE", "ID"], rows),
|
|
25403
|
+
"",
|
|
25404
|
+
" Finish one: oxygen crm tasks complete <id>",
|
|
25405
|
+
...(link ? [` In the app: ${link}`] : []),
|
|
25406
|
+
"",
|
|
25407
|
+
].join("\n");
|
|
25408
|
+
}
|
|
25409
|
+
/** A deadline is a day to whoever reads the list; the time of day is noise. */
|
|
25410
|
+
function formatCrmTaskDue(value) {
|
|
25411
|
+
if (typeof value !== "string" || !value)
|
|
25412
|
+
return "—";
|
|
25413
|
+
return value.slice(0, 10);
|
|
25414
|
+
}
|
|
24374
25415
|
/** "Website domain (website domain)" is noise, so the comparison is named only
|
|
24375
25416
|
* when it says something the field name does not. An object with no identity
|
|
24376
25417
|
* is called out rather than left blank: it duplicates on every re-import. */
|
|
@@ -24677,8 +25718,14 @@ function formatSequenceAnalyticsHealth(data) {
|
|
|
24677
25718
|
const from = stringValue(range.from);
|
|
24678
25719
|
const to = stringValue(range.to);
|
|
24679
25720
|
const rangeLabel = from || to ? `${from ?? "start"} → ${to ?? "now"}` : stringValue(range.preset) ?? "selected window";
|
|
25721
|
+
const senderOptions = Array.isArray(analytics.senders) ? analytics.senders.filter(isRecord) : [];
|
|
25722
|
+
const senderFilter = Array.isArray(analytics.senderFilter)
|
|
25723
|
+
? analytics.senderFilter.filter((key) => typeof key === "string")
|
|
25724
|
+
: null;
|
|
25725
|
+
const senderName = (key) => stringValue(senderOptions.find((option) => option.key === key)?.name) ?? key;
|
|
24680
25726
|
const lines = [
|
|
24681
25727
|
`Sequencer analytics (${rangeLabel})`,
|
|
25728
|
+
...(senderFilter ? [`Senders: ${senderFilter.map(senderName).join(", ")}`] : []),
|
|
24682
25729
|
...formatSequenceFleetSummary(analytics.fleet),
|
|
24683
25730
|
`Open work: ${formatSequenceOpenWork(summary.openWork)}`,
|
|
24684
25731
|
`Selected-window outcomes: ${formatSequenceFailureTotals(failureAnalytics)}`,
|
|
@@ -24701,6 +25748,10 @@ function formatSequenceAnalyticsHealth(data) {
|
|
|
24701
25748
|
const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
24702
25749
|
if (link)
|
|
24703
25750
|
lines.push(link);
|
|
25751
|
+
const firstSender = stringValue(senderOptions[0]?.key);
|
|
25752
|
+
if (!senderFilter && senderOptions.length > 1 && firstSender) {
|
|
25753
|
+
lines.push(`Scope to one sender: add --sender ${firstSender} (${senderName(firstSender)}); all ${senderOptions.length} are under analytics.senders with --json.`);
|
|
25754
|
+
}
|
|
24704
25755
|
return `${lines.join("\n")}\n`;
|
|
24705
25756
|
}
|
|
24706
25757
|
function sequenceHealthRow(sequence) {
|
|
@@ -27191,11 +28242,21 @@ options) {
|
|
|
27191
28242
|
const warmupPreset = readOption(options.warmupPreset);
|
|
27192
28243
|
const hasPreset = warmupPreset !== null && warmupPreset !== undefined;
|
|
27193
28244
|
const hasRandomize = options.randomizeCaps !== undefined;
|
|
28245
|
+
const hasWeekendFullVolume = options.weekendFullVolume !== undefined;
|
|
28246
|
+
// The READ ramp is a separate axis from the send-side warm-up above: it is
|
|
28247
|
+
// default-on, anchored on when network capture was armed, and governs read
|
|
28248
|
+
// budgets only. Disabling one must never disable the other.
|
|
28249
|
+
const readWarmup = options.readWarmupDisable
|
|
28250
|
+
? false
|
|
28251
|
+
: options.readWarmupRestart
|
|
28252
|
+
? true
|
|
28253
|
+
: undefined;
|
|
27194
28254
|
const hasLimits = Object.keys(limits).length > 0;
|
|
27195
28255
|
const hasWorkingHours = Object.keys(workingHours).length > 0;
|
|
27196
28256
|
const hasWarmup = warmup !== undefined;
|
|
27197
|
-
|
|
27198
|
-
|
|
28257
|
+
const hasReadWarmup = readWarmup !== undefined;
|
|
28258
|
+
if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize && !hasReadWarmup && !hasWeekendFullVolume) {
|
|
28259
|
+
throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --read-warmup-disable / --read-warmup-restart, --warmup-preset, --randomize-caps, or --weekend-full-volume.", { exitCode: 1 });
|
|
27199
28260
|
}
|
|
27200
28261
|
return {
|
|
27201
28262
|
...(hasLimits ? { limits } : {}),
|
|
@@ -27203,6 +28264,8 @@ options) {
|
|
|
27203
28264
|
...(hasWarmup ? { warmup } : {}),
|
|
27204
28265
|
...(hasPreset ? { warmup_preset: warmupPreset } : {}),
|
|
27205
28266
|
...(hasRandomize ? { randomize_daily_caps: options.randomizeCaps } : {}),
|
|
28267
|
+
...(hasWeekendFullVolume ? { weekend_full_volume: options.weekendFullVolume } : {}),
|
|
28268
|
+
...(hasReadWarmup ? { read_warmup: readWarmup } : {}),
|
|
27206
28269
|
};
|
|
27207
28270
|
}
|
|
27208
28271
|
/**
|