@oxygen-agent/cli 1.982.3 → 1.987.20

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/functions-commands.js +21 -5
  3. package/dist/help.d.ts +21 -0
  4. package/dist/help.js +93 -0
  5. package/dist/index.js +322 -57
  6. package/dist/ugc-commands.d.ts +3 -6
  7. package/dist/ugc-commands.js +2 -1200
  8. package/node_modules/@oxygen/cli-ugc/dist/commands.d.ts +3 -0
  9. package/node_modules/@oxygen/cli-ugc/dist/commands.js +1178 -0
  10. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +7 -0
  11. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +25 -0
  12. package/node_modules/@oxygen/cli-ugc/dist/index.d.ts +14 -0
  13. package/node_modules/@oxygen/cli-ugc/dist/index.js +5 -0
  14. package/node_modules/@oxygen/cli-ugc/package.json +15 -0
  15. package/node_modules/@oxygen/formula/dist/expression.js +14 -1
  16. package/node_modules/@oxygen/formula/dist/formula-functions.js +71 -1
  17. package/node_modules/@oxygen/formula/dist/index.d.ts +1 -0
  18. package/node_modules/@oxygen/formula/dist/index.js +1 -0
  19. package/node_modules/@oxygen/formula/dist/value-cleaners.d.ts +69 -0
  20. package/node_modules/@oxygen/formula/dist/value-cleaners.js +374 -0
  21. package/node_modules/@oxygen/shared/dist/billing.d.ts +27 -27
  22. package/node_modules/@oxygen/shared/dist/capability-discovery.js +29 -4
  23. package/node_modules/@oxygen/shared/dist/column-output-fields.js +12 -4
  24. package/node_modules/@oxygen/shared/dist/copilot-playbooks.d.ts +18 -0
  25. package/node_modules/@oxygen/shared/dist/copilot-playbooks.js +43 -0
  26. package/node_modules/@oxygen/shared/dist/copilot-skills.d.ts +15 -0
  27. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +31 -0
  28. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +41 -0
  29. package/node_modules/@oxygen/shared/dist/copilot-skills.js +6 -0
  30. package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
  31. package/node_modules/@oxygen/shared/dist/index.js +1 -0
  32. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +15 -0
  33. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +26 -4
  34. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +12 -1
  35. package/node_modules/@oxygen/shared/dist/langfuse.js +48 -8
  36. package/node_modules/@oxygen/shared/dist/research-output-contract.d.ts +33 -1
  37. package/node_modules/@oxygen/shared/dist/research-output-contract.js +64 -2
  38. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +1 -1
  39. package/node_modules/@oxygen/shared/dist/sequence-hubspot-sync.d.ts +1 -1
  40. package/node_modules/@oxygen/shared/dist/sequences.d.ts +26 -0
  41. package/node_modules/@oxygen/shared/dist/sequences.js +24 -0
  42. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  43. package/node_modules/@oxygen/shared/package.json +10 -0
  44. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +15 -15
  45. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-comm
12
12
  import { registerVisualCommands } from "./visual-commands.js";
13
13
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
14
14
  import { registerFunctionsCommands } from "./functions-commands.js";
15
- import { applyOxygenHelp } from "./help.js";
15
+ import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint } 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, 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
18
  import { TAG_COLORS } from "@oxygen/shared/select-options";
@@ -3221,9 +3221,9 @@ export function createProgram() {
3221
3221
  .description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
3222
3222
  .option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
3223
3223
  .option("--tag <tag>", "Only objects carrying this workspace tag. Answered by the Tags footprint read, so it also covers kinds this map does not model.")
3224
- .option("--query <text>", "Match on name, slug, or tag.")
3224
+ .option("--query <text>", "Match on name, slug, or tag within each kind's scanned window; a kind with `capped: true` may hold older matches this did not search.")
3225
3225
  .option("--json", "Print a JSON envelope.")
3226
- .addHelpText("after", "\nEach kind returns its newest few objects under a character budget the payload echoes back; `capped` says when a kind holds more than the scan saw. Hydrate one object with its own primitive's read (`oxygen tables describe`, `oxygen workflows get`, `oxygen knowledge page get`).\n\nRead-only means read-only: the map creates nothing in your workspace in order to answer, so an empty wiki reports zero pages rather than a starter set this command seeded. Use `oxygen knowledge index` when you do want the starter wiki created.\n")
3226
+ .addHelpText("after", "\nEach kind returns its newest few objects under a character budget the payload echoes back; `capped` says when a kind holds more than the scan saw. It is a snapshot, not a rollup: per-folder table counts come from `oxygen projects list` (`tableCount`). Hydrate one object with its own primitive's read (`oxygen tables describe`, `oxygen workflows get`, `oxygen knowledge page get`).\n\nRead-only means read-only: the map creates nothing in your workspace in order to answer, so an empty wiki reports zero pages rather than a starter set this command seeded. Use `oxygen knowledge index` when you do want the starter wiki created.\n")
3227
3227
  .action(async (options) => {
3228
3228
  await handleAsyncAction("workspace map", options, async () => {
3229
3229
  const params = new URLSearchParams();
@@ -3408,7 +3408,7 @@ export function createProgram() {
3408
3408
  await handleAsyncAction("orgs billing-owners", options, () => requestOxygen("/api/cli/orgs/billing-owners"));
3409
3409
  }))
3410
3410
  .addCommand(new Command("billing-link")
3411
- .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. Credit billing moves, workspace data access does not.")
3411
+ .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked.")
3412
3412
  .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
3413
3413
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
3414
3414
  .option("--organization-id <id>", "Alias for --organization.")
@@ -4049,13 +4049,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4049
4049
  .command("projects")
4050
4050
  .description("Manage table folders (projects). Inspect contents with `oxygen tables list --project <project>`.")
4051
4051
  .addCommand(new Command("list")
4052
- .description("List table projects in the current tenant database.")
4052
+ .description("List table projects (folders) with each one's active table count (`tableCount`), so empty and oversized folders show without listing every folder's tables. Free.")
4053
4053
  .option("--json", "Print a JSON envelope.")
4054
4054
  .action(async (options) => {
4055
4055
  await handleAsyncAction("projects list", options, () => requestOxygen("/api/cli/projects"));
4056
4056
  }))
4057
4057
  .addCommand(new Command("create")
4058
- .description("Create a schema-backed table project.")
4058
+ .description("Create a schema-backed table project (folder). Free. Group tables into it with `oxygen tables move <tables...> --project <folder>`.")
4059
4059
  .argument("<name>", "Display name for the project.")
4060
4060
  .option("--json", "Print a JSON envelope.")
4061
4061
  .action(async (name, options) => {
@@ -5656,7 +5656,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5656
5656
  .option("--limit <n>", "Maximum tables to list. Defaults to 200; hard cap is 1000.")
5657
5657
  .option("--all", "List every table instead of the newest window.")
5658
5658
  .option("--json", "Print a JSON envelope.")
5659
- .addHelpText("after", "\nLists the 200 newest tables by default; `capped` in the JSON envelope says when there are more.\n")
5659
+ .addHelpText("after", "\nLists the 200 newest tables by default; `returned` in the JSON envelope is the count in this response and `capped` says when there are more (pass --all for every table). Per-folder totals live on `oxygen projects list` (`tableCount`). `oxygen workspace map --kind table --query <text>` finds a recent table by name but searches only the 200 newest tables (its `capped` says when older ones were not searched); to match a name across every table, read `oxygen tables list --all --json`.\n")
5660
5660
  .action(async (options) => {
5661
5661
  await handleAsyncAction("tables list", options, async () => {
5662
5662
  const params = new URLSearchParams();
@@ -5863,14 +5863,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5863
5863
  }));
5864
5864
  }))
5865
5865
  .addCommand(new Command("move")
5866
- .description("Move a workspace table to a different project.")
5867
- .argument("<table>", "Table id or slug.")
5868
- .requiredOption("--project <project>", "Destination project id or slug.")
5866
+ .description("Move one or more workspace tables into a project (folder) in one call. Rows, columns, runs, and provenance are preserved. Free; reverse it by moving the table back.")
5867
+ .argument("<tables...>", "Table ids or slugs; several move together, each reported separately.")
5868
+ .requiredOption("--project <project>", "Destination project id or slug (see `oxygen projects list`).")
5869
5869
  .option("--json", "Print a JSON envelope.")
5870
- .action(async (table, options) => {
5870
+ .action(async (tables, options) => {
5871
5871
  await handleAsyncAction("tables move", options, () => requestOxygen("/api/cli/tables/move", {
5872
5872
  method: "POST",
5873
- body: { table, project: options.project },
5873
+ body: {
5874
+ ...(tables.length === 1 ? { table: tables[0] } : { tables }),
5875
+ project: options.project,
5876
+ },
5874
5877
  }));
5875
5878
  }))
5876
5879
  .addCommand(new Command("recover-pending")
@@ -7803,7 +7806,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7803
7806
  ].join("\n"));
7804
7807
  program
7805
7808
  .command("recipes")
7806
- .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out.")
7809
+ .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out. Whole-motion operator playbooks (TAM sourcing, LinkedIn content strategy, inbound-led outbound, signal-based outbound) are the oxygen-playbooks skill: oxygen skills get oxygen-playbooks --json.")
7807
7810
  .addCommand(new Command("list")
7808
7811
  .description("List recipes, optionally filtered by text, business-case category, journey stage, or audience.")
7809
7812
  .argument("[query]", "Search text (matches slug/title/business case/tags).")
@@ -7973,12 +7976,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7973
7976
  }));
7974
7977
  program
7975
7978
  .command("columns")
7976
- .description("Workspace table column commands.")
7979
+ .description("Workspace table column commands. Ready-made templates (pricing pages, job posts, event pages, 10-Ks, website facts, market questions, text extraction, plus free 0-credit cleanups — company names, job titles, email type, text to list; person questions over an enriched LinkedIn profile and person appearances; company research questions — founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ and footprint, LinkedIn/Crunchbase/careers page lookups): `columns catalog`, then `columns add <table> --prompt-key <key> --input <name>=<column>`. Column choice and provider routing guide: the oxygen-gtm skill's enriching-and-researching.md (`oxygen skills install`). With --json parse stdout only: the envelope is stdout, and the spend and cost notes (`estimated N credits for a live run`) are stderr, so merging the streams breaks the JSON.")
7977
7980
  .addCommand(new Command("add")
7978
- .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.")
7981
+ .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.")
7979
7982
  .argument("<table>", "Table id or slug.")
7980
- .option("--preset <preset>", "Add a pre-built enrichment bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free) or `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers). Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
7981
- .option("--input <slot=column...>", "Bind a preset input to an exact column, e.g. --input url=linkedin_url, or --input company_name=account --input domain=website. Repeatable. Only needed when the automatic match is wrong or missing.", collectRepeatable, [])
7983
+ .option("--preset <preset>", "Add a pre-built column bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free), `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers), 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. Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
7984
+ .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 company_name=account --input domain=website 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, [])
7982
7985
  .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, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall. 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>`.")
7983
7986
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
7984
7987
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
@@ -7992,9 +7995,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7992
7995
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
7993
7996
  .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).")
7994
7997
  .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.")
7995
- .option("--research-engine <engine>", "Research columns: pin the search provider (exa, parallel, or firecrawl). Defaults to exa, falling back automatically. Most users should not set this.")
7996
- .option("--prompt-key <key>", "OXYGEN prompt-library key (e.g. email_draft_v1). Materializes prompt + output_schema and forces kind=ai.")
7997
- .option("--input-mapping <json>", "Required with --prompt-key. JSON object mapping prompt input names to column or literal refs. 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.")
7998
+ .option("--research-engine <engine>", "Research columns: pin the search provider (exa, parallel, or firecrawl), or with --research-url the page-fetch provider (firecrawl, linkup, or exa). Defaults to exa for search and firecrawl for fetch, falling back automatically. Most users should not set this.")
7999
+ .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.")
8000
+ .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>.")
8001
+ .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.")
7998
8002
  .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
7999
8003
  .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
8000
8004
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
@@ -8069,8 +8073,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8069
8073
  if (!options.promptKey && !capability && !options.label) {
8070
8074
  throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
8071
8075
  }
8072
- if (options.promptKey && !options.inputMapping) {
8073
- throw new OxygenError("missing_input_mapping", "--input-mapping is required when --prompt-key is provided.", { exitCode: 1 });
8076
+ const templateInputs = options.promptKey ? parseKeyValuePairs(options.input ?? []) : {};
8077
+ if (options.promptKey && !options.inputMapping && Object.keys(templateInputs).length === 0) {
8078
+ 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 });
8074
8079
  }
8075
8080
  const prompt = readAiPromptOption(options.prompt);
8076
8081
  if (prompt !== null && options.promptKey) {
@@ -8185,8 +8190,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8185
8190
  const body = { table, column };
8186
8191
  if (options.promptKey)
8187
8192
  body.prompt_key = options.promptKey;
8188
- if (options.inputMapping)
8193
+ if (options.inputMapping) {
8189
8194
  body.input_mapping = parseJsonObject(options.inputMapping);
8195
+ }
8196
+ else if (Object.keys(templateInputs).length > 0) {
8197
+ // `--input url=pricing_url` → the same typed column refs the JSON form
8198
+ // carries, so the server sees one shape from both spellings.
8199
+ body.input_mapping = Object.fromEntries(Object.entries(templateInputs).map(([name, columnKey]) => [name, { type: "column", columnKey }]));
8200
+ }
8190
8201
  // Every authoring path, not just --prompt: a definition pasted into
8191
8202
  // --definition-json used to skip this entirely and fail once per row.
8192
8203
  await assertColumnDefinitionReferences(table, column.definition);
@@ -8559,6 +8570,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8559
8570
  },
8560
8571
  });
8561
8572
  });
8573
+ }))
8574
+ .addCommand(new Command("catalog")
8575
+ .description("List the column templates you can add with `columns add <table> --prompt-key <key>`: the extract templates (pricing-page, job-posting, event, 10-K, website-fact, market and text extractors) and the company research questions (founders, parent company, subsidiaries, funding rounds, market cap, cloud provider, is SaaS, offers demos, industry, NAICS, HQ / states / cities, competitors, LinkedIn / Crunchbase / Glassdoor / careers page lookups) and the person templates (--category people: skill set, grad school, location, job fit and current company read from the person_enrich payload for 15 credits; events, keynotes, podcasts and GitHub profile searched by name), each with the input it reads (bind it with --input <name>=<column_key>), the providers behind it, the answer fields, and the credits per row. Read-only and free.")
8576
+ .option("--category <category>", "Only templates in one picker section: extract (page and text extraction), normalize, organize, summarize (free cleanups and classifications), people (person questions and appearances) or company (company research questions); comma-separate to combine (extract,people). The outreach prompts sit under copy, scoring, qa, research.")
8577
+ .option("--family <family>", "Only one family. Extract: pricing, job_posting, events, filings, site_facts, market, portfolio, text_extraction. Cleanups: company, person, email, lists, classification. Company: web_presence, corporate_structure, funding, footprint, business_model, tech, market. People: profile, appearances.")
8578
+ .option("--kind <kind>", "Only one column kind: research (reads the web or a page, answer with sources), search (a managed web search whose cell is a page URL or the result list, fixed price), ai (reads row text), or formula (free).")
8579
+ .option("--search <term>", "Match a word against template keys, titles and descriptions, e.g. pricing or 10-K.")
8580
+ .option("--json", "Print a JSON envelope.")
8581
+ .action(async (options) => {
8582
+ await handleAsyncAction("columns catalog", options, async () => {
8583
+ const params = new URLSearchParams();
8584
+ for (const [name, value] of [
8585
+ ["category", readOption(options.category)],
8586
+ ["family", readOption(options.family)],
8587
+ ["kind", readOption(options.kind)],
8588
+ ["search", readOption(options.search)],
8589
+ ]) {
8590
+ if (value)
8591
+ params.set(name, value);
8592
+ }
8593
+ const query = params.toString();
8594
+ const result = await requestOxygen(`/api/cli/tables/columns/catalog${query ? `?${query}` : ""}`, { method: "GET" });
8595
+ if (!options.json)
8596
+ printColumnCatalog(result);
8597
+ return result;
8598
+ });
8562
8599
  }))
8563
8600
  .addCommand(new Command("deps")
8564
8601
  .description("Show the column dependency graph: which columns feed which (formulas, input mappings, run conditions, waterfall targets), transitive up/downstream, and cycles. Read-only.")
@@ -8625,7 +8662,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8625
8662
  .command("formulas")
8626
8663
  .description("Formula language for formula columns and per-column run conditions (only-run-if).")
8627
8664
  .addCommand(new Command("functions")
8628
- .description("List every formula function (name, signature, examples) and the operator grammar. The same language powers formula columns and --run-condition gates.")
8665
+ .description("List every formula function (name, signature, examples), the operator grammar, and the expression_notes that a signature cannot teach (bare column keys, string escapes, no regex flags, read-time evaluation). The same language powers formula columns and --run-condition gates.")
8629
8666
  .option("--category <category>", "Filter by category: logic, string, number, date, url_email, array, json, null_handling, cross_row.")
8630
8667
  .option("--json", "Print a JSON envelope.")
8631
8668
  .action(async (options) => {
@@ -9099,6 +9136,22 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9099
9136
  .description("Company prospecting and account enrichment workflows.")
9100
9137
  .addCommand(new Command("search")
9101
9138
  .description("Plan, dry-run, or queue provider-backed company search.")
9139
+ .addCommand(new Command("filters")
9140
+ .description("List Company Search filter fields and options without a provider call.")
9141
+ .option("--json", "Print a JSON envelope.")
9142
+ .action(async (options) => {
9143
+ await handleAsyncAction("companies search filters", options, () => requestOxygen("/api/cli/companies/search/preview"));
9144
+ }))
9145
+ .addCommand(new Command("preview")
9146
+ .description("Preview up to 50 companies free, using native filters from companies search filters.")
9147
+ .requiredOption("--filters-json <json-or-file>", 'Nested native filters as JSON or @file/path, e.g. {"employee_count":{"min":10}}; {} searches all.')
9148
+ .option("--json", "Print a JSON envelope.")
9149
+ .action(async (options) => {
9150
+ await handleAsyncAction("companies search preview", options, () => requestOxygen("/api/cli/companies/search/preview", {
9151
+ method: "POST",
9152
+ body: { filters: parseJsonObject(readFileIfPresent(options.filtersJson)) },
9153
+ }));
9154
+ }))
9102
9155
  .addCommand(new Command("plan")
9103
9156
  .description("Compile a company-search prompt into ordered provider routes without provider calls. To turn a list of company NAMES into websites or domains, pass --source-intent url_recovery (e.g. --prompt \"Find the websites for these companies: <names>\" --source-intent url_recovery). The plan carries provider_availability: a route on a benched managed provider reads degraded with a next_action.")
9104
9157
  .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
@@ -9118,7 +9171,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9118
9171
  .option("--founded <range>", "Founded year range: 2015-2024, 2020+, or -2010.")
9119
9172
  .option("--lookalike <csv>", "Comma-separated lookalike company domains.")
9120
9173
  .option("--estimate", "Run a free server-side preflight pass: resolves provider enums and a free count probe for an estimated match count (zero credits).")
9121
- .option("--materialize-preview", "Create a preview table with route rows.")
9174
+ .option("--materialize-preview", "Create a route-plan table, without company results. For free company rows use companies search preview.")
9122
9175
  .option("--json", "Print a JSON envelope.")
9123
9176
  .action(async (promptArg, options) => {
9124
9177
  await handleAsyncAction("companies search plan", options, () => requestOxygen("/api/cli/companies/search/plan", {
@@ -9128,6 +9181,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9128
9181
  }))
9129
9182
  .addCommand(new Command("run")
9130
9183
  .description("Return a dry-run request or queue a live company-search ingestion run.")
9184
+ .option("--source <source>", "Use company_search with --source-filters-json instead of a prompt or plan.")
9185
+ .option("--source-filters-json <json-or-file>", 'Same nested filters as the free preview, e.g. {"employee_count":{"min":10}}; use with --source company_search.')
9131
9186
  .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
9132
9187
  .option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
9133
9188
  .option("--plan-json <json-or-file>", "Plan JSON returned by companies search plan, or a path to a JSON file.")
@@ -9186,7 +9241,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9186
9241
  .argument("<table>", "Table id or slug.")
9187
9242
  .option("--missing-fields <fields>", "Comma-separated fields to fill.")
9188
9243
  .option("--providers <providers>", "Comma-separated provider pool.")
9189
- .option("--mode <mode>", "dry_run or live. Defaults to live.")
9244
+ .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 attach a company field for free and run it later, bind the OXYGEN-managed Function instead (`functions list --managed`, then `functions bind`).")
9190
9245
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
9191
9246
  .option("--approved", "Approve this live paid run after inspecting the preview.")
9192
9247
  .option("--all", "Run on all rows.")
@@ -9209,7 +9264,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9209
9264
  .description("Plan, dry-run, or queue provider-backed people/contact search.")
9210
9265
  .addCommand(new Command("plan")
9211
9266
  .description("Compile a people-search prompt and optional typed persona filters into ordered provider routes without provider calls.")
9212
- .requiredOption("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
9267
+ .option("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file. Required unless --from-table names the companies to source employees from.")
9213
9268
  .option("--target-count <n>", "Desired contact count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
9214
9269
  .option("--source-intent <intent>", "Override detected intent: persona_search, account_contacts, audience_sizing, profile_lookup, concept_persona, or fallback_broad.")
9215
9270
  .option("--filters-json <json-or-file>", "PeopleSearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
@@ -9219,7 +9274,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9219
9274
  .option("--exclude-titles <csv>", "Comma-separated job titles to exclude.")
9220
9275
  .option("--seniorities <csv>", "Comma-separated seniority levels: C-Suite, VP, Director, Manager, Staff.")
9221
9276
  .option("--departments <csv>", "Comma-separated departments/functions: Sales, Marketing, Engineering, ...")
9277
+ .option("--job-functions <csv>", PEOPLE_SEARCH_JOB_FUNCTIONS_HELP)
9222
9278
  .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 person country codes.")
9279
+ .option("--continents <csv>", PEOPLE_SEARCH_CONTINENTS_HELP)
9280
+ .option("--sales-regions <csv>", PEOPLE_SEARCH_SALES_REGIONS_HELP)
9281
+ .option("--min-connections <n>", PEOPLE_SEARCH_MIN_CONNECTIONS_HELP)
9282
+ .option("--from-table <table>", PEOPLE_SEARCH_FROM_TABLE_HELP)
9283
+ .option("--linkedin-url-column <key>", PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP)
9284
+ .option("--company-offset <n>", PEOPLE_SEARCH_COMPANY_OFFSET_HELP)
9285
+ .option("--company-limit <n>", PEOPLE_SEARCH_COMPANY_LIMIT_HELP)
9223
9286
  .option("--keywords <csv>", "Comma-separated free-text persona keywords.")
9224
9287
  .option("--company-domains <csv>", "Comma-separated company domains to scope contacts to.")
9225
9288
  .option("--company-names <csv>", "Comma-separated company names to scope contacts to.")
@@ -9247,7 +9310,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9247
9310
  .option("--project <project>", "Folder (project) id or slug the created table lands in. Ignored with --table. Defaults to the workspace default folder.")
9248
9311
  .option("--upsert-key <column>", "Column key used for live upsert. Must match the plan upsert key (linkedin_url).")
9249
9312
  .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
9250
- .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate.")
9313
+ .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate. With --from-table it means pages per company.")
9251
9314
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
9252
9315
  .option("--target-count <n>", "Desired contact count when planning from --prompt. Single-plan ceiling 50,000.")
9253
9316
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
@@ -9258,7 +9321,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9258
9321
  .option("--exclude-titles <csv>", "Comma-separated job titles to exclude when planning from --prompt.")
9259
9322
  .option("--seniorities <csv>", "Comma-separated seniority levels when planning from --prompt.")
9260
9323
  .option("--departments <csv>", "Comma-separated departments/functions when planning from --prompt.")
9324
+ .option("--job-functions <csv>", PEOPLE_SEARCH_JOB_FUNCTIONS_HELP)
9261
9325
  .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 person country codes when planning from --prompt.")
9326
+ .option("--continents <csv>", PEOPLE_SEARCH_CONTINENTS_HELP)
9327
+ .option("--sales-regions <csv>", PEOPLE_SEARCH_SALES_REGIONS_HELP)
9328
+ .option("--min-connections <n>", PEOPLE_SEARCH_MIN_CONNECTIONS_HELP)
9329
+ .option("--from-table <table>", PEOPLE_SEARCH_FROM_TABLE_HELP)
9330
+ .option("--linkedin-url-column <key>", PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP)
9331
+ .option("--company-offset <n>", PEOPLE_SEARCH_COMPANY_OFFSET_HELP)
9332
+ .option("--company-limit <n>", PEOPLE_SEARCH_COMPANY_LIMIT_HELP)
9333
+ .option("--pages-per-company <n>", "Alias for --max-pages on a --from-table run: provider pages per company, 50 people per page.")
9262
9334
  .option("--keywords <csv>", "Comma-separated persona keywords when planning from --prompt.")
9263
9335
  .option("--company-domains <csv>", "Comma-separated company domains when planning from --prompt.")
9264
9336
  .option("--company-names <csv>", "Comma-separated company names when planning from --prompt.")
@@ -11595,7 +11667,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11595
11667
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
11596
11668
  }))
11597
11669
  .addCommand(new Command("connect")
11598
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). 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.")
11670
+ .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.")
11599
11671
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
11600
11672
  .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).")
11601
11673
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
@@ -12673,14 +12745,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12673
12745
  });
12674
12746
  })));
12675
12747
  program.addCommand(new Command("inbox")
12676
- .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (--channel all), or a single channel. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
12748
+ .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.")
12677
12749
  .addCommand(new Command("list")
12678
- .description("List conversations newest first. --channel all merges email + LinkedIn + WhatsApp into one stream (narrow it with --channels); --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
12679
- .option("--channel <channel>", "Inbox channel: all (merged), linkedin (default), whatsapp, or email.")
12680
- .option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
12750
+ .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.")
12751
+ .option("--channel <channel>", "Inbox channel: all (default, the merged stream), email, linkedin, or whatsapp.")
12752
+ .option("--channels <list>", "Narrow the merged stream: comma-separated channel groups to include (email,linkedin,whatsapp). Implies --channel all.")
12681
12753
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
12682
12754
  .option("--unread", "Only show conversations with unread messages.")
12683
- .option("--unanswered", "Only conversations awaiting YOUR reply — the last message in the thread is inbound. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.)")
12755
+ .option("--unanswered", "Only conversations awaiting YOUR reply — the last message in the thread is inbound. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.) 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.")
12684
12756
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
12685
12757
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
12686
12758
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -12696,7 +12768,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12696
12768
  .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.")
12697
12769
  .option("--include-archived", "Include archived conversations.")
12698
12770
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
12699
- .option("--cursor <cursor>", "channel=all only: the previous page's next_cursor — resumes the merged stream after that row.")
12771
+ .option("--cursor <cursor>", "Merged stream only: the previous page's next_cursor — resumes after that row.")
12700
12772
  .option("--no-counts", "channel=all only: skip the sidebar facet counts for a faster paged read (total_conversations/unread_conversations/sidebar_counts omitted from the envelope). Other channels ignore it.")
12701
12773
  .option("--json", "Print a JSON envelope.")
12702
12774
  .action(async (options) => {
@@ -12750,9 +12822,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12750
12822
  });
12751
12823
  }))
12752
12824
  .addCommand(new Command("get")
12753
- .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id.")
12825
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp).")
12754
12826
  .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
12755
- .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
12827
+ .option("--channel <channel>", "Inbox channel: all (default, resolves the id on any channel), email, linkedin, or whatsapp.")
12756
12828
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
12757
12829
  .option("--json", "Print a JSON envelope.")
12758
12830
  .action(async (conversation, options) => {
@@ -12769,10 +12841,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12769
12841
  });
12770
12842
  }))
12771
12843
  .addCommand(new Command("send")
12772
- .description("Reply into a conversation. --channel email replies from the conversation's own mailbox (Zapbox/native Gmail/Graph, threaded); --channel whatsapp warm-replies into an existing WhatsApp conversation; default replies into the LinkedIn conversation. Sends a real message — requires --approved. Without it, returns a preview.")
12844
+ .description("Reply into a conversation on its own channel, resolved from the conversation when --channel is omitted: an email thread replies from its own mailbox (Zapbox/native Gmail/Graph, threaded); a WhatsApp thread gets a warm reply; a LinkedIn thread replies via Unipile. Sends a real message — requires --approved. Without it, returns a preview.")
12773
12845
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
12774
12846
  .requiredOption("--text <message>", "Reply text to send.")
12775
- .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp (warm reply), or email.")
12847
+ .option("--channel <channel>", "Optional: email, linkedin, or whatsapp. Resolved from the conversation when omitted.")
12776
12848
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
12777
12849
  .option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
12778
12850
  .option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
@@ -12902,7 +12974,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12902
12974
  });
12903
12975
  }))
12904
12976
  .addCommand(new Command("sync")
12905
- .description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
12977
+ .description("Force a backstop inbox sync from Unipile for the active LinkedIn and WhatsApp sender accounts (pulls recent chats + messages into the unibox). Email mailboxes are not synced here: they are polled automatically every worker tick, so email replies reach `inbox list` on their own.")
12906
12978
  .option("--account <id>", "Force-sync one sender account (sender id, connection id, or Unipile account id).")
12907
12979
  .option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
12908
12980
  .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
@@ -12923,14 +12995,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12923
12995
  });
12924
12996
  }))
12925
12997
  .addCommand(new Command("backfill")
12926
- .description("Opt-in: pull a LinkedIn account's OLDER message history into the unibox. A SLOW, durable background drip walks each conversation's thread strictly backward — one page per worker tick under a dedicated conservative read budget — so a long history fills in over many days and never burns the account's limits. New messages keep arriving in real time via webhooks; this only backfills old history. No messages are sent and no Oxygen credits are charged.")
12998
+ .description("Opt-in, LinkedIn only: pull a LinkedIn account's OLDER message history into the unibox. A SLOW, durable background drip walks each conversation's thread strictly backward — one page per worker tick under a dedicated conservative read budget — so a long history fills in over many days and never burns the account's limits. New messages keep arriving in real time via webhooks; this only backfills old history. --cancel stops an armed drip. No messages are sent and no Oxygen credits are charged.")
12927
12999
  .option("--account <ref>", "Sender account to backfill (sender id, connection id, or Unipile account id). Omit to backfill every active LinkedIn account.")
12928
13000
  .option("--conversation <ref>", "Scope to a single conversation (conversation id or Unipile chat id).")
13001
+ .option("--cancel", "Cancel the active history backfill drip for --account (or every LinkedIn account) instead of arming one. Messages already mirrored stay.")
12929
13002
  .option("--json", "Print a JSON envelope.")
12930
13003
  .action(async (options) => {
12931
13004
  await handleAsyncAction("inbox backfill", options, () => {
12932
13005
  const account = readOption(options.account);
12933
13006
  const conversation = readOption(options.conversation);
13007
+ if (options.cancel) {
13008
+ if (conversation) {
13009
+ throw new OxygenError("invalid_request", "--cancel stops a sender's whole drip; it cannot be combined with --conversation. Pass --account (or nothing) with --cancel.", { exitCode: 1 });
13010
+ }
13011
+ return requestOxygen("/api/cli/linkedin/inbox/backfill/cancel", {
13012
+ method: "POST",
13013
+ body: { ...(account ? { account } : {}) },
13014
+ });
13015
+ }
12934
13016
  return requestOxygen("/api/cli/linkedin/inbox/backfill", {
12935
13017
  method: "POST",
12936
13018
  body: {
@@ -13584,11 +13666,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13584
13666
  .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys); bind an Instantly email track with --email-*. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
13585
13667
  .requiredOption("--name <name>", "Human-readable sequence name.")
13586
13668
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
13587
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread (what the retired email_reply kind used to be — still accepted on input and rewritten into this shape). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue; A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
13669
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
13588
13670
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
13589
13671
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
13590
13672
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
13591
- .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids). Optional at create; enroll and start require at least one when the journey has LinkedIn steps (attach later with `sequences update --senders`).")
13673
+ .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them). Optional at create; enroll and start require at least one when the journey has LinkedIn or WhatsApp steps (attach later with `sequences update --senders`).")
13592
13674
  .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
13593
13675
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
13594
13676
  .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
@@ -13691,7 +13773,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13691
13773
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
13692
13774
  .option("--email-column-key <key>", "Native email: row_values key holding each lead's email (else falls back to email/email_address/work_email). Maps a non-English header (e.g. E-Mail).")
13693
13775
  .option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
13694
- .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids).")
13776
+ .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them).")
13695
13777
  .option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug). Only lands while the sequence has no source table yet (first-writer-wins); its rows become enrollable leads. Draft/paused only.")
13696
13778
  .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
13697
13779
  .option("--email-connection <id>", "Instantly connection id for the email track.")
@@ -13834,12 +13916,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13834
13916
  });
13835
13917
  }))
13836
13918
  .addCommand(new Command("start")
13837
- .description("Preview or start a sequence. Without --approved, preview scans pending LinkedIn copy and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. Use --dry-run to simulate without any send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
13919
+ .description("Preview or start a sequence. Without --approved this is the launch check: it returns launch_readiness (blockers such as a missing send cap or the WhatsApp cold-initiate opt-in, plus warnings), scans pending LinkedIn copy, and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. Use --dry-run to simulate without any send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
13838
13920
  .argument("<sequence>", "Sequence id or slug.")
13839
13921
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
13840
13922
  .option("--max-credits <n>", "Optional credit ceiling for the LinkedIn track (omit for an unbounded run).")
13841
13923
  .option("--max-live-sends <n>", "Live external-action ceiling (positive integer). Required for email, WhatsApp, or crm_task steps.")
13842
- .option("--dry-run", "Activate in dry-run mode: advance every step with simulated actions, no sends, CRM writes, provider calls, or credits.")
13924
+ .option("--dry-run", "Activate the sequence (status draft → active, startedAt set) in dry-run mode: every step advances with simulated actions — no sends, CRM writes, provider calls, or credits. This is NOT the preview: `sequences start` with no flags is the side-effect-free launch preview, and a dry-run sequence must be paused or deleted to leave that state.")
13843
13925
  .option("--json", "Print a JSON envelope.")
13844
13926
  .action(async (sequence, options) => {
13845
13927
  await handleAsyncAction("sequences start", options, () => {
@@ -17235,6 +17317,9 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
17235
17317
  registerFunctionsCommands(program, handleAsyncAction);
17236
17318
  registerUgcCommands(program, handleAsyncAction);
17237
17319
  registerKnowledgeRepositoryCommands(program, handleAsyncAction);
17320
+ // Last, so every command registered above is covered — including the ones the
17321
+ // register* helpers add after applyOxygenHelp.
17322
+ enableCommandSuggestions(program);
17238
17323
  return program;
17239
17324
  }
17240
17325
  /**
@@ -18526,6 +18611,74 @@ async function assertPromptColumnReferences(table, prompt, inputNames = []) {
18526
18611
  exitCode: 1,
18527
18612
  });
18528
18613
  }
18614
+ /**
18615
+ * Human rendering of `columns catalog`: one line per template with the input it
18616
+ * reads and the credits per row, grouped by family. The JSON envelope is the
18617
+ * contract; this is what a person reads in a terminal.
18618
+ */
18619
+ function printColumnCatalog(result) {
18620
+ // `requestOxygen` hands back the envelope's `data` already unwrapped, so the
18621
+ // entries sit at the top level; a wrapped envelope is accepted too. Reading
18622
+ // only `result.data` printed "No column templates match." for every catalog
18623
+ // that actually matched (v1.985.2–v1.987.0).
18624
+ const data = isRecord(result) ? (isRecord(result.data) ? result.data : result) : null;
18625
+ const entries = data && Array.isArray(data.entries) ? data.entries.filter(isRecord) : [];
18626
+ const bundles = data && Array.isArray(data.bundles) ? data.bundles.filter(isRecord) : [];
18627
+ if (entries.length === 0 && bundles.length === 0) {
18628
+ process.stderr.write("No column templates match.\n");
18629
+ return;
18630
+ }
18631
+ const byFamily = new Map();
18632
+ for (const entry of entries) {
18633
+ const family = typeof entry.family === "string" ? entry.family : (typeof entry.category === "string" ? entry.category : "other");
18634
+ const bucket = byFamily.get(family) ?? [];
18635
+ bucket.push(entry);
18636
+ byFamily.set(family, bucket);
18637
+ }
18638
+ const lines = [];
18639
+ for (const [family, bucket] of byFamily) {
18640
+ lines.push(`${family.replaceAll("_", " ")}:`);
18641
+ for (const entry of bucket) {
18642
+ const inputs = Array.isArray(entry.inputs)
18643
+ ? entry.inputs.filter(isRecord).map((input) => `${String(input.name)}${input.required === false ? "?" : ""}`).join(", ")
18644
+ : "";
18645
+ const credits = isRecord(entry.credit_estimate) && typeof entry.credit_estimate.label === "string"
18646
+ ? entry.credit_estimate.label
18647
+ : "";
18648
+ lines.push(` ${String(entry.key)} [${String(entry.source)}${inputs ? ` · ${inputs}` : ""}] ${credits}`);
18649
+ if (typeof entry.description === "string" && entry.description)
18650
+ lines.push(` ${entry.description}`);
18651
+ }
18652
+ }
18653
+ // Bundles after the templates, because a bundle is the bigger commitment (it
18654
+ // creates several columns) and a search that matches both should read as
18655
+ // "here is the single column, and here is the pre-built set". Before this,
18656
+ // `columns catalog --search domain` printed nothing at all for the
18657
+ // domain_check preset and the only surface naming it was `columns add --help`
18658
+ // (blind user eval, 2026-09-17).
18659
+ if (bundles.length > 0) {
18660
+ lines.push("");
18661
+ lines.push("Bundles (several columns at once):");
18662
+ for (const bundle of bundles) {
18663
+ const credits = isRecord(bundle.credit_estimate) && typeof bundle.credit_estimate.label === "string"
18664
+ ? bundle.credit_estimate.label
18665
+ : "";
18666
+ const columnCount = typeof bundle.column_count === "number" ? `${bundle.column_count} columns` : "";
18667
+ const facts = [columnCount, credits].filter(Boolean).join(" · ");
18668
+ lines.push(` ${String(bundle.id)} [${String(bundle.category ?? "")}${facts ? ` · ${facts}` : ""}]`);
18669
+ if (typeof bundle.description === "string" && bundle.description)
18670
+ lines.push(` ${bundle.description}`);
18671
+ if (typeof bundle.caveat === "string" && bundle.caveat)
18672
+ lines.push(` ${bundle.caveat}`);
18673
+ if (typeof bundle.add_command === "string" && bundle.add_command)
18674
+ lines.push(` ${bundle.add_command}`);
18675
+ }
18676
+ }
18677
+ lines.push("");
18678
+ lines.push("Prices: a fixed figure is what every row costs; `~X credits/row (up to Y)` is X when the first grounding lane answers and Y reserved per row — `columns run --dry-run` quotes Y for your rows.");
18679
+ lines.push("Add one: oxygen columns add <table> --prompt-key <key> --input <input>=<column_key>, then `columns run <table> <key> --dry-run` for the exact cost.");
18680
+ process.stderr.write(`${lines.join("\n")}\n`);
18681
+ }
18529
18682
  function applyAiColumnConfig(definition, options) {
18530
18683
  const model = readOption(options.model);
18531
18684
  if (model)
@@ -18563,6 +18716,7 @@ function applyAiColumnConfig(definition, options) {
18563
18716
  return definition;
18564
18717
  }
18565
18718
  const RESEARCH_ENGINES = new Set(["exa", "parallel", "firecrawl"]);
18719
+ const RESEARCH_FETCH_ENGINES = new Set(["firecrawl", "linkup", "exa"]);
18566
18720
  const RESEARCH_MODES = new Set(["strict", "estimate"]);
18567
18721
  /**
18568
18722
  * Fold the `--research-*` flags into `definition.webSearch`.
@@ -18590,10 +18744,24 @@ function applyResearchColumnConfig(definition, options) {
18590
18744
  }
18591
18745
  webSearch.evidenceMode = mode;
18592
18746
  }
18747
+ // Fetch mode: the column reads one page per row instead of searching. The
18748
+ // URL column is a row column key (the same thing a {{token}} would name), so
18749
+ // the server can reject a typo at write time instead of once per row.
18750
+ const researchUrl = readOption(options.researchUrl);
18751
+ if (researchUrl) {
18752
+ if (readOption(options.researchQuery)) {
18753
+ throw new OxygenError("invalid_request", "--research-url reads one page per row, so --research-query does not apply. Drop one of the two.", { exitCode: 1 });
18754
+ }
18755
+ webSearch.source = "fetch";
18756
+ webSearch.urlInput = researchUrl;
18757
+ }
18593
18758
  const engine = readOption(options.researchEngine)?.toLowerCase();
18594
18759
  if (engine) {
18595
- if (!RESEARCH_ENGINES.has(engine)) {
18596
- throw new OxygenError("invalid_request", `--research-engine must be exa, parallel, or firecrawl (got ${engine}).`, { exitCode: 1 });
18760
+ const allowed = researchUrl ? RESEARCH_FETCH_ENGINES : RESEARCH_ENGINES;
18761
+ if (!allowed.has(engine)) {
18762
+ throw new OxygenError("invalid_request", researchUrl
18763
+ ? `--research-engine with --research-url must be firecrawl, linkup, or exa (got ${engine}).`
18764
+ : `--research-engine must be exa, parallel, or firecrawl (got ${engine}).`, { exitCode: 1 });
18597
18765
  }
18598
18766
  webSearch.engine = engine;
18599
18767
  }
@@ -19382,8 +19550,8 @@ function readCompaniesSearchRunBody(options, promptArg) {
19382
19550
  const promptSource = options.prompt ?? promptArg;
19383
19551
  const prompt = promptSource ? readFileIfPresent(promptSource) : null;
19384
19552
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
19385
- if (!prompt && !plan) {
19386
- throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
19553
+ if (!prompt && !plan && options.source !== "company_search") {
19554
+ throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --source company_search with --source-filters-json.", { exitCode: 1 });
19387
19555
  }
19388
19556
  const maxPages = readPositiveInt(options.maxPages);
19389
19557
  const maxCredits = readPositiveNumber(options.maxCredits);
@@ -19392,6 +19560,8 @@ function readCompaniesSearchRunBody(options, promptArg) {
19392
19560
  return {
19393
19561
  ...(prompt ? { prompt } : {}),
19394
19562
  ...(plan ? { plan } : {}),
19563
+ ...(options.source ? { source: options.source } : {}),
19564
+ ...(options.sourceFiltersJson ? { source_filters: parseJsonObject(readFileIfPresent(options.sourceFiltersJson)) } : {}),
19395
19565
  ...(options.routeId ? { route_id: options.routeId } : {}),
19396
19566
  ...(options.toolId ? { tool_id: options.toolId } : {}),
19397
19567
  ...(options.table ? { table: options.table } : {}),
@@ -19540,13 +19710,72 @@ function readSearchPlanJson(value) {
19540
19710
  ? data
19541
19711
  : parsed;
19542
19712
  }
19713
+ // Table-driven Employee Finder help, written once and shared by `people search
19714
+ // plan` and `people search run` so the price shape reads the same on both.
19715
+ const PEOPLE_SEARCH_FROM_TABLE_HELP = "Fan out Blitz Employee Finder over every company LinkedIn URL in a table column: 10 credits per company per page, rows land in a new people table linked back to each company row. Pass the table id or slug, plus --linkedin-url-column.";
19716
+ const PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP = "Column key in --from-table holding each company's LinkedIn URL.";
19717
+ const PEOPLE_SEARCH_COMPANY_OFFSET_HELP = "Skip this many companies in --from-table. Use it to source the rest after a first-company preview.";
19718
+ const PEOPLE_SEARCH_COMPANY_LIMIT_HELP = "Fetch people for at most this many companies from --from-table. Start with --company-limit 1.";
19719
+ const PEOPLE_SEARCH_JOB_FUNCTIONS_HELP = "Comma-separated job functions: Sales, Marketing, Engineering, Finance, ...";
19720
+ const PEOPLE_SEARCH_CONTINENTS_HELP = "Comma-separated continents: Europe, North America, Asia, ...";
19721
+ const PEOPLE_SEARCH_SALES_REGIONS_HELP = "Comma-separated sales regions: EMEA, NAMER, APAC, LATAM.";
19722
+ const PEOPLE_SEARCH_MIN_CONNECTIONS_HELP = "Only return people with at least this many LinkedIn connections (0-500).";
19723
+ // `source` names the companies table the Employee Finder fans out over. The
19724
+ // offset/limit window rides INSIDE `source` when the source is declared here; with
19725
+ // a saved plan it rides as `source_overrides`, which the run route applies on top
19726
+ // of the plan's own source.
19727
+ function readPeopleSearchSource(options) {
19728
+ const table = readOption(options.fromTable);
19729
+ const column = readOption(options.linkedinUrlColumn);
19730
+ if (!table && !column)
19731
+ return null;
19732
+ if (!table || !column) {
19733
+ throw new OxygenError("invalid_request", "--from-table and --linkedin-url-column go together: name the table holding your companies and the column holding each company's LinkedIn URL.", { details: { from_table: table ?? null, linkedin_url_column: column ?? null }, exitCode: 1 });
19734
+ }
19735
+ return { table, linkedin_url_column: column, ...readPeopleSearchCompanyWindow(options) };
19736
+ }
19737
+ function readPeopleSearchCompanyWindow(options) {
19738
+ const offset = readNonNegativeInt(options.companyOffset);
19739
+ const limit = readPositiveInt(options.companyLimit);
19740
+ return {
19741
+ ...(offset !== undefined ? { company_offset: offset } : {}),
19742
+ ...(limit !== undefined ? { company_limit: limit } : {}),
19743
+ };
19744
+ }
19745
+ // `--pages-per-company` is the customer-readable spelling of `--max-pages` on a
19746
+ // table-driven run, where a page is one provider request per company. Two spellings
19747
+ // of one ceiling must never disagree silently on a paid run.
19748
+ function readPeopleSearchMaxPages(options) {
19749
+ const perCompany = readPositiveInt(options.pagesPerCompany);
19750
+ const maxPages = readPositiveInt(options.maxPages);
19751
+ if (perCompany !== undefined && maxPages !== undefined && perCompany !== maxPages) {
19752
+ throw new OxygenError("invalid_request", "--pages-per-company and --max-pages set the same ceiling. Pass one of them.", { details: { pages_per_company: perCompany, max_pages: maxPages }, exitCode: 1 });
19753
+ }
19754
+ return perCompany ?? maxPages;
19755
+ }
19756
+ // A customer pointing at a table has already said what they want; making them
19757
+ // also invent a sentence is a question with one answer. The synthesized prompt is
19758
+ // the same one the web wizard sends, so both surfaces plan from identical input.
19759
+ function readPeopleSearchPrompt(options, source) {
19760
+ if (options.prompt)
19761
+ return readFileIfPresent(options.prompt);
19762
+ if (!source)
19763
+ return null;
19764
+ return `Employees at companies in ${String(source.table)}`;
19765
+ }
19543
19766
  function readPeopleSearchPlanBody(options) {
19544
19767
  const targetCount = readPositiveInt(options.targetCount);
19545
19768
  const filters = readPeopleSearchFilters(options);
19769
+ const source = readPeopleSearchSource(options);
19770
+ const prompt = readPeopleSearchPrompt(options, source);
19771
+ if (!prompt) {
19772
+ throw new OxygenError("invalid_request", "Pass --prompt, or --from-table with --linkedin-url-column to source employees from a table you already have.", { exitCode: 1 });
19773
+ }
19546
19774
  return {
19547
- prompt: readFileIfPresent(options.prompt),
19775
+ prompt,
19548
19776
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
19549
19777
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
19778
+ ...(source ? { source } : {}),
19550
19779
  ...(filters ? { filters } : {}),
19551
19780
  // Free sizing is on by default; only --no-estimate (options.estimate === false) opts out.
19552
19781
  estimate: options.estimate !== false,
@@ -19554,15 +19783,20 @@ function readPeopleSearchPlanBody(options) {
19554
19783
  };
19555
19784
  }
19556
19785
  function readPeopleSearchRunBody(options) {
19557
- const prompt = options.prompt ? readFileIfPresent(options.prompt) : null;
19558
19786
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
19787
+ const source = readPeopleSearchSource(options);
19788
+ // --from-table is itself the request, so it satisfies the prompt requirement the
19789
+ // same way --plan-json does. A submitted plan already carries its own source and
19790
+ // prompt, so nothing is synthesized on top of one.
19791
+ const prompt = readPeopleSearchPrompt(options, plan ? null : source);
19559
19792
  if (!prompt && !plan) {
19560
- throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
19793
+ throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --from-table with --linkedin-url-column.", { exitCode: 1 });
19561
19794
  }
19562
- const maxPages = readPositiveInt(options.maxPages);
19795
+ const maxPages = readPeopleSearchMaxPages(options);
19563
19796
  const maxCredits = readPositiveNumber(options.maxCredits);
19564
19797
  const targetCount = readPositiveInt(options.targetCount);
19565
19798
  const filters = prompt ? readPeopleSearchFilters(options) : null;
19799
+ const sourceOverrides = source ? {} : readPeopleSearchCompanyWindow(options);
19566
19800
  return {
19567
19801
  ...(prompt ? { prompt } : {}),
19568
19802
  ...(plan ? { plan } : {}),
@@ -19576,6 +19810,8 @@ function readPeopleSearchRunBody(options) {
19576
19810
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
19577
19811
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
19578
19812
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
19813
+ ...(source ? { source } : {}),
19814
+ ...(Object.keys(sourceOverrides).length > 0 ? { source_overrides: sourceOverrides } : {}),
19579
19815
  ...(filters ? { filters } : {}),
19580
19816
  // Free sizing is on by default when planning from a prompt; --no-estimate opts out.
19581
19817
  ...(prompt ? { estimate: options.estimate !== false } : {}),
@@ -19616,12 +19852,18 @@ function readPeopleSearchFilters(options) {
19616
19852
  const departments = readCsvOption(options.departments);
19617
19853
  if (departments.length > 0)
19618
19854
  filters.departments = { include: departments };
19855
+ const jobFunctions = readCsvOption(options.jobFunctions);
19856
+ if (jobFunctions.length > 0)
19857
+ filters.job_functions = jobFunctions;
19619
19858
  const keywords = readCsvOption(options.keywords);
19620
19859
  if (keywords.length > 0)
19621
19860
  filters.keywords = { include: keywords };
19622
- const countries = readCsvOption(options.countries);
19623
- if (countries.length > 0)
19624
- filters.geo = { countries };
19861
+ const geo = readPeopleSearchGeo(options);
19862
+ if (geo)
19863
+ filters.geo = geo;
19864
+ const minConnections = readNonNegativeInt(options.minConnections);
19865
+ if (minConnections !== undefined)
19866
+ filters.min_connections = minConnections;
19625
19867
  const contactability = {};
19626
19868
  if (options.requireEmail)
19627
19869
  contactability.require_work_email = true;
@@ -19652,6 +19894,22 @@ function readPeopleSearchFilters(options) {
19652
19894
  }
19653
19895
  return Object.keys(filters).length > 0 ? filters : null;
19654
19896
  }
19897
+ // Person location filters share one `geo` object: country, continent, and sales
19898
+ // region are separate provider fields, so each one must merge into it rather than
19899
+ // replace it.
19900
+ function readPeopleSearchGeo(options) {
19901
+ const geo = {};
19902
+ const countries = readCsvOption(options.countries);
19903
+ if (countries.length > 0)
19904
+ geo.countries = countries;
19905
+ const continents = readCsvOption(options.continents);
19906
+ if (continents.length > 0)
19907
+ geo.continents = continents;
19908
+ const salesRegions = readCsvOption(options.salesRegions);
19909
+ if (salesRegions.length > 0)
19910
+ geo.sales_regions = salesRegions;
19911
+ return Object.keys(geo).length > 0 ? geo : null;
19912
+ }
19655
19913
  function readCompaniesEnrichBody(table, options) {
19656
19914
  const body = { table };
19657
19915
  const fields = readCsvOption(options.missingFields);
@@ -25760,6 +26018,13 @@ catch (error) {
25760
26018
  cliArgs.slice(-2).join(" ") === "limits --json") {
25761
26019
  process.stderr.write(`Hint: --json belongs to the show subcommand. Run \`${program.name()} limits show --json\`.\n`);
25762
26020
  }
26021
+ // `error: unknown command 'get'` names what is wrong and nothing to try
26022
+ // next; commander's edit-distance suggester cannot reach a synonym.
26023
+ if (error.code === "commander.unknownCommand") {
26024
+ const hint = unknownCommandHint(program, cliArgs);
26025
+ if (hint)
26026
+ process.stderr.write(`${hint}\n`);
26027
+ }
25763
26028
  if (error.code === "commander.excessArguments" &&
25764
26029
  cliArgs[0] === "skills" &&
25765
26030
  cliArgs[1] === "install") {