@oxygen-agent/cli 1.1014.135 → 1.1014.618

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 (292) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.js +27 -19
  3. package/dist/cli-output.d.ts +12 -0
  4. package/dist/cli-output.js +395 -0
  5. package/dist/cli-paths.d.ts +1 -0
  6. package/dist/cli-paths.js +3 -0
  7. package/dist/cli-profile-context.d.ts +3 -0
  8. package/dist/cli-profile-context.js +13 -0
  9. package/dist/command-manifest.d.ts +15 -0
  10. package/dist/command-manifest.js +23 -1
  11. package/dist/command-support.d.ts +438 -0
  12. package/dist/command-support.js +1610 -0
  13. package/dist/commands/action-column.d.ts +3 -0
  14. package/dist/commands/action-column.js +43 -0
  15. package/dist/commands/activation.d.ts +3 -0
  16. package/dist/commands/activation.js +24 -0
  17. package/dist/commands/admin.d.ts +3 -0
  18. package/dist/commands/admin.js +347 -0
  19. package/dist/commands/ads.d.ts +3 -0
  20. package/dist/commands/ads.js +62 -0
  21. package/dist/commands/agent.d.ts +3 -0
  22. package/dist/commands/agent.js +274 -0
  23. package/dist/commands/api-keys.d.ts +3 -0
  24. package/dist/commands/api-keys.js +77 -0
  25. package/dist/commands/approvals.d.ts +3 -0
  26. package/dist/commands/approvals.js +518 -0
  27. package/dist/commands/auth.d.ts +3 -0
  28. package/dist/commands/auth.js +230 -0
  29. package/dist/commands/billing.d.ts +3 -0
  30. package/dist/commands/billing.js +357 -0
  31. package/dist/commands/blueprints.d.ts +3 -0
  32. package/dist/commands/blueprints.js +312 -0
  33. package/dist/commands/budget.d.ts +3 -0
  34. package/dist/commands/budget.js +55 -0
  35. package/dist/commands/callables.d.ts +3 -0
  36. package/dist/commands/callables.js +111 -0
  37. package/dist/commands/capabilities.d.ts +3 -0
  38. package/dist/commands/capabilities.js +44 -0
  39. package/dist/commands/cells.d.ts +3 -0
  40. package/dist/commands/cells.js +94 -0
  41. package/dist/commands/columns.d.ts +3 -0
  42. package/dist/commands/columns.js +1628 -0
  43. package/dist/commands/commands.d.ts +3 -0
  44. package/dist/commands/commands.js +45 -0
  45. package/dist/commands/comments.d.ts +3 -0
  46. package/dist/commands/comments.js +188 -0
  47. package/dist/commands/companies.d.ts +3 -0
  48. package/dist/commands/companies.js +312 -0
  49. package/dist/commands/connections.d.ts +3 -0
  50. package/dist/commands/connections.js +39 -0
  51. package/dist/commands/context.d.ts +3 -0
  52. package/dist/commands/context.js +170 -0
  53. package/dist/commands/copilot.d.ts +3 -0
  54. package/dist/commands/copilot.js +565 -0
  55. package/dist/commands/crm.d.ts +3 -0
  56. package/dist/commands/crm.js +1669 -0
  57. package/dist/commands/custom-integrations.d.ts +3 -0
  58. package/dist/commands/custom-integrations.js +140 -0
  59. package/dist/commands/dashboards.d.ts +3 -0
  60. package/dist/commands/dashboards.js +240 -0
  61. package/dist/commands/db.d.ts +3 -0
  62. package/dist/commands/db.js +190 -0
  63. package/dist/commands/deliverability.d.ts +3 -0
  64. package/dist/commands/deliverability.js +90 -0
  65. package/dist/commands/directory.d.ts +3 -0
  66. package/dist/commands/directory.js +101 -0
  67. package/dist/commands/domains.d.ts +3 -0
  68. package/dist/commands/domains.js +604 -0
  69. package/dist/commands/egress.d.ts +3 -0
  70. package/dist/commands/egress.js +130 -0
  71. package/dist/commands/email.d.ts +3 -0
  72. package/dist/commands/email.js +49 -0
  73. package/dist/commands/engagement.d.ts +3 -0
  74. package/dist/commands/engagement.js +189 -0
  75. package/dist/commands/enrich-column.d.ts +3 -0
  76. package/dist/commands/enrich-column.js +149 -0
  77. package/dist/commands/enrichment.d.ts +3 -0
  78. package/dist/commands/enrichment.js +49 -0
  79. package/dist/commands/feedback.d.ts +3 -0
  80. package/dist/commands/feedback.js +96 -0
  81. package/dist/commands/feeds.d.ts +3 -0
  82. package/dist/commands/feeds.js +486 -0
  83. package/dist/commands/find.d.ts +3 -0
  84. package/dist/commands/find.js +159 -0
  85. package/dist/commands/followers.d.ts +3 -0
  86. package/dist/commands/followers.js +39 -0
  87. package/dist/commands/formulas.d.ts +3 -0
  88. package/dist/commands/formulas.js +43 -0
  89. package/dist/commands/home.d.ts +3 -0
  90. package/dist/commands/home.js +22 -0
  91. package/dist/commands/inbox.d.ts +3 -0
  92. package/dist/commands/inbox.js +779 -0
  93. package/dist/commands/integrations.d.ts +3 -0
  94. package/dist/commands/integrations.js +290 -0
  95. package/dist/commands/knowledge.d.ts +3 -0
  96. package/dist/commands/knowledge.js +874 -0
  97. package/dist/commands/lead-sourcing.d.ts +3 -0
  98. package/dist/commands/lead-sourcing.js +52 -0
  99. package/dist/commands/limits.d.ts +3 -0
  100. package/dist/commands/limits.js +54 -0
  101. package/dist/commands/linkedin.d.ts +3 -0
  102. package/dist/commands/linkedin.js +275 -0
  103. package/dist/commands/login.d.ts +3 -0
  104. package/dist/commands/login.js +347 -0
  105. package/dist/commands/logout.d.ts +3 -0
  106. package/dist/commands/logout.js +12 -0
  107. package/dist/commands/mailboxes.d.ts +3 -0
  108. package/dist/commands/mailboxes.js +1290 -0
  109. package/dist/commands/managed-inboxes.d.ts +3 -0
  110. package/dist/commands/managed-inboxes.js +361 -0
  111. package/dist/commands/messages.d.ts +3 -0
  112. package/dist/commands/messages.js +78 -0
  113. package/dist/commands/notetaker.d.ts +3 -0
  114. package/dist/commands/notetaker.js +79 -0
  115. package/dist/commands/observability.d.ts +3 -0
  116. package/dist/commands/observability.js +125 -0
  117. package/dist/commands/orgs.d.ts +3 -0
  118. package/dist/commands/orgs.js +237 -0
  119. package/dist/commands/people.d.ts +3 -0
  120. package/dist/commands/people.js +353 -0
  121. package/dist/commands/posts.d.ts +3 -0
  122. package/dist/commands/posts.js +139 -0
  123. package/dist/commands/profiles.d.ts +3 -0
  124. package/dist/commands/profiles.js +250 -0
  125. package/dist/commands/projects.d.ts +3 -0
  126. package/dist/commands/projects.js +94 -0
  127. package/dist/commands/prompts.d.ts +3 -0
  128. package/dist/commands/prompts.js +84 -0
  129. package/dist/commands/publishing.d.ts +3 -0
  130. package/dist/commands/publishing.js +1419 -0
  131. package/dist/commands/recipes.d.ts +3 -0
  132. package/dist/commands/recipes.js +120 -0
  133. package/dist/commands/reviews.d.ts +3 -0
  134. package/dist/commands/reviews.js +75 -0
  135. package/dist/commands/rows.d.ts +3 -0
  136. package/dist/commands/rows.js +27 -0
  137. package/dist/commands/runs.d.ts +3 -0
  138. package/dist/commands/runs.js +37 -0
  139. package/dist/commands/search.d.ts +3 -0
  140. package/dist/commands/search.js +182 -0
  141. package/dist/commands/senders.d.ts +3 -0
  142. package/dist/commands/senders.js +393 -0
  143. package/dist/commands/sequences.d.ts +3 -0
  144. package/dist/commands/sequences.js +2322 -0
  145. package/dist/commands/session.d.ts +3 -0
  146. package/dist/commands/session.js +109 -0
  147. package/dist/commands/signals.d.ts +3 -0
  148. package/dist/commands/signals.js +289 -0
  149. package/dist/commands/signup-leads.d.ts +3 -0
  150. package/dist/commands/signup-leads.js +46 -0
  151. package/dist/commands/skills.d.ts +3 -0
  152. package/dist/commands/skills.js +50 -0
  153. package/dist/commands/sourcing.d.ts +3 -0
  154. package/dist/commands/sourcing.js +27 -0
  155. package/dist/commands/status.d.ts +3 -0
  156. package/dist/commands/status.js +51 -0
  157. package/dist/commands/supabase.d.ts +3 -0
  158. package/dist/commands/supabase.js +128 -0
  159. package/dist/commands/support.d.ts +3 -0
  160. package/dist/commands/support.js +651 -0
  161. package/dist/commands/suppressions.d.ts +3 -0
  162. package/dist/commands/suppressions.js +577 -0
  163. package/dist/commands/table-ingestions.d.ts +3 -0
  164. package/dist/commands/table-ingestions.js +145 -0
  165. package/dist/commands/table-runs.d.ts +3 -0
  166. package/dist/commands/table-runs.js +277 -0
  167. package/dist/commands/tables.d.ts +3 -0
  168. package/dist/commands/tables.js +3492 -0
  169. package/dist/commands/tags.d.ts +3 -0
  170. package/dist/commands/tags.js +147 -0
  171. package/dist/commands/tools.d.ts +3 -0
  172. package/dist/commands/tools.js +246 -0
  173. package/dist/commands/update.d.ts +3 -0
  174. package/dist/commands/update.js +106 -0
  175. package/dist/commands/verify.d.ts +3 -0
  176. package/dist/commands/verify.js +27 -0
  177. package/dist/commands/viewers.d.ts +3 -0
  178. package/dist/commands/viewers.js +72 -0
  179. package/dist/commands/voice.d.ts +3 -0
  180. package/dist/commands/voice.js +351 -0
  181. package/dist/commands/whatsapp.d.ts +3 -0
  182. package/dist/commands/whatsapp.js +226 -0
  183. package/dist/commands/whoami.d.ts +3 -0
  184. package/dist/commands/whoami.js +110 -0
  185. package/dist/commands/worker.d.ts +3 -0
  186. package/dist/commands/worker.js +60 -0
  187. package/dist/commands/workflows.d.ts +3 -0
  188. package/dist/commands/workflows.js +1752 -0
  189. package/dist/commands/workspace.d.ts +3 -0
  190. package/dist/commands/workspace.js +94 -0
  191. package/dist/compact-list-flags.d.ts +7 -0
  192. package/dist/compact-list-flags.js +12 -0
  193. package/dist/crm-support-flags.js +14 -5
  194. package/dist/customer-operation-commands.js +8 -6
  195. package/dist/diagnostic-uploader.d.ts +3 -1
  196. package/dist/diagnostic-uploader.js +29 -8
  197. package/dist/help.d.ts +5 -0
  198. package/dist/help.js +22 -0
  199. package/dist/index.d.ts +98 -1
  200. package/dist/index.js +331 -30719
  201. package/dist/local-module-loader.d.ts +4 -0
  202. package/dist/local-module-loader.js +156 -0
  203. package/dist/visual-commands.js +9 -5
  204. package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
  205. package/node_modules/@oxygen/shared/dist/agent-session-schedule.d.ts +36 -0
  206. package/node_modules/@oxygen/shared/dist/agent-session-schedule.js +69 -0
  207. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +14 -5
  208. package/node_modules/@oxygen/shared/dist/agent-sessions.js +14 -3
  209. package/node_modules/@oxygen/shared/dist/capability-discovery.js +175 -14
  210. package/node_modules/@oxygen/shared/dist/compact-list-result.d.ts +6 -0
  211. package/node_modules/@oxygen/shared/dist/compact-list-result.js +27 -0
  212. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +12 -1
  213. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +5 -2
  214. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
  215. package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.d.ts +57 -0
  216. package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.js +23 -0
  217. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +52 -0
  218. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -2
  219. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +17 -14
  220. package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.js +1 -1
  221. package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.d.ts +49 -0
  222. package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.js +14 -0
  223. package/node_modules/@oxygen/shared/dist/contracts/knowledge-sync.js +1 -1
  224. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +60 -7
  225. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +9 -1
  226. package/node_modules/@oxygen/shared/dist/contracts/operations.js +13 -5
  227. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -1
  228. package/node_modules/@oxygen/shared/dist/contracts/teammates.d.ts +16 -0
  229. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +115 -0
  230. package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +21 -3
  231. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +31 -1
  232. package/node_modules/@oxygen/shared/dist/cost-estimate.js +56 -15
  233. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +2 -0
  234. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +2 -0
  235. package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +71 -44
  236. package/node_modules/@oxygen/shared/dist/diagnostic-spool.d.ts +18 -0
  237. package/node_modules/@oxygen/shared/dist/diagnostic-spool.js +348 -0
  238. package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.d.ts +10 -0
  239. package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.js +42 -0
  240. package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +1 -1
  241. package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +59 -213
  242. package/node_modules/@oxygen/shared/dist/diagnostics.js +3 -2
  243. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +16 -5
  244. package/node_modules/@oxygen/shared/dist/email-dsn.js +97 -1
  245. package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +5 -0
  246. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +4 -5
  247. package/node_modules/@oxygen/shared/dist/file-import.js +5 -2
  248. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +30 -0
  249. package/node_modules/@oxygen/shared/dist/hosted-ai.js +20 -0
  250. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -2
  251. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +17 -7
  252. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +81 -17
  253. package/node_modules/@oxygen/shared/dist/langfuse.js +8 -4
  254. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +1 -1
  255. package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +6 -3
  256. package/node_modules/@oxygen/shared/dist/mailbox-import.js +9 -9
  257. package/node_modules/@oxygen/shared/dist/mcp-server-info.d.ts +8 -0
  258. package/node_modules/@oxygen/shared/dist/mcp-server-info.js +10 -0
  259. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +10 -0
  260. package/node_modules/@oxygen/shared/dist/object-storage.js +14 -0
  261. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +5 -0
  262. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +17 -1
  263. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
  264. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
  265. package/node_modules/@oxygen/shared/dist/provider-selection.d.ts +15 -0
  266. package/node_modules/@oxygen/shared/dist/provider-selection.js +3 -0
  267. package/node_modules/@oxygen/shared/dist/repricing.d.ts +1 -1
  268. package/node_modules/@oxygen/shared/dist/repricing.js +2 -2
  269. package/node_modules/@oxygen/shared/dist/schedule-label.d.ts +6 -0
  270. package/node_modules/@oxygen/shared/dist/schedule-label.js +12 -0
  271. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +1 -0
  272. package/node_modules/@oxygen/shared/dist/sequence-failures.js +20 -6
  273. package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +55 -0
  274. package/node_modules/@oxygen/shared/dist/teammate-groups.js +74 -0
  275. package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +52 -0
  276. package/node_modules/@oxygen/shared/dist/teammate-messages.js +88 -0
  277. package/node_modules/@oxygen/shared/dist/teammate-notes.d.ts +58 -0
  278. package/node_modules/@oxygen/shared/dist/teammate-notes.js +73 -0
  279. package/node_modules/@oxygen/shared/dist/teammate-routines.d.ts +95 -0
  280. package/node_modules/@oxygen/shared/dist/teammate-routines.js +266 -0
  281. package/node_modules/@oxygen/shared/dist/teammate-tasks.d.ts +56 -0
  282. package/node_modules/@oxygen/shared/dist/teammate-tasks.js +114 -0
  283. package/node_modules/@oxygen/shared/dist/teammates.d.ts +78 -0
  284. package/node_modules/@oxygen/shared/dist/teammates.js +140 -0
  285. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +120 -0
  286. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  287. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  288. package/node_modules/@oxygen/shared/dist/visual-render.d.ts +9 -1
  289. package/node_modules/@oxygen/shared/dist/visual-render.js +12 -5
  290. package/node_modules/@oxygen/shared/dist/warmup-metrics.d.ts +5 -0
  291. package/node_modules/@oxygen/shared/dist/warmup-metrics.js +44 -0
  292. package/package.json +1 -1
@@ -0,0 +1,1628 @@
1
+ import { parseKeyValuePairs, parseJsonObject, readNonNegativeInt, readPositiveInt, readRecordString } from "../cli-values.js";
2
+ import { readColumnDecisionFlags } from "../column-decision-options.js";
3
+ import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, formatEnrichmentInputMappingNotice } from "../column-run-notices.js";
4
+ import { collectRepeatable, parseJsonArray, parseJsonValue, readCsvOption, readFileIfPresent, readFilterSelectionOption, readFormulaValuesOption, readJsonFileValue, readNonNegativeNumber, readPositiveNumber, tableRunsListPath, waitForTableActionRun } from "../command-support.js";
5
+ import { requestOxygen } from "../http-client.js";
6
+ import { runLocalCustomHttpColumn } from "../local-custom-http-column.js";
7
+ import { isRecord, readOption } from "../util.js";
8
+ import { OxygenError } from "@oxygen/shared/cli-result";
9
+ import { AI_COLUMN_CREDITS, AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS } from "@oxygen/shared/pricing-sheet";
10
+ import { lookupColumnDataType } from "@oxygen/shared/table-field-types";
11
+ import { sleep } from "@oxygen/shared/timing";
12
+ import { Command, Option } from "commander";
13
+ import { randomUUID } from "node:crypto";
14
+ import { resolve } from "node:path";
15
+ // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
16
+ // long for the cell to finish before handing back the queued run envelope.
17
+ const SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 90;
18
+ const SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 2;
19
+ function writeAiPromptPreviewNotice(data) {
20
+ const notice = formatAiPromptPreviewNotice(data);
21
+ if (notice)
22
+ process.stderr.write(`${notice}\n`);
23
+ }
24
+ function writeColumnReferenceNotices(data) {
25
+ for (const notice of [...formatColumnReferenceNotices(data), ...formatEnrichmentInputMappingNotice(data)]) {
26
+ process.stderr.write(`${notice}\n`);
27
+ }
28
+ }
29
+ // `--prompt <text-or-file>` on `columns add` / `columns update`, using the same
30
+ // inline-text-or-path reader as `sourcing plan --prompt` and friends: an
31
+ // unreadable path is the prompt itself.
32
+ function readAiPromptOption(value) {
33
+ const raw = readOption(value);
34
+ return raw === null ? null : readFileIfPresent(raw);
35
+ }
36
+ // `--prompt` layers onto `--definition-json` (the escape hatch for credentialMode,
37
+ // webSearch, promptTemplateSlug, ...) rather than replacing it. A `prompt` in both
38
+ // is genuinely ambiguous, so it fails instead of picking a silent winner.
39
+ /** --prompt authors an AI column; bind/lookup flags author a different kind. */
40
+ function assertPromptNotAlsoOtherKind(prompt, flag) {
41
+ if (prompt === null)
42
+ return;
43
+ throw new OxygenError("invalid_request", `--prompt authors an AI column, but ${flag} authors a different column kind. Add them as separate columns.`, { exitCode: 1 });
44
+ }
45
+ function assertPromptNotAlsoInDefinition(definition, prompt) {
46
+ if (typeof definition.prompt === "string") {
47
+ throw new OxygenError("invalid_request", "Pass the prompt either as --prompt or as \"prompt\" inside --definition-json, not both.", { exitCode: 1 });
48
+ }
49
+ return prompt;
50
+ }
51
+ const AI_PROMPT_TEMPLATE_TOKEN = /{{\s*([^{}]+?)\s*}}/g;
52
+ // Rows carry these system fields next to the workspace columns (WorkspaceTableRow
53
+ // in packages/tenant-db), so a prompt may legitimately reference them.
54
+ const AI_PROMPT_SYSTEM_ROW_KEYS = ["_row_id", "_created_at", "_updated_at"];
55
+ const AI_PROMPT_AVAILABLE_COLUMNS_SHOWN = 20;
56
+ function readAiPromptTemplateTokens(prompt) {
57
+ const tokens = [];
58
+ const seen = new Set();
59
+ for (const match of prompt.matchAll(AI_PROMPT_TEMPLATE_TOKEN)) {
60
+ const raw = (match[1] ?? "").trim();
61
+ if (!raw || seen.has(raw))
62
+ continue;
63
+ seen.add(raw);
64
+ const segments = raw.split(".").map((segment) => segment.trim()).filter(Boolean);
65
+ const root = segments.shift();
66
+ if (!root)
67
+ continue;
68
+ tokens.push({ raw, root, path: segments.length > 0 ? segments.join(".") : null });
69
+ }
70
+ return tokens;
71
+ }
72
+ /**
73
+ * The first `{{column.path}}` whose column declares fields but not that one.
74
+ *
75
+ * Author-time rather than per-row, and deliberately narrow: only a column that
76
+ * actually publishes `output_fields` is checked. An undeclared jsonb payload can
77
+ * hold anything, so rejecting a path into one would refuse references that work.
78
+ */
79
+ function findUnknownColumnFieldReference(tokens, columns) {
80
+ for (const token of tokens) {
81
+ if (!token.path)
82
+ continue;
83
+ const column = (columns ?? []).find((entry) => entry.key === token.root);
84
+ const fields = column?.output_fields ?? null;
85
+ if (!fields || fields.length === 0)
86
+ continue;
87
+ const paths = fields.map((field) => field.path);
88
+ if (paths.includes(token.path))
89
+ continue;
90
+ // A path INTO a declared object field is fine — the declaration stops at the
91
+ // depth the schema described, not at the depth the payload goes.
92
+ if (paths.some((path) => token.path?.startsWith(`${path}.`)))
93
+ continue;
94
+ const nearMisses = nearMissColumnKeys(token.path, paths);
95
+ return new OxygenError("invalid_prompt_column", [
96
+ `Prompt references {{${token.raw}}}, but "${token.root}" has no field "${token.path}".`,
97
+ nearMisses.length > 0
98
+ ? ` Did you mean {{${token.root}.${nearMisses[0]}}}?`
99
+ : "",
100
+ ` Fields on "${token.root}": ${paths.slice(0, AI_PROMPT_AVAILABLE_COLUMNS_SHOWN).join(", ")}.`,
101
+ ].join(""), {
102
+ details: {
103
+ column: token.root,
104
+ unknown_field: token.path,
105
+ near_misses: nearMisses,
106
+ available_fields: paths,
107
+ },
108
+ exitCode: 1,
109
+ });
110
+ }
111
+ return null;
112
+ }
113
+ // Plain Levenshtein distance, capped at the short keys this is used on — enough to
114
+ // catch the single-character and transposition typos ({{frist_name}}) the near-miss
115
+ // hint exists for.
116
+ function columnKeyEditDistance(a, b) {
117
+ let previous = Array.from({ length: b.length + 1 }, (_unused, index) => index);
118
+ for (let i = 1; i <= a.length; i += 1) {
119
+ const current = [i];
120
+ for (let j = 1; j <= b.length; j += 1) {
121
+ const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
122
+ current[j] = Math.min(substitution, previous[j] + 1, current[j - 1] + 1);
123
+ }
124
+ previous = current;
125
+ }
126
+ return previous[b.length];
127
+ }
128
+ function nearMissColumnKeys(reference, columnKeys) {
129
+ const needle = reference.toLowerCase();
130
+ // Scale the tolerance with the key length so short keys don't match everything.
131
+ const budget = Math.max(1, Math.floor(needle.length / 3));
132
+ return columnKeys
133
+ .map((key) => ({ key, distance: columnKeyEditDistance(needle, key.toLowerCase()) }))
134
+ .filter((candidate) => candidate.distance <= budget)
135
+ .sort((a, b) => a.distance - b.distance || a.key.localeCompare(b.key))
136
+ .slice(0, 3)
137
+ .map((candidate) => candidate.key);
138
+ }
139
+ /**
140
+ * Resolve a column definition's `{{tokens}}` against the table's live columns
141
+ * BEFORE the write, so a typo is one typed error instead of one failed row per
142
+ * row. `analyzeColumnReferences` (packages/tenant-db) is the authority and the
143
+ * server now rejects the same thing on every surface; this is the local
144
+ * pre-flight that saves a round trip and prints the near-miss hint.
145
+ *
146
+ * Scans the whole definition, not just `--prompt`: the prompt, the research
147
+ * query template, and every `type: "template"` input all share one grammar, and
148
+ * a definition pasted into `--definition-json` used to skip the check entirely.
149
+ * A token naming one of the column's OWN inputs resolves at run time, so those
150
+ * names count as known.
151
+ */
152
+ async function assertColumnDefinitionReferences(table, definition) {
153
+ if (!isRecord(definition))
154
+ return;
155
+ const inputMapping = isRecord(definition.inputMapping) ? definition.inputMapping : {};
156
+ const templates = [];
157
+ if (typeof definition.prompt === "string")
158
+ templates.push(definition.prompt);
159
+ const webSearch = isRecord(definition.webSearch) ? definition.webSearch : {};
160
+ if (typeof webSearch.queryTemplate === "string")
161
+ templates.push(webSearch.queryTemplate);
162
+ for (const ref of Object.values(inputMapping)) {
163
+ if (isRecord(ref) && ref.type === "template" && typeof ref.value === "string") {
164
+ templates.push(ref.value);
165
+ }
166
+ }
167
+ if (templates.length === 0)
168
+ return;
169
+ await assertPromptColumnReferences(table, templates.join("\n"), Object.keys(inputMapping));
170
+ }
171
+ // The core of the check above. The server is authoritative — this is the local
172
+ // pre-flight that fails before the round trip and names the near miss.
173
+ async function assertPromptColumnReferences(table, prompt, inputNames = []) {
174
+ const tokens = readAiPromptTemplateTokens(prompt);
175
+ if (tokens.length === 0)
176
+ return;
177
+ // include_archived: an archived column is a metadata flag over a physical
178
+ // column that still exists, so `{{archived_key}}` still resolves at run time.
179
+ // Leaving them out would reject here what the server accepts — the one
180
+ // divergence a local pre-flight must never have.
181
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table, include_archived: true } });
182
+ const columns = describe.columns ?? [];
183
+ const columnKeys = columns.map((column) => column.key).filter(Boolean);
184
+ const known = new Set([...columnKeys, ...AI_PROMPT_SYSTEM_ROW_KEYS, ...inputNames]);
185
+ const refs = [...new Set(tokens.map((token) => token.root))];
186
+ const unknown = refs.filter((ref) => !known.has(ref));
187
+ // A path into a column that DOES exist is checked against what that column
188
+ // declares it exposes, so `{{icp_fit.scoer}}` fails here rather than resolving
189
+ // to null on every row of a paid run. Only checked when the column actually
190
+ // declares fields — an undeclared jsonb payload can hold anything.
191
+ if (unknown.length === 0) {
192
+ const badPath = findUnknownColumnFieldReference(tokens, columns);
193
+ if (badPath)
194
+ throw badPath;
195
+ return;
196
+ }
197
+ const nearMisses = nearMissColumnKeys(unknown[0], columnKeys);
198
+ const shown = columnKeys.slice(0, AI_PROMPT_AVAILABLE_COLUMNS_SHOWN);
199
+ const overflow = columnKeys.length - shown.length;
200
+ throw new OxygenError(
201
+ // invalid_* so exitCodeForOxygenError classifies this as a validation
202
+ // failure (exit 2) alongside the server's invalid_column_definition.
203
+ "invalid_prompt_column", [
204
+ `Prompt references {{${unknown[0]}}}, which is not a column on table "${table}" and not one of this column's inputs.`,
205
+ nearMisses.length > 0
206
+ ? ` Did you mean ${nearMisses.map((key) => `{{${key}}}`).join(", ")}?`
207
+ : "",
208
+ ` Available columns: ${shown.join(", ") || "(none)"}${overflow > 0 ? `, +${overflow} more` : ""}.`,
209
+ ].join(""), {
210
+ details: {
211
+ table,
212
+ unknown_tokens: unknown,
213
+ near_misses: nearMisses,
214
+ available_columns: columnKeys,
215
+ },
216
+ exitCode: 1,
217
+ });
218
+ }
219
+ /**
220
+ * Human rendering of `columns catalog`: one line per template with the input it
221
+ * reads and the credits per row, grouped by family. The JSON envelope is the
222
+ * contract; this is what a person reads in a terminal.
223
+ */
224
+ function printColumnCatalog(result) {
225
+ // `requestOxygen` hands back the envelope's `data` already unwrapped, so the
226
+ // entries sit at the top level; a wrapped envelope is accepted too. Reading
227
+ // only `result.data` printed "No column templates match." for every catalog
228
+ // that actually matched (v1.985.2–v1.987.0).
229
+ const data = isRecord(result) ? (isRecord(result.data) ? result.data : result) : null;
230
+ const entries = data && Array.isArray(data.entries) ? data.entries.filter(isRecord) : [];
231
+ const bundles = data && Array.isArray(data.bundles) ? data.bundles.filter(isRecord) : [];
232
+ // Native column kinds (Classify) are sent only by a server that runs them.
233
+ const native = data && Array.isArray(data.native) ? data.native.filter(isRecord) : [];
234
+ if (entries.length === 0 && bundles.length === 0 && native.length === 0) {
235
+ process.stderr.write("No column templates match. No preset bundle matches either.\n"
236
+ + "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"
237
+ + "Shorten --search, drop it, or pick a section: `columns catalog --category people`.\n");
238
+ return;
239
+ }
240
+ const byFamily = new Map();
241
+ for (const entry of entries) {
242
+ const family = typeof entry.family === "string" ? entry.family : (typeof entry.category === "string" ? entry.category : "other");
243
+ const bucket = byFamily.get(family) ?? [];
244
+ bucket.push(entry);
245
+ byFamily.set(family, bucket);
246
+ }
247
+ const lines = [];
248
+ for (const kind of native) {
249
+ const credits = isRecord(kind.credit_estimate) && typeof kind.credit_estimate.label === "string" ? kind.credit_estimate.label : "";
250
+ lines.push(`${String(kind.label)} [native${credits ? ` · ${credits}` : ""}]`);
251
+ if (typeof kind.description === "string" && kind.description)
252
+ lines.push(` ${kind.description}`);
253
+ for (const mode of Array.isArray(kind.modes) ? kind.modes.filter(isRecord) : []) {
254
+ if (typeof mode.add_command === "string")
255
+ lines.push(` ${mode.add_command}`);
256
+ }
257
+ if (typeof kind.review_uncertain === "string" && kind.review_uncertain)
258
+ lines.push(` ${kind.review_uncertain}`);
259
+ }
260
+ if (native.length > 0 && byFamily.size > 0)
261
+ lines.push("");
262
+ for (const [family, bucket] of byFamily) {
263
+ lines.push(`${family.replaceAll("_", " ")}:`);
264
+ for (const entry of bucket) {
265
+ const inputs = Array.isArray(entry.inputs)
266
+ ? entry.inputs.filter(isRecord).map((input) => `${String(input.name)}${input.required === false ? "?" : ""}`).join(", ")
267
+ : "";
268
+ const credits = isRecord(entry.credit_estimate) && typeof entry.credit_estimate.label === "string"
269
+ ? entry.credit_estimate.label
270
+ : "";
271
+ lines.push(` ${String(entry.key)} [${String(entry.source)}${inputs ? ` · ${inputs}` : ""}] ${credits}`);
272
+ if (typeof entry.description === "string" && entry.description)
273
+ lines.push(` ${entry.description}`);
274
+ }
275
+ }
276
+ // Bundles after the templates, because a bundle is the bigger commitment (it
277
+ // creates several columns) and a search that matches both should read as
278
+ // "here is the single column, and here is the pre-built set". Before this,
279
+ // `columns catalog --search domain` printed nothing at all for the
280
+ // domain_check preset and the only surface naming it was `columns add --help`
281
+ // (blind user eval, 2026-09-17).
282
+ if (bundles.length > 0) {
283
+ lines.push("");
284
+ lines.push("Bundles (several columns at once):");
285
+ for (const bundle of bundles) {
286
+ const credits = isRecord(bundle.credit_estimate) && typeof bundle.credit_estimate.label === "string"
287
+ ? bundle.credit_estimate.label
288
+ : "";
289
+ const columnCount = typeof bundle.column_count === "number" ? `${bundle.column_count} columns` : "";
290
+ const facts = [columnCount, credits].filter(Boolean).join(" · ");
291
+ lines.push(` ${String(bundle.id)} [${String(bundle.category ?? "")}${facts ? ` · ${facts}` : ""}]`);
292
+ if (typeof bundle.description === "string" && bundle.description)
293
+ lines.push(` ${bundle.description}`);
294
+ if (typeof bundle.caveat === "string" && bundle.caveat)
295
+ lines.push(` ${bundle.caveat}`);
296
+ if (typeof bundle.add_command === "string" && bundle.add_command)
297
+ lines.push(` ${bundle.add_command}`);
298
+ }
299
+ }
300
+ lines.push("");
301
+ 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.");
302
+ 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.");
303
+ // The blind acceptance judge watched an agent filter this list with python3
304
+ // because nothing on screen said the command can narrow itself.
305
+ lines.push("Narrow this list: --family pricing|job_posting|events|filings|site_facts|market|portfolio|text_extraction, --category extract, --search <word>, --kind research|ai|formula. Template credits may reserve primary and fallback lanes; Classify has the fixed per-row price shown in its entry.");
306
+ process.stderr.write(`${lines.join("\n")}\n`);
307
+ }
308
+ function applyAiColumnConfig(definition, options) {
309
+ const model = readOption(options.model);
310
+ if (model)
311
+ definition.model = model;
312
+ const reasoningLevel = readOption(options.reasoningLevel);
313
+ if (reasoningLevel)
314
+ definition.reasoningLevel = reasoningLevel;
315
+ const runCondition = readOption(options.runCondition);
316
+ if (runCondition) {
317
+ definition.runCondition = {
318
+ type: "formula",
319
+ expression: runCondition,
320
+ referencedColumns: readCsvOption(options.runConditionColumns),
321
+ };
322
+ }
323
+ const outputSchemaJson = readOption(options.outputSchemaJson);
324
+ const outputSchemaFile = readOption(options.outputSchemaFile);
325
+ if (outputSchemaJson && outputSchemaFile) {
326
+ throw new OxygenError("invalid_request", "Pass either --output-schema-json or --output-schema-file, not both.", { exitCode: 1 });
327
+ }
328
+ if (outputSchemaJson) {
329
+ definition.outputSchema = parseJsonValue(outputSchemaJson, "--output-schema-json");
330
+ }
331
+ else if (outputSchemaFile) {
332
+ definition.outputSchema = readJsonFileValue(resolve(outputSchemaFile), "--output-schema-file");
333
+ }
334
+ // Only written when the flag was actually passed. Absent must stay absent: on
335
+ // `columns update` the definition is merged, so writing `true` by default
336
+ // would re-enable structure on a column the author had deliberately set to
337
+ // prose without ever mentioning it.
338
+ if (options.structuredOutput === false)
339
+ definition.structuredOutput = false;
340
+ else if (options.structuredOutput === true)
341
+ definition.structuredOutput = true;
342
+ return definition;
343
+ }
344
+ // Client-side mirror of the server's managed grounding rosters
345
+ // (AI_WEB_SEARCH_ENGINES / AI_WEB_FETCH_ENGINES in @oxygen/tenant-db). Kept in
346
+ // step by hand: if this set is wider than the server's, `--research-engine` takes
347
+ // a value the server then rejects; if it is narrower, the CLI blocks an engine
348
+ // that works. D52 took parallel and linkup to BYOK and moved firecrawl off the
349
+ // search rail onto scraping only.
350
+ //
351
+ // Restored 2026-09-21: b152df38b (a Dashboards CLI change) rebased a 295-line
352
+ // addition over a stale copy of this file and silently reverted both sets to the
353
+ // pre-D52 values, so dev shipped a CLI that accepted `--research-engine parallel`
354
+ // and let the server reject it. `research-engine-roster.test.ts` now asserts
355
+ // these against the tenant-db unions so the next stale rebase fails the gate
356
+ // instead of reaching a customer.
357
+ const RESEARCH_DEPTHS = new Set(["quick", "standard", "deep"]);
358
+ // A quick research row pays the tier's model price plus the typical grounding
359
+ // read; derived from the pricing sheet so a re-price cannot orphan the help.
360
+ function quickResearchRowCredits(level) {
361
+ return Math.round((AI_COLUMN_CREDITS[level] + AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS) * 10) / 10;
362
+ }
363
+ const REASONING_LEVEL_OPTION_HELP = "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max). "
364
+ + `On a research column it applies only at --research-depth quick (typical row: low about ${quickResearchRowCredits("low")}, `
365
+ + `medium about ${quickResearchRowCredits("medium")}, high about ${quickResearchRowCredits("high")} credits); standard and deep choose their own tier and ignore it.`;
366
+ const RESEARCH_DEPTH_OPTION_HELP = "Research columns: how hard each row searches. quick: one search and one page read (about 2.1 credits/row on the default tier; --reasoning-level applies to quick only). standard: the agent plans a few searches and page reads on Oxygen Fast (about 5.5; the default for new columns). deep: more searches and subpages on Oxygen Balanced (about 10). Standard and deep cost the same whatever --reasoning-level says. Each row reserves a higher maximum; `columns run --dry-run` quotes this column's typical and max.";
367
+ /** `--research-depth`, validated before the round trip; null when not passed. */
368
+ function readResearchDepthOption(value) {
369
+ const depth = readOption(value)?.toLowerCase();
370
+ if (!depth)
371
+ return null;
372
+ if (!RESEARCH_DEPTHS.has(depth)) {
373
+ throw new OxygenError("invalid_request", `--research-depth must be quick, standard or deep (got ${depth}).`, { exitCode: 1 });
374
+ }
375
+ return depth;
376
+ }
377
+ const RESEARCH_ENGINES = new Set(["serper", "exa"]);
378
+ const RESEARCH_FETCH_ENGINES = new Set(["firecrawl", "exa"]);
379
+ const RESEARCH_MODES = new Set(["strict", "estimate"]);
380
+ /**
381
+ * Fold the `--research-*` flags into `definition.webSearch`.
382
+ *
383
+ * Only keys the user actually passed are written: the server fills engine,
384
+ * evidence mode, and result count, so an untouched research column stays on
385
+ * Oxygen's defaults rather than freezing today's values into the definition.
386
+ */
387
+ function applyResearchColumnConfig(definition, options) {
388
+ const existing = isRecord(definition.webSearch) ? definition.webSearch : {};
389
+ // Supply the required default only when absent. Preserving an explicit false
390
+ // lets the server reject it instead of silently turning paid web access on.
391
+ const webSearch = { ...existing };
392
+ if (!Object.hasOwn(webSearch, "enabled"))
393
+ webSearch.enabled = true;
394
+ const queryTemplate = readOption(options.researchQuery);
395
+ if (queryTemplate)
396
+ webSearch.queryTemplate = queryTemplate;
397
+ const allowedDomains = readCsvOption(options.researchDomains);
398
+ if (allowedDomains.length > 0)
399
+ webSearch.allowedDomains = allowedDomains;
400
+ const excludedDomains = readCsvOption(options.researchExcludeDomains);
401
+ if (excludedDomains.length > 0)
402
+ webSearch.excludedDomains = excludedDomains;
403
+ const mode = readOption(options.researchMode)?.toLowerCase();
404
+ if (mode) {
405
+ if (!RESEARCH_MODES.has(mode)) {
406
+ throw new OxygenError("invalid_request", `--research-mode must be strict or estimate (got ${mode}).`, { exitCode: 1 });
407
+ }
408
+ webSearch.evidenceMode = mode;
409
+ }
410
+ // Fetch mode: the column reads one page per row instead of searching. The
411
+ // URL column is a row column key (the same thing a {{token}} would name), so
412
+ // the server can reject a typo at write time instead of once per row.
413
+ const researchUrl = readOption(options.researchUrl);
414
+ if (researchUrl) {
415
+ if (readOption(options.researchQuery)) {
416
+ 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 });
417
+ }
418
+ webSearch.source = "fetch";
419
+ webSearch.urlInput = researchUrl;
420
+ }
421
+ const engine = readOption(options.researchEngine)?.toLowerCase();
422
+ if (engine) {
423
+ const allowed = researchUrl ? RESEARCH_FETCH_ENGINES : RESEARCH_ENGINES;
424
+ if (!allowed.has(engine)) {
425
+ throw new OxygenError("invalid_request", researchUrl
426
+ ? `--research-engine with --research-url must be firecrawl or exa (got ${engine}).`
427
+ : `--research-engine must be serper or exa (got ${engine}).`, { exitCode: 1 });
428
+ }
429
+ webSearch.engine = engine;
430
+ }
431
+ const results = readOption(options.researchResults);
432
+ if (results) {
433
+ const parsed = Number(results);
434
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 25) {
435
+ throw new OxygenError("invalid_request", `--research-results must be a whole number from 1 to 25 (got ${results}).`, { exitCode: 1 });
436
+ }
437
+ webSearch.maxResults = parsed;
438
+ webSearch.maxTotalResults = parsed;
439
+ }
440
+ definition.webSearch = webSearch;
441
+ return definition;
442
+ }
443
+ // Parse a `--bind-map identity=column,identity2=column2` value into an
444
+ // inputMapping of column refs. Server-side normalizeBindColumnDefinition also
445
+ // accepts a bare column-key string, but we emit the explicit ref shape so the
446
+ // wire payload is self-describing. Full JSON control stays available via
447
+ // --definition-json.
448
+ function parseBindMapOption(value) {
449
+ const mapping = {};
450
+ for (const entry of readCsvOption(value)) {
451
+ const separator = entry.indexOf("=");
452
+ if (separator <= 0 || separator === entry.length - 1) {
453
+ throw new OxygenError("invalid_bind_map", "--bind-map entries must be identity=column pairs, e.g. domain=website,linkedin_url=li.", { details: { entry }, exitCode: 1 });
454
+ }
455
+ const identity = entry.slice(0, separator).trim();
456
+ const columnKey = entry.slice(separator + 1).trim();
457
+ mapping[identity] = { type: "column", columnKey };
458
+ }
459
+ return mapping;
460
+ }
461
+ // `columns add` bind sugar: assemble the { version, object, inputMapping,
462
+ // onNoMatch } definition from --bind-object / --bind-map / --bind-create. The API
463
+ // column normalizer does all validation (identity slugs, ref shapes); the CLI only
464
+ // marshals. --definition-json remains the escape hatch for createValues /
465
+ // minConfidence / runCondition.
466
+ function applyBindColumnConfig(definition, options) {
467
+ definition.version = 1;
468
+ const object = readOption(options.bindObject);
469
+ if (object)
470
+ definition.object = object;
471
+ const mapping = parseBindMapOption(options.bindMap);
472
+ if (Object.keys(mapping).length > 0) {
473
+ const existing = isRecord(definition.inputMapping) ? definition.inputMapping : {};
474
+ definition.inputMapping = { ...existing, ...mapping };
475
+ }
476
+ if (options.bindCreate)
477
+ definition.onNoMatch = "create";
478
+ return definition;
479
+ }
480
+ const LOOKUP_NORMALIZE_ALIASES = {
481
+ exact: "exact",
482
+ "lower-trim": "lower_trim",
483
+ lower_trim: "lower_trim",
484
+ email: "email_v1",
485
+ domain: "domain_v1",
486
+ linkedin: "linkedin_url_v1",
487
+ };
488
+ const LOOKUP_MODE_ALIASES = {
489
+ "first-match": "first_match",
490
+ first_match: "first_match",
491
+ count: "count",
492
+ exists: "exists",
493
+ aggregate: "aggregate",
494
+ };
495
+ // Parse `--lookup-match localColumn=sourceColumn` into its two halves (LHS is the
496
+ // working-row column, RHS the source table's join column).
497
+ function parseLookupMatchOption(value) {
498
+ const trimmed = readOption(value);
499
+ if (!trimmed)
500
+ return null;
501
+ const separator = trimmed.indexOf("=");
502
+ if (separator <= 0 || separator === trimmed.length - 1) {
503
+ throw new OxygenError("invalid_lookup_match", "--lookup-match must be localColumn=sourceColumn, e.g. company_domain=domain.", { details: { value: trimmed }, exitCode: 1 });
504
+ }
505
+ return { localColumn: trimmed.slice(0, separator).trim(), sourceColumn: trimmed.slice(separator + 1).trim() };
506
+ }
507
+ function parseLookupNormalizeOption(value) {
508
+ const raw = readOption(value);
509
+ if (!raw)
510
+ return null;
511
+ const mapped = LOOKUP_NORMALIZE_ALIASES[raw.trim().toLowerCase()];
512
+ if (!mapped) {
513
+ throw new OxygenError("invalid_lookup_normalize", "--lookup-normalize must be exact, lower-trim, email, domain, or linkedin.", { details: { value: raw }, exitCode: 1 });
514
+ }
515
+ return mapped;
516
+ }
517
+ function parseLookupModeOption(value) {
518
+ const raw = readOption(value);
519
+ if (!raw)
520
+ return null;
521
+ const mapped = LOOKUP_MODE_ALIASES[raw.trim().toLowerCase()];
522
+ if (!mapped) {
523
+ throw new OxygenError("invalid_lookup_mode", "--lookup-mode must be first-match, count, exists, or aggregate.", { details: { value: raw }, exitCode: 1 });
524
+ }
525
+ return mapped;
526
+ }
527
+ function parseLookupOrderOption(value) {
528
+ const raw = readOption(value);
529
+ if (!raw)
530
+ return null;
531
+ const separator = raw.indexOf(":");
532
+ const column = (separator === -1 ? raw : raw.slice(0, separator)).trim();
533
+ const directionRaw = (separator === -1 ? "asc" : raw.slice(separator + 1)).trim().toLowerCase();
534
+ if (!column || (directionRaw !== "asc" && directionRaw !== "desc")) {
535
+ throw new OxygenError("invalid_lookup_order", "--lookup-order must be column or column:asc|desc, e.g. created_at:desc.", { details: { value: raw }, exitCode: 1 });
536
+ }
537
+ return { column, direction: directionRaw };
538
+ }
539
+ function parseLookupAggregateOption(value) {
540
+ const raw = readOption(value);
541
+ if (!raw)
542
+ return null;
543
+ const separator = raw.indexOf(":");
544
+ if (separator <= 0 || separator === raw.length - 1) {
545
+ throw new OxygenError("invalid_lookup_aggregate", "--lookup-aggregate must be fn:column, e.g. sum:amount.", { details: { value: raw }, exitCode: 1 });
546
+ }
547
+ const fn = raw.slice(0, separator).trim().toLowerCase();
548
+ const column = raw.slice(separator + 1).trim();
549
+ if (fn !== "sum" && fn !== "avg" && fn !== "min" && fn !== "max") {
550
+ throw new OxygenError("invalid_lookup_aggregate", "--lookup-aggregate fn must be sum, avg, min, or max.", { details: { value: raw }, exitCode: 1 });
551
+ }
552
+ return { fn, column };
553
+ }
554
+ // `columns add` lookup sugar: assemble the { version, sourceTable, match,
555
+ // inputMapping, mode, returnColumns, orderBy, aggregate } definition from the
556
+ // --lookup-* flags. The API column normalizer does all validation (mode-specific
557
+ // requirements, self-lookup, ref shapes); the CLI only marshals. --definition-json
558
+ // remains the escape hatch for runCondition and literal match_value refs.
559
+ function applyLookupColumnConfig(definition, options) {
560
+ definition.version = 1;
561
+ const sourceTable = readOption(options.lookupTable);
562
+ if (sourceTable)
563
+ definition.sourceTable = sourceTable;
564
+ const match = parseLookupMatchOption(options.lookupMatch);
565
+ const normalization = parseLookupNormalizeOption(options.lookupNormalize);
566
+ if (match || normalization) {
567
+ const existingMatch = isRecord(definition.match) ? definition.match : {};
568
+ definition.match = {
569
+ ...existingMatch,
570
+ ...(match ? { sourceColumn: match.sourceColumn } : {}),
571
+ ...(normalization ? { normalization } : {}),
572
+ };
573
+ }
574
+ if (match) {
575
+ definition.inputMapping = { match_value: { type: "column", columnKey: match.localColumn } };
576
+ }
577
+ const mode = parseLookupModeOption(options.lookupMode);
578
+ if (mode)
579
+ definition.mode = mode;
580
+ const returnColumns = readCsvOption(options.lookupReturn);
581
+ if (returnColumns.length > 0)
582
+ definition.returnColumns = returnColumns;
583
+ const orderBy = parseLookupOrderOption(options.lookupOrder);
584
+ if (orderBy)
585
+ definition.orderBy = orderBy;
586
+ const aggregate = parseLookupAggregateOption(options.lookupAggregate);
587
+ if (aggregate)
588
+ definition.aggregate = aggregate;
589
+ return definition;
590
+ }
591
+ // `--options a,b,c` or `--options-json '[...]'`: a choice column's option list in
592
+ // the shape the server's `options` field takes (strings or {value,label,color}).
593
+ function readColumnOptionsFlags(options) {
594
+ const csv = readOption(options.options);
595
+ const json = readOption(options.optionsJson);
596
+ if (csv && json) {
597
+ throw new OxygenError("invalid_request", "Pass --options or --options-json, not both.", { exitCode: 1 });
598
+ }
599
+ if (json)
600
+ return parseJsonArray(json);
601
+ if (!csv)
602
+ return null;
603
+ return csv.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
604
+ }
605
+ function hasLookupColumnFlags(options) {
606
+ return Boolean(readOption(options.lookupTable)
607
+ || readOption(options.lookupMatch)
608
+ || readOption(options.lookupNormalize)
609
+ || readOption(options.lookupMode)
610
+ || readOption(options.lookupReturn)
611
+ || readOption(options.lookupOrder)
612
+ || readOption(options.lookupAggregate));
613
+ }
614
+ async function readStoredColumnForUpdate(table, column) {
615
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table, include_archived: true } });
616
+ const stored = (describe.columns ?? []).find((entry) => entry.key === column || entry.id === column);
617
+ if (!stored) {
618
+ throw new OxygenError("column_not_found", `Column ${column} was not found on ${table}.`, {
619
+ details: { table, column },
620
+ exitCode: 1,
621
+ });
622
+ }
623
+ return stored;
624
+ }
625
+ function storedDefinitionCopy(stored) {
626
+ return isRecord(stored.definition) ? structuredClone(stored.definition) : {};
627
+ }
628
+ function pickDefinitionKeys(definition, keys) {
629
+ return Object.fromEntries(keys.filter((key) => definition[key] !== undefined).map((key) => [key, definition[key]]));
630
+ }
631
+ // `columns update --lookup-*`: the same flags as `columns add`, applied over the
632
+ // stored lookup so a partial edit (only the normalization, only the mode) keeps
633
+ // the rest of the join. A new source table needs its own join column.
634
+ function lookupDefinitionEdit(stored, options) {
635
+ if (stored.kind !== "lookup") {
636
+ throw new OxygenError("invalid_request", `--lookup-* flags edit a lookup column, and ${stored.key} is a ${stored.kind ?? "plain"} column.`, { details: { column: stored.key, kind: stored.kind ?? null }, exitCode: 1 });
637
+ }
638
+ if (readOption(options.lookupTable) && !readOption(options.lookupMatch)) {
639
+ throw new OxygenError("invalid_request", "--lookup-table changes the source table, so pass --lookup-match localColumn=sourceColumn with it: the join column belongs to the source table.", { exitCode: 1 });
640
+ }
641
+ const next = applyLookupColumnConfig(storedDefinitionCopy(stored), options);
642
+ return pickDefinitionKeys(next, ["version", "sourceTable", "match", "inputMapping", "mode", "returnColumns", "orderBy", "aggregate"]);
643
+ }
644
+ // `columns update --bind-*`: applied over the stored binding; --bind-map adds or
645
+ // changes identities and keeps the others (`--input-mapping` replaces them all).
646
+ function bindDefinitionEdit(stored, options) {
647
+ if (stored.kind !== "bind") {
648
+ throw new OxygenError("invalid_request", `--bind-* flags edit a bind column, and ${stored.key} is a ${stored.kind ?? "plain"} column.`, { details: { column: stored.key, kind: stored.kind ?? null }, exitCode: 1 });
649
+ }
650
+ const next = applyBindColumnConfig(storedDefinitionCopy(stored), options);
651
+ if (options.bindCreate === false)
652
+ next.onNoMatch = "none";
653
+ return pickDefinitionKeys(next, ["version", "object", "inputMapping", "onNoMatch"]);
654
+ }
655
+ async function requestColumnsRun(body, table, options) {
656
+ const traceId = randomUUID();
657
+ let result;
658
+ try {
659
+ result = await requestOxygen("/api/cli/tables/columns/run", {
660
+ method: "POST",
661
+ body,
662
+ traceId,
663
+ });
664
+ }
665
+ catch (error) {
666
+ // Paid runs are durable background runs server-side (even single-row since
667
+ // v1.144.0), so a network timeout is always recoverable by locating the
668
+ // created run via its trace id - not only when --background was passed.
669
+ // Inline (formula) runs have no run to recover; recovery returns null and
670
+ // the original error propagates.
671
+ if (!isNetworkTimeoutError(error))
672
+ throw error;
673
+ const recovered = await recoverBackgroundColumnRun(table, traceId);
674
+ if (!recovered)
675
+ throw error;
676
+ return recovered;
677
+ }
678
+ // "Run this cell" still resolves to the finished value in one command: when
679
+ // the server auto-backgrounds a single-row paid run (caller did not pass
680
+ // --background), wait for the created run and attach its item output.
681
+ // Inline (formula) results carry no action_run_id and pass through as-is.
682
+ if (!options.background && typeof body.row_id === "string" && isRecord(result)) {
683
+ const actionRunId = readRecordString(result, "action_run_id");
684
+ if (actionRunId) {
685
+ writeSingleRowColumnRunReceipt(result, actionRunId);
686
+ return resolveSingleRowColumnRun(result, actionRunId);
687
+ }
688
+ }
689
+ return result;
690
+ }
691
+ // The durable run exists before the convenience auto-wait begins. Emit its
692
+ // receipt on stderr immediately so strict JSON stdout remains one envelope while
693
+ // a shell/agent timeout can still recover the charged run instead of reporting
694
+ // a silent failure with no inspectable id.
695
+ function writeSingleRowColumnRunReceipt(envelope, actionRunId) {
696
+ const webUrl = readRecordString(envelope, "web_url")
697
+ ?? readRecordString(envelope, "run_web_url");
698
+ const inspection = webUrl ? ` (${webUrl})` : "";
699
+ process.stderr.write(`queued: column run ${actionRunId}${inspection}\n`);
700
+ process.stderr.write(`hint: if this process stops, continue with oxygen table-runs wait ${actionRunId}\n`);
701
+ }
702
+ /**
703
+ * Wait for an auto-backgrounded single-row column run to finish and return the
704
+ * terminal run merged with its item output (the cell value). On wait timeout
705
+ * the queued run envelope is returned unchanged, plus the follow-up command -
706
+ * the run keeps executing server-side either way.
707
+ */
708
+ async function resolveSingleRowColumnRun(envelope, actionRunId) {
709
+ const timeoutSeconds = readEnvPositiveInt("OXYGEN_COLUMN_RUN_WAIT_TIMEOUT_SECONDS")
710
+ ?? SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS;
711
+ const intervalSeconds = readEnvPositiveInt("OXYGEN_COLUMN_RUN_WAIT_INTERVAL_SECONDS")
712
+ ?? SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_INTERVAL_SECONDS;
713
+ let waited;
714
+ try {
715
+ waited = await waitForTableActionRun(actionRunId, {
716
+ timeoutSeconds: String(timeoutSeconds),
717
+ intervalSeconds: String(intervalSeconds),
718
+ });
719
+ }
720
+ catch (error) {
721
+ if (error instanceof OxygenError && error.code === "table_action_run_wait_timeout") {
722
+ return {
723
+ ...envelope,
724
+ auto_wait_timed_out: true,
725
+ next_step: `oxygen table-runs wait ${actionRunId}`,
726
+ };
727
+ }
728
+ throw error;
729
+ }
730
+ const finalRun = isRecord(waited.actionRun) ? waited.actionRun : envelope;
731
+ const items = await listColumnRunItems(actionRunId);
732
+ return {
733
+ ...finalRun,
734
+ ...(items ? { items } : {}),
735
+ auto_waited: {
736
+ polls: waited.polls,
737
+ elapsed_ms: waited.elapsedMs,
738
+ },
739
+ };
740
+ }
741
+ async function listColumnRunItems(actionRunId) {
742
+ try {
743
+ const response = await requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(actionRunId)}/items?limit=5`);
744
+ const items = Array.isArray(response.items) ? response.items.filter(isRecord) : [];
745
+ return items.length > 0 ? items : null;
746
+ }
747
+ catch {
748
+ // The terminal run state is the answer; item output is best-effort.
749
+ return null;
750
+ }
751
+ }
752
+ function readEnvPositiveInt(name) {
753
+ const value = process.env[name]?.trim();
754
+ if (!value)
755
+ return undefined;
756
+ const parsed = Number(value);
757
+ return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined;
758
+ }
759
+ async function recoverBackgroundColumnRun(table, traceId) {
760
+ for (let attempt = 0; attempt < 3; attempt += 1) {
761
+ const listed = await requestOxygen(tableRunsListPath({ table, status: "active", limit: "5", traceId }), { timeoutMs: 30_000 });
762
+ const runs = Array.isArray(listed.runs)
763
+ ? listed.runs.filter(isRecord)
764
+ : [];
765
+ const run = runs[0];
766
+ if (run) {
767
+ const actionRunId = readRecordString(run, "action_run_id") ?? readRecordString(run, "id");
768
+ const workspaceRunId = readRecordString(run, "workspace_run_id") ?? readRecordString(run, "runId");
769
+ return {
770
+ ...run,
771
+ ...(actionRunId ? { action_run_id: actionRunId } : {}),
772
+ ...(workspaceRunId ? { workspace_run_id: workspaceRunId } : {}),
773
+ timeout_recovered: true,
774
+ recovery: {
775
+ reason: "columns_run_background_timeout",
776
+ trace_id: traceId,
777
+ lookup_command: `oxygen table-runs list --table ${table} --status active --json`,
778
+ },
779
+ };
780
+ }
781
+ if (attempt < 2)
782
+ await sleep(750);
783
+ }
784
+ return null;
785
+ }
786
+ function isNetworkTimeoutError(error) {
787
+ return error instanceof OxygenError && error.code === "network_timeout";
788
+ }
789
+ // `columns reorder` accepts either an absolute --position or a relative
790
+ // --before/--after naming a sibling column. The relative form is resolved
791
+ // client-side into an absolute index against the table's current column order
792
+ // (fetched via /api/cli/tables/describe — the same describe the other column
793
+ // commands read) so the server only ever receives a single numeric position.
794
+ // Exactly one of the three flags must be supplied.
795
+ async function resolveColumnReorderPosition(table, column, options) {
796
+ const before = readOption(options.before);
797
+ const after = readOption(options.after);
798
+ const explicit = readNonNegativeInt(options.position);
799
+ const provided = [explicit !== undefined, Boolean(before), Boolean(after)].filter(Boolean).length;
800
+ if (provided === 0) {
801
+ throw new OxygenError("invalid_request", "Provide --position <n>, --before <column>, or --after <column>.", { exitCode: 1 });
802
+ }
803
+ if (provided > 1) {
804
+ throw new OxygenError("invalid_request", "Pass only one of --position, --before, or --after.", { exitCode: 1 });
805
+ }
806
+ if (explicit !== undefined)
807
+ return explicit;
808
+ const reference = (before ?? after);
809
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
810
+ const columns = describe.columns ?? [];
811
+ const index = columns.findIndex((candidate) => candidate.id === reference || candidate.key === reference);
812
+ if (index < 0) {
813
+ throw new OxygenError("invalid_request", `Column "${reference}" was not found on table "${table}".`, { details: { table, column: reference }, exitCode: 1 });
814
+ }
815
+ // --before lands the column at the reference's index; --after lands it one slot later.
816
+ const target = before ? index : index + 1;
817
+ // The server removes the source column before splicing it back in at `target`.
818
+ // When the source currently sits before the reference, that removal shifts the
819
+ // reference (and everything after it) down one slot, so decrement to compensate.
820
+ const sourceIndex = columns.findIndex((candidate) => candidate.id === column || candidate.key === column);
821
+ if (sourceIndex >= 0 && sourceIndex < index) {
822
+ return target - 1;
823
+ }
824
+ return target;
825
+ }
826
+ export function registerColumnsFamily(program, handleAsyncAction) {
827
+ program
828
+ .command("columns")
829
+ .description("Workspace table column commands. Price first: `tools search --for-table <table>` lists every enrichment this table's columns support with its credit cost per row (0 credits). 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 guide: the oxygen-tables skill (`oxygen skills get oxygen-tables --json`); enrichment presets and waterfalls: the oxygen-enrichment skill. 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.")
830
+ .addCommand(new Command("add")
831
+ .description("Add a nullable column, a catalog template (--prompt-key) or a multi-column enrichment bundle (--preset) 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-tables skill (`oxygen skills get oxygen-tables --json`). For ICP fit, use Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt 'Assess {{company_name}}: {{description}}, {{employees}} employees' --grounding workspace` (0.075 credits/row). A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately. A plain field is --type: text, number, checkbox, date, json, markdown, image, or a choice column — select, multi_select or status, with --options \"Lead,Contacted,Won\". To link another table row to row, use `oxygen tables relate <table> --to <other-table> --live`; to read a value out of one, --lookup-table.")
832
+ .argument("<table>", "Table id or slug.")
833
+ .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, followers only on rows the LinkedIn Scraper answers because Blitz publishes none; 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), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — plus `company_lookalikes` (one managed lookup from --input company_domain=website; keeps similar companies separate from the seed; dry-run for price) — 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.")
834
+ .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, [])
835
+ // No preset takes a field list any more (founder, 2026-09-24). Hidden,
836
+ // not removed: an old script passing them gets the server's refusal,
837
+ // which names the funding, revenue, hiring and tech-stack presets,
838
+ // instead of an unknown-option error that explains nothing.
839
+ .addOption(new Option("--preset-fields <fields>", "Retired: presets take no field list.").hideHelp())
840
+ .addOption(new Option("--check-technologies <names>", "Retired: presets take no field list.").hideHelp())
841
+ .option("--kind <kind>", "Column kind: manual, research (the Web Research Agent), ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web (an AI column cannot search; `columns convert --to agent` turns one into an agent); one research column answers several fields at once when its prompt names them as sections — one row, one read, one charge — so never add a second column for the same page. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
842
+ // Research flags sit directly under --kind: agents read help with head/tail,
843
+ // and the depth choice (and its price) must land on the first screen.
844
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
845
+ .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.")
846
+ .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. For a personal (non-work) email add --include-personal-email to work_email: one column finds both (personal_email alone still works for existing scripts). Sets kind=enrichment and jsonb; label and key default from the capability. Inputs are mapped from this table's columns; the response names any it could not find and the command that maps it. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
847
+ .option("--include-personal-email", "With --capability work_email: also find the person's personal (non-work) email from their LinkedIn URL, billed only on a hit and graded before it is written, into the same cell's personal_email.")
848
+ .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
849
+ .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
850
+ .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
851
+ .option("--research-query <template>", "Research columns: the per-row web search query, templated like the prompt (e.g. \"{{company_name}} pricing page\"). Omit to derive the query from the row's values and the prompt.")
852
+ .option("--research-domains <csv>", "Research columns: comma-separated domains to search within, e.g. techcrunch.com,sec.gov. Omit to search the whole web.")
853
+ .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
854
+ .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).")
855
+ .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.")
856
+ .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.")
857
+ .option("--type <type>", "Field type, the web picker's names: text, number, checkbox, date, json, markdown, image, select, multi_select or status. Sets kind manual with the matching data and semantic type (multi_select stores jsonb); text/number/checkbox/date/json also work as a data type beside --kind.")
858
+ .option("--options <choices>", "Choices for a select, multi_select or status column, comma-separated, in display order, e.g. \"Lead,Contacted,Replied,Won\". Each value is what a cell stores; colors are assigned. Use --options-json for labels or colors.")
859
+ .option("--options-json <json>", "Choices as a JSON array of strings or {value,label?,color?} objects (colors: gray, red, orange, amber, yellow, green, teal, blue, indigo, purple, pink). value is what a cell stores; label is what the grid shows.")
860
+ .option("--description <text>", "A note on the column, shown in its header and the column editor.")
861
+ .option("--semantic-type <type>", "Optional semantic type: a display hint such as company.domain or person.email, or a choice type (select, multi_select, status) — --type sets those for you.")
862
+ .option("--definition-json <json>", "Optional JSON object with column definition metadata. Required for --kind formula as a flat object: {\"expression\":\"trim(company_name)\"}. Check the expression with `formulas validate` first.")
863
+ .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to make it a Web Research Agent that searches the web per row. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
864
+ .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>.")
865
+ .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.")
866
+ .option("--model <openrouter-model-id>", "OpenRouter model id. Managed columns accept allow-listed models and bill by Oxygen tier; --reasoning-level selects a tier. BYOK uses your own key and provider pricing.")
867
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
868
+ .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}}.")
869
+ .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
870
+ .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
871
+ .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
872
+ .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
873
+ .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
874
+ .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`."))
875
+ .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}]}'."))
876
+ .addOption(new Option("--score <levels>", "Classification column: rate each row on these comma-separated levels, lowest first (2-10), answering --prompt."))
877
+ .addOption(new Option("--min-confidence <0-1>", "Classification columns: below this the answer is marked uncertain. Default 0.85 (0.7 for --yes-no)."))
878
+ .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."))
879
+ .option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
880
+ .option("--bind-map <pairs>", "Bind identity mapping as identity=column pairs, e.g. domain=website,linkedin_url=li.")
881
+ .option("--bind-create", "Bind columns: assert a new CRM record for unmatched rows (onNoMatch=create; a truth write, needs --approved on run).")
882
+ .option("--lookup-table <table>", "Lookup column: source table id or slug to read from (same workspace). Sets kind=lookup. Free; reads resolve current source values and runs materialize them with provenance.")
883
+ .option("--lookup-match <pair>", "Lookup join as localColumn=sourceColumn, e.g. company_domain=domain.")
884
+ .option("--lookup-normalize <mode>", "Lookup key normalization: exact, lower-trim (default), email, domain, or linkedin.")
885
+ .option("--lookup-mode <mode>", "Lookup mode: first-match (default), count, exists, or aggregate.")
886
+ .option("--lookup-return <csv>", "Lookup first-match: source column keys to pull (single -> scalar cell, multiple -> object).")
887
+ .option("--lookup-order <column:dir>", "Lookup first-match tie-break when a key matches many source rows, e.g. created_at:desc.")
888
+ .option("--lookup-aggregate <fn:column>", "Lookup aggregate reducer as fn:column, e.g. sum:amount (fn: sum, avg, min, or max).")
889
+ .option("--json", "Print a JSON envelope.")
890
+ // skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
891
+ .action(async (table, options) => {
892
+ await handleAsyncAction("columns add", options, async () => {
893
+ // --capability seeds a COMPLETE enrichment column server-side (kind,
894
+ // jsonb data type, intent, the default provider order, label and
895
+ // key), so any flag that authors a different column is a conflict,
896
+ // not a modifier. Caught here rather than server-side so the user
897
+ // never gets back a column they did not ask for.
898
+ const capability = readOption(options.capability);
899
+ if (capability) {
900
+ const conflicting = [
901
+ readOption(options.preset) ? "--preset" : null,
902
+ readOption(options.prompt) ? "--prompt" : null,
903
+ readOption(options.promptKey) ? "--prompt-key" : null,
904
+ readOption(options.bindObject) ? "--bind-object" : null,
905
+ readOption(options.bindMap) ? "--bind-map" : null,
906
+ options.bindCreate ? "--bind-create" : null,
907
+ readOption(options.lookupTable) ? "--lookup-table" : null,
908
+ readOption(options.lookupMatch) ? "--lookup-match" : null,
909
+ readOption(options.lookupMode) ? "--lookup-mode" : null,
910
+ readOption(options.lookupReturn) ? "--lookup-return" : null,
911
+ readOption(options.lookupOrder) ? "--lookup-order" : null,
912
+ readOption(options.lookupAggregate) ? "--lookup-aggregate" : null,
913
+ readOption(options.lookupNormalize) ? "--lookup-normalize" : null,
914
+ ].filter((flag) => flag !== null);
915
+ if (conflicting.length > 0) {
916
+ throw new OxygenError("invalid_request", `--capability ${capability} seeds a complete enrichment column, so it cannot be combined with ${conflicting.join(", ")}. Drop one of the two.`, { exitCode: 1 });
917
+ }
918
+ const capabilityKind = readOption(options.kind)?.toLowerCase() ?? null;
919
+ if (capabilityKind && capabilityKind !== "enrichment") {
920
+ throw new OxygenError("invalid_request", `--capability authors an enrichment column, but --kind ${capabilityKind} was requested. Drop --kind, or drop --capability.`, { exitCode: 1 });
921
+ }
922
+ }
923
+ // A preset names its own columns, so --label does not apply to it.
924
+ const preset = readOption(options.preset);
925
+ if (preset) {
926
+ if (options.label || options.promptKey || options.definitionJson || options.type || options.options || options.optionsJson) {
927
+ throw new OxygenError("invalid_request", "--preset builds its own column set. Drop --label/--prompt-key/--definition-json/--type/--options, or add the column by hand without --preset.", { exitCode: 1 });
928
+ }
929
+ // Named only when passed: the classification flags are hidden from help.
930
+ const classificationFlag = options.classify ? "--classify" : options.yesNo ? "--yes-no" : options.score ? "--score" : null;
931
+ if (classificationFlag) {
932
+ throw new OxygenError("invalid_request", `--preset builds its own column set. Drop ${classificationFlag}, or add the classification column without --preset.`, { exitCode: 1 });
933
+ }
934
+ const presetBody = { table, preset };
935
+ const inputs = parseKeyValuePairs(options.input ?? []);
936
+ if (Object.keys(inputs).length > 0)
937
+ presetBody.inputs = inputs;
938
+ if (options.key)
939
+ presetBody.payload_column = options.key;
940
+ const presetFields = readCsvOption(options.presetFields);
941
+ if (presetFields.length > 0)
942
+ presetBody.fields = presetFields;
943
+ const checkTechnologies = readCsvOption(options.checkTechnologies);
944
+ if (checkTechnologies.length > 0)
945
+ presetBody.check_technologies = checkTechnologies;
946
+ return requestOxygen("/api/cli/tables/columns", {
947
+ method: "POST",
948
+ body: presetBody,
949
+ });
950
+ }
951
+ // --capability supplies its own label ("Email Verification", ...)
952
+ // server-side, the same one the web picker writes.
953
+ if (!options.promptKey && !capability && !options.label) {
954
+ throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
955
+ }
956
+ const templateInputs = options.promptKey ? parseKeyValuePairs(options.input ?? []) : {};
957
+ if (options.promptKey && !options.inputMapping && Object.keys(templateInputs).length === 0) {
958
+ 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 });
959
+ }
960
+ const promptOption = readAiPromptOption(options.prompt);
961
+ const decisionFlags = readColumnDecisionFlags(options);
962
+ if (decisionFlags) {
963
+ const conflicting = [
964
+ capability ? "--capability" : null,
965
+ readOption(options.promptKey) ? "--prompt-key" : null,
966
+ readOption(options.bindObject) || readOption(options.bindMap) || options.bindCreate ? "--bind-object" : null,
967
+ readOption(options.lookupTable) ? "--lookup-table" : null,
968
+ readOption(options.model) ? "--model" : null,
969
+ readOption(options.outputSchemaJson) || readOption(options.outputSchemaFile) ? "--output-schema-json" : null,
970
+ readOption(options.researchQuery) || readOption(options.researchUrl) ? "--research-query/--research-url" : null,
971
+ readOption(options.kind) && readOption(options.kind).toLowerCase() !== "ai" ? `--kind ${readOption(options.kind)}` : null,
972
+ ].filter((flag) => flag !== null);
973
+ if (conflicting.length > 0) {
974
+ 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 });
975
+ }
976
+ if (decisionFlags.question !== null && promptOption !== null) {
977
+ throw new OxygenError("invalid_request", "--yes-no carries the question itself. Drop --prompt, or use --classify \"yes,no\" with --prompt.", { exitCode: 1 });
978
+ }
979
+ if (decisionFlags.question === null && promptOption === null) {
980
+ 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 });
981
+ }
982
+ }
983
+ const prompt = decisionFlags?.question ?? promptOption;
984
+ if (prompt !== null && options.promptKey) {
985
+ throw new OxygenError("invalid_request", "Pass either --prompt (a freeform prompt) or --prompt-key (a prompt-library entry), not both.", { exitCode: 1 });
986
+ }
987
+ const column = {};
988
+ if (options.label)
989
+ column.label = options.label;
990
+ if (options.key)
991
+ column.key = options.key;
992
+ if (options.dataType)
993
+ column.data_type = options.dataType;
994
+ if (options.kind)
995
+ column.kind = options.kind;
996
+ if (options.semanticType)
997
+ column.semantic_type = options.semanticType;
998
+ // --description joins --definition-json in one assignment: a second
999
+ // self-spread of column.definition multiplies the parity scanner's work.
1000
+ const description = readOption(options.description);
1001
+ if (options.definitionJson || description) {
1002
+ column.definition = {
1003
+ ...(options.definitionJson ? parseJsonObject(options.definitionJson) : {}),
1004
+ ...(description ? { description } : {}),
1005
+ };
1006
+ }
1007
+ // A field type and its choices travel as themselves; the server
1008
+ // resolves them exactly as the web picker's rows do.
1009
+ if (readOption(options.type))
1010
+ column.type = readOption(options.type);
1011
+ const choices = readColumnOptionsFlags(options);
1012
+ if (choices)
1013
+ column.options = choices;
1014
+ const requestedKind = readOption(options.kind)?.toLowerCase() ?? null;
1015
+ const isResearch = requestedKind === "research";
1016
+ if (capability)
1017
+ column.capability = capability;
1018
+ if (options.includePersonalEmail)
1019
+ column.include_personal_email = true;
1020
+ // An enrichment cell is always the provider envelope, so the server
1021
+ // rejects any other data type ("Enrichment columns must use jsonb
1022
+ // data type."). Default it, the way --bind-object already does,
1023
+ // instead of making the caller bolt on --data-type jsonb.
1024
+ if (requestedKind === "enrichment" && !options.dataType) {
1025
+ column.data_type = "jsonb";
1026
+ }
1027
+ if (prompt !== null) {
1028
+ if (requestedKind && requestedKind !== "ai" && !isResearch) {
1029
+ throw new OxygenError("invalid_request", `--prompt authors an AI or research column, but --kind ${requestedKind} was requested. Drop --kind, or drop --prompt.`, { exitCode: 1 });
1030
+ }
1031
+ column.kind = isResearch ? "research" : "ai";
1032
+ // A research cell is always the {answer, found, confidence, sources}
1033
+ // envelope, so it is jsonb regardless of --output-schema-*.
1034
+ // An output schema makes an AI cell a structured object, which is only
1035
+ // queryable/materializable as jsonb; a bare prompt returns prose. The
1036
+ // runner serializes structured output into a text cell too, so this
1037
+ // only picks the more useful shape — it can never make the column fail.
1038
+ if (!options.dataType) {
1039
+ column.data_type = isResearch
1040
+ || readOption(options.outputSchemaJson)
1041
+ || readOption(options.outputSchemaFile)
1042
+ ? "jsonb"
1043
+ : "text";
1044
+ }
1045
+ }
1046
+ else if (isResearch) {
1047
+ throw new OxygenError("invalid_request", "A research column needs a question to answer. Add --prompt \"<what to look up>\", referencing row values as {{column_key}} — for example --prompt \"What does {{company_name}} sell?\".", { exitCode: 1 });
1048
+ }
1049
+ if (prompt !== null ||
1050
+ readOption(options.model) ||
1051
+ readOption(options.reasoningLevel) ||
1052
+ readOption(options.runCondition) ||
1053
+ readOption(options.outputSchemaJson) ||
1054
+ readOption(options.outputSchemaFile)) {
1055
+ const definition = isRecord(column.definition) ? column.definition : {};
1056
+ if (prompt !== null)
1057
+ definition.prompt = assertPromptNotAlsoInDefinition(definition, prompt);
1058
+ // normalizeAiColumnDefinition requires inputMapping unconditionally, so
1059
+ // a bare --prompt would 400 on every call without this. It is genuinely
1060
+ // empty and not a placeholder: prompt {{tokens}} resolve straight off the
1061
+ // row in composeAiColumnPrompt, independently of inputMapping — which is
1062
+ // the whole reason --prompt does not make the user restate their columns.
1063
+ // The web AI seed emits the same {} for the same reason.
1064
+ if (!isRecord(definition.inputMapping))
1065
+ definition.inputMapping = {};
1066
+ column.definition = isResearch
1067
+ ? applyResearchColumnConfig(applyAiColumnConfig(definition, options), options)
1068
+ : applyAiColumnConfig(definition, options);
1069
+ if (decisionFlags) {
1070
+ column.definition.decision = decisionFlags.decision;
1071
+ // The cell is {answer, confidence, uncertain, reason}: JSON, so it filters.
1072
+ if (!options.dataType)
1073
+ column.data_type = "jsonb";
1074
+ }
1075
+ }
1076
+ if (readOption(options.bindObject) || readOption(options.bindMap) || options.bindCreate) {
1077
+ // A column is one kind. --prompt already set kind=ai above, and the
1078
+ // bind/lookup blocks below would silently overwrite it — the same
1079
+ // conflict --kind raises explicitly, so raise it here too rather than
1080
+ // handing back a bind column the user never asked for.
1081
+ assertPromptNotAlsoOtherKind(prompt, "--bind-object");
1082
+ const definition = isRecord(column.definition) ? column.definition : {};
1083
+ column.kind = "bind";
1084
+ // Bind columns write a jsonb status cell; the server rejects any other
1085
+ // data type (invalid_column_definition). Default it so callers don't have
1086
+ // to pass --data-type jsonb by hand alongside --bind-object.
1087
+ if (!options.dataType)
1088
+ column.data_type = "jsonb";
1089
+ column.definition = applyBindColumnConfig(definition, options);
1090
+ }
1091
+ if (readOption(options.lookupTable)
1092
+ || readOption(options.lookupMatch)
1093
+ || readOption(options.lookupMode)
1094
+ || readOption(options.lookupReturn)
1095
+ || readOption(options.lookupOrder)
1096
+ || readOption(options.lookupAggregate)
1097
+ || readOption(options.lookupNormalize)) {
1098
+ assertPromptNotAlsoOtherKind(prompt, "--lookup-table");
1099
+ const definition = isRecord(column.definition) ? column.definition : {};
1100
+ column.kind = "lookup";
1101
+ const lookupDefinition = applyLookupColumnConfig(definition, options);
1102
+ column.definition = lookupDefinition;
1103
+ // Derive the output data type from the mode so callers don't have to know
1104
+ // it (the bind lesson), with the web editor's rule: count/aggregate ->
1105
+ // numeric, exists -> boolean, first_match -> text for one return column,
1106
+ // jsonb for several. Sent explicitly so an older server agrees too.
1107
+ if (!options.dataType) {
1108
+ column.data_type = lookupColumnDataType(lookupDefinition.mode ?? "first_match", lookupDefinition.returnColumns);
1109
+ }
1110
+ }
1111
+ const body = { table, column };
1112
+ if (options.promptKey)
1113
+ body.prompt_key = options.promptKey;
1114
+ const researchDepth = readResearchDepthOption(options.researchDepth);
1115
+ if (researchDepth)
1116
+ body.research_depth = researchDepth;
1117
+ if (options.inputMapping) {
1118
+ body.input_mapping = parseJsonObject(options.inputMapping);
1119
+ }
1120
+ else if (Object.keys(templateInputs).length > 0) {
1121
+ // `--input url=pricing_url` → the same typed column refs the JSON form
1122
+ // carries, so the server sees one shape from both spellings.
1123
+ body.input_mapping = Object.fromEntries(Object.entries(templateInputs).map(([name, columnKey]) => [name, { type: "column", columnKey }]));
1124
+ }
1125
+ // Every authoring path, not just --prompt: a definition pasted into
1126
+ // --definition-json used to skip this entirely and fail once per row.
1127
+ await assertColumnDefinitionReferences(table, column.definition);
1128
+ const created = await requestOxygen("/api/cli/tables/columns", {
1129
+ method: "POST",
1130
+ body,
1131
+ });
1132
+ if (!options.json)
1133
+ writeColumnReferenceNotices(created);
1134
+ return created;
1135
+ });
1136
+ }))
1137
+ .addCommand(new Command("run")
1138
+ .description("Run an executable AI, tool, formula, enrichment, bind, lookup, or custom HTTP column for one row or a bounded batch. Paid server-side columns always run durably in the background. Bind create-mode (onNoMatch=create) needs --approved.")
1139
+ .argument("<table>", "Table id or slug.")
1140
+ .argument("<column>", "Column id or key.")
1141
+ .option("--idempotency-key <key>", "Replay key for durable server-side run admission.")
1142
+ .option("--row-id <row_id>", "Workspace row id to run. Get one from `oxygen tables query <table> --limit 1 --json` — the field is `_row_id`, not `id`.")
1143
+ .option("--limit <n>", "Run the next N rows whose target cell is still empty (--force runs the first N regardless). Repeat until rowCount is 0 to page through a table. Defaults to 10; inline deterministic runs have a hard cap of 25.")
1144
+ .option("--all", "Run all rows. Requires --background, except with --dry-run, which previews the background run without queueing it.")
1145
+ .option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
1146
+ .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.")
1147
+ .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.")
1148
+ .option("--connection-id <connection_id>", "Optional provider integration connection id.")
1149
+ .option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment server runs are always backgrounded.")
1150
+ .option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
1151
+ .option("--approved-effect-unknown", "With --row-id, allow rerunning a cell whose previous external write was dispatched but never confirmed. Verify the destination first: this can apply the same external effect twice.")
1152
+ .option("--max-credits <n>", "Maximum OXYGEN credits for this run, including managed calls, web grounding and own-key provider platform fees. Vendor charges on your own key are outside this ceiling. BYOK model inference is free; managed grounding still counts.")
1153
+ .option("--max-concurrency <n>", "Maximum concurrent row items for a background run (1-1000; a ceiling shared with worker and provider capacity). Defaults to 250 for AI columns and 50 otherwise (160 for Firecrawl scrape columns).")
1154
+ .option("--local", "Run a custom HTTP column in this CLI process, where {env:...} secrets read your own machine's environment. Without it the column runs in the background on Oxygen and the same reference resolves from this workspace's registered custom integration.")
1155
+ .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
1156
+ .option("--dry-run", "Preview the model tier, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
1157
+ .option("--json", "Print a JSON envelope.")
1158
+ .action(async (table, column, options) => {
1159
+ const limit = readPositiveInt(options.limit);
1160
+ const localConcurrency = readPositiveInt(options.localConcurrency);
1161
+ const maxCredits = readPositiveNumber(options.maxCredits);
1162
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
1163
+ const filterSelection = readFilterSelectionOption(options.filterJson);
1164
+ const formulaValues = readFormulaValuesOption(options.formulaValues);
1165
+ if (formulaValues && !filterSelection) {
1166
+ throw new OxygenError("invalid_selection", "--formula-values requires --filter-json on columns run.", { exitCode: 1 });
1167
+ }
1168
+ const effectiveFilterSelection = filterSelection
1169
+ ? { ...filterSelection, ...(formulaValues ? { formula_values: formulaValues } : {}) }
1170
+ : undefined;
1171
+ const selectedModes = [
1172
+ Boolean(options.all),
1173
+ limit !== undefined,
1174
+ Boolean(readOption(options.rowId)),
1175
+ Boolean(effectiveFilterSelection),
1176
+ ].filter(Boolean).length;
1177
+ // skipcq: JS-R1005 — intentional branching for local/background/filter column-run modes
1178
+ await handleAsyncAction("columns run", options, async () => {
1179
+ // Selection-shape errors are raised inside the action so --json
1180
+ // callers get the failure envelope, not a bare stack trace.
1181
+ if (selectedModes > 1) {
1182
+ throw new OxygenError("invalid_selection", "Pass only one of --all, --limit, --row-id, or --filter-json.", {
1183
+ exitCode: 1,
1184
+ });
1185
+ }
1186
+ // A dry run of an all-rows run previews the background run the live
1187
+ // command would create and queues nothing, so demanding --background
1188
+ // for it only teaches a flag with no behavioural basis (blind eval
1189
+ // 2026-09-10: the agent tripped the error, then re-ran with the flag).
1190
+ if (options.all && options.dryRun && !options.background && !options.local) {
1191
+ options.background = true;
1192
+ }
1193
+ if (options.all && !options.background) {
1194
+ throw new OxygenError("invalid_column_run", "--all requires --background.", {
1195
+ exitCode: 1,
1196
+ });
1197
+ }
1198
+ if (options.local) {
1199
+ if (options.background) {
1200
+ throw new OxygenError("invalid_column_run", "Pass either --local or --background, not both.", {
1201
+ exitCode: 1,
1202
+ });
1203
+ }
1204
+ if (effectiveFilterSelection) {
1205
+ throw new OxygenError("invalid_column_run", "--filter-json requires --background for columns run.", {
1206
+ exitCode: 1,
1207
+ });
1208
+ }
1209
+ return runLocalCustomHttpColumn(table, column, {
1210
+ rowId: readOption(options.rowId),
1211
+ force: Boolean(options.force),
1212
+ ...(limit ? { limit } : {}),
1213
+ ...(localConcurrency ? { concurrency: localConcurrency } : {}),
1214
+ });
1215
+ }
1216
+ const body = {
1217
+ ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
1218
+ table,
1219
+ column,
1220
+ ...(options.all ? { selection: { mode: "all" } } : {}),
1221
+ ...(effectiveFilterSelection ? { selection: effectiveFilterSelection } : {}),
1222
+ ...(!options.all && readOption(options.rowId) ? { row_id: readOption(options.rowId) } : {}),
1223
+ ...(!options.all && !filterSelection && limit ? { limit } : {}),
1224
+ ...(options.force ? { force: true } : {}),
1225
+ ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
1226
+ ...(options.background ? { background: true } : {}),
1227
+ ...(options.approved ? { approved: true } : {}),
1228
+ ...(options.approvedEffectUnknown ? { approved_effect_unknown: true } : {}),
1229
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1230
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
1231
+ ...(options.dryRun ? { dry_run: true } : {}),
1232
+ };
1233
+ const data = await requestColumnsRun(body, table, {
1234
+ background: Boolean(options.background),
1235
+ });
1236
+ if (!options.json)
1237
+ writeAiPromptPreviewNotice(data);
1238
+ return data;
1239
+ });
1240
+ }))
1241
+ .addCommand(new Command("rerun")
1242
+ .description("Re-run a single AI column cell, optionally threading a prior message review's feedback into the prompt.")
1243
+ .requiredOption("--table <table>", "Table id or slug.")
1244
+ .requiredOption("--column <column>", "Column id or key.")
1245
+ .requiredOption("--row <row_id>", "Row UUID.")
1246
+ .option("--from-review-id <review_id>", "Prior message review whose feedback to thread into the regeneration prompt.")
1247
+ .option("--dry-run", "Preview the single-row credit estimate and posture without spending credits or writing a review.")
1248
+ .option("--max-credits <n>", "Required managed-credit ceiling for a live AI rerun; inspect --dry-run first.")
1249
+ .option("--json", "Print a JSON envelope.")
1250
+ .action(async (options) => {
1251
+ const maxCredits = readNonNegativeNumber(options.maxCredits);
1252
+ await handleAsyncAction("columns rerun", options, () => requestOxygen("/api/cli/columns/rerun", {
1253
+ method: "POST",
1254
+ body: {
1255
+ table: options.table,
1256
+ column: options.column,
1257
+ row_id: options.row,
1258
+ ...(readOption(options.fromReviewId) ? { from_review_id: readOption(options.fromReviewId) } : {}),
1259
+ ...(options.dryRun ? { dry_run: true } : {}),
1260
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1261
+ },
1262
+ }));
1263
+ }))
1264
+ .addCommand(new Command("materialize")
1265
+ .description("Copy JSON paths from a result column into existing or new target columns. Without --mappings-json, the fixed work_email preset creates email, email_provider, email_status, and email_enriched_at; it does not target a column named work_email.")
1266
+ .argument("<table>", "Table id or slug.")
1267
+ .argument("<source_column>", "JSONB source column id or key.")
1268
+ .option("--preset <preset>", "Fixed companion-column bundle. work_email creates email, email_provider, email_status, and email_enriched_at; it never binds to an existing work_email column.")
1269
+ .option("--mappings-json <json>", "JSON array of {source_path,target_column,label?,data_type?,semantic_type?} mappings. Use this form to write into an existing column such as work_email.")
1270
+ .option("--no-create-if-missing", "Require target companion columns to already exist.")
1271
+ .option("--overwrite-existing", "Replace target values when the materialized source differs. By default only empty targets are filled.")
1272
+ .option("--json", "Print a JSON envelope.")
1273
+ .action(async (table, sourceColumn, options) => {
1274
+ const mappings = options.mappingsJson ? parseJsonArray(options.mappingsJson) : undefined;
1275
+ const preset = readOption(options.preset);
1276
+ await handleAsyncAction("columns materialize", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(sourceColumn)}/materialize`, {
1277
+ method: "POST",
1278
+ body: {
1279
+ ...(mappings ? { mappings } : {}),
1280
+ ...(!mappings ? { preset: preset ?? "work_email" } : preset ? { preset } : {}),
1281
+ create_if_missing: options.createIfMissing !== false,
1282
+ overwrite_existing: options.overwriteExisting === true,
1283
+ },
1284
+ }));
1285
+ }))
1286
+ .addCommand(new Command("rename")
1287
+ .description("Rename a workspace column's stable key (requires --key), optionally updating its display label. Free (0 credits). For a label-only change, use columns update <table> <column> --label <label>. Warns when another column reads the old key; a rename does not rewrite those references.")
1288
+ .argument("<table>", "Table id or slug.")
1289
+ .argument("<column>", "Column id or key.")
1290
+ .requiredOption("--key <key>", "Required new stable column key.")
1291
+ .option("--label <label>", "Optional new display label.")
1292
+ .option("--json", "Print a JSON envelope.")
1293
+ .action(async (table, column, options) => {
1294
+ await handleAsyncAction("columns rename", options, async () => {
1295
+ const renamed = await requestOxygen("/api/cli/tables/columns/rename", {
1296
+ method: "POST",
1297
+ body: {
1298
+ table,
1299
+ column,
1300
+ key: options.key,
1301
+ ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
1302
+ },
1303
+ });
1304
+ if (!options.json)
1305
+ writeColumnReferenceNotices(renamed);
1306
+ return renamed;
1307
+ });
1308
+ }))
1309
+ .addCommand(new Command("update")
1310
+ .description("Update a workspace column: label, note, position and visibility; a choice column's options; a lookup or bind column's source, join and mode; and for AI/research columns the prompt, named inputs, model, reasoning level, run condition, output schema, and web-research settings.")
1311
+ .argument("<table>", "Table id or slug.")
1312
+ .argument("<column>", "Column id or key.")
1313
+ .option("--label <label>", "New display label.")
1314
+ .option("--semantic-type <type>", "New semantic type: a display hint such as company.domain, or a choice type (select, multi_select, status) to give a column options.")
1315
+ .option("--clear-semantic-type", "Clear the column's semantic type.")
1316
+ .option("--options <choices>", "Replace a select, multi_select or status column's choices, comma-separated in display order. Cells keep the values they hold; keep a value in the list to keep it selectable.")
1317
+ .option("--options-json <json>", "Replacement choices as a JSON array of strings or {value,label?,color?}. Rename a choice by giving the same value a new label.")
1318
+ .option("--description <text>", "Replace the column's note.")
1319
+ .option("--lookup-table <table>", "Lookup columns: read from this source table instead; pass --lookup-match with it, since the join column belongs to the source.")
1320
+ .option("--lookup-match <pair>", "Lookup columns: new join as localColumn=sourceColumn.")
1321
+ .option("--lookup-normalize <mode>", "Lookup columns: key normalization exact, lower-trim, email, domain, or linkedin.")
1322
+ .option("--lookup-mode <mode>", "Lookup columns: first-match, count, exists, or aggregate. The column's data type follows the mode.")
1323
+ .option("--lookup-return <csv>", "Lookup columns: the source columns first-match returns.")
1324
+ .option("--lookup-order <column:dir>", "Lookup columns: first-match tie-break, e.g. created_at:desc.")
1325
+ .option("--lookup-aggregate <fn:column>", "Lookup columns: aggregate reducer as fn:column (sum, avg, min or max).")
1326
+ .option("--bind-object <slug>", "Bind columns: resolve rows to this CRM object instead (companies, people, deals, ...).")
1327
+ .option("--bind-map <pairs>", "Bind columns: identity=column pairs to add or change, e.g. domain=website.")
1328
+ .option("--bind-create", "Bind columns: create a CRM record for unmatched rows (a truth write; runs need --approved).")
1329
+ .option("--no-bind-create", "Bind columns: leave unmatched rows unbound.")
1330
+ .option("--position <n>", "Move the column to this zero-based position.")
1331
+ .option("--definition-json <json>", "Definition metadata to shallow-merge into the column; arrays are replaced wholesale and missing keys leave existing fields unchanged.")
1332
+ .option("--definition-unset <keys>", "Comma-separated definition keys to REMOVE after the merge (e.g. outputSchema,model) — a merge can only add or overwrite. Lifecycle and provenance keys are rejected.")
1333
+ .option("--data-type <type>", "New output data type for a lookup column (text, numeric, boolean, jsonb, timestamptz). Discards the cached cells; other column kinds use `oxygen columns retype`.")
1334
+ .option("--prompt <text-or-file>", "Replacement AI column prompt, or a path to a prompt file. Reference other columns inline as {{column_key}}; unknown keys are rejected here instead of failing per row. Merges into --definition-json; a `prompt` in both is an error.")
1335
+ .option("--input-mapping <json>", "Replace the column's named inputs wholesale. Row columns belong in the prompt as {{column_key}}; use this for the inputs a prompt cannot name on its own — literals and workspace context ({\"icp\":{\"type\":\"context_profile\",\"path\":\"icp\"}}), which the prompt then reads as {{icp}}. Pass {} to clear it; --definition-json can only add mapping keys, never remove one.")
1336
+ .option("--model <openrouter-model-id>", "OpenRouter model id. Managed columns accept allow-listed models and bill by Oxygen tier; --reasoning-level selects a tier. BYOK uses your own key and provider pricing.")
1337
+ .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
1338
+ .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}}.")
1339
+ .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
1340
+ // Positive first keeps omission undefined; negative first defaults true
1341
+ // and silently overwrites a saved prose choice on an unrelated update.
1342
+ .option("--structured-output", "Restore the prompt's named JSON fields. Existing AI columns must store jsonb; for a text column, add a new AI column with --data-type jsonb.")
1343
+ .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
1344
+ .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
1345
+ .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
1346
+ .option("--research-query <template>", "Research columns: replace the per-row web search query template, e.g. \"{{company_name}} pricing page\".")
1347
+ .option("--research-domains <csv>", "Research columns: replace the comma-separated list of domains to search within.")
1348
+ .option("--research-exclude-domains <csv>", "Research columns: replace the comma-separated list of domains to exclude.")
1349
+ .option("--research-mode <mode>", "Research columns: strict (answer only from the sources) or estimate (reason to a figure from them).")
1350
+ .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25).")
1351
+ .option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa).")
1352
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
1353
+ // Declaring BOTH forms leaves the default undefined (Commander only
1354
+ // defaults to true when --no- is declared alone), so an update that
1355
+ // doesn't mention visibility leaves it untouched.
1356
+ .option("--always-show", "Keep this column visible even when it holds no values.")
1357
+ .option("--no-always-show", "Stop pinning this column, so it collapses again while empty.")
1358
+ .option("--hidden-by-default", "Leave this column out of the grid on first open. It stays readable everywhere else, and anyone can reveal it from the column menu.")
1359
+ .option("--no-hidden-by-default", "Show this column in the grid on first open. Read the current opening set with `oxygen tables describe <table>` — every column reports its hiddenByDefault state.")
1360
+ .option("--dry-run", "Return the would-be merged definition without writing.")
1361
+ .option("--json", "Print a JSON envelope.")
1362
+ .action(async (table, column, options) => {
1363
+ await handleAsyncAction("columns update", options, async () => {
1364
+ const prompt = readAiPromptOption(options.prompt);
1365
+ const hasAiConfig = prompt !== null ||
1366
+ readOption(options.model) ||
1367
+ readOption(options.reasoningLevel) ||
1368
+ readOption(options.runCondition) ||
1369
+ readOption(options.outputSchemaJson) ||
1370
+ readOption(options.outputSchemaFile) ||
1371
+ typeof options.structuredOutput === "boolean";
1372
+ let definition = options.definitionJson
1373
+ ? parseJsonObject(options.definitionJson)
1374
+ : undefined;
1375
+ const hasResearchConfig = Boolean(readOption(options.researchQuery)
1376
+ || readOption(options.researchDomains)
1377
+ || readOption(options.researchExcludeDomains)
1378
+ || readOption(options.researchMode)
1379
+ || readOption(options.researchResults)
1380
+ || readOption(options.researchEngine));
1381
+ if (hasAiConfig || hasResearchConfig) {
1382
+ const merged = definition ?? {};
1383
+ if (prompt !== null)
1384
+ merged.prompt = assertPromptNotAlsoInDefinition(merged, prompt);
1385
+ definition = applyAiColumnConfig(merged, options);
1386
+ // The server shallow-merges `definition`, so webSearch is replaced
1387
+ // wholesale; applyResearchColumnConfig carries the untouched keys over
1388
+ // from --definition-json rather than dropping them.
1389
+ if (hasResearchConfig)
1390
+ definition = applyResearchColumnConfig(definition, options);
1391
+ }
1392
+ const definitionUnset = readCsvOption(options.definitionUnset);
1393
+ // --input-mapping is the only way to repair a bad mapping in place:
1394
+ // the server SHALLOW-merges `definition`, so a nested inputMapping
1395
+ // sent through --definition-json adds keys and can never remove one.
1396
+ // This replaces the object wholesale.
1397
+ // Every flag below adds whole keys, merged into `definition` ONCE. The
1398
+ // surface-parity scanner re-reads each assignment that spreads a
1399
+ // variable into itself, so stacking `definition = { ...definition }`
1400
+ // assignments multiplied its work 13x and timed the gate out (#5086).
1401
+ const additions = {};
1402
+ const inputMapping = readOption(options.inputMapping);
1403
+ if (inputMapping)
1404
+ additions.inputMapping = parseJsonObject(inputMapping);
1405
+ const description = readOption(options.description);
1406
+ if (description)
1407
+ additions.description = description;
1408
+ // Lookup and bind flags edit the STORED configuration: the server merges
1409
+ // `definition` one level deep, so a partial `match` would replace the
1410
+ // whole join. Read the column, apply the flags over it, send the result.
1411
+ const editsLookup = hasLookupColumnFlags(options);
1412
+ const editsBind = Boolean(readOption(options.bindObject) || readOption(options.bindMap))
1413
+ || typeof options.bindCreate === "boolean";
1414
+ if (editsLookup || editsBind) {
1415
+ const stored = await readStoredColumnForUpdate(table, column);
1416
+ if (editsLookup)
1417
+ Object.assign(additions, lookupDefinitionEdit(stored, options));
1418
+ if (editsBind)
1419
+ Object.assign(additions, bindDefinitionEdit(stored, options));
1420
+ }
1421
+ if (Object.keys(additions).length > 0)
1422
+ definition = { ...(definition ?? {}), ...additions };
1423
+ const choices = readColumnOptionsFlags(options);
1424
+ await assertColumnDefinitionReferences(table, definition);
1425
+ // Sent beside `definition`, never inside it: the server merges the
1426
+ // depth into the column's STORED search block, so it cannot drop a
1427
+ // query template or domains the way a partial webSearch would.
1428
+ const researchDepth = readResearchDepthOption(options.researchDepth);
1429
+ const updated = await requestOxygen("/api/cli/tables/columns/update", {
1430
+ method: "POST",
1431
+ body: {
1432
+ table,
1433
+ column,
1434
+ ...(choices ? { options: choices } : {}),
1435
+ ...(researchDepth ? { research_depth: researchDepth } : {}),
1436
+ ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
1437
+ ...(options.clearSemanticType ? { semantic_type: null } : readOption(options.semanticType) ? { semantic_type: readOption(options.semanticType) } : {}),
1438
+ ...(readOption(options.position) !== null ? { position: readNonNegativeInt(options.position) } : {}),
1439
+ ...(definition ? { definition } : {}),
1440
+ ...(definitionUnset.length > 0 ? { definition_unset: definitionUnset } : {}),
1441
+ ...(readOption(options.dataType) ? { data_type: readOption(options.dataType) } : {}),
1442
+ ...(typeof options.alwaysShow === "boolean" ? { always_show: options.alwaysShow } : {}),
1443
+ ...(typeof options.hiddenByDefault === "boolean"
1444
+ ? { hidden_by_default: options.hiddenByDefault }
1445
+ : {}),
1446
+ ...(options.dryRun ? { dry_run: true } : {}),
1447
+ },
1448
+ });
1449
+ if (!options.json)
1450
+ writeColumnReferenceNotices(updated);
1451
+ return updated;
1452
+ });
1453
+ }))
1454
+ .addCommand(new Command("convert")
1455
+ .description("Convert a column between an AI column and a Web Research Agent column (--to agent or --to ai), or switch an agent to Keyword search and back. Keyword search returns a raw search-result list, not a sourced answer; the saved search question becomes its query. Conversion keeps the key, label and position, clears results, and needs --yes; without it this prints what would change.")
1456
+ .argument("<table>", "Table id or slug.")
1457
+ .argument("<column>", "Column id or key.")
1458
+ .requiredOption("--to <target>", "agent (Web Research Agent), ai (AI column, no web access) or keyword (Keyword search).")
1459
+ .option("--prompt <text>", "Keyword search to agent only: the question the agent answers for each row. Defaults to the column's saved search, as the web's mode switch does.")
1460
+ .option("--yes", "Convert, clearing the column's current results.")
1461
+ .option("--json", "Print a JSON envelope.")
1462
+ .action(async (table, column, options) => {
1463
+ await handleAsyncAction("columns convert", options, async () => {
1464
+ const to = readOption(options.to)?.toLowerCase() ?? "";
1465
+ if (!["agent", "ai", "keyword"].includes(to)) {
1466
+ throw new OxygenError("invalid_request", `--to must be agent, ai or keyword (got ${to || "nothing"}).`, { exitCode: 1 });
1467
+ }
1468
+ const prompt = readOption(options.prompt);
1469
+ const body = { table, column, to, ...(prompt ? { prompt } : {}) };
1470
+ if (!options.yes) {
1471
+ const plan = await requestOxygen("/api/cli/tables/columns/convert", {
1472
+ method: "POST",
1473
+ body: { ...body, dry_run: true },
1474
+ });
1475
+ if (plan.effect_outcome === "no_change" && plan.results_cleared !== true)
1476
+ return plan;
1477
+ throw new OxygenError("confirmation_required", `Converting ${column} to ${String(plan.to_label ?? to)} clears its current results. Re-run with --yes to convert.`, { details: plan, exitCode: 2 });
1478
+ }
1479
+ return requestOxygen("/api/cli/tables/columns/convert", {
1480
+ method: "POST",
1481
+ body: { ...body, confirm: true },
1482
+ });
1483
+ });
1484
+ }))
1485
+ .addCommand(new Command("retype")
1486
+ .description("Convert a manual text column to a stronger data type, migrating stored values.")
1487
+ .argument("<table>", "Table id or slug.")
1488
+ .argument("<column>", "Column id or key.")
1489
+ .requiredOption("--data-type <type>", "Target data type: text, numeric, boolean, jsonb, or timestamptz (the web's Data type choices).")
1490
+ .option("--dry-run", "Preview the conversion (row counts and samples) without writing.")
1491
+ .option("--json", "Print a JSON envelope.")
1492
+ .action(async (table, column, options) => {
1493
+ await handleAsyncAction("columns retype", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/retype`, {
1494
+ method: "POST",
1495
+ body: {
1496
+ data_type: options.dataType,
1497
+ ...(options.dryRun ? { dry_run: true } : {}),
1498
+ },
1499
+ }));
1500
+ }))
1501
+ .addCommand(new Command("archive")
1502
+ .description("Archive a workspace column without dropping physical data. Hides it indefinitely with no purge timer; for cleanup with a restorable deadline use `columns delete`.")
1503
+ .argument("<table>", "Table id or slug.")
1504
+ .argument("<column>", "Column id or key.")
1505
+ .option("--json", "Print a JSON envelope.")
1506
+ .action(async (table, column, options) => {
1507
+ await handleAsyncAction("columns archive", options, () => requestOxygen(`/api/cli/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/archive`, {
1508
+ method: "POST",
1509
+ body: {},
1510
+ }));
1511
+ }))
1512
+ .addCommand(new Command("delete")
1513
+ .description("Delete workspace columns: archives now, frees the key for reuse, and schedules the physical purge (default 14 days; restorable via `columns restore` until then). --now purges immediately and permanently — it previews and refuses without --yes.")
1514
+ .argument("<table>", "Table id or slug.")
1515
+ .argument("<columns...>", "Column ids or keys.")
1516
+ .option("--now", "Purge immediately instead of scheduling. Irreversible; single column only.")
1517
+ .option("--yes", "Approve the immediate purge previewed by --now.")
1518
+ .option("--purge-after <days>", "Days until the scheduled purge runs. Defaults to 14; 0 purges on the next background sweep (sweeps visit each workspace every few minutes).")
1519
+ .option("--reason <text>", "Recorded with the delete for the audit trail.")
1520
+ .option("--json", "Print a JSON envelope.")
1521
+ .action(async (table, columns, options) => {
1522
+ await handleAsyncAction("columns delete", options, () => {
1523
+ const purgeAfter = readOption(options.purgeAfter) ?? undefined;
1524
+ const purgeAfterDays = purgeAfter === undefined ? undefined : Number.parseInt(purgeAfter, 10);
1525
+ if (purgeAfterDays !== undefined && (!Number.isFinite(purgeAfterDays) || purgeAfterDays < 0)) {
1526
+ throw new OxygenError("invalid_request", "--purge-after must be a non-negative number of days.", {
1527
+ exitCode: 2,
1528
+ });
1529
+ }
1530
+ return requestOxygen("/api/cli/tables/columns/delete", {
1531
+ method: "POST",
1532
+ body: {
1533
+ table,
1534
+ ...(columns.length === 1 ? { column: columns[0] } : { columns }),
1535
+ ...(options.now ? { now: true } : {}),
1536
+ ...(options.yes ? { approved: true } : {}),
1537
+ ...(purgeAfterDays !== undefined ? { purge_after_days: purgeAfterDays } : {}),
1538
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
1539
+ },
1540
+ });
1541
+ });
1542
+ }))
1543
+ .addCommand(new Command("restore")
1544
+ .description("Restore an archived or delete-scheduled column: cancels the purge timer and reclaims its key.")
1545
+ .argument("<table>", "Table id or slug.")
1546
+ .argument("<column>", "Column id or key (the pre-delete key works).")
1547
+ .option("--json", "Print a JSON envelope.")
1548
+ .action(async (table, column, options) => {
1549
+ await handleAsyncAction("columns restore", options, () => requestOxygen("/api/cli/tables/columns/restore", {
1550
+ method: "POST",
1551
+ body: { table, column },
1552
+ }));
1553
+ }))
1554
+ .addCommand(new Command("reorder")
1555
+ .description("Move a workspace column to a new position, by absolute index or relative to another column.")
1556
+ .argument("<table>", "Table id or slug.")
1557
+ .argument("<column>", "Column id or key to move.")
1558
+ .option("--position <n>", "Target zero-based index for the column.")
1559
+ .option("--before <column>", "Place the column immediately before this column id or key.")
1560
+ .option("--after <column>", "Place the column immediately after this column id or key.")
1561
+ .option("--dry-run", "Return the would-be position without persisting the reorder.")
1562
+ .option("--json", "Print a JSON envelope.")
1563
+ .action(async (table, column, options) => {
1564
+ await handleAsyncAction("columns reorder", options, async () => {
1565
+ const position = await resolveColumnReorderPosition(table, column, options);
1566
+ return requestOxygen("/api/cli/tables/columns/reorder", {
1567
+ method: "POST",
1568
+ body: {
1569
+ table,
1570
+ column,
1571
+ position,
1572
+ ...(options.dryRun ? { dry_run: true } : {}),
1573
+ },
1574
+ });
1575
+ });
1576
+ }))
1577
+ .addCommand(new Command("catalog")
1578
+ .description("List column templates and native Classify; multi-column enrichment bundles (presets such as person_enrich and company_enrich) are listed by `oxygen enrichment catalog`. For ICP fit, Classify Score (0.075 credits/row) appears in `data.native`, including with `--family classification`; `data.entries` lists separate prompt templates. Templates can be added 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. Website contacts (emails and phones from each row's site) is here as `--kind tool`; other tool-backed columns that read a page or call a provider are found with `oxygen tools search <words>`. Provider waterfalls and enrichment bundles are a different list with their own per-row prices: `oxygen enrichment catalog`.")
1579
+ .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.")
1580
+ .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.")
1581
+ .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), formula (free), or tool (a native fetch over a website, such as website contacts).")
1582
+ .option("--search <term>", "Match a word against template keys, titles and descriptions, e.g. pricing or 10-K.")
1583
+ .option("--json", "Print a JSON envelope.")
1584
+ .action(async (options) => {
1585
+ await handleAsyncAction("columns catalog", options, async () => {
1586
+ const params = new URLSearchParams();
1587
+ for (const [name, value] of [
1588
+ ["category", readOption(options.category)],
1589
+ ["family", readOption(options.family)],
1590
+ ["kind", readOption(options.kind)],
1591
+ ["search", readOption(options.search)],
1592
+ ]) {
1593
+ if (value)
1594
+ params.set(name, value);
1595
+ }
1596
+ const query = params.toString();
1597
+ const result = await requestOxygen(`/api/cli/tables/columns/catalog${query ? `?${query}` : ""}`, { method: "GET" });
1598
+ if (!options.json)
1599
+ printColumnCatalog(result);
1600
+ return result;
1601
+ });
1602
+ }))
1603
+ .addCommand(new Command("deps")
1604
+ .description("Show the column dependency graph: which columns feed which (formulas, input mappings, run conditions, waterfall targets), transitive up/downstream, and cycles. Read-only.")
1605
+ .argument("<table>", "Table id or slug.")
1606
+ .option("--column <key>", "Focus on one column: its upstream inputs, downstream dependents, and edges.")
1607
+ .option("--json", "Print a JSON envelope.")
1608
+ .action(async (table, options) => {
1609
+ await handleAsyncAction("columns deps", options, () => {
1610
+ const params = new URLSearchParams({ table });
1611
+ const column = readOption(options.column);
1612
+ if (column)
1613
+ params.set("column", column);
1614
+ return requestOxygen(`/api/cli/tables/columns/deps?${params.toString()}`, { method: "GET" });
1615
+ });
1616
+ }))
1617
+ .addCommand(new Command("fields")
1618
+ .description("List what a column's output exposes: every field you can reference as {{column.field}}, filter on, sort by, or promote to its own column with `columns materialize`. Works before the column has ever run. Read-only and free.")
1619
+ .argument("<table>", "Table id or slug.")
1620
+ .argument("<column>", "Column key or id.")
1621
+ .option("--json", "Print a JSON envelope.")
1622
+ .action(async (table, column, options) => {
1623
+ await handleAsyncAction("columns fields", options, () => {
1624
+ const params = new URLSearchParams({ table, column });
1625
+ return requestOxygen(`/api/cli/tables/columns/fields?${params.toString()}`, { method: "GET" });
1626
+ });
1627
+ }));
1628
+ }