@oxygen-agent/cli 1.1014.136 → 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,1669 @@
1
+ import { emitCliFailure, writeJson } from "../cli-output.js";
2
+ import { parseJsonObject, readPositiveInt, readRecordString, resolveLiveDryRunMode } from "../cli-values.js";
3
+ import { ansi, buildCrmLeadsTodayPath, buildCrmSignalRecordBody, clipCell, handleReadActionWithLens, parseJsonArray, parseJsonValue, readCsvOption, readPositiveNumber, renderTextTable, splitCommaList } from "../command-support.js";
4
+ import { addContractFlags, readContractFlags } from "../contract-flags.js";
5
+ import { readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath, buildCrmRecordPath } from "../crm-support-flags.js";
6
+ import { requestOxygen } from "../http-client.js";
7
+ import { isRecord, readClearableOption, readOption } from "../util.js";
8
+ import { OxygenError, success } from "@oxygen/shared/cli-result";
9
+ import { CRM_ENRICHMENT_READ_CONTRACT } from "@oxygen/shared/contracts/crm-enrichment-read";
10
+ import { Command } from "commander";
11
+ import { stdout as output } from "node:process";
12
+ const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people", "deals"];
13
+ function buildCrmSetupBody(options) {
14
+ return {
15
+ objects: readCrmSetupObjects(options.objects),
16
+ mode: resolveLiveDryRunMode(options),
17
+ ...(options.withEnrichment === true ? { with_enrichment: true } : {}),
18
+ ...(options.withoutEnrichment === true ? { without_enrichment: true } : {}),
19
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
20
+ };
21
+ }
22
+ function buildCrmFieldsBody(options) {
23
+ const object = readOption(options.object);
24
+ const limit = readOption(options.limit);
25
+ return {
26
+ mode: resolveLiveDryRunMode(options),
27
+ ...(object ? { objects: object } : {}),
28
+ ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
29
+ };
30
+ }
31
+ function buildCrmRollupBody(options) {
32
+ const object = readOption(options.object);
33
+ const row = readOption(options.row);
34
+ const limit = readOption(options.limit);
35
+ return {
36
+ mode: resolveLiveDryRunMode(options),
37
+ ...(object ? { object } : {}),
38
+ ...(row ? { row } : {}),
39
+ ...(limit ? { limit: Number.parseInt(limit, 10) } : {}),
40
+ ...(options.includeLifecycle ? { include_lifecycle: true } : {}),
41
+ };
42
+ }
43
+ function readCrmSetupObjects(value) {
44
+ const objects = readCsvOption(value);
45
+ return objects.length > 0 ? [...new Set(objects)] : DEFAULT_CRM_SETUP_OBJECTS;
46
+ }
47
+ function buildCrmObjectCreateBody(options) {
48
+ const slug = readOption(options.slug);
49
+ if (!slug) {
50
+ throw new OxygenError("missing_slug", "--slug is required.", { exitCode: 1 });
51
+ }
52
+ const displayName = readOption(options.displayName);
53
+ if (!displayName) {
54
+ throw new OxygenError("missing_display_name", "--display-name is required.", { exitCode: 1 });
55
+ }
56
+ const columns = options.columnsJson ? parseJsonArray(options.columnsJson) : [];
57
+ const identities = options.identitiesJson ? parseJsonArray(options.identitiesJson) : [];
58
+ return {
59
+ slug,
60
+ display_name: displayName,
61
+ columns,
62
+ identities,
63
+ mode: resolveLiveDryRunMode(options),
64
+ ...(readOption(options.singularName) ? { singular_name: readOption(options.singularName) } : {}),
65
+ ...(readOption(options.pluralName) ? { plural_name: readOption(options.pluralName) } : {}),
66
+ ...(readOption(options.labelColumn) ? { label_column: readOption(options.labelColumn) } : {}),
67
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
68
+ };
69
+ }
70
+ function buildCrmObjectDeleteBody(options) {
71
+ const mode = resolveLiveDryRunMode(options);
72
+ if (mode === "live" && options.confirm !== true) {
73
+ throw new OxygenError("confirm_required", "crm objects delete --live requires --confirm after inspecting the dry-run impact.", { exitCode: 1 });
74
+ }
75
+ return {
76
+ mode,
77
+ ...(options.confirm === true ? { confirm: true } : {}),
78
+ ...(options.keepTable === true ? { keep_table: true } : {}),
79
+ ...(readOption(options.purgeAfter) ? { purge_after_days: options.purgeAfter } : {}),
80
+ };
81
+ }
82
+ function buildCrmObjectAddAttrBody(options) {
83
+ const column = readOption(options.columnJson)
84
+ ? parseJsonObject(options.columnJson)
85
+ : null;
86
+ if (!column) {
87
+ throw new OxygenError("missing_column", "--column-json is required.", { exitCode: 1 });
88
+ }
89
+ return {
90
+ column,
91
+ mode: resolveLiveDryRunMode(options),
92
+ ...(options.asIdentityJson ? { identity: parseJsonObject(options.asIdentityJson) } : {}),
93
+ };
94
+ }
95
+ function buildCrmObjectUpdateAttrBody(options) {
96
+ const name = readOption(options.name);
97
+ const optionsJson = readOption(options.optionsJson);
98
+ if (!name && options.required === undefined && !optionsJson) {
99
+ throw new OxygenError("missing_attribute_change", "Nothing to change. Pass --name <label>, --required / --no-required, and/or --options-json <json>.", { exitCode: 1 });
100
+ }
101
+ let choices;
102
+ if (optionsJson) {
103
+ const parsed = parseJsonValue(optionsJson, "--options-json");
104
+ if (!Array.isArray(parsed)) {
105
+ throw new OxygenError("invalid_options_json", "--options-json must be a JSON array of {value,label,color?}.", { exitCode: 1 });
106
+ }
107
+ choices = parsed;
108
+ }
109
+ return {
110
+ ...(name ? { display_name: name } : {}),
111
+ ...(options.required !== undefined ? { is_required: options.required } : {}),
112
+ ...(choices !== undefined ? { options: choices } : {}),
113
+ mode: resolveLiveDryRunMode(options),
114
+ };
115
+ }
116
+ function buildCrmAssertBody(object, options) {
117
+ return {
118
+ object,
119
+ identity: parseCrmIdentityOption(options.identity),
120
+ values: options.valuesJson ? parseJsonObject(options.valuesJson) : {},
121
+ mode: resolveLiveDryRunMode(options),
122
+ };
123
+ }
124
+ function buildCrmSyncImportBody(provider, options) {
125
+ return {
126
+ provider,
127
+ object: options.object,
128
+ mode: resolveLiveDryRunMode(options),
129
+ ...(options.into ? { into: options.into } : {}),
130
+ ...(readPositiveInt(options.limit) !== undefined ? { limit: readPositiveInt(options.limit) } : {}),
131
+ ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
132
+ ...(options.approved ? { approved: true } : {}),
133
+ };
134
+ }
135
+ function buildCrmSyncConfigureBody(options) {
136
+ return {
137
+ provider: options.provider,
138
+ object: options.object,
139
+ mode: resolveLiveDryRunMode(options),
140
+ ...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
141
+ ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
142
+ ...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
143
+ ...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
144
+ ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
145
+ ...(options.fieldMappingsJson
146
+ ? { field_mappings: parseJsonArray(options.fieldMappingsJson) }
147
+ : {}),
148
+ ...(options.approved ? { approved: true } : {}),
149
+ };
150
+ }
151
+ function buildCrmSyncRunBody(options) {
152
+ return {
153
+ provider: options.provider,
154
+ object: options.object,
155
+ mode: resolveLiveDryRunMode(options),
156
+ ...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
157
+ ...(readOption(options.since) ? { since: readOption(options.since) } : {}),
158
+ ...(readOption(options.configId) ? { config_id: readOption(options.configId) } : {}),
159
+ ...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
160
+ ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
161
+ ...(options.approved ? { approved: true } : {}),
162
+ };
163
+ }
164
+ function buildCrmSyncEnableBody(options) {
165
+ return {
166
+ provider: options.provider,
167
+ object: options.object,
168
+ ...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
169
+ ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
170
+ ...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
171
+ ...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
172
+ ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
173
+ ...(options.fieldMappingsJson
174
+ ? { field_mappings: parseJsonArray(options.fieldMappingsJson) }
175
+ : {}),
176
+ ...(options.approved ? { approved: true } : {}),
177
+ // Commander maps --no-run-now to runNow:false (default true); only send the
178
+ // opt-out so the route's default (run an immediate first cycle) holds.
179
+ ...(options.runNow === false ? { run_now: false } : {}),
180
+ };
181
+ }
182
+ function buildCrmSyncDisableBody(options) {
183
+ return { provider: options.provider, object: options.object };
184
+ }
185
+ function buildCrmSyncConfigStatusPath(options) {
186
+ const runId = readOption(options.runId);
187
+ const provider = readOption(options.provider);
188
+ const object = readOption(options.object);
189
+ const hasPair = provider !== null && object !== null;
190
+ if (runId !== null && (provider !== null || object !== null)) {
191
+ throw new OxygenError("conflicting_flags", "Pass either --run-id or --provider/--object, not both.", { exitCode: 1 });
192
+ }
193
+ if (runId === null && !hasPair) {
194
+ throw new OxygenError("missing_target", "Pass --run-id for a single run, or --provider and --object for a standing sync config.", { exitCode: 1 });
195
+ }
196
+ const params = new URLSearchParams();
197
+ if (runId !== null) {
198
+ params.set("run_id", runId);
199
+ }
200
+ else {
201
+ params.set("provider", provider);
202
+ params.set("object", object);
203
+ }
204
+ return `/api/cli/crm/sync/status?${params.toString()}`;
205
+ }
206
+ function buildCrmSyncLinksPath(object, rowId) {
207
+ const params = new URLSearchParams();
208
+ params.set("object", object);
209
+ params.set("row_id", rowId);
210
+ return `/api/cli/crm/sync/links?${params.toString()}`;
211
+ }
212
+ const CRM_ENRICHMENT_READ_FLAGS = {
213
+ object: { valueName: "<object>", help: "CRM object slug: companies or people. Omit for every object this workspace has.", parse: readOption },
214
+ };
215
+ function buildCrmEnrichmentListPath(options) {
216
+ const fields = readContractFlags(options, CRM_ENRICHMENT_READ_CONTRACT, CRM_ENRICHMENT_READ_FLAGS);
217
+ const params = new URLSearchParams();
218
+ for (const [key, value] of Object.entries(fields))
219
+ if (value !== undefined && value !== null)
220
+ params.set(key, String(value));
221
+ return params.size ? `/api/cli/crm/enrichment?${params.toString()}` : "/api/cli/crm/enrichment";
222
+ }
223
+ // The delta form: flip one named enrichment and leave every other decision
224
+ // (the rest of the presets, the ceiling, force, the trigger set) exactly as it
225
+ // was. Mutually exclusive with the whole-set form the API rejects mixing.
226
+ function buildCrmEnrichmentDeltaBody(preset, options, key) {
227
+ return { object: readOption(options.object) ?? "", [key]: [preset] };
228
+ }
229
+ function buildCrmEnrichmentSetBody(options) {
230
+ return {
231
+ object: readOption(options.object) ?? "",
232
+ presets: readCsvOption(options.presets),
233
+ ...readCrmEnrichmentMaxCredits(options.maxCredits),
234
+ ...(options.force === true ? { force: true } : {}),
235
+ };
236
+ }
237
+ // `--max-credits auto` is the explicit "hand the ceiling back to the plan
238
+ // default" write, which the API only performs for a literal null. An ABSENT
239
+ // flag preserves whatever is armed, so the two must not collapse into the same
240
+ // request body.
241
+ function readCrmEnrichmentMaxCredits(value) {
242
+ const raw = readOption(value);
243
+ if (!raw)
244
+ return {};
245
+ if (raw.toLowerCase() === "auto")
246
+ return { max_credits: null };
247
+ const credits = readPositiveNumber(raw);
248
+ return credits === undefined ? {} : { max_credits: credits };
249
+ }
250
+ function buildCrmSearchBody(query, options) {
251
+ // `crm search` is the only command in the family that took the plural, which
252
+ // cost a failed invocation every time an agent reached for the sibling
253
+ // spelling. Both are accepted; the plural still wins if somebody passes both.
254
+ const objects = readCsvOption(options.objects ?? options.object);
255
+ const limit = readPositiveInt(options.limit);
256
+ return {
257
+ query,
258
+ ...(objects.length > 0 ? { objects } : {}),
259
+ ...(limit !== undefined ? { limit } : {}),
260
+ ...(options.suggest === true ? { mode: "suggest" } : {}),
261
+ };
262
+ }
263
+ function buildCrmMergeBody(object, survivorRowId, loserRowId, options) {
264
+ const mode = resolveLiveDryRunMode(options);
265
+ if (mode === "live" && options.confirm !== true) {
266
+ throw new OxygenError("confirm_required", "crm merge --live requires --confirm after inspecting the dry-run field diff.", { exitCode: 1 });
267
+ }
268
+ return {
269
+ object,
270
+ survivor_row_id: survivorRowId,
271
+ loser_row_id: loserRowId,
272
+ mode,
273
+ ...(options.confirm === true ? { confirm: true } : {}),
274
+ ...(options.fieldOverridesJson ? { field_overrides: parseJsonObject(options.fieldOverridesJson) } : {}),
275
+ };
276
+ }
277
+ function buildCrmRelationshipUpsertBody(object, rowId, options) {
278
+ return {
279
+ object,
280
+ row_id: rowId,
281
+ relationship: options.relationship,
282
+ target: {
283
+ ...(readOption(options.targetObject) ? { object: readOption(options.targetObject) } : {}),
284
+ row_id: options.targetRowId,
285
+ },
286
+ ...(readOption(options.role) ? { role: readOption(options.role) } : {}),
287
+ mode: resolveLiveDryRunMode(options),
288
+ };
289
+ }
290
+ function buildCrmActivityBody(object, rowId, options) {
291
+ const body = {
292
+ mode: resolveLiveDryRunMode(options),
293
+ activity_type: options.type,
294
+ links: [{ object, row_id: rowId, role: options.role ?? "actor" }],
295
+ };
296
+ if (options.summary)
297
+ body.summary = options.summary;
298
+ if (options.body)
299
+ body.body = options.body;
300
+ if (options.channel)
301
+ body.channel = options.channel;
302
+ if (options.direction)
303
+ body.direction = options.direction;
304
+ if (options.occurredAt)
305
+ body.occurred_at = options.occurredAt;
306
+ if (options.sourceProvider)
307
+ body.source_provider = options.sourceProvider;
308
+ if (options.providerEventId)
309
+ body.provider_event_id = options.providerEventId;
310
+ if (options.metadataJson)
311
+ body.metadata = parseJsonObject(options.metadataJson);
312
+ return body;
313
+ }
314
+ function buildCrmActivityNoteBody(object, rowId, text, options) {
315
+ return {
316
+ mode: resolveLiveDryRunMode(options),
317
+ activity_type: "note",
318
+ summary: text,
319
+ links: [{ object, row_id: rowId, role: "actor" }],
320
+ };
321
+ }
322
+ function buildCrmTimelinePath(object, rowId, options) {
323
+ const params = new URLSearchParams();
324
+ if (options.limit)
325
+ params.set("limit", options.limit);
326
+ if (options.cursor)
327
+ params.set("cursor", options.cursor);
328
+ const query = params.toString();
329
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/timeline`;
330
+ return query ? `${base}?${query}` : base;
331
+ }
332
+ function buildCrmRelatedPath(object, rowId, options) {
333
+ const params = new URLSearchParams();
334
+ if (options.previewLimit)
335
+ params.set("preview_limit", options.previewLimit);
336
+ const query = params.toString();
337
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/related`;
338
+ return query ? `${base}?${query}` : base;
339
+ }
340
+ function buildCrmNotesPath(object, rowId, options) {
341
+ const params = new URLSearchParams();
342
+ if (options.limit)
343
+ params.set("limit", options.limit);
344
+ if (options.cursor)
345
+ params.set("cursor", options.cursor);
346
+ const query = params.toString();
347
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/notes`;
348
+ return query ? `${base}?${query}` : base;
349
+ }
350
+ /**
351
+ * One record's tasks, or — with neither argument — every task in the workspace
352
+ * (Philipp, 2026-09-22). The record scope is BOTH arguments or neither: one
353
+ * alone is an unfinished command, and guessing the other half would silently
354
+ * read a different set than the operator asked for.
355
+ */
356
+ function buildCrmTasksPath(object, rowId, options) {
357
+ if (Boolean(object) !== Boolean(rowId)) {
358
+ throw new OxygenError("invalid_request", "Name both the object and the record, or neither: `oxygen crm tasks list companies acme.com` reads one record, `oxygen crm tasks list` reads the whole workspace.", { exitCode: 1 });
359
+ }
360
+ const params = new URLSearchParams();
361
+ // The workspace read is "what do I owe anyone": without --status it lists
362
+ // OPEN work, as its description promises — the blind acceptance run got
363
+ // cancelled rows back and had to confirm with a second call. A record's own
364
+ // list keeps every status, the way its tab shows Done and Cancelled groups.
365
+ // `--status all` asks the workspace read for everything.
366
+ const status = options.status ?? (object && rowId ? undefined : "open");
367
+ if (status && status !== "all")
368
+ params.set("status", status);
369
+ if (options.assignee)
370
+ params.set("assignee", options.assignee);
371
+ if (options.limit)
372
+ params.set("limit", options.limit);
373
+ if (options.cursor)
374
+ params.set("cursor", options.cursor);
375
+ const query = params.toString();
376
+ const base = object && rowId
377
+ ? `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tasks`
378
+ : "/api/cli/crm/tasks";
379
+ return query ? `${base}?${query}` : base;
380
+ }
381
+ function buildCrmPipelinePath(options) {
382
+ const params = new URLSearchParams();
383
+ if (readOption(options.pipeline))
384
+ params.set("pipeline", readOption(options.pipeline));
385
+ if (options.limit)
386
+ params.set("limit", options.limit);
387
+ const query = params.toString();
388
+ return query ? `/api/cli/crm/pipeline?${query}` : "/api/cli/crm/pipeline";
389
+ }
390
+ function buildCrmDuplicatesPath(object, options) {
391
+ const params = new URLSearchParams();
392
+ if (options.limit)
393
+ params.set("limit", options.limit);
394
+ const query = params.toString();
395
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/duplicates`;
396
+ return query ? `${base}?${query}` : base;
397
+ }
398
+ function buildCrmAutomationSetBody(templateId, options) {
399
+ if (options.armed === true && options.disarmed === true) {
400
+ throw new OxygenError("conflicting_flags", "Pass either --armed or --disarmed, not both.", { exitCode: 1 });
401
+ }
402
+ if (options.armed !== true && options.disarmed !== true) {
403
+ throw new OxygenError("missing_flag", "Pass --armed or --disarmed.", { exitCode: 1 });
404
+ }
405
+ const body = {
406
+ template_id: templateId,
407
+ armed: options.armed === true,
408
+ mode: resolveLiveDryRunMode(options),
409
+ };
410
+ if (options.configJson)
411
+ body.config = parseJsonObject(options.configJson);
412
+ if (options.approved === true)
413
+ body.approved = true;
414
+ const maxCredits = readPositiveNumber(options.maxCredits);
415
+ if (maxCredits !== undefined)
416
+ body.max_credits = maxCredits;
417
+ return body;
418
+ }
419
+ function buildCrmAutomationAuditPath(options) {
420
+ const params = new URLSearchParams();
421
+ if (readOption(options.templateId))
422
+ params.set("template_id", readOption(options.templateId));
423
+ if (options.limit)
424
+ params.set("limit", options.limit);
425
+ const query = params.toString();
426
+ return query ? `/api/cli/crm/automation/audit?${query}` : "/api/cli/crm/automation/audit";
427
+ }
428
+ function parseCrmIdentityOption(value) {
429
+ const separator = value.indexOf("=");
430
+ if (separator <= 0 || separator === value.length - 1) {
431
+ throw new OxygenError("invalid_identity", "--identity must be formatted as key=value, for example domain=acme.com or email=ceo@acme.com.", { exitCode: 1 });
432
+ }
433
+ return {
434
+ key: value.slice(0, separator).trim(),
435
+ value: value.slice(separator + 1).trim(),
436
+ };
437
+ }
438
+ /**
439
+ * `crm objects`, as an operator reads it.
440
+ *
441
+ * Every crm command printed raw JSON whether or not you asked for it, so the
442
+ * 2026-09-20 blind eval's agent piped this exact command through `python3 -c`
443
+ * just to see which objects existed. A list of four objects should not need a
444
+ * program to read. `--json` is untouched and remains the contract.
445
+ */
446
+ const CRM_NORMALIZATION_WORDS = {
447
+ email_v1: "email address",
448
+ domain_v1: "website domain",
449
+ linkedin_url_v1: "LinkedIn URL",
450
+ exact_text_v1: "exact text",
451
+ uuid_v1: "ID",
452
+ };
453
+ function formatCrmObjects(data) {
454
+ const objects = Array.isArray(data.objects) ? data.objects.filter(isRecord) : [];
455
+ if (objects.length === 0) {
456
+ return "No CRM objects yet. Create the standard set with: oxygen crm setup --live\n";
457
+ }
458
+ const rows = objects.map((object) => {
459
+ const attributes = Array.isArray(object.attributes) ? object.attributes.filter(isRecord) : [];
460
+ const identities = Array.isArray(object.identities) ? object.identities.filter(isRecord) : [];
461
+ // System attributes are bookkeeping, not fields anyone chose.
462
+ const fieldCount = attributes.filter((attribute) => attribute.attributeKind !== "system").length;
463
+ const primary = identities.find((identity) => identity.isPrimary === true) ?? identities[0];
464
+ // A compact projection carries no attributes or identities (and an older
465
+ // server no standardKey): print what it does carry, never "custom, 0
466
+ // fields, can duplicate" for companies/people/deals.
467
+ const compact = !Array.isArray(object.attributes);
468
+ const kind = object.standardKey ? "standard" : object.standardKey === null || !compact ? "custom" : "—";
469
+ let identity = describeCrmIdentity(primary, attributes);
470
+ if (!Array.isArray(object.identities) && Object.hasOwn(object, "primaryIdentitySlug")) {
471
+ identity = typeof object.primaryIdentitySlug === "string" ? object.primaryIdentitySlug : "nothing — can duplicate";
472
+ }
473
+ else if (compact && !Array.isArray(object.identities)) {
474
+ identity = "—";
475
+ }
476
+ return [
477
+ String(object.pluralName || object.displayName || object.slug || "—"),
478
+ kind,
479
+ compact ? "—" : String(fieldCount),
480
+ identity,
481
+ String(object.slug ?? "—"),
482
+ ];
483
+ });
484
+ return [
485
+ `${objects.length} object${objects.length === 1 ? "" : "s"}`,
486
+ "",
487
+ ...renderTextTable(["OBJECT", "KIND", "FIELDS", "SAME RECORD WHEN", "SLUG"], rows),
488
+ "",
489
+ " One object's fields and links: oxygen crm describe <slug>",
490
+ " In the app: /settings/data-model",
491
+ "",
492
+ ].join("\n");
493
+ }
494
+ /**
495
+ * A to-do list an operator can read without a JSON parser.
496
+ *
497
+ * The record column is the point of the workspace read — a task title alone
498
+ * ("Send the deck") says nothing about who it is for — so it comes first, from
499
+ * the same primary link the web row shows. A record-scoped read prints the
500
+ * same table; the column is simply the record you asked about.
501
+ */
502
+ function formatCrmTasks(data) {
503
+ const tasks = Array.isArray(data.tasks) ? data.tasks.filter(isRecord) : [];
504
+ // The workspace read names its scope as `object: null`; the record-scoped
505
+ // envelope carries no `object` key at all.
506
+ const scoped = !Object.hasOwn(data, "object");
507
+ if (tasks.length === 0) {
508
+ return scoped
509
+ ? "No tasks on this record. Add one with: oxygen crm tasks add <object> <record> \"<title>\"\n"
510
+ : "No tasks in this workspace. A task is filed on a record: oxygen crm tasks add companies acme.com \"Send the deck\"\n";
511
+ }
512
+ const rows = tasks.map((task) => {
513
+ const links = Array.isArray(task.links) ? task.links.filter(isRecord) : [];
514
+ const primary = links[0];
515
+ const assignee = isRecord(task.assignee) ? task.assignee : null;
516
+ return [
517
+ // Clipped, so one essay-length title cannot push every other column off
518
+ // the terminal. `--json` still carries the whole thing.
519
+ clipCell(String(primary?.label ?? primary?.objectSlug ?? "—"), 28),
520
+ clipCell(String(task.title ?? "—"), 48),
521
+ String(task.status ?? "—"),
522
+ String(task.priority ?? "—"),
523
+ formatCrmTaskDue(task.dueAt),
524
+ String(assignee?.name ?? assignee?.email ?? "unassigned"),
525
+ String(task.id ?? "—"),
526
+ ];
527
+ });
528
+ const link = typeof data.web_url === "string" ? data.web_url : null;
529
+ return [
530
+ `${tasks.length} task${tasks.length === 1 ? "" : "s"}${data.hasMore === true ? " (more available — pass --cursor)" : ""}`,
531
+ "",
532
+ ...renderTextTable(["RECORD", "TASK", "STATUS", "PRIORITY", "DUE", "ASSIGNEE", "ID"], rows),
533
+ "",
534
+ " Finish one: oxygen crm tasks complete <id>",
535
+ ...(link ? [` In the app: ${link}`] : []),
536
+ "",
537
+ ].join("\n");
538
+ }
539
+ /** A deadline is a day to whoever reads the list; the time of day is noise. */
540
+ function formatCrmTaskDue(value) {
541
+ if (typeof value !== "string" || !value)
542
+ return "—";
543
+ return value.slice(0, 10);
544
+ }
545
+ /** "Website domain (website domain)" is noise, so the comparison is named only
546
+ * when it says something the field name does not. An object with no identity
547
+ * is called out rather than left blank: it duplicates on every re-import. */
548
+ function describeCrmIdentity(identity, attributes) {
549
+ if (!identity)
550
+ return "nothing — can duplicate";
551
+ const attributeSlug = typeof identity.attributeSlug === "string" ? identity.attributeSlug : null;
552
+ const attribute = attributeSlug
553
+ ? attributes.find((candidate) => candidate.slug === attributeSlug)
554
+ : undefined;
555
+ const fieldName = String(attribute?.displayName ?? identity.displayName ?? attributeSlug ?? "—");
556
+ const normalization = typeof identity.normalization === "string" ? identity.normalization : "";
557
+ const words = CRM_NORMALIZATION_WORDS[normalization];
558
+ return words && words !== fieldName.toLowerCase() ? `${fieldName} (${words})` : fieldName;
559
+ }
560
+ // The stored trigger names are internal (`rows_insert`); the words the rest of
561
+ // the CLI uses for the same writes are `insert` / `upsert` / `send`.
562
+ // Width of the widest on/off marker (`[off]`), so every preset row's label
563
+ // starts in the same column.
564
+ const CRM_ENRICHMENT_MARKER_WIDTH = 5;
565
+ const CRM_ENRICHMENT_SOURCE_LABELS = {
566
+ import: "import",
567
+ rows_insert: "insert",
568
+ rows_upsert: "upsert",
569
+ table_send: "send",
570
+ };
571
+ // `crm enrichment` reads and writes ONE thing: the standing auto-run on a CRM
572
+ // object's table. --json returns the raw envelope; the default rendering exists
573
+ // because this is where a founder decides what to spend on, and a JSON dump
574
+ // buries the free-vs-paid split, the per-batch ceiling, and the fact that the
575
+ // saved set is a standing permission.
576
+ async function handleCrmEnrichmentAction(command, options, request) {
577
+ try {
578
+ const data = await request();
579
+ if (options.json) {
580
+ writeJson(success(command, data));
581
+ return;
582
+ }
583
+ process.stdout.write(formatCrmEnrichment(data));
584
+ }
585
+ catch (error) {
586
+ emitCliFailure(command, error);
587
+ }
588
+ }
589
+ function formatCrmEnrichment(data) {
590
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
591
+ const lines = [];
592
+ if (!("objects" in data)) {
593
+ pushCrmEnrichmentObject(lines, styles, data);
594
+ lines.push("");
595
+ return lines.join("\n");
596
+ }
597
+ if (data.objects.length === 0) {
598
+ lines.push("", ` ${styles.dim("No CRM objects to enrich yet — run `oxygen crm setup` first.")}`, "", styles.dim(`View CRM: ${data.deepLink}`), "");
599
+ return lines.join("\n");
600
+ }
601
+ for (const view of data.objects) {
602
+ pushCrmEnrichmentObject(lines, styles, view);
603
+ }
604
+ lines.push("");
605
+ return lines.join("\n");
606
+ }
607
+ function pushCrmEnrichmentObject(lines, styles, view) {
608
+ lines.push("", `${styles.bold(`Automatic enrichment — ${view.object}`)} ${formatCrmEnrichmentState(view, styles)}`, ` ${styles.dim("Standing permission: records written to this object enrich automatically, with no per-record approval, bounded by the per-batch ceiling below.")}`, "");
609
+ const labelWidth = Math.max(...view.presets.map((preset) => preset.label.length), 0);
610
+ const armed = new Set(view.presets.filter((preset) => preset.enabled).map((preset) => preset.id));
611
+ for (const preset of view.presets) {
612
+ pushCrmEnrichmentPreset(lines, styles, preset, labelWidth, armed);
613
+ }
614
+ const summary = [
615
+ ["Per record", formatCrmEnrichmentCredits(view.credits_per_record, styles, "credits")],
616
+ ["Per batch", formatCrmEnrichmentCeiling(view, styles)],
617
+ ["Columns", `${view.column_budget.used} of ${view.column_budget.max} allowed`],
618
+ ["Fires on", formatCrmEnrichmentSources(view.sources)],
619
+ // Only worth a line when it is on: force is what turns a re-import into a
620
+ // second bill for records that already carry the values.
621
+ ...(view.force
622
+ ? [["Force", "on — re-runs columns that already have a value"]]
623
+ : []),
624
+ ];
625
+ const summaryWidth = Math.max(...summary.map(([label]) => label.length));
626
+ lines.push("", ...summary.map(([label, value]) => ` ${styles.dim(label.padEnd(summaryWidth))} ${value}`));
627
+ if (view.custom_columns.length > 0) {
628
+ // A hand-tuned config carries columns no preset owns, so the per-record
629
+ // figure above can be a floor rather than the bill.
630
+ // Uncosted only when one of them may bill; a formula needs no run and is free.
631
+ const note = view.custom_columns_uncosted
632
+ ? ` ${styles.yellow("(cost not included above)")}`
633
+ : ` ${styles.dim("(free: no run needed)")}`;
634
+ lines.push(` ${styles.dim("Extra".padEnd(summaryWidth))} ${view.custom_columns.join(", ")}${note}`);
635
+ }
636
+ // The half the config alone cannot show: an armed set says nothing about the
637
+ // records that were already there when it was armed. Without this line a
638
+ // workspace reads a correct configuration and never learns that 303 of its
639
+ // 317 companies are still blank.
640
+ const backfillLine = formatCrmEnrichmentBacklog(view, styles);
641
+ if (backfillLine) {
642
+ lines.push(` ${styles.dim("Not enriched".padEnd(summaryWidth))} ${backfillLine}`);
643
+ }
644
+ if (view.changed) {
645
+ lines.push("", ...formatCrmEnrichmentChange(view.changed, styles));
646
+ }
647
+ lines.push("", styles.dim(`View: ${view.deepLink}`), styles.dim(`Runs: ${view.runs_web_url}`));
648
+ }
649
+ function formatCrmEnrichmentBacklog(view, styles) {
650
+ const backfill = view.backfill;
651
+ if (!backfill)
652
+ return null;
653
+ if (backfill.remaining_rows === 0) {
654
+ return styles.green(`none — all ${backfill.total_rows.toLocaleString("en-US")} records carry these enrichments`);
655
+ }
656
+ return `${styles.yellow(`${backfill.remaining_rows.toLocaleString("en-US")} of ${backfill.total_rows.toLocaleString("en-US")} records`)}`
657
+ + ` ${styles.dim(`— fill them with \`oxygen crm enrichment backfill --object ${view.object}\``)}`;
658
+ }
659
+ function pushCrmEnrichmentPreset(lines, styles, preset, labelWidth, armed) {
660
+ // Pad the raw marker before colouring it: `[off]` is one character wider than
661
+ // `[on]`, and an ANSI-wrapped padEnd would count the escape codes.
662
+ const state = !preset.available ? "[--]" : preset.enabled ? "[on]" : "[off]";
663
+ const marker = (!preset.available || !preset.enabled ? styles.dim : styles.green)(state)
664
+ + " ".repeat(CRM_ENRICHMENT_MARKER_WIDTH - state.length);
665
+ lines.push(` ${marker} ${preset.label.padEnd(labelWidth)} ${formatCrmEnrichmentCredits(preset.credits_per_record, styles, "credits/record")} ${styles.dim(preset.id)}`, ` ${styles.dim(preset.description)}`);
666
+ if (preset.credit_note)
667
+ lines.push(` ${styles.dim(`Cost note: ${preset.credit_note}`)}`);
668
+ if (preset.caveat)
669
+ lines.push(` ${styles.yellow(`Caveat: ${preset.caveat}`)}`);
670
+ if (!preset.available) {
671
+ lines.push(` ${styles.dim("This table has none of its columns yet — re-run `oxygen crm setup`.")}`);
672
+ return;
673
+ }
674
+ if (!preset.enabled)
675
+ return;
676
+ // An enabled preset missing some of its own columns is a hand-tuned config:
677
+ // say which columns are not armed rather than implying the whole preset runs.
678
+ if (preset.missing_columns.length > 0) {
679
+ lines.push(` ${styles.yellow(`Partly armed — not running: ${preset.missing_columns.join(", ")}`)}`);
680
+ }
681
+ // A preset that reads another preset's output writes nothing while that one is
682
+ // off — the quietest way an armed set under-delivers.
683
+ const unmet = (preset.depends_on ?? []).filter((id) => !armed.has(id));
684
+ if (unmet.length > 0) {
685
+ lines.push(` ${styles.yellow(`Reads ${unmet.join(", ")} — turn that on or this stays empty.`)}`);
686
+ }
687
+ }
688
+ function formatCrmEnrichmentCredits(credits, styles, unit) {
689
+ if (credits === 0)
690
+ return styles.green("free");
691
+ // "1 credits/record" read wrong in a blind pass (2026-10-04); the unit is
692
+ // "credits" or "credits/record", so singularize its leading word.
693
+ const label = credits === 1 ? unit.replace(/^credits\b/, "credit") : unit;
694
+ return styles.yellow(`${credits.toLocaleString("en-US")} ${label}`);
695
+ }
696
+ // "on" alone reads as "the enrichments listed below are on" even when every one
697
+ // of them is off and only a hand-added column is armed.
698
+ function formatCrmEnrichmentState(view, styles) {
699
+ if (!view.enabled)
700
+ return styles.dim("off");
701
+ const anyPreset = view.presets.some((preset) => preset.enabled);
702
+ if (!anyPreset && view.custom_columns.length > 0) {
703
+ return `${styles.green("on")} ${styles.dim("— your own columns only")}`;
704
+ }
705
+ return styles.green("on");
706
+ }
707
+ function formatCrmEnrichmentCeiling(view, styles) {
708
+ if (view.max_credits === null)
709
+ return styles.dim("—");
710
+ const source = view.max_credits_source === "explicit"
711
+ ? "you set this"
712
+ : "your plan's per-delivery default";
713
+ const unit = view.max_credits === 1 ? "credit" : "credits";
714
+ return `${view.max_credits.toLocaleString("en-US")} ${unit} ${styles.dim(`(${source})`)}`;
715
+ }
716
+ function formatCrmEnrichmentSources(sources) {
717
+ if (sources.length === 0)
718
+ return "—";
719
+ const labels = sources.map((source) => CRM_ENRICHMENT_SOURCE_LABELS[source] ?? source);
720
+ // `crm assert` writes land on the insert/upsert triggers, so an armed object
721
+ // enriches CRM-asserted records too — the one place that is not obvious from
722
+ // the trigger names alone.
723
+ const assertCovered = sources.includes("rows_insert") || sources.includes("rows_upsert");
724
+ return `${labels.join(", ")}${assertCovered ? " (includes `crm assert` writes)" : ""}`;
725
+ }
726
+ function buildCrmEnrichmentBackfillBody(options) {
727
+ const presets = readCsvOption(options.presets);
728
+ const limit = readPositiveInt(options.limit);
729
+ return {
730
+ object: readOption(options.object) ?? "",
731
+ // An ABSENT --presets means "whatever is armed"; an explicitly empty one is
732
+ // still a caller-supplied set, so the two must not collapse.
733
+ ...(options.presets !== undefined ? { presets } : {}),
734
+ ...(limit !== undefined ? { limit } : {}),
735
+ ...(options.all === true ? { all: true } : {}),
736
+ ...(options.live === true ? { live: true } : {}),
737
+ ...(options.approved === true ? { approved: true } : {}),
738
+ ...(readPositiveNumber(options.maxCredits) !== undefined
739
+ ? { max_credits: readPositiveNumber(options.maxCredits) }
740
+ : {}),
741
+ };
742
+ }
743
+ // Backfill prints its own rendering rather than reusing the preset editor's:
744
+ // this command's whole job is to say what is missing and what filling it costs,
745
+ // and a JSON dump buries the one number that decides whether to run it.
746
+ async function handleCrmEnrichmentBackfill(options) {
747
+ const command = "crm enrichment backfill";
748
+ try {
749
+ const data = await requestOxygen("/api/cli/crm/enrichment/backfill", { method: "POST", body: buildCrmEnrichmentBackfillBody(options) });
750
+ if (options.json) {
751
+ writeJson(success(command, data));
752
+ return;
753
+ }
754
+ process.stdout.write(formatCrmEnrichmentBackfill(data));
755
+ }
756
+ catch (error) {
757
+ emitCliFailure(command, error);
758
+ }
759
+ }
760
+ function formatCrmEnrichmentBackfill(view) {
761
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
762
+ const lines = [
763
+ "",
764
+ `${styles.bold(`Backfill — ${view.object}`)} ${view.live ? styles.green("queued") : styles.dim("preview")}`,
765
+ ` ${styles.dim("Arming an enrichment only covers records written from then on. This fills the ones already here.")}`,
766
+ "",
767
+ ` ${formatBackfillHeadline(view, styles)}`,
768
+ "",
769
+ ];
770
+ const labelWidth = Math.max(...view.columns.map((column) => column.label.length), 0);
771
+ for (const column of view.columns) {
772
+ const rate = column.credits_per_row === null
773
+ ? styles.yellow("cost unknown")
774
+ : formatCrmEnrichmentCredits(column.credits_per_row, styles, "credits/record");
775
+ const pending = column.billable
776
+ ? `${column.pending_rows.toLocaleString("en-US")} missing`
777
+ : styles.dim("derived — fills itself, free");
778
+ lines.push(` ${column.label.padEnd(labelWidth)} ${pending.padEnd(18)} ${rate}`);
779
+ }
780
+ const summary = [
781
+ ["This batch", `${view.batch.rows.toLocaleString("en-US")} records · ${formatBackfillCredits(view.batch.estimated_credits, styles)}`],
782
+ ["To finish", `${view.to_finish.rows.toLocaleString("en-US")} records · ${formatBackfillCredits(view.to_finish.estimated_credits, styles)} · ${view.to_finish.batches_at_this_size} batch${view.to_finish.batches_at_this_size === 1 ? "" : "es"} this size`],
783
+ ["Spendable", view.spendable_credits === null
784
+ ? styles.dim("unknown")
785
+ : `${view.spendable_credits.toLocaleString("en-US")} credits ${styles.dim("(balance minus fixed monthly commitments)")}`],
786
+ ];
787
+ if (view.batch.recommended_max_credits !== null) {
788
+ summary.push(["Suggested cap", `${view.batch.recommended_max_credits.toLocaleString("en-US")} credits for this batch`]);
789
+ }
790
+ if (view.run) {
791
+ summary.push(["Run", `${view.run.status} · ${view.run.item_count.toLocaleString("en-US")} items · ${view.run.action_run_id}`]);
792
+ }
793
+ if (view.remaining_after_batch !== undefined) {
794
+ summary.push(["Left after this", `${view.remaining_after_batch.toLocaleString("en-US")} records`]);
795
+ }
796
+ const summaryWidth = Math.max(...summary.map(([label]) => label.length));
797
+ lines.push("", ...summary.map(([label, value]) => ` ${styles.dim(label.padEnd(summaryWidth))} ${value}`));
798
+ if (view.to_finish.uncosted_columns.length > 0) {
799
+ lines.push(` ${styles.yellow(`Cost unknown for: ${view.to_finish.uncosted_columns.join(", ")} — the figures above are a floor.`)}`);
800
+ }
801
+ // The silent failure this command exists to make loud: a ceiling that
802
+ // truncates the run leaves the rest terminally skipped while the run still
803
+ // reports completed.
804
+ if (view.skipped_credit_limit.rows > 0) {
805
+ lines.push("", ` ${styles.yellow(view.skipped_credit_limit.message)}`);
806
+ }
807
+ if (view.credit_guidance && view.credit_posture !== "sufficient") {
808
+ lines.push("", ` ${styles.yellow(view.credit_guidance)}`);
809
+ }
810
+ lines.push("", ` ${styles.dim("Next")} ${view.next_step}`, ...(view.run_all_step === view.next_step ? [] : [` ${styles.dim("All ")} ${view.run_all_step}`]), "", styles.dim(`View: ${view.deepLink}`), styles.dim(`Runs: ${view.run?.web_url ?? view.runs_web_url}`), "");
811
+ return lines.join("\n");
812
+ }
813
+ function formatBackfillHeadline(view, styles) {
814
+ if (view.remaining_rows === 0) {
815
+ return styles.green(`All ${view.total_rows.toLocaleString("en-US")} records already carry these enrichments.`);
816
+ }
817
+ return `${styles.bold(view.remaining_rows.toLocaleString("en-US"))} of ${view.total_rows.toLocaleString("en-US")} records are still missing at least one of these enrichments.`;
818
+ }
819
+ function formatBackfillCredits(credits, styles) {
820
+ if (credits === null)
821
+ return styles.yellow("cost unknown");
822
+ return credits === 0 ? styles.green("free") : `${credits.toLocaleString("en-US")} credits`;
823
+ }
824
+ function formatCrmEnrichmentChange(changed, styles) {
825
+ if (changed.enabled_added.length === 0 && changed.enabled_removed.length === 0) {
826
+ return [` ${styles.dim("No change — that set was already armed.")}`];
827
+ }
828
+ const lines = [];
829
+ if (changed.enabled_added.length > 0) {
830
+ lines.push(` ${styles.green("+")} armed: ${changed.enabled_added.join(", ")}`);
831
+ }
832
+ if (changed.enabled_removed.length > 0) {
833
+ lines.push(` ${styles.dim("-")} disarmed: ${changed.enabled_removed.join(", ")}`);
834
+ }
835
+ return lines;
836
+ }
837
+ export function registerCrmFamily(program, handleAsyncAction) {
838
+ program
839
+ .command("crm")
840
+ .description("Agent-native CRM object setup, records, automatic enrichment, and metadata commands. Who to contact first today: crm signals leads-today lists people ranked by their strongest intent signal in the last 7 days (website visit > profile view > post reaction > new follower), most recent first within a type.")
841
+ .addCommand(new Command("setup")
842
+ .description("Create or repair standard CRM object-backed tables; defaults to dry-run. A live run that creates the companies or people list also switches on automatic paid enrichment for every record added later: the dry run says what it costs, and --without-enrichment skips it.")
843
+ .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people,deals.")
844
+ .option("--project <project>", "Project id or slug for created CRM tables.")
845
+ .option("--without-enrichment", "Set up the lists without switching on automatic enrichment, not even on a freshly created table. Wins over --with-enrichment. Switch an enrichment on later with `oxygen crm enrichment enable`.")
846
+ .option("--with-enrichment", "Also arm the standing cheap-enrichment auto-run on objects that already existed (fresh-created tables arm it by default). New records then auto-enrich within the per-batch credit cap, whether they arrive by import, insert, upsert, or `crm assert`. If your plan runs fewer columns per record than the defaults, the columns left out are listed under autoRunColumnsOverLimit. Pick which enrichments run with `oxygen crm enrichment`.")
847
+ .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
848
+ .option("--live", "Apply CRM setup changes. Default is dry-run.")
849
+ .option("--json", "Print a JSON envelope.")
850
+ .action(async (options) => {
851
+ await handleAsyncAction("crm setup", options, () => requestOxygen("/api/cli/crm/setup", {
852
+ method: "POST",
853
+ body: buildCrmSetupBody(options),
854
+ }));
855
+ }))
856
+ .addCommand(new Command("photos")
857
+ .description("Fill CRM people photos from LinkedIn data this workspace already holds \u2014 inbox conversations, connections, followers, and post engagers. Free: no provider calls, no credits. LinkedIn photo URLs expire after about two weeks, so re-run to refresh (an expired photo falls back to initials, never a broken image). Defaults to dry-run.")
858
+ .option("--limit <n>", "Maximum people rows to scan. Defaults to 2000.")
859
+ .option("--dry-run", "Preview how many photos would be filled, without writing.")
860
+ .option("--live", "Write the resolved photos onto the people records.")
861
+ .option("--json", "Print a JSON envelope.")
862
+ .action(async (options) => {
863
+ await handleAsyncAction("crm photos", options, () => requestOxygen("/api/cli/crm/photos", {
864
+ method: "POST",
865
+ body: readCrmPhotoFields(options),
866
+ }));
867
+ }))
868
+ .addCommand(new Command("fields")
869
+ .description("Fill the stored CRM fields (Industry, Employees, HQ, and their siblings) from enrichment data this workspace already holds. These are ordinary text/number fields you can edit \u2014 the fill only ever writes an EMPTY cell, so a value you typed is never overwritten. Free: no provider calls, no credits. Run this once on a workspace whose records were enriched before those fields became editable. Defaults to dry-run.")
870
+ .option("--object <object>", "Limit the fill to one CRM object (companies, people, deals). Defaults to all.")
871
+ .option("--limit <n>", "Maximum rows to scan per object. Defaults to 2000.")
872
+ .option("--dry-run", "Preview how many cells would be filled, without writing.")
873
+ .option("--live", "Write the extracted values onto the records.")
874
+ .option("--json", "Print a JSON envelope.")
875
+ .action(async (options) => {
876
+ await handleAsyncAction("crm fields", options, () => requestOxygen("/api/cli/crm/fields", {
877
+ method: "POST",
878
+ body: buildCrmFieldsBody(options),
879
+ }));
880
+ }))
881
+ .addCommand(new Command("rollup")
882
+ .description("Recompute the Open Deal column on companies (and the copy stamped on their people) from this workspace's deals. It normally converges on every deal write; run this to repair after a bulk import, or on a workspace whose deals predate the column. Free: no provider calls, no credits. Defaults to dry-run.")
883
+ .option("--object <object>", "CRM object to recompute. Only companies today. Defaults to companies.")
884
+ .option("--row <id>", "Recompute one company record by row id instead of sweeping the object.")
885
+ .option("--limit <n>", "Maximum company rows to scan in one sweep. Defaults to 2000.")
886
+ .option("--dry-run", "Report how many companies disagree with their deals, without writing. A deal with no stage set is not an open deal and is excluded from the comparison, so a blank-stage deal is never reported as drift.")
887
+ .option("--live", "Write the recomputed Open Deal cells. Default is dry-run.")
888
+ .option("--include-lifecycle", "Also recompute Account Stage from each company's people and deals. Account Stage only moves when a person or deal changes, so a workspace whose records were imported before that projection existed reads Prospect for everything. Free, and dry-run previews it like the rest.")
889
+ .option("--json", "Print a JSON envelope.")
890
+ .action(async (options) => {
891
+ await handleAsyncAction("crm rollup", options, () => requestOxygen("/api/cli/crm/rollup", {
892
+ method: "POST",
893
+ body: buildCrmRollupBody(options),
894
+ }));
895
+ }))
896
+ .addCommand(
897
+ // `crm objects` lists (parent action runs when no subcommand matches);
898
+ // `crm objects create` / `crm objects add-attr` create custom objects.
899
+ new Command("objects")
900
+ .description("List configured CRM objects, or create/extend custom objects.")
901
+ .option("--json", "Print a JSON envelope.")
902
+ .action(async (options) => {
903
+ await handleReadActionWithLens("crm objects", options, () => requestOxygen(buildCrmObjectsPath(options)), formatCrmObjects);
904
+ })
905
+ .addCommand(new Command("create")
906
+ .description("Create or repair a custom CRM object. Defaults to dry-run.")
907
+ .requiredOption("--slug <slug>", "Object slug (snake_case), such as projects or tasks.")
908
+ .requiredOption("--display-name <name>", "Human-readable object name, such as Projects.")
909
+ .requiredOption("--columns-json <json>", 'JSON array. Every column requires key,label,data_type,semantic_type; camelCase aliases are accepted. Example: [{"key":"name","label":"Name","data_type":"text","semantic_type":"text","is_record_label":true}].')
910
+ .option("--identities-json <json>", "JSON array. Every identity requires column_key and normalization; camelCase columnKey is accepted. normalization is one of email_v1, domain_v1, linkedin_url_v1, exact_text_v1, uuid_v1 — the versioned CRM names, NOT the short exact/email/domain spellings --lookup-normalize takes. At most one identity may set is_primary.")
911
+ .option("--label-column <key>", "Column key to use as the record label. Defaults to the isRecordLabel column or the first column.")
912
+ .option("--singular-name <name>", "Singular display name, such as Project.")
913
+ .option("--plural-name <name>", "Plural display name, such as Projects.")
914
+ .option("--project <project>", "Project id or slug for the created CRM table.")
915
+ .option("--dry-run", "Preview the object plan without creating tables.")
916
+ .option("--live", "Apply the object creation. Default is dry-run.")
917
+ .option("--json", "Print a JSON envelope.")
918
+ .action(async (options, command) => {
919
+ // The `crm objects` parent declares its own --json + a default
920
+ // action, so commander binds `objects create --json` to the parent
921
+ // and options.json is undefined here. optsWithGlobals() reads --json
922
+ // wherever it landed, so the envelope is emitted like every other
923
+ // crm_* command instead of printing the bare result.
924
+ await handleAsyncAction("crm objects create", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen("/api/cli/crm/objects", {
925
+ method: "POST",
926
+ body: buildCrmObjectCreateBody(options),
927
+ }));
928
+ }))
929
+ .addCommand(new Command("delete")
930
+ .description("Delete a CUSTOM CRM object: removes the object and schedules its backing table for deletion (restorable for 14 days). Standard objects (companies, people, deals) are permanent and refused. Defaults to dry-run; --live requires --confirm. Re-run on an object a teammate already deleted, it reports alreadyArchived with the table's deletion state and purge date. Restoring that table returns its rows as an ordinary table; the object stays deleted. Purging it also removes the object's archived definition.")
931
+ .argument("<object>", "Custom CRM object slug, such as projects or tasks.")
932
+ .option("--keep-table", "Deregister the object but keep its table as an ordinary workspace table (no delete timer). Ignored once the table is already scheduled.")
933
+ .option("--purge-after <days>", "Days until the backing table's physical purge. Defaults to 14; restorable via `tables restore` until then. Ignored once the table is already scheduled.")
934
+ .option("--dry-run", "Preview the object, rows, relationships, and edges the delete would take. Default.")
935
+ .option("--live", "Apply the delete. Requires --confirm.")
936
+ .option("--confirm", "Confirm the live delete after inspecting the dry-run impact.")
937
+ .option("--json", "Print a JSON envelope.")
938
+ .action(async (object, options, command) => {
939
+ // See `objects create` above: optsWithGlobals() recovers --json,
940
+ // which the parent command otherwise swallows.
941
+ await handleAsyncAction("crm objects delete", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`, {
942
+ method: "DELETE",
943
+ body: buildCrmObjectDeleteBody(options),
944
+ }));
945
+ }))
946
+ .addCommand(new Command("add-attr")
947
+ .description("Add one field to an existing CRM object, optionally as an identity. Works on custom objects and on companies/people/deals — a field you add is yours and survives `crm setup`. Defaults to dry-run.")
948
+ .argument("<object>", "CRM object slug, such as companies, people, deals, or a custom object like projects.")
949
+ .requiredOption("--column-json <json>", "JSON column. Required: key,label,data_type,semantic_type; camelCase aliases are accepted.")
950
+ .option("--as-identity-json <json>", "JSON identity definition {columnKey,normalization,isPrimary?} promoting the new column. normalization is one of email_v1, domain_v1, linkedin_url_v1, exact_text_v1, uuid_v1.")
951
+ .option("--dry-run", "Preview the attribute plan without altering the table.")
952
+ .option("--live", "Apply the attribute. Default is dry-run.")
953
+ .option("--json", "Print a JSON envelope.")
954
+ .action(async (object, options, command) => {
955
+ // See `objects create` above: optsWithGlobals() recovers --json,
956
+ // which the parent command otherwise swallows.
957
+ await handleAsyncAction("crm objects add-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes`, {
958
+ method: "POST",
959
+ body: buildCrmObjectAddAttrBody(options),
960
+ }));
961
+ }))
962
+ .addCommand(new Command("update-attr")
963
+ .description("Change one field on a CRM object: rename it, make it required or optional, or replace the choices of a select/status field. The field's key never changes, so prompts and formulas that read it keep working. An Oxygen-shipped field on companies/people/deals can be renamed and its choices changed (its key, type and required flag stay); a field you added is fully yours. Defaults to dry-run.")
964
+ .argument("<object>", "CRM object slug, such as companies, people, deals, or a custom object like projects.")
965
+ .argument("<attribute>", "The field's key as `crm describe` lists it, e.g. renewal_date or pipeline_stage.")
966
+ .option("--name <label>", "New display name for the field.")
967
+ .option("--required", "Make the field required.")
968
+ .option("--no-required", "Make the field optional.")
969
+ .option("--options-json <json>", "Replacement choice list for a select/status field: a JSON array of {value,label,color?}. Keep an existing value to keep the records that hold it.")
970
+ .option("--dry-run", "Validate the change without applying it.")
971
+ .option("--live", "Apply the change. Default is dry-run.")
972
+ .option("--json", "Print a JSON envelope.")
973
+ .action(async (object, attribute, options, command) => {
974
+ await handleAsyncAction("crm objects update-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "PATCH", body: buildCrmObjectUpdateAttrBody(options) }));
975
+ }))
976
+ .addCommand(new Command("remove-attr")
977
+ .description("Remove a field you added to a CRM object. The column is archived (restorable with `columns restore <table> <key>`, which brings the field back; no cell destroyed), the object stops listing it, and the key is free for a new field at once. Oxygen-shipped fields, the title field, identity fields and relations cannot be removed. Defaults to dry-run.")
978
+ .argument("<object>", "CRM object slug, such as companies, people, deals, or a custom object like projects.")
979
+ .argument("<attribute>", "The field's key as `crm describe` lists it.")
980
+ .option("--dry-run", "Show what would be removed without applying it.")
981
+ .option("--live", "Remove the field. Default is dry-run.")
982
+ .option("--json", "Print a JSON envelope.")
983
+ .action(async (object, attribute, options, command) => {
984
+ await handleAsyncAction("crm objects remove-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "DELETE", body: { mode: resolveLiveDryRunMode(options) } }));
985
+ })))
986
+ .addCommand(new Command("search")
987
+ .description("Search CRM records by identity or record label. Takes ONE query; narrow the objects with --object, not a second argument.")
988
+ .argument("<query>", "Domain, email, LinkedIn URL, or record name to search for. Quote it if it contains spaces; to restrict to one object use --object companies.")
989
+ .option("--objects <objects>", "Comma-separated CRM object slugs to search. Defaults to all configured objects.")
990
+ .option("--object <object>", "Alias for --objects; every sibling crm command spells it singular.")
991
+ .option("--limit <limit>", "Maximum records to return.")
992
+ .option("--suggest", "Typeahead mode: ranked name/email/domain matches on a partial string, returning a compact suggestion per record instead of the full row. This is what the app's CRM search bar calls; use it when you have a fragment, and the default when you have an exact identity.")
993
+ .option("--json", "Print a JSON envelope.")
994
+ .action(async (query, options) => {
995
+ await handleAsyncAction("crm search", options, () => requestOxygen("/api/cli/crm/records/search", {
996
+ method: "POST",
997
+ body: buildCrmSearchBody(query, options),
998
+ }));
999
+ }))
1000
+ .addCommand(new Command("assert")
1001
+ .description("Create or update one CRM record by object identity. Defaults to dry-run. A live assert fires the object's standing auto-run (automatic enrichment) for the written record, exactly like `tables insert|upsert|import` — so new records enrich hands-free within the per-batch credit cap. Inspect or change what runs with `oxygen crm enrichment list`.")
1002
+ .argument("<object>", "CRM object slug, such as companies or people.")
1003
+ .requiredOption("--identity <key=value>", "Identity key/value, for example domain=acme.com or email=ceo@acme.com.")
1004
+ .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key. RELATIONSHIP columns (a deal's Associated Company and Associated People, a person's Company) are not set here; a value for one is left out and reported in `warnings`. Link by the target's identity with `company_domain` (people and deals) or `primary_contact_email` (deals), or attach an existing record with `oxygen crm relationships upsert`.")
1005
+ .option("--dry-run", "Preview the assert without writing a row.")
1006
+ .option("--live", "Apply the assert. Default is dry-run.")
1007
+ .option("--json", "Print a JSON envelope.")
1008
+ .action(async (object, options) => {
1009
+ await handleAsyncAction("crm assert", options, () => requestOxygen("/api/cli/crm/assert", {
1010
+ method: "POST",
1011
+ body: buildCrmAssertBody(object, options),
1012
+ }));
1013
+ }))
1014
+ .addCommand(new Command("get")
1015
+ .description("Get one CRM record aggregate with row values, identities, and relationships.")
1016
+ .argument("<object>", "CRM object slug, such as companies or people.")
1017
+ .argument("<row_id>", "CRM record row id.")
1018
+ .option("--json", "Print a JSON envelope.")
1019
+ .action(async (object, rowId, options) => {
1020
+ await handleAsyncAction("crm get", options, () => requestOxygen(buildCrmRecordPath(object, rowId, options)));
1021
+ }))
1022
+ .addCommand(new Command("connections")
1023
+ .description("Show the conversations and sequence enrollments connected to one CRM record, resolved through its people and identities \u2014 never by fuzzy name or domain.")
1024
+ .argument("<object>", "CRM object slug, such as companies or people.")
1025
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1026
+ .option("--conversation-limit <n>", "Maximum conversations to return.")
1027
+ .option("--sequence-limit <n>", "Maximum sequence enrollments to return.")
1028
+ .option("--json", "Print a JSON envelope.")
1029
+ .action(async (object, rowId, options) => {
1030
+ await handleAsyncAction("crm connections", options, () => {
1031
+ return requestOxygen(buildCrmConnectionsPath(object, rowId, options));
1032
+ });
1033
+ }))
1034
+ .addCommand(new Command("files")
1035
+ .description("Attach, list, download and remove files on a CRM record. The bytes live in the workspace file store, which owns dedupe, the per-file ceiling and the retained-bytes quota.")
1036
+ .addCommand(new Command("list")
1037
+ .description("List one record's attachments, newest first.")
1038
+ .argument("<object>", "CRM object slug, such as companies or people.")
1039
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1040
+ .option("--json", "Print a JSON envelope.")
1041
+ .action(async (object, rowId, options) => {
1042
+ await handleAsyncAction("crm files list", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`));
1043
+ }))
1044
+ .addCommand(new Command("attach")
1045
+ .description("Upload a local file and attach it to a CRM record. Each call creates a new file and attachment, including identical retries. If a response is lost, inspect the record's files before retrying. No credits.")
1046
+ .argument("<object>", "CRM object slug, such as companies or people.")
1047
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1048
+ .argument("<path>", "Local file path.")
1049
+ .option("--label <label>", "What to call it on this record. The stored file keeps its own filename.")
1050
+ .option("--json", "Print a JSON envelope.")
1051
+ .action(async (object, rowId, filePath, options) => {
1052
+ await handleAsyncAction("crm files attach", options, async () => {
1053
+ const { readFile } = await import("node:fs/promises");
1054
+ const { basename } = await import("node:path");
1055
+ const { createHash } = await import("node:crypto");
1056
+ const bytes = await readFile(filePath);
1057
+ // Presign, PUT straight to object storage, then have the server
1058
+ // verify and attach — the same three hops the web drop zone and
1059
+ // `copilot attach` make. Bytes never ride in the JSON body, whose
1060
+ // 2,000,000-byte ceiling held attachments to ~1.5 MB against a
1061
+ // store that allows 100 MiB.
1062
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`;
1063
+ const ticket = await requestOxygen(`${base}/upload-url`, {
1064
+ method: "POST",
1065
+ body: {
1066
+ name: basename(filePath),
1067
+ byte_length: bytes.byteLength,
1068
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1069
+ },
1070
+ });
1071
+ const uploadUrl = readRecordString(ticket, "upload_url");
1072
+ const fileId = isRecord(ticket.file) ? readRecordString(ticket.file, "id") : null;
1073
+ const reservedRowId = isRecord(ticket.record) ? readRecordString(ticket.record, "row_id") : null;
1074
+ if (!uploadUrl || !fileId || !reservedRowId) {
1075
+ throw new OxygenError("attachment_upload_failed", "The server did not return an upload URL, file ID and resolved record ID for that file.", { exitCode: 1 });
1076
+ }
1077
+ const controller = new AbortController();
1078
+ const timer = setTimeout(() => controller.abort(), 300_000);
1079
+ let put;
1080
+ try {
1081
+ put = await fetch(uploadUrl, {
1082
+ method: "PUT",
1083
+ headers: { "content-type": readRecordString(ticket, "upload_content_type") ?? "application/octet-stream" },
1084
+ body: new Uint8Array(bytes),
1085
+ signal: controller.signal,
1086
+ });
1087
+ }
1088
+ finally {
1089
+ clearTimeout(timer);
1090
+ }
1091
+ if (!put.ok) {
1092
+ throw new OxygenError("attachment_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
1093
+ }
1094
+ return requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(reservedRowId)}/files/${encodeURIComponent(fileId)}/uploaded`, {
1095
+ method: "POST",
1096
+ body: readCrmFileLabel(options),
1097
+ });
1098
+ });
1099
+ }))
1100
+ .addCommand(new Command("detach")
1101
+ .description("Remove a file from a record. Removes the LINK only \u2014 the stored file is kept, so anywhere else it is attached still has it.")
1102
+ .argument("<attachment_id>", "Attachment id from `crm files list`. Not the stored file id.")
1103
+ .option("--json", "Print a JSON envelope.")
1104
+ .action(async (attachmentId, options) => {
1105
+ await handleAsyncAction("crm files detach", options, () => requestOxygen(`/api/cli/crm/files/${encodeURIComponent(attachmentId)}`, {
1106
+ method: "DELETE",
1107
+ }));
1108
+ })))
1109
+ .addCommand(new Command("tasks")
1110
+ .description("Reminders and to-dos on CRM records: file a task on a record, list the open work across the whole workspace or one record's, complete it. Distinct from `oxygen voice tasks`, which is the phone queue a rep dials from.")
1111
+ .addCommand(new Command("list")
1112
+ .description("List open reminders across the whole workspace, or one record's with <object> <record>. Open work first, then by deadline; --assignee me for yours, --status completed for the done ones. An undated task sorts last inside its group rather than first.")
1113
+ .argument("[object]", "CRM object slug, such as companies or people. Leave both arguments off to read every record's tasks.")
1114
+ .argument("[record]", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1115
+ .option("--status <status>", "open, completed, cancelled or all. The workspace read defaults to open; a record's own list shows every status.")
1116
+ .option("--assignee <email|me>", "Only this member's tasks. `me` is whoever this key authenticates as.")
1117
+ .option("--limit <n>", "Maximum tasks to return. Defaults to 50.")
1118
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
1119
+ .option("--json", "Print a JSON envelope.")
1120
+ .action(async (object, rowId, options) => {
1121
+ await handleReadActionWithLens("crm tasks list", options, () => requestOxygen(buildCrmTasksPath(object, rowId, options)), formatCrmTasks);
1122
+ }))
1123
+ .addCommand(new Command("add")
1124
+ .description("Create a task on a CRM record. Free and internal, so it writes immediately rather than previewing first. The returned web_url opens THIS task on the record (?tab=tasks&task=<id>) — hand that one to the user, not the bare record link.")
1125
+ .argument("<object>", "CRM object slug, such as companies or people.")
1126
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1127
+ .argument("<title>", "What needs doing.")
1128
+ .option("--body <body>", "Longer description.")
1129
+ .option("--priority <priority>", "urgent, high, medium or low.")
1130
+ .option("--due <iso>", "ISO-8601 due timestamp. An unparseable value is rejected, never silently dropped.")
1131
+ .option("--assignee <email>", "Workspace member to assign it to, by email (a user id also works). Unassigned means the team, not nobody.")
1132
+ .option("--json", "Print a JSON envelope.")
1133
+ .action(async (object, rowId, title, options) => {
1134
+ await handleAsyncAction("crm tasks add", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tasks`, {
1135
+ method: "POST",
1136
+ body: {
1137
+ title,
1138
+ ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
1139
+ ...(readOption(options.priority) ? { priority: readOption(options.priority) } : {}),
1140
+ ...(readOption(options.due) ? { due_at: readOption(options.due) } : {}),
1141
+ ...(readOption(options.assignee) ? { assignee_actor_id: readOption(options.assignee) } : {}),
1142
+ },
1143
+ }));
1144
+ }))
1145
+ .addCommand(new Command("update")
1146
+ .description("Change a task's title, body, status, priority, deadline or assignee. Addressed by task id because one task can be filed on several records.")
1147
+ .argument("<task_id>", "Task id.")
1148
+ .option("--title <title>", "Replacement title.")
1149
+ .option("--body <body>", "Replacement description. Pass an empty string to clear it.")
1150
+ .option("--status <status>", "open, completed or cancelled.")
1151
+ .option("--priority <priority>", "urgent, high, medium, low, or an empty string to clear it.")
1152
+ .option("--due <iso>", "ISO-8601 due timestamp, or an empty string to clear the deadline.")
1153
+ .option("--assignee <email>", "Workspace member by email (a user id also works), or an empty string to unassign.")
1154
+ .option("--json", "Print a JSON envelope.")
1155
+ .action(async (taskId, options) => {
1156
+ await handleAsyncAction("crm tasks update", options, () => {
1157
+ // Absent flag: leave the field alone. Empty string: clear it.
1158
+ // The two must stay distinguishable all the way to the route.
1159
+ const body = readClearableOption(options.body);
1160
+ const priority = readClearableOption(options.priority);
1161
+ const due = readClearableOption(options.due);
1162
+ const assignee = readClearableOption(options.assignee);
1163
+ return requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
1164
+ method: "PATCH",
1165
+ body: {
1166
+ ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
1167
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
1168
+ ...(body !== undefined ? { body } : {}),
1169
+ ...(priority !== undefined ? { priority } : {}),
1170
+ ...(due !== undefined ? { due_at: due } : {}),
1171
+ ...(assignee !== undefined ? { assignee_actor_id: assignee } : {}),
1172
+ },
1173
+ });
1174
+ });
1175
+ }))
1176
+ .addCommand(new Command("complete")
1177
+ .description("Mark a task done. Completing an already-completed task keeps the original completion time and adds no second timeline entry.")
1178
+ .argument("<task_id>", "Task id.")
1179
+ .option("--json", "Print a JSON envelope.")
1180
+ .action(async (taskId, options) => {
1181
+ await handleAsyncAction("crm tasks complete", options, () => requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
1182
+ method: "PATCH",
1183
+ body: { status: "completed" },
1184
+ }));
1185
+ }))
1186
+ .addCommand(new Command("reopen")
1187
+ .description("Reopen a completed task, clearing its completion stamp.")
1188
+ .argument("<task_id>", "Task id.")
1189
+ .option("--json", "Print a JSON envelope.")
1190
+ .action(async (taskId, options) => {
1191
+ await handleAsyncAction("crm tasks reopen", options, () => requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
1192
+ method: "PATCH",
1193
+ body: { status: "open" },
1194
+ }));
1195
+ })))
1196
+ .addCommand(new Command("notes")
1197
+ .description("Read and write a CRM record's notes. A note is editable, pinnable and authored \u2014 and still lands on the record's timeline.")
1198
+ .addCommand(new Command("list")
1199
+ .description("List one record's notes, pinned first then newest. Address the record by row id OR by any of the object's identities.")
1200
+ .argument("<object>", "CRM object slug, such as companies or people.")
1201
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1202
+ .option("--limit <n>", "Maximum notes to return. Defaults to 25.")
1203
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
1204
+ .option("--json", "Print a JSON envelope.")
1205
+ .action(async (object, rowId, options) => {
1206
+ await handleAsyncAction("crm notes list", options, () => requestOxygen(buildCrmNotesPath(object, rowId, options)));
1207
+ }))
1208
+ .addCommand(new Command("add")
1209
+ .description("Write a note on a CRM record. Free, internal, and editable afterwards, so it writes immediately rather than previewing first. The returned web_url opens THIS note on the record (?tab=notes&note=<id>) — hand that one to the user, not the bare record link.")
1210
+ .argument("<object>", "CRM object slug, such as companies or people.")
1211
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1212
+ .argument("<body>", "Note text. Markdown.")
1213
+ .option("--title <title>", "Optional note title.")
1214
+ .option("--pin", "Pin the note to the top of the record's notes.")
1215
+ .option("--json", "Print a JSON envelope.")
1216
+ .action(async (object, rowId, body, options) => {
1217
+ await handleAsyncAction("crm notes add", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/notes`, {
1218
+ method: "POST",
1219
+ body: {
1220
+ body,
1221
+ ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
1222
+ ...(options.pin ? { pinned: true } : {}),
1223
+ },
1224
+ }));
1225
+ }))
1226
+ .addCommand(new Command("update")
1227
+ .description("Edit a note's body, title or pin. Addressed by note id because one note can be filed on several records.")
1228
+ .argument("<note_id>", "Note id.")
1229
+ .option("--body <body>", "Replacement note text.")
1230
+ .option("--title <title>", "Replacement title. Pass an empty string to clear it.")
1231
+ .option("--pin", "Pin the note.")
1232
+ .option("--unpin", "Unpin the note.")
1233
+ .option("--json", "Print a JSON envelope.")
1234
+ .action(async (noteId, options) => {
1235
+ await handleAsyncAction("crm notes update", options, () => {
1236
+ return requestOxygen(`/api/cli/crm/notes/${encodeURIComponent(noteId)}`, {
1237
+ method: "PATCH",
1238
+ body: readCrmNoteEditFields(options),
1239
+ });
1240
+ });
1241
+ }))
1242
+ .addCommand(new Command("delete")
1243
+ .description("Delete a note. Soft: the note stops showing its text while its place in the timeline stays, so the record still shows that a note was written and removed.")
1244
+ .argument("<note_id>", "Note id.")
1245
+ .option("--json", "Print a JSON envelope.")
1246
+ .action(async (noteId, options) => {
1247
+ await handleAsyncAction("crm notes delete", options, () => requestOxygen(`/api/cli/crm/notes/${encodeURIComponent(noteId)}`, { method: "DELETE" }));
1248
+ })))
1249
+ .addCommand(new Command("related")
1250
+ .description("Show the records linked to one CRM record, grouped by relationship with a true count per group. Address the record by row id OR by any of the object's identities \u2014 a company domain, a person's email or LinkedIn URL.")
1251
+ .argument("<object>", "CRM object slug, such as companies or people.")
1252
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1253
+ .option("--preview-limit <n>", "Linked records to preview per group. Defaults to 6, max 50. The count is always the real total.")
1254
+ .option("--json", "Print a JSON envelope.")
1255
+ .action(async (object, rowId, options) => {
1256
+ await handleAsyncAction("crm related", options, () => requestOxygen(buildCrmRelatedPath(object, rowId, options)));
1257
+ }))
1258
+ .addCommand(new Command("tag")
1259
+ .description("Add/remove workspace tags on one CRM record (Tags primitive — the same vocabulary as sequences, tables, conversations; see `oxygen tags list`). Delta semantics: --add unions, --remove subtracts, remove wins. Standard objects carry the tags attribute after `oxygen crm setup`.")
1260
+ .argument("<object>", "CRM object slug, such as companies or people.")
1261
+ .argument("<row_id>", "CRM record row id.")
1262
+ .option("--add <tags>", "Comma-separated tags to add.")
1263
+ .option("--remove <tags>", "Comma-separated tags to remove.")
1264
+ .option("--json", "Print a JSON envelope.")
1265
+ .action(async (object, rowId, options) => {
1266
+ await handleAsyncAction("crm tag", options, () => {
1267
+ const add = splitCommaList(options.add);
1268
+ const remove = splitCommaList(options.remove);
1269
+ if (add.length === 0 && remove.length === 0) {
1270
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
1271
+ }
1272
+ return requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tags`, {
1273
+ method: "POST",
1274
+ body: {
1275
+ ...(add.length > 0 ? { add } : {}),
1276
+ ...(remove.length > 0 ? { remove } : {}),
1277
+ },
1278
+ });
1279
+ });
1280
+ }))
1281
+ .addCommand(new Command("keep")
1282
+ .description("Keep one sample record as real. A new workspace starts with sample companies, CEOs and deals: their reads carry `_sample: true` (`sample: true` on `crm pipeline` deals) and pipeline totals leave them out. Keep one only when it is real; it then counts like any record. Linked records stay as they are: a kept sample deal's company and contact stay samples until each is kept. Free and idempotent, no undo.")
1283
+ .argument("<object>", "CRM object slug: companies, people or deals.")
1284
+ .argument("<record>", "Row id (`crm pipeline` lists each deal's), or an identity value such as stripe.com.")
1285
+ .option("--json", "Print a JSON envelope.")
1286
+ .action(async (object, record, options) => {
1287
+ await handleAsyncAction("crm keep", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(record)}/keep`, { method: "POST" }));
1288
+ }))
1289
+ .addCommand(new Command("relationships")
1290
+ .description("Define CRM relationships and manage record relationship edges.")
1291
+ .addCommand(new Command("define")
1292
+ .description("Define a relationship (and its relation column) from a custom object to any object. Defaults to dry-run.")
1293
+ .argument("<object>", "Source CRM object slug. Must be a custom object, such as projects.")
1294
+ .argument("<slug>", "Relationship slug — also the relation column key on the source, such as client.")
1295
+ .requiredOption("--target-object <object>", "Target CRM object slug, custom or standard (companies, people, deals, ...).")
1296
+ .option("--display-name <name>", "Display name for the relation column. Defaults to the slug in Title Case.")
1297
+ .option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many (source:target). Default many_to_many. one_to_many means each source record links many targets and each target links back to one source.")
1298
+ .option("--inverse-slug <slug>", "Inverse relationship slug on the target. On a custom target this also creates the inverse relation column.")
1299
+ .option("--inverse-display-name <name>", "Display name for the inverse side.")
1300
+ .option("--dry-run", "Preview the relationship definition without writing.")
1301
+ .option("--live", "Apply the definition. Default is dry-run.")
1302
+ .option("--json", "Print a JSON envelope.")
1303
+ .action(async (object, slug, options) => {
1304
+ await handleAsyncAction("crm relationships define", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/relationships`, {
1305
+ method: "POST",
1306
+ body: {
1307
+ relationship: {
1308
+ slug,
1309
+ target_object: options.targetObject,
1310
+ ...(options.displayName ? { display_name: options.displayName } : {}),
1311
+ ...(options.cardinality ? { cardinality: options.cardinality } : {}),
1312
+ ...(options.inverseSlug ? { inverse_slug: options.inverseSlug } : {}),
1313
+ ...(options.inverseDisplayName ? { inverse_display_name: options.inverseDisplayName } : {}),
1314
+ },
1315
+ mode: options.live ? "live" : "dry_run",
1316
+ },
1317
+ }));
1318
+ }))
1319
+ .addCommand(new Command("upsert")
1320
+ .description("Create or replace a CRM relationship edge. Defaults to dry-run.")
1321
+ .argument("<object>", "Source CRM object slug, such as companies or people.")
1322
+ .argument("<row_id>", "Source CRM record row id.")
1323
+ .requiredOption("--relationship <slug>", "Relationship slug from the source object, such as team or company.")
1324
+ .option("--target-object <object>", "Target CRM object slug. Optional when the relationship has one target.")
1325
+ .requiredOption("--target-row-id <row_id>", "Target CRM record row id.")
1326
+ .option("--role <role>", "Buying-committee role for a deals.stakeholders link: champion, economic_buyer, decision_maker, influencer, or end_user.")
1327
+ .option("--dry-run", "Preview the relationship write without changing edges.")
1328
+ .option("--live", "Apply the relationship write. Default is dry-run.")
1329
+ .option("--json", "Print a JSON envelope.")
1330
+ .action(async (object, rowId, options) => {
1331
+ await handleAsyncAction("crm relationships upsert", options, () => requestOxygen("/api/cli/crm/relationships/upsert", {
1332
+ method: "POST",
1333
+ body: buildCrmRelationshipUpsertBody(object, rowId, options),
1334
+ }));
1335
+ })))
1336
+ .addCommand(new Command("activity")
1337
+ .description("Log and read CRM record timeline activities.")
1338
+ .addCommand(new Command("log")
1339
+ .description("Log a timeline activity on a CRM record. Defaults to dry-run.")
1340
+ .argument("<object>", "CRM object slug, such as companies or people.")
1341
+ .argument("<row_id>", "CRM record row id.")
1342
+ .requiredOption("--type <type>", "Activity type, such as note, call, meeting_booked, stage_changed.")
1343
+ .option("--summary <summary>", "Short one-line summary of the activity.")
1344
+ .option("--body <body>", "Longer activity body or details.")
1345
+ .option("--channel <channel>", "Channel, such as email, linkedin, or phone.")
1346
+ .option("--direction <direction>", "inbound, outbound, internal, or system.")
1347
+ .option("--occurred-at <iso>", "ISO-8601 timestamp the activity occurred. Defaults to now.")
1348
+ .option("--role <role>", "Link role for the record. Defaults to actor.")
1349
+ .option("--source-provider <provider>", "Provider that produced the activity (for idempotency).")
1350
+ .option("--provider-event-id <id>", "Provider event id (dedupes redelivered events).")
1351
+ .option("--metadata-json <json>", "JSON object of extra activity metadata.")
1352
+ .option("--dry-run", "Preview the activity without writing it.")
1353
+ .option("--live", "Write the activity. Default is dry-run.")
1354
+ .option("--json", "Print a JSON envelope.")
1355
+ .action(async (object, rowId, options) => {
1356
+ await handleAsyncAction("crm activity log", options, () => requestOxygen("/api/cli/crm/activities", {
1357
+ method: "POST",
1358
+ body: buildCrmActivityBody(object, rowId, options),
1359
+ }));
1360
+ }))
1361
+ .addCommand(new Command("note")
1362
+ .description("Log a free-text note on a CRM record. Defaults to dry-run.")
1363
+ .argument("<object>", "CRM object slug, such as companies or people.")
1364
+ .argument("<row_id>", "CRM record row id.")
1365
+ .argument("<text>", "Note text.")
1366
+ .option("--dry-run", "Preview the note without writing it.")
1367
+ .option("--live", "Write the note. Default is dry-run.")
1368
+ .option("--json", "Print a JSON envelope.")
1369
+ .action(async (object, rowId, text, options) => {
1370
+ await handleAsyncAction("crm activity note", options, () => requestOxygen("/api/cli/crm/activities", {
1371
+ method: "POST",
1372
+ body: buildCrmActivityNoteBody(object, rowId, text, options),
1373
+ }));
1374
+ }))
1375
+ .addCommand(new Command("timeline")
1376
+ .description("Show a CRM record's activity timeline, newest first. Address the record by row id OR by any of the object's identities \u2014 a company domain, a person's email or LinkedIn URL.")
1377
+ .argument("<object>", "CRM object slug, such as companies or people.")
1378
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
1379
+ .option("--limit <limit>", "Maximum activities to return.")
1380
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
1381
+ .option("--json", "Print a JSON envelope.")
1382
+ .action(async (object, rowId, options) => {
1383
+ await handleAsyncAction("crm activity timeline", options, () => requestOxygen(buildCrmTimelinePath(object, rowId, options)));
1384
+ })))
1385
+ .addCommand(new Command("describe")
1386
+ .description("Describe one configured CRM object: its fields, identities and relationships. Pass --field <key> to read just one field (its name, type, choices, flags) instead of the whole object. Custom slugs accept hyphens. A deleted custom object answers crm_object_not_found with state archived; its next step, `crm objects delete <slug> --dry-run`, shows the purge time.")
1387
+ .argument("<object>", "CRM object slug, such as companies or people.")
1388
+ .option("--field <key>", "Narrow the answer to one field by its key, e.g. --field pipeline_stage. Also accepted as --attribute.")
1389
+ .option("--attribute <key>", "Alias for --field.")
1390
+ .option("--json", "Print a JSON envelope.")
1391
+ .action(async (object, options) => {
1392
+ const field = readOption(options.field) ?? readOption(options.attribute);
1393
+ await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}${field ? `?attribute=${encodeURIComponent(field)}` : ""}`));
1394
+ }))
1395
+ .addCommand(new Command("duplicates")
1396
+ .description("List ranked duplicate-candidate record pairs for one CRM object (identity-overlap + fuzzy-label). Read-only.")
1397
+ .argument("<object>", "CRM object slug, such as companies or people.")
1398
+ .option("--limit <limit>", "Maximum candidate pairs to return. Defaults to 25, max 100.")
1399
+ .option("--json", "Print a JSON envelope.")
1400
+ .action(async (object, options) => {
1401
+ await handleAsyncAction("crm duplicates", options, () => requestOxygen(buildCrmDuplicatesPath(object, options)));
1402
+ }))
1403
+ .addCommand(new Command("merge")
1404
+ .description("Merge two CRM records: survivor-wins-with-loser-fill field write + relink/tombstone. Defaults to dry-run; --live requires --confirm.")
1405
+ .argument("<object>", "CRM object slug, such as companies or people.")
1406
+ .argument("<survivor_row_id>", "Row id of the record to KEEP (older record by default).")
1407
+ .argument("<loser_row_id>", "Row id of the record to MERGE AWAY and tombstone.")
1408
+ .option("--field-overrides-json <json>", "JSON object of resolved field values keyed by column key, overriding survivor-wins-with-loser-fill.")
1409
+ .option("--dry-run", "Preview the field-level diff and relink plan without writing. Default.")
1410
+ .option("--live", "Apply the merge. Requires --confirm.")
1411
+ .option("--confirm", "Confirm the live merge after inspecting the dry-run diff.")
1412
+ .option("--json", "Print a JSON envelope.")
1413
+ .action(async (object, survivorRowId, loserRowId, options) => {
1414
+ await handleAsyncAction("crm merge", options, () => requestOxygen("/api/cli/crm/merge", {
1415
+ method: "POST",
1416
+ body: buildCrmMergeBody(object, survivorRowId, loserRowId, options),
1417
+ }));
1418
+ }))
1419
+ .addCommand(new Command("pipeline")
1420
+ .description("Show the deals pipeline grouped by stage, with per-stage counts and amount totals. Seeded sample deals are listed with `sample: true` but never counted; `samples` reports them.")
1421
+ .option("--pipeline <name>", "Filter to one pipeline, such as sales, expansion, or renewal.")
1422
+ .option("--limit <limit>", "Maximum deals to return per stage.")
1423
+ .option("--json", "Print a JSON envelope.")
1424
+ .action(async (options) => {
1425
+ await handleAsyncAction("crm pipeline", options, () => requestOxygen(buildCrmPipelinePath(options)));
1426
+ }))
1427
+ .addCommand(new Command("signals")
1428
+ .description("Record inbound GTM intent signals and read the ranked leads-to-contact-today list.")
1429
+ .addCommand(new Command("record")
1430
+ .description("Record one inbound GTM signal (website_visit, profile_view, post_engager, new_follower) into the CRM. Internal write — free, no approval. Idempotent on --external-event-id.")
1431
+ .requiredOption("--event <event>", "Signal event: website_visit | profile_view | post_engager | new_follower.")
1432
+ .option("--external-event-id <id>", "Stable provider event id (redelivery/idempotency guard). Derived from --session-id when omitted.")
1433
+ .option("--email <email>", "Person email (identity).")
1434
+ .option("--linkedin-url <url>", "Person LinkedIn URL (identity when no email).")
1435
+ .option("--company-domain <domain>", "Company domain for the company assert.")
1436
+ .option("--company-name <name>", "Company name.")
1437
+ .option("--first-name <name>", "Person first name.")
1438
+ .option("--last-name <name>", "Person last name.")
1439
+ .option("--page-url <url>", "Page the signal fired on (website visit).")
1440
+ .option("--session-id <id>", "Provider session id (provenance + idempotency).")
1441
+ .option("--payload-json <json>", "JSON object merged into the signal payload (wins over the flags above).")
1442
+ .option("--json", "Print a JSON envelope.")
1443
+ .action(async (options) => {
1444
+ await handleAsyncAction("crm signals record", options, () => requestOxygen("/api/cli/crm/signals", {
1445
+ method: "POST",
1446
+ body: buildCrmSignalRecordBody(options),
1447
+ }));
1448
+ }))
1449
+ .addCommand(new Command("leads-today")
1450
+ .description("List people to contact today, ranked by their most recent GTM intent signal. When the output has rankedBy, equal signals are ordered by the company's ICP fit (see each lead's fit). Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
1451
+ .option("--limit <limit>", "Maximum leads to return. Defaults to 25, max 100.")
1452
+ .option("--within-days <days>", "Signal look-back window in days. Defaults to 7, max 30.")
1453
+ .option("--json", "Print a JSON envelope.")
1454
+ .action(async (options) => {
1455
+ await handleAsyncAction("crm signals leads-today", options, () => requestOxygen(buildCrmLeadsTodayPath(options)));
1456
+ })))
1457
+ .addCommand(new Command("automation")
1458
+ .description("Arm / disarm standing CRM automation templates and read the audit trail.")
1459
+ .addCommand(new Command("rules")
1460
+ .description("List the standing CRM automation templates and their armed state.")
1461
+ .option("--json", "Print a JSON envelope.")
1462
+ .action(async (options) => {
1463
+ await handleAsyncAction("crm automation rules", options, () => requestOxygen("/api/cli/crm/automation"));
1464
+ }))
1465
+ .addCommand(new Command("set")
1466
+ .description("Arm or disarm a standing CRM automation template. Defaults to dry-run. crm-lead-stage-router (replies → CRM person + lead stage) is armed automatically the first time a sequence goes live; --disarmed is the explicit off switch, and a disarmed template is never re-armed automatically.")
1467
+ .argument("<template-id>", "Template id, such as crm-lead-stage-router.")
1468
+ .option("--armed", "Arm the template (let bridged signals advance stages).")
1469
+ .option("--disarmed", "Disarm the template.")
1470
+ .option("--config-json <json>", "JSON object of per-template config.")
1471
+ .option("--dry-run", "Preview the change without writing it.")
1472
+ .option("--live", "Write the change. Default is dry-run.")
1473
+ .option("--approved", "Approve standing tool authority when arming live after inspecting the dry-run preview.")
1474
+ .option("--max-credits <n>", "Positive credit ceiling for each event delivery when arming live.")
1475
+ .option("--json", "Print a JSON envelope.")
1476
+ .action(async (templateId, options) => {
1477
+ await handleAsyncAction("crm automation set", options, () => requestOxygen("/api/cli/crm/automation", {
1478
+ method: "POST",
1479
+ body: buildCrmAutomationSetBody(templateId, options),
1480
+ }));
1481
+ }))
1482
+ .addCommand(new Command("audit")
1483
+ .description("Show recent CRM automation rule changes, newest first.")
1484
+ .option("--template-id <id>", "Filter to one template id.")
1485
+ .option("--limit <limit>", "Maximum audit entries to return.")
1486
+ .option("--json", "Print a JSON envelope.")
1487
+ .action(async (options) => {
1488
+ await handleAsyncAction("crm automation audit", options, () => requestOxygen(buildCrmAutomationAuditPath(options)));
1489
+ })))
1490
+ .addCommand(new Command("sync")
1491
+ .description("Sync or import a connected CRM with Oxygen CRM objects: import records (HubSpot/Attio/Pipedrive), configure a standing two-way sync (HubSpot/Attio), run a reconciliation cycle, and inspect runs and provider links.")
1492
+ .addCommand(new Command("import")
1493
+ .description("Import provider records into an Oxygen CRM object (canonical CRM truth) as a durable workflow run. Defaults to dry-run. This pulls the WHOLE object and has no list/segment filter; to import one HubSpot saved list or one Pipedrive saved filter into a new table you can filter and enrich, use `oxygen tables hubspot import` or `oxygen tables pipedrive import` instead.")
1494
+ .argument("<provider>", "Connected CRM to import from: hubspot, attio, or pipedrive.")
1495
+ .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, attio people|companies, or pipedrive persons|organizations|deals. Import Pipedrive persons and organizations before deals so each deal links to them.")
1496
+ .option("--into <object>", "Oxygen CRM object slug to import into. Defaults to the natural mapping.")
1497
+ .option("--limit <n>", "Max records to pull this run.")
1498
+ .option("--max-credits <n>", "OXYGEN credit ceiling for the run. Review the current quote: own-API-key calls cost 0.05 credits each after repricing takes effect; OAuth follows its tool quote. Workflow steps count toward the ceiling after repricing takes effect. Use --limit for rows; provider billing and API quota are separate.")
1499
+ .option("--approved", "Confirm a live import after inspecting a dry run.")
1500
+ .option("--dry-run", "Preview the import without pulling. Default.")
1501
+ .option("--live", "Run the live import. Requires --approved and --max-credits.")
1502
+ .option("--json", "Print a JSON envelope.")
1503
+ .action(async (provider, options) => {
1504
+ await handleAsyncAction("crm sync import", options, () => requestOxygen("/api/cli/crm/sync/import", {
1505
+ method: "POST",
1506
+ body: buildCrmSyncImportBody(provider, options),
1507
+ }));
1508
+ }))
1509
+ .addCommand(new Command("configure")
1510
+ .description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle; inbound and bidirectional previews perform bounded CRM provider reads and may use external API quota.")
1511
+ .requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
1512
+ .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1513
+ .option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
1514
+ .option("--cron <cron>", "Cron expression for the scheduled reconciliation cadence, e.g. '*/15 * * * *'.")
1515
+ .option("--timezone <tz>", "IANA timezone the cron schedule runs in, e.g. America/New_York. Defaults to UTC.")
1516
+ .option("--max-rows <n>", "Per-cycle row cap, separate from the --max-credits OXYGEN spend ceiling. OAuth provider calls follow their tool quote; own-API-key calls and live Workflow steps have separate charges.")
1517
+ .option("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
1518
+ .option("--field-mappings-json <json>", "HubSpot custom-property mappings as a JSON array of {oxygen_field, provider_field, direction}. Pass [] to clear; canonical identity/name mappings stay fixed.")
1519
+ .option("--approved", "Confirm a live configuration after inspecting a dry run.")
1520
+ .option("--dry-run", "Preview the configuration and next cycle without writing. Inbound/bidirectional previews perform bounded CRM reads and may use external API quota. Default.")
1521
+ .option("--live", "Write the configuration. Requires --approved and --max-credits.")
1522
+ .option("--json", "Print a JSON envelope.")
1523
+ .action(async (options) => {
1524
+ await handleAsyncAction("crm sync configure", options, () => requestOxygen("/api/cli/crm/sync/configure", {
1525
+ method: "POST",
1526
+ body: buildCrmSyncConfigureBody(options),
1527
+ }));
1528
+ }))
1529
+ .addCommand(new Command("run")
1530
+ .description("Run one bidirectional reconciliation cycle for a configured CRM object pair as a durable workflow run. Defaults to dry-run.")
1531
+ .requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
1532
+ .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1533
+ .option("--direction <direction>", "Override this cycle's direction: inbound, outbound, or bidirectional.")
1534
+ .option("--since <iso>", "Read provider changes since this watermark.")
1535
+ .option("--config-id <id>", "Use this standing CRM sync configuration for the cycle.")
1536
+ .option("--max-rows <n>", "Per-cycle row cap, separate from the --max-credits OXYGEN spend ceiling. OAuth provider calls follow their tool quote; own-API-key calls and live Workflow steps have separate charges.")
1537
+ .option("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
1538
+ .option("--approved", "Confirm a live cycle after inspecting a dry run.")
1539
+ .option("--dry-run", "Preview the cycle without pulling or pushing. Default.")
1540
+ .option("--live", "Run the live reconciliation cycle. Requires --approved and --max-credits.")
1541
+ .option("--json", "Print a JSON envelope.")
1542
+ .action(async (options) => {
1543
+ await handleAsyncAction("crm sync run", options, () => requestOxygen("/api/cli/crm/sync/run", {
1544
+ method: "POST",
1545
+ body: buildCrmSyncRunBody(options),
1546
+ }));
1547
+ }))
1548
+ .addCommand(new Command("enable")
1549
+ .description("Enable continuous (scheduled) bidirectional sync for one CRM object pair: arms the cron trigger + the standing write permission, and runs an immediate first cycle. Always live — requires --approved, --max-credits, and --max-rows.")
1550
+ .requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
1551
+ .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1552
+ .option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
1553
+ .option("--cron <cron>", "Cron expression for the cadence, e.g. '*/15 * * * *'. Defaults to every 15 minutes.")
1554
+ .option("--timezone <tz>", "IANA timezone the cron runs in. Defaults to UTC.")
1555
+ .requiredOption("--max-rows <n>", "Per-cycle row cap — the volume brake on provider writes (required).")
1556
+ .requiredOption("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
1557
+ .option("--field-mappings-json <json>", "HubSpot custom-property mappings as a JSON array of {oxygen_field, provider_field, direction}. Omit to preserve the configured mappings.")
1558
+ .option("--approved", "Confirm enabling scheduled provider writes after inspecting a dry run.")
1559
+ .option("--no-run-now", "Do not run an immediate first cycle after enabling.")
1560
+ .option("--json", "Print a JSON envelope.")
1561
+ .action(async (options) => {
1562
+ await handleAsyncAction("crm sync enable", options, () => requestOxygen("/api/cli/crm/sync/enable", {
1563
+ method: "POST",
1564
+ body: buildCrmSyncEnableBody(options),
1565
+ }));
1566
+ }))
1567
+ .addCommand(new Command("disable")
1568
+ .description("Disable the scheduled sync for one CRM object pair: stops the cron trigger and revokes the standing write permission. The standing config (direction, cadence, mappings, watermarks) is preserved for re-enable.")
1569
+ .requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
1570
+ .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1571
+ .option("--json", "Print a JSON envelope.")
1572
+ .action(async (options) => {
1573
+ await handleAsyncAction("crm sync disable", options, () => requestOxygen("/api/cli/crm/sync/disable", {
1574
+ method: "POST",
1575
+ body: buildCrmSyncDisableBody(options),
1576
+ }));
1577
+ }))
1578
+ .addCommand(new Command("status")
1579
+ .description("Show a CRM sync status: pass --run-id for one workflow run, or --provider/--object for the standing sync config (cadence, watermarks, last-cycle counts).")
1580
+ .argument("[run_id]", "Workflow run id. Equivalent to --run-id.")
1581
+ .option("--run-id <id>", "Workflow run id to read a single run.")
1582
+ .option("--provider <provider>", "Connected CRM (hubspot|attio) for a standing-config read. Use with --object.")
1583
+ .option("--object <object>", "Provider object for a standing-config read. Use with --provider.")
1584
+ .option("--json", "Print a JSON envelope.")
1585
+ .action(async (runId, options) => {
1586
+ const resolved = {
1587
+ ...options,
1588
+ ...(runId !== undefined && readOption(options.runId) === null ? { runId } : {}),
1589
+ };
1590
+ await handleAsyncAction("crm sync status", resolved, () => requestOxygen(buildCrmSyncConfigStatusPath(resolved)));
1591
+ }))
1592
+ .addCommand(new Command("links")
1593
+ .description("Show provider record links (HubSpot/Attio/Pipedrive ids) mapped to one Oxygen CRM record.")
1594
+ .argument("<object>", "Oxygen CRM object slug, such as companies or people.")
1595
+ .argument("<row_id>", "Oxygen CRM record row id.")
1596
+ .option("--json", "Print a JSON envelope.")
1597
+ .action(async (object, rowId, options) => {
1598
+ await handleAsyncAction("crm sync links", options, () => requestOxygen(buildCrmSyncLinksPath(object, rowId)));
1599
+ })))
1600
+ .addCommand(new Command("enrichment")
1601
+ .description("Choose which named enrichments run automatically on new CRM records. The saved set is a STANDING PERMISSION: every record written to the object (imports, inserts, upserts, and `crm assert`) enriches without per-record approval, bounded by the per-batch credit ceiling. Same storage as `oxygen tables auto-run`, in founder words.")
1602
+ .addCommand(addContractFlags(new Command("list")
1603
+ .description("Show the enrichments available per CRM object: which are armed, what each costs per record, the per-record total, the per-batch credit ceiling, how much of the column allowance is spent, and how many records already in the object have never been enriched. Read-only, free.")
1604
+ .option("--json", "Print a JSON envelope."), CRM_ENRICHMENT_READ_CONTRACT, CRM_ENRICHMENT_READ_FLAGS)
1605
+ .action(async (options) => {
1606
+ if (options.projection === "summary" || options.projection === "value")
1607
+ await handleAsyncAction("crm enrichment list", options, () => requestOxygen(buildCrmEnrichmentListPath(options)));
1608
+ else
1609
+ await handleCrmEnrichmentAction("crm enrichment list", options, () => requestOxygen(buildCrmEnrichmentListPath(options)));
1610
+ }))
1611
+ .addCommand(new Command("backfill")
1612
+ .description("Enrich the records you ALREADY have. Arming an enrichment only covers records written from that moment on, so the companies and people already in your CRM stay blank until you run this. Previews for free by default: what is still missing, what one batch costs, and what finishing costs. Add --live --approved --max-credits to actually run a batch; repeat it and the next batch picks up where the last one stopped.")
1613
+ .requiredOption("--object <object>", "CRM object slug: companies or people.")
1614
+ .option("--presets <csv>", "Comma-separated enrichment ids to backfill. Defaults to whatever is armed on the object.")
1615
+ .option("--limit <n>", "How many records this batch enriches. Defaults to 50, max 5000.")
1616
+ .option("--all", "Enrich every record still missing these enrichments, up to 5000 in one batch. Costs the whole `to finish` figure the preview quotes.")
1617
+ .option("--live", "Actually run the batch. Without it this is a free preview that spends nothing.")
1618
+ .option("--approved", "Approve THIS batch after reading the preview. A live paid batch fails with approval_required (exit 7) without it.")
1619
+ .option("--max-credits <n>", "Credit ceiling for this batch. Records beyond it are skipped with credit_limit_reached — the preview says how many that would be. Required for a live paid batch; unused credits are not spent.")
1620
+ .option("--json", "Print a JSON envelope.")
1621
+ .action(async (options) => {
1622
+ await handleCrmEnrichmentBackfill(options);
1623
+ }))
1624
+ .addCommand(new Command("enable")
1625
+ .description("Arm one enrichment on a CRM object and leave every other choice untouched. Takes effect immediately as a standing permission: new records enrich without per-record approval, capped per batch by the object's credit ceiling.")
1626
+ .argument("<preset>", "Enrichment id from `oxygen crm enrichment list`, such as company_linkedin_profile.")
1627
+ .requiredOption("--object <object>", "CRM object slug: companies or people.")
1628
+ .option("--json", "Print a JSON envelope.")
1629
+ .action(async (preset, options) => {
1630
+ await handleCrmEnrichmentAction("crm enrichment enable", options, () => requestOxygen("/api/cli/crm/enrichment", {
1631
+ method: "POST",
1632
+ body: buildCrmEnrichmentDeltaBody(preset, options, "enable"),
1633
+ }));
1634
+ }))
1635
+ .addCommand(new Command("disable")
1636
+ .description("Disarm one enrichment on a CRM object and leave every other choice untouched. New records stop running its columns; already-written values stay.")
1637
+ .argument("<preset>", "Enrichment id from `oxygen crm enrichment list`.")
1638
+ .requiredOption("--object <object>", "CRM object slug: companies or people.")
1639
+ .option("--json", "Print a JSON envelope.")
1640
+ .action(async (preset, options) => {
1641
+ await handleCrmEnrichmentAction("crm enrichment disable", options, () => requestOxygen("/api/cli/crm/enrichment", {
1642
+ method: "POST",
1643
+ body: buildCrmEnrichmentDeltaBody(preset, options, "disable"),
1644
+ }));
1645
+ }))
1646
+ .addCommand(new Command("set")
1647
+ .description("Replace the armed enrichment set for a CRM object outright — any preset not listed is disarmed, while columns you added to the config yourself are kept. The saved set is a standing permission: new records enrich without per-record approval, and --max-credits is the ceiling each write batch may spend. An empty --presets list is the full disarm that `off` spells.")
1648
+ .requiredOption("--object <object>", "CRM object slug: companies or people.")
1649
+ .requiredOption("--presets <csv>", "Comma-separated enrichment ids to arm. Pass an empty value to disarm everything (same as `crm enrichment off`).")
1650
+ .option("--max-credits <n|auto>", "Credit ceiling PER enqueued batch; records beyond it are skipped with credit_limit_reached. Pass auto to fall back to your plan's per-delivery default. Omit to keep the current ceiling.")
1651
+ .option("--force", "Re-run the enrichment columns even when the target cell already has a value.")
1652
+ .option("--json", "Print a JSON envelope.")
1653
+ .action(async (options) => {
1654
+ await handleCrmEnrichmentAction("crm enrichment set", options, () => requestOxygen("/api/cli/crm/enrichment", {
1655
+ method: "POST",
1656
+ body: buildCrmEnrichmentSetBody(options),
1657
+ }));
1658
+ }))
1659
+ .addCommand(new Command("off")
1660
+ .description("Turn automatic enrichment off for a CRM object: revokes the standing permission and clears the WHOLE configuration — every named preset plus any columns you added to it yourself, so nothing keeps spending after you turn it off. New records are written without enrichment; existing values stay. To drop one preset and keep the rest, use `disable` instead.")
1661
+ .requiredOption("--object <object>", "CRM object slug: companies or people.")
1662
+ .option("--json", "Print a JSON envelope.")
1663
+ .action(async (options) => {
1664
+ await handleCrmEnrichmentAction("crm enrichment off", options, () => requestOxygen("/api/cli/crm/enrichment", {
1665
+ method: "POST",
1666
+ body: { object: readOption(options.object) ?? "", presets: [] },
1667
+ }));
1668
+ })));
1669
+ }