@oxygen-agent/cli 1.1014.135 → 1.1014.618

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.js +27 -19
  3. package/dist/cli-output.d.ts +12 -0
  4. package/dist/cli-output.js +395 -0
  5. package/dist/cli-paths.d.ts +1 -0
  6. package/dist/cli-paths.js +3 -0
  7. package/dist/cli-profile-context.d.ts +3 -0
  8. package/dist/cli-profile-context.js +13 -0
  9. package/dist/command-manifest.d.ts +15 -0
  10. package/dist/command-manifest.js +23 -1
  11. package/dist/command-support.d.ts +438 -0
  12. package/dist/command-support.js +1610 -0
  13. package/dist/commands/action-column.d.ts +3 -0
  14. package/dist/commands/action-column.js +43 -0
  15. package/dist/commands/activation.d.ts +3 -0
  16. package/dist/commands/activation.js +24 -0
  17. package/dist/commands/admin.d.ts +3 -0
  18. package/dist/commands/admin.js +347 -0
  19. package/dist/commands/ads.d.ts +3 -0
  20. package/dist/commands/ads.js +62 -0
  21. package/dist/commands/agent.d.ts +3 -0
  22. package/dist/commands/agent.js +274 -0
  23. package/dist/commands/api-keys.d.ts +3 -0
  24. package/dist/commands/api-keys.js +77 -0
  25. package/dist/commands/approvals.d.ts +3 -0
  26. package/dist/commands/approvals.js +518 -0
  27. package/dist/commands/auth.d.ts +3 -0
  28. package/dist/commands/auth.js +230 -0
  29. package/dist/commands/billing.d.ts +3 -0
  30. package/dist/commands/billing.js +357 -0
  31. package/dist/commands/blueprints.d.ts +3 -0
  32. package/dist/commands/blueprints.js +312 -0
  33. package/dist/commands/budget.d.ts +3 -0
  34. package/dist/commands/budget.js +55 -0
  35. package/dist/commands/callables.d.ts +3 -0
  36. package/dist/commands/callables.js +111 -0
  37. package/dist/commands/capabilities.d.ts +3 -0
  38. package/dist/commands/capabilities.js +44 -0
  39. package/dist/commands/cells.d.ts +3 -0
  40. package/dist/commands/cells.js +94 -0
  41. package/dist/commands/columns.d.ts +3 -0
  42. package/dist/commands/columns.js +1628 -0
  43. package/dist/commands/commands.d.ts +3 -0
  44. package/dist/commands/commands.js +45 -0
  45. package/dist/commands/comments.d.ts +3 -0
  46. package/dist/commands/comments.js +188 -0
  47. package/dist/commands/companies.d.ts +3 -0
  48. package/dist/commands/companies.js +312 -0
  49. package/dist/commands/connections.d.ts +3 -0
  50. package/dist/commands/connections.js +39 -0
  51. package/dist/commands/context.d.ts +3 -0
  52. package/dist/commands/context.js +170 -0
  53. package/dist/commands/copilot.d.ts +3 -0
  54. package/dist/commands/copilot.js +565 -0
  55. package/dist/commands/crm.d.ts +3 -0
  56. package/dist/commands/crm.js +1669 -0
  57. package/dist/commands/custom-integrations.d.ts +3 -0
  58. package/dist/commands/custom-integrations.js +140 -0
  59. package/dist/commands/dashboards.d.ts +3 -0
  60. package/dist/commands/dashboards.js +240 -0
  61. package/dist/commands/db.d.ts +3 -0
  62. package/dist/commands/db.js +190 -0
  63. package/dist/commands/deliverability.d.ts +3 -0
  64. package/dist/commands/deliverability.js +90 -0
  65. package/dist/commands/directory.d.ts +3 -0
  66. package/dist/commands/directory.js +101 -0
  67. package/dist/commands/domains.d.ts +3 -0
  68. package/dist/commands/domains.js +604 -0
  69. package/dist/commands/egress.d.ts +3 -0
  70. package/dist/commands/egress.js +130 -0
  71. package/dist/commands/email.d.ts +3 -0
  72. package/dist/commands/email.js +49 -0
  73. package/dist/commands/engagement.d.ts +3 -0
  74. package/dist/commands/engagement.js +189 -0
  75. package/dist/commands/enrich-column.d.ts +3 -0
  76. package/dist/commands/enrich-column.js +149 -0
  77. package/dist/commands/enrichment.d.ts +3 -0
  78. package/dist/commands/enrichment.js +49 -0
  79. package/dist/commands/feedback.d.ts +3 -0
  80. package/dist/commands/feedback.js +96 -0
  81. package/dist/commands/feeds.d.ts +3 -0
  82. package/dist/commands/feeds.js +486 -0
  83. package/dist/commands/find.d.ts +3 -0
  84. package/dist/commands/find.js +159 -0
  85. package/dist/commands/followers.d.ts +3 -0
  86. package/dist/commands/followers.js +39 -0
  87. package/dist/commands/formulas.d.ts +3 -0
  88. package/dist/commands/formulas.js +43 -0
  89. package/dist/commands/home.d.ts +3 -0
  90. package/dist/commands/home.js +22 -0
  91. package/dist/commands/inbox.d.ts +3 -0
  92. package/dist/commands/inbox.js +779 -0
  93. package/dist/commands/integrations.d.ts +3 -0
  94. package/dist/commands/integrations.js +290 -0
  95. package/dist/commands/knowledge.d.ts +3 -0
  96. package/dist/commands/knowledge.js +874 -0
  97. package/dist/commands/lead-sourcing.d.ts +3 -0
  98. package/dist/commands/lead-sourcing.js +52 -0
  99. package/dist/commands/limits.d.ts +3 -0
  100. package/dist/commands/limits.js +54 -0
  101. package/dist/commands/linkedin.d.ts +3 -0
  102. package/dist/commands/linkedin.js +275 -0
  103. package/dist/commands/login.d.ts +3 -0
  104. package/dist/commands/login.js +347 -0
  105. package/dist/commands/logout.d.ts +3 -0
  106. package/dist/commands/logout.js +12 -0
  107. package/dist/commands/mailboxes.d.ts +3 -0
  108. package/dist/commands/mailboxes.js +1290 -0
  109. package/dist/commands/managed-inboxes.d.ts +3 -0
  110. package/dist/commands/managed-inboxes.js +361 -0
  111. package/dist/commands/messages.d.ts +3 -0
  112. package/dist/commands/messages.js +78 -0
  113. package/dist/commands/notetaker.d.ts +3 -0
  114. package/dist/commands/notetaker.js +79 -0
  115. package/dist/commands/observability.d.ts +3 -0
  116. package/dist/commands/observability.js +125 -0
  117. package/dist/commands/orgs.d.ts +3 -0
  118. package/dist/commands/orgs.js +237 -0
  119. package/dist/commands/people.d.ts +3 -0
  120. package/dist/commands/people.js +353 -0
  121. package/dist/commands/posts.d.ts +3 -0
  122. package/dist/commands/posts.js +139 -0
  123. package/dist/commands/profiles.d.ts +3 -0
  124. package/dist/commands/profiles.js +250 -0
  125. package/dist/commands/projects.d.ts +3 -0
  126. package/dist/commands/projects.js +94 -0
  127. package/dist/commands/prompts.d.ts +3 -0
  128. package/dist/commands/prompts.js +84 -0
  129. package/dist/commands/publishing.d.ts +3 -0
  130. package/dist/commands/publishing.js +1419 -0
  131. package/dist/commands/recipes.d.ts +3 -0
  132. package/dist/commands/recipes.js +120 -0
  133. package/dist/commands/reviews.d.ts +3 -0
  134. package/dist/commands/reviews.js +75 -0
  135. package/dist/commands/rows.d.ts +3 -0
  136. package/dist/commands/rows.js +27 -0
  137. package/dist/commands/runs.d.ts +3 -0
  138. package/dist/commands/runs.js +37 -0
  139. package/dist/commands/search.d.ts +3 -0
  140. package/dist/commands/search.js +182 -0
  141. package/dist/commands/senders.d.ts +3 -0
  142. package/dist/commands/senders.js +393 -0
  143. package/dist/commands/sequences.d.ts +3 -0
  144. package/dist/commands/sequences.js +2322 -0
  145. package/dist/commands/session.d.ts +3 -0
  146. package/dist/commands/session.js +109 -0
  147. package/dist/commands/signals.d.ts +3 -0
  148. package/dist/commands/signals.js +289 -0
  149. package/dist/commands/signup-leads.d.ts +3 -0
  150. package/dist/commands/signup-leads.js +46 -0
  151. package/dist/commands/skills.d.ts +3 -0
  152. package/dist/commands/skills.js +50 -0
  153. package/dist/commands/sourcing.d.ts +3 -0
  154. package/dist/commands/sourcing.js +27 -0
  155. package/dist/commands/status.d.ts +3 -0
  156. package/dist/commands/status.js +51 -0
  157. package/dist/commands/supabase.d.ts +3 -0
  158. package/dist/commands/supabase.js +128 -0
  159. package/dist/commands/support.d.ts +3 -0
  160. package/dist/commands/support.js +651 -0
  161. package/dist/commands/suppressions.d.ts +3 -0
  162. package/dist/commands/suppressions.js +577 -0
  163. package/dist/commands/table-ingestions.d.ts +3 -0
  164. package/dist/commands/table-ingestions.js +145 -0
  165. package/dist/commands/table-runs.d.ts +3 -0
  166. package/dist/commands/table-runs.js +277 -0
  167. package/dist/commands/tables.d.ts +3 -0
  168. package/dist/commands/tables.js +3492 -0
  169. package/dist/commands/tags.d.ts +3 -0
  170. package/dist/commands/tags.js +147 -0
  171. package/dist/commands/tools.d.ts +3 -0
  172. package/dist/commands/tools.js +246 -0
  173. package/dist/commands/update.d.ts +3 -0
  174. package/dist/commands/update.js +106 -0
  175. package/dist/commands/verify.d.ts +3 -0
  176. package/dist/commands/verify.js +27 -0
  177. package/dist/commands/viewers.d.ts +3 -0
  178. package/dist/commands/viewers.js +72 -0
  179. package/dist/commands/voice.d.ts +3 -0
  180. package/dist/commands/voice.js +351 -0
  181. package/dist/commands/whatsapp.d.ts +3 -0
  182. package/dist/commands/whatsapp.js +226 -0
  183. package/dist/commands/whoami.d.ts +3 -0
  184. package/dist/commands/whoami.js +110 -0
  185. package/dist/commands/worker.d.ts +3 -0
  186. package/dist/commands/worker.js +60 -0
  187. package/dist/commands/workflows.d.ts +3 -0
  188. package/dist/commands/workflows.js +1752 -0
  189. package/dist/commands/workspace.d.ts +3 -0
  190. package/dist/commands/workspace.js +94 -0
  191. package/dist/compact-list-flags.d.ts +7 -0
  192. package/dist/compact-list-flags.js +12 -0
  193. package/dist/crm-support-flags.js +14 -5
  194. package/dist/customer-operation-commands.js +8 -6
  195. package/dist/diagnostic-uploader.d.ts +3 -1
  196. package/dist/diagnostic-uploader.js +29 -8
  197. package/dist/help.d.ts +5 -0
  198. package/dist/help.js +22 -0
  199. package/dist/index.d.ts +98 -1
  200. package/dist/index.js +331 -30719
  201. package/dist/local-module-loader.d.ts +4 -0
  202. package/dist/local-module-loader.js +156 -0
  203. package/dist/visual-commands.js +9 -5
  204. package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
  205. package/node_modules/@oxygen/shared/dist/agent-session-schedule.d.ts +36 -0
  206. package/node_modules/@oxygen/shared/dist/agent-session-schedule.js +69 -0
  207. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +14 -5
  208. package/node_modules/@oxygen/shared/dist/agent-sessions.js +14 -3
  209. package/node_modules/@oxygen/shared/dist/capability-discovery.js +175 -14
  210. package/node_modules/@oxygen/shared/dist/compact-list-result.d.ts +6 -0
  211. package/node_modules/@oxygen/shared/dist/compact-list-result.js +27 -0
  212. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +12 -1
  213. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +5 -2
  214. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
  215. package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.d.ts +57 -0
  216. package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.js +23 -0
  217. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +52 -0
  218. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -2
  219. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +17 -14
  220. package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.js +1 -1
  221. package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.d.ts +49 -0
  222. package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.js +14 -0
  223. package/node_modules/@oxygen/shared/dist/contracts/knowledge-sync.js +1 -1
  224. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +60 -7
  225. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +9 -1
  226. package/node_modules/@oxygen/shared/dist/contracts/operations.js +13 -5
  227. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -1
  228. package/node_modules/@oxygen/shared/dist/contracts/teammates.d.ts +16 -0
  229. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +115 -0
  230. package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +21 -3
  231. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +31 -1
  232. package/node_modules/@oxygen/shared/dist/cost-estimate.js +56 -15
  233. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +2 -0
  234. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +2 -0
  235. package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +71 -44
  236. package/node_modules/@oxygen/shared/dist/diagnostic-spool.d.ts +18 -0
  237. package/node_modules/@oxygen/shared/dist/diagnostic-spool.js +348 -0
  238. package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.d.ts +10 -0
  239. package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.js +42 -0
  240. package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +1 -1
  241. package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +59 -213
  242. package/node_modules/@oxygen/shared/dist/diagnostics.js +3 -2
  243. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +16 -5
  244. package/node_modules/@oxygen/shared/dist/email-dsn.js +97 -1
  245. package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +5 -0
  246. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +4 -5
  247. package/node_modules/@oxygen/shared/dist/file-import.js +5 -2
  248. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +30 -0
  249. package/node_modules/@oxygen/shared/dist/hosted-ai.js +20 -0
  250. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -2
  251. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +17 -7
  252. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +81 -17
  253. package/node_modules/@oxygen/shared/dist/langfuse.js +8 -4
  254. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +1 -1
  255. package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +6 -3
  256. package/node_modules/@oxygen/shared/dist/mailbox-import.js +9 -9
  257. package/node_modules/@oxygen/shared/dist/mcp-server-info.d.ts +8 -0
  258. package/node_modules/@oxygen/shared/dist/mcp-server-info.js +10 -0
  259. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +10 -0
  260. package/node_modules/@oxygen/shared/dist/object-storage.js +14 -0
  261. package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +5 -0
  262. package/node_modules/@oxygen/shared/dist/plan-limits-view.js +17 -1
  263. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
  264. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
  265. package/node_modules/@oxygen/shared/dist/provider-selection.d.ts +15 -0
  266. package/node_modules/@oxygen/shared/dist/provider-selection.js +3 -0
  267. package/node_modules/@oxygen/shared/dist/repricing.d.ts +1 -1
  268. package/node_modules/@oxygen/shared/dist/repricing.js +2 -2
  269. package/node_modules/@oxygen/shared/dist/schedule-label.d.ts +6 -0
  270. package/node_modules/@oxygen/shared/dist/schedule-label.js +12 -0
  271. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +1 -0
  272. package/node_modules/@oxygen/shared/dist/sequence-failures.js +20 -6
  273. package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +55 -0
  274. package/node_modules/@oxygen/shared/dist/teammate-groups.js +74 -0
  275. package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +52 -0
  276. package/node_modules/@oxygen/shared/dist/teammate-messages.js +88 -0
  277. package/node_modules/@oxygen/shared/dist/teammate-notes.d.ts +58 -0
  278. package/node_modules/@oxygen/shared/dist/teammate-notes.js +73 -0
  279. package/node_modules/@oxygen/shared/dist/teammate-routines.d.ts +95 -0
  280. package/node_modules/@oxygen/shared/dist/teammate-routines.js +266 -0
  281. package/node_modules/@oxygen/shared/dist/teammate-tasks.d.ts +56 -0
  282. package/node_modules/@oxygen/shared/dist/teammate-tasks.js +114 -0
  283. package/node_modules/@oxygen/shared/dist/teammates.d.ts +78 -0
  284. package/node_modules/@oxygen/shared/dist/teammates.js +140 -0
  285. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +120 -0
  286. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  287. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  288. package/node_modules/@oxygen/shared/dist/visual-render.d.ts +9 -1
  289. package/node_modules/@oxygen/shared/dist/visual-render.js +12 -5
  290. package/node_modules/@oxygen/shared/dist/warmup-metrics.d.ts +5 -0
  291. package/node_modules/@oxygen/shared/dist/warmup-metrics.js +44 -0
  292. package/package.json +1 -1
@@ -0,0 +1,1752 @@
1
+ import { addCompactListReadFlags, appendCompactListReadQuery } from "../compact-list-flags.js";
2
+ import { emitCliFailure, writeJson } from "../cli-output.js";
3
+ import { CLI_MODULE_DIR } from "../cli-paths.js";
4
+ import { parseJsonObject, readPositiveInt, readRecordString } from "../cli-values.js";
5
+ import { collectMultiple, formatDisabledWorkflowNotices, parseJsonValue, readCsvOption, readPositiveNumber, writeListCapNotice } from "../command-support.js";
6
+ import { addContractFlags } from "../contract-flags.js";
7
+ import { requestOxygen } from "../http-client.js";
8
+ import { importJavaScriptWorkflowModule, importTranspiledWorkflowModule, loadRecipeFromBundle } from "../local-module-loader.js";
9
+ import { waitForCliRun } from "../run-wait.js";
10
+ import { isRecord, readOption } from "../util.js";
11
+ import { formatWorkflowPlanLimitNotices } from "../workflow-plan-limit-notices.js";
12
+ import { WORKFLOW_RUN_LIST_FLAG_OVERLAY, readWorkflowRunListFlags } from "../workflow-run-list-flags.js";
13
+ import { OxygenError, success } from "@oxygen/shared/cli-result";
14
+ import { WORKFLOW_RUN_LIST_CONTRACT, workflowRunListQueryParams } from "@oxygen/shared/contracts/workflow-run-list";
15
+ import { MAX_MCP_TOOL_NAME_LENGTH, workflowMcpToolName } from "@oxygen/shared/workflow-mcp-tools";
16
+ import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION } from "@oxygen/workflows";
17
+ import { Command, Option } from "commander";
18
+ import { createHash, randomUUID } from "node:crypto";
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { basename, dirname, extname, join, resolve } from "node:path";
21
+ const WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS = 600;
22
+ const WORKFLOW_TAIL_DEFAULT_INTERVAL_SECONDS = 2;
23
+ const RECIPE_ESBUILD_NODE_PATHS = [
24
+ resolve("node_modules"),
25
+ resolve("../../node_modules"),
26
+ resolve(CLI_MODULE_DIR, "../node_modules"),
27
+ resolve(CLI_MODULE_DIR, "../../../node_modules"),
28
+ ];
29
+ function writeWorkflowPlanLimitNotices(data) {
30
+ for (const notice of formatWorkflowPlanLimitNotices(data)) {
31
+ process.stderr.write(`${notice}\n`);
32
+ }
33
+ }
34
+ function writeDisabledWorkflowNotices(data) {
35
+ for (const notice of formatDisabledWorkflowNotices(data)) {
36
+ process.stderr.write(`${notice}\n`);
37
+ }
38
+ }
39
+ // Arming a cron commits recurring spend, so `workflows enable` mirrors its
40
+ // `automation` block as stderr lines: what the schedule burns per day in steps
41
+ // and credits, what share of the monthly allowance that is, and when it runs
42
+ // out. Observed burn (this workflow's own billed runs) is the honest number and
43
+ // is preferred; the manifest floor is a MINIMUM and is labelled as one, because
44
+ // recipe checkpoints and retries add steps. Writing rows is free.
45
+ function writeAutomationProjection(data) {
46
+ if (!data || typeof data !== "object" || Array.isArray(data))
47
+ return;
48
+ const automation = data.automation;
49
+ if (!automation || typeof automation !== "object" || Array.isArray(automation))
50
+ return;
51
+ const block = automation;
52
+ const estimate = block.estimate;
53
+ const observed = block.observed;
54
+ const allowance = block.allowance;
55
+ const runsPer30Days = typeof estimate?.scheduledRunsPer30Days === "number"
56
+ ? estimate.scheduledRunsPer30Days
57
+ : null;
58
+ if (runsPer30Days === null)
59
+ return; // On-demand: no schedule, no burn to project.
60
+ const projected = typeof observed?.projectedActionsPer30Days === "number"
61
+ ? observed.projectedActionsPer30Days
62
+ : typeof estimate?.scheduledActionsPer30DaysFloor === "number"
63
+ ? estimate.scheduledActionsPer30DaysFloor
64
+ : null;
65
+ if (projected === null)
66
+ return;
67
+ const fromHistory = typeof observed?.projectedActionsPer30Days === "number";
68
+ const perDay = Math.round(projected / 30);
69
+ const included = typeof allowance?.includedActions === "number" ? allowance.includedActions : null;
70
+ // A cheap schedule on a big plan rounds to "0% of the allowance", which reads
71
+ // as a bug rather than as reassurance. Floor the display at "<1%".
72
+ const sharePct = included !== null && included > 0 ? (projected / included) * 100 : null;
73
+ const share = sharePct === null
74
+ ? ""
75
+ : ` (${sharePct < 1 ? "<1" : Math.round(sharePct)}% of the ${included.toLocaleString("en-US")}/mo allowance)`;
76
+ const basis = fromHistory
77
+ ? `based on ${typeof observed?.runSample === "number" ? observed.runSample : 0} past billed runs`
78
+ : "MINIMUM — recipe checkpoints and retries can add steps";
79
+ // Older servers send no creditsPerStep; say nothing about credits rather than guess.
80
+ const creditsPerStep = typeof block.creditsPerStep === "number" ? block.creditsPerStep : null;
81
+ const credits = creditsPerStep === null
82
+ ? ""
83
+ : ` (~${(Math.round(projected * creditsPerStep * 100) / 3_000).toLocaleString("en-US", { maximumFractionDigits: 2 })} credits/day at ${creditsPerStep} per step; writing rows is free)`;
84
+ process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} workflow steps/day${credits}${share}\n`);
85
+ process.stderr.write(` ${basis}\n`);
86
+ const exhaustion = observed?.projectedExhaustionAt;
87
+ if (typeof exhaustion === "string") {
88
+ process.stderr.write(` allowance runs out ${exhaustion} at this pace — the schedule pauses until the window resets\n`);
89
+ }
90
+ }
91
+ // `workflows apply` and `workflows enable` can return a non-blocking `warnings`
92
+ // array (e.g. an aggressive cron cadence that still fits the plan's allowance).
93
+ // Mirror each to stderr as one `warning: <message>` line — matching
94
+ // writeAutomationProjection's stderr styling and keeping --json stdout clean.
95
+ function writeScheduleWarnings(data) {
96
+ if (!data || typeof data !== "object" || Array.isArray(data))
97
+ return;
98
+ const warnings = data.warnings;
99
+ if (!Array.isArray(warnings))
100
+ return;
101
+ for (const warning of warnings) {
102
+ if (typeof warning === "string" && warning.length > 0) {
103
+ process.stderr.write(`warning: ${warning}\n`);
104
+ }
105
+ }
106
+ }
107
+ // Shared human-output tail for the state-changing `workflows` commands (delete /
108
+ // rename and the bulk enable/disable/delete summaries): print the response's
109
+ // `next` action hints, then its deep link. Doctrine requires every state-changing
110
+ // response to surface an oxygen-agent.com link in the terminal.
111
+ function writeWorkflowActionTail(record) {
112
+ const next = Array.isArray(record.next) ? record.next : [];
113
+ for (const hint of next) {
114
+ if (typeof hint === "string" && hint.length > 0)
115
+ process.stdout.write(`${hint}\n`);
116
+ }
117
+ const link = typeof record.deepLink === "string"
118
+ ? record.deepLink
119
+ : typeof record.web_url === "string" ? record.web_url : null;
120
+ if (link)
121
+ process.stdout.write(`${link}\n`);
122
+ }
123
+ // Bulk `workflows enable | disable | delete` return a per-item results[] plus
124
+ // summary counts. In human (non --json) output there is no JSON dump, so render
125
+ // one readable line per item and a summary-count line to stdout. `okVerb` is the
126
+ // past-tense success verb (enabled / disabled / deleted).
127
+ function writeBulkWorkflowResults(data, okVerb) {
128
+ if (!data || typeof data !== "object" || Array.isArray(data))
129
+ return;
130
+ const record = data;
131
+ const results = Array.isArray(record.results) ? record.results : [];
132
+ for (const entry of results) {
133
+ if (!isRecord(entry))
134
+ continue;
135
+ const ref = typeof entry.ref === "string" ? entry.ref : "workflow";
136
+ const workflow = isRecord(entry.workflow) ? entry.workflow : null;
137
+ const slug = workflow && typeof workflow.slug === "string" ? workflow.slug : ref;
138
+ if (typeof entry.skipped_reason === "string") {
139
+ process.stdout.write(`skipped ${slug}: ${entry.skipped_reason}\n`);
140
+ }
141
+ else if (entry.ok === true) {
142
+ process.stdout.write(`${okVerb} ${slug}\n`);
143
+ }
144
+ else {
145
+ const error = isRecord(entry.error) ? entry.error : null;
146
+ const message = error && typeof error.message === "string"
147
+ ? error.message
148
+ : error && typeof error.code === "string"
149
+ ? error.code
150
+ : "failed";
151
+ process.stdout.write(`failed ${slug}: ${message}\n`);
152
+ }
153
+ }
154
+ const counts = [];
155
+ for (const key of ["deleted", "enabled", "disabled", "skipped", "failed", "archived", "purged"]) {
156
+ const value = record[key];
157
+ if (typeof value === "number")
158
+ counts.push(`${value} ${key}`);
159
+ }
160
+ if (counts.length > 0)
161
+ process.stdout.write(`${counts.join(", ")}\n`);
162
+ writeWorkflowActionTail(record);
163
+ }
164
+ const WORKSPACE_EVENTS_HUMAN_LIMIT = 20;
165
+ function workspaceEventFilterText(event) {
166
+ const filters = Array.isArray(event.filters) ? event.filters : [];
167
+ if (filters.length === 0)
168
+ return "all payloads";
169
+ return filters
170
+ .filter(isRecord)
171
+ .map((filter) => {
172
+ const path = typeof filter.path === "string" ? filter.path : "?";
173
+ const op = typeof filter.op === "string" ? filter.op : "?";
174
+ return filter.value === undefined ? `${path} ${op}` : `${path} ${op} ${String(filter.value)}`;
175
+ })
176
+ .join(" · ");
177
+ }
178
+ function formatWorkspaceEventsList(data) {
179
+ const events = Array.isArray(data.events) ? data.events.filter(isRecord) : [];
180
+ const total = typeof data.total === "number" ? data.total : events.length;
181
+ const availableTotal = typeof data.available_total === "number" ? data.available_total : total;
182
+ const builtin = typeof data.builtin_count === "number"
183
+ ? data.builtin_count
184
+ : events.filter((event) => event.kind === "builtin").length;
185
+ const integrations = typeof data.integration_count === "number"
186
+ ? data.integration_count
187
+ : events.filter((event) => event.kind === "integration").length;
188
+ const existing = typeof data.existing_count === "number"
189
+ ? data.existing_count
190
+ : events.filter((event) => event.kind === "existing").length;
191
+ const query = isRecord(data.query) ? data.query : {};
192
+ const queryParts = ["search", "source", "kind"].flatMap((key) => typeof query[key] === "string" ? [`${key}=${query[key]}`] : []);
193
+ const lines = [
194
+ total === availableTotal
195
+ ? `${total} workspace event${total === 1 ? "" : "s"} available`
196
+ : `${total} of ${availableTotal} workspace events matched`,
197
+ `${builtin} curated preset${builtin === 1 ? "" : "s"} · ${integrations} connected · ${existing} existing`,
198
+ ];
199
+ if (queryParts.length > 0)
200
+ lines.push(`Filters: ${queryParts.join(" · ")}`);
201
+ lines.push("");
202
+ if (events.length === 0) {
203
+ lines.push("No workspace events matched. Try a broader --search, or remove --source/--kind.");
204
+ }
205
+ else {
206
+ for (const event of events.slice(0, WORKSPACE_EVENTS_HUMAN_LIMIT)) {
207
+ const label = typeof event.label === "string"
208
+ ? event.label
209
+ : typeof event.event === "string" ? event.event : "Workspace event";
210
+ const kind = typeof event.kind === "string" ? event.kind : "event";
211
+ lines.push(`- ${label} [${kind}]`);
212
+ if (typeof event.catalogId === "string")
213
+ lines.push(` catalog: ${event.catalogId}`);
214
+ const source = typeof event.source === "string" ? event.source : "?";
215
+ const eventName = typeof event.event === "string" ? event.event : "?";
216
+ lines.push(` trigger: ${source}.${eventName} · ${workspaceEventFilterText(event)}`);
217
+ if (typeof event.description === "string")
218
+ lines.push(` ${event.description}`);
219
+ const readiness = isRecord(event.readiness) ? event.readiness : null;
220
+ if (readiness?.status === "needs_setup") {
221
+ const reason = typeof readiness.reason === "string" ? readiness.reason : "Setup is required before this event can fire.";
222
+ lines.push(` readiness: setup required — ${reason}`);
223
+ const action = isRecord(readiness.action) ? readiness.action : null;
224
+ if (action && typeof action.command === "string")
225
+ lines.push(` next: ${action.command}`);
226
+ }
227
+ else if (readiness?.status === "ready") {
228
+ lines.push(" readiness: ready");
229
+ }
230
+ }
231
+ }
232
+ if (events.length > WORKSPACE_EVENTS_HUMAN_LIMIT) {
233
+ lines.push("", `Showing ${WORKSPACE_EVENTS_HUMAN_LIMIT} of ${events.length}; narrow with --search, --source, or --kind. Use --json for every match.`);
234
+ }
235
+ lines.push("", "Inspect one: oxygen workflows events get <catalog-id>");
236
+ if (typeof data.web_url === "string")
237
+ lines.push(data.web_url);
238
+ return `${lines.join("\n")}\n`;
239
+ }
240
+ function formatWorkspaceEventDetail(data) {
241
+ const event = isRecord(data.event) ? data.event : null;
242
+ if (!event)
243
+ return `${JSON.stringify(data, null, 2)}\n`;
244
+ const label = typeof event.label === "string" ? event.label : String(event.event ?? "Workspace event");
245
+ const lines = [label];
246
+ if (typeof event.catalogId === "string")
247
+ lines.push(`Catalog: ${event.catalogId}`);
248
+ if (typeof event.group === "string")
249
+ lines.push(`Group: ${event.group}`);
250
+ if (typeof event.kind === "string")
251
+ lines.push(`Kind: ${event.kind}`);
252
+ if (typeof event.description === "string")
253
+ lines.push(`When it fires: ${event.description}`);
254
+ const readiness = isRecord(event.readiness) ? event.readiness : null;
255
+ if (readiness?.status === "ready")
256
+ lines.push("Readiness: ready");
257
+ if (readiness?.status === "needs_setup") {
258
+ lines.push(`Readiness: setup required${typeof readiness.reason === "string" ? ` — ${readiness.reason}` : ""}`);
259
+ const action = isRecord(readiness.action) ? readiness.action : null;
260
+ if (action && typeof action.command === "string")
261
+ lines.push(`Next: ${action.command}`);
262
+ }
263
+ if (isRecord(data.trigger)) {
264
+ lines.push("", "Trigger:", ...JSON.stringify(data.trigger, null, 2).split("\n").map((line) => ` ${line}`));
265
+ }
266
+ const payloadFields = Array.isArray(event.payloadFields)
267
+ ? event.payloadFields.filter((field) => typeof field === "string")
268
+ : [];
269
+ if (payloadFields.length > 0)
270
+ lines.push("", `Payload fields: ${payloadFields.join(", ")}`);
271
+ if (typeof data.web_url === "string")
272
+ lines.push("", data.web_url);
273
+ return `${lines.join("\n")}\n`;
274
+ }
275
+ /**
276
+ * Map a workflow compile/validation throw to a typed `OxygenError`. The
277
+ * compiler and linter (`compileWorkflowDefinition`, `assertWorkflowManifest`,
278
+ * `serializeWorkflowFunction`, cron parsing) throw ordinary `Error`s shaped
279
+ * `"<code>: <message>"`, which `@oxygen/shared`'s `toFailure` would otherwise
280
+ * flatten to a machine-hostile `unexpected_error` — making `workflows lint`
281
+ * (whose whole job is to report authoring mistakes) read like an internal
282
+ * crash. Preserve the embedded lint code so the failure mirrors what a manifest
283
+ * lint would return (`duplicate_step_id`, `invalid_cron`, `invalid_max_credits`,
284
+ * …). Same precedent as `parseJsonValue`.
285
+ */
286
+ function asWorkflowCompileError(error, filePath) {
287
+ if (error instanceof OxygenError)
288
+ return error;
289
+ const fsCode = error?.code;
290
+ if (typeof fsCode === "string" && /^E[A-Z]+$/.test(fsCode)) {
291
+ return new OxygenError("workflow_file_unreadable", `Could not read workflow file: ${error instanceof Error ? error.message : fsCode}`, { details: { file: filePath }, exitCode: 1 });
292
+ }
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ const match = /^([a-z][a-z0-9_]*): ([\s\S]+)$/.exec(message);
295
+ if (match?.[1] && match[2]) {
296
+ return new OxygenError(match[1], match[2], {
297
+ details: { file: filePath, phase: "compile" },
298
+ exitCode: 1,
299
+ });
300
+ }
301
+ return new OxygenError("invalid_workflow", message, {
302
+ details: { file: filePath, phase: "compile" },
303
+ exitCode: 1,
304
+ });
305
+ }
306
+ // ---- oxygen workflows init: graph-first authoring, recipe compatibility ----
307
+ //
308
+ // @oxygen/recipe-sdk is never published to npm — it ships bundled inside this CLI
309
+ // (scripts/build-cli-npm-package.mjs → bundledDependencies). `init` vendors the SDK's
310
+ // compiled type declarations into the user's project so their editor resolves
311
+ // `@oxygen/recipe-sdk` (and the `@oxygen/workflows` types it re-exports) without an npm
312
+ // install. We read the .d.ts from the same node paths esbuild uses to bundle the runtime
313
+ // at `apply` time, so the vendored types stay locked to the CLI actually installed.
314
+ const RECIPE_TYPES_VENDOR_DIR = ".oxygen/types";
315
+ const RECIPE_INIT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
316
+ function renderStarterGraph(id, name, now = new Date()) {
317
+ const manifest = {
318
+ manifest_version: WORKFLOW_GRAPH_MANIFEST_VERSION,
319
+ workflow: { id, name, status: "disabled" },
320
+ trigger: { type: "api", status: "disabled" },
321
+ input_schema: { type: "object", properties: {} },
322
+ nodes: [{
323
+ id: "trigger",
324
+ name: "API trigger",
325
+ description: "Configure this trigger in the workflow editor.",
326
+ kind: "trigger",
327
+ ui: { x: 0, y: 0 },
328
+ }],
329
+ edges: [],
330
+ source_hash: "",
331
+ compiler_version: WORKFLOW_GRAPH_COMPILER_VERSION,
332
+ created_at: now.toISOString(),
333
+ };
334
+ manifest.source_hash = hashPortableWorkflowGraphManifest(manifest);
335
+ return `${JSON.stringify(manifest, null, 2)}\n`;
336
+ }
337
+ function scaffoldGraphProject(options) {
338
+ const workflowId = readOption(options.id) ?? "my-workflow";
339
+ if (!RECIPE_INIT_ID_PATTERN.test(workflowId)) {
340
+ throw new OxygenError("invalid_workflow_id", "Workflow id must start with a letter or digit and contain only letters, digits, dashes, or underscores.", {
341
+ details: { id: workflowId },
342
+ exitCode: 1,
343
+ });
344
+ }
345
+ const workflowName = readOption(options.name) ?? "My workflow";
346
+ const directory = resolve(readOption(options.dir) ?? ".");
347
+ const workflowFileName = `${workflowId}.workflow.json`;
348
+ const workflowFile = join(directory, workflowFileName);
349
+ const filesWritten = [];
350
+ const filesSkipped = [];
351
+ if (existsSync(workflowFile) && options.force !== true)
352
+ filesSkipped.push(workflowFile);
353
+ else {
354
+ mkdirSync(directory, { recursive: true });
355
+ writeFileSync(workflowFile, renderStarterGraph(workflowId, workflowName), "utf8");
356
+ filesWritten.push(workflowFile);
357
+ }
358
+ return {
359
+ format: "graph",
360
+ directory,
361
+ workflow_file: workflowFile,
362
+ files_written: filesWritten,
363
+ files_skipped: filesSkipped,
364
+ next_steps: [
365
+ `Edit ${workflowFileName}, then validate the draft: oxygen workflows lint --file ./${workflowFileName} --phase draft --json`,
366
+ `Save it without publishing: oxygen workflows apply --file ./${workflowFileName} --draft --json`,
367
+ `Inspect the saved draft: oxygen workflows revisions ${workflowId} --include-manifest --json`,
368
+ `Before publishing, validate readiness: oxygen workflows lint --file ./${workflowFileName} --phase publish --json`,
369
+ ],
370
+ };
371
+ }
372
+ function scaffoldWorkflowProject(options) {
373
+ return readOption(options.format) === "recipe"
374
+ ? scaffoldRecipeProject(options)
375
+ : scaffoldGraphProject(options);
376
+ }
377
+ function readBundledSdkDts(pkg) {
378
+ for (const base of RECIPE_ESBUILD_NODE_PATHS) {
379
+ const candidate = join(base, "@oxygen", pkg, "dist", "index.d.ts");
380
+ if (existsSync(candidate)) {
381
+ try {
382
+ return readFileSync(candidate, "utf8");
383
+ }
384
+ catch {
385
+ // Unreadable copy — fall through to the next node path.
386
+ }
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+ function renderStarterRecipe(id, name) {
392
+ return [
393
+ 'import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";',
394
+ "",
395
+ "// A durable recipe is the default export of defineRecipe(...); it compiles into an",
396
+ "// Oxygen workflow. Recipes reach external systems ONLY through catalog tools —",
397
+ "// raw fetch/network is disabled in the recipe sandbox.",
398
+ "//",
399
+ `// Deploy: oxygen workflows apply --file ./${id}.ts`,
400
+ "// Dry-run: oxygen workflows call <workflow-id> --mode dry_run --json",
401
+ "// Docs: https://oxygen-agent.com/docs/authoring/recipes",
402
+ "export default defineRecipe({",
403
+ ` id: ${JSON.stringify(id)},`,
404
+ ` name: ${JSON.stringify(name)},`,
405
+ " // Allowlist of every tool id this recipe may call — including native table",
406
+ " // ops, which dispatch through oxygen.* tools (e.g. oxygen.rows_upsert for",
407
+ " // ctx.rows.upsert). Discover provider tools with: oxygen tools search <query>",
408
+ ' tools: ["firecrawl.scrape", "oxygen.rows_upsert"],',
409
+ ' trigger: { type: "api" },',
410
+ " inputSchema: {",
411
+ ' type: "object",',
412
+ " properties: {},",
413
+ " },",
414
+ " async run(ctx: RecipeContext) {",
415
+ ' ctx.log("info", "recipe started", { mode: ctx.mode });',
416
+ " // Example: pull data through a tool, then persist it to a table.",
417
+ ' // const page = await ctx.tools.run("firecrawl.scrape", { url }, { key: "scrape" });',
418
+ ' // await ctx.rows.upsert("my_table", rows, { key: "save", upsertKey: "id" });',
419
+ " return { ok: true, at: await ctx.now() };",
420
+ " },",
421
+ "});",
422
+ "",
423
+ ].join("\n");
424
+ }
425
+ function renderRecipeTsconfig() {
426
+ const tsconfig = {
427
+ compilerOptions: {
428
+ target: "ES2022",
429
+ module: "ESNext",
430
+ moduleResolution: "Bundler",
431
+ strict: true,
432
+ noEmit: true,
433
+ skipLibCheck: true,
434
+ types: [],
435
+ // paths are resolved relative to this tsconfig (moduleResolution "Bundler"
436
+ // needs no baseUrl — and baseUrl is deprecated in TypeScript 6+).
437
+ paths: {
438
+ "@oxygen/recipe-sdk": [`./${RECIPE_TYPES_VENDOR_DIR}/@oxygen/recipe-sdk/index.d.ts`],
439
+ "@oxygen/workflows": [`./${RECIPE_TYPES_VENDOR_DIR}/@oxygen/workflows/index.d.ts`],
440
+ },
441
+ },
442
+ include: ["*.ts"],
443
+ };
444
+ return `${JSON.stringify(tsconfig, null, 2)}\n`;
445
+ }
446
+ function scaffoldRecipeProject(options) {
447
+ const recipeId = readOption(options.id) ?? "my-recipe";
448
+ if (!RECIPE_INIT_ID_PATTERN.test(recipeId)) {
449
+ throw new OxygenError("invalid_recipe_id", "Recipe id must start with a letter or digit and contain only letters, digits, dashes, or underscores.", {
450
+ details: { id: recipeId },
451
+ exitCode: 1,
452
+ });
453
+ }
454
+ const recipeName = readOption(options.name) ?? "My recipe";
455
+ const directory = resolve(readOption(options.dir) ?? ".");
456
+ const force = options.force === true;
457
+ const recipeDts = readBundledSdkDts("recipe-sdk");
458
+ const workflowsDts = readBundledSdkDts("workflows");
459
+ if (!recipeDts || !workflowsDts) {
460
+ throw new OxygenError("recipe_sdk_types_unavailable", "Could not locate the @oxygen/recipe-sdk types bundled with this CLI. Update the CLI (npm install -g @oxygen-agent/cli@latest) and retry.", { exitCode: 1 });
461
+ }
462
+ const recipeFileName = `${recipeId}.ts`;
463
+ const targets = [
464
+ { path: join(directory, recipeFileName), content: renderStarterRecipe(recipeId, recipeName) },
465
+ { path: join(directory, "tsconfig.json"), content: renderRecipeTsconfig() },
466
+ { path: join(directory, RECIPE_TYPES_VENDOR_DIR, "@oxygen", "recipe-sdk", "index.d.ts"), content: recipeDts },
467
+ { path: join(directory, RECIPE_TYPES_VENDOR_DIR, "@oxygen", "workflows", "index.d.ts"), content: workflowsDts },
468
+ ];
469
+ const filesWritten = [];
470
+ const filesSkipped = [];
471
+ for (const target of targets) {
472
+ if (existsSync(target.path) && !force) {
473
+ filesSkipped.push(target.path);
474
+ continue;
475
+ }
476
+ mkdirSync(dirname(target.path), { recursive: true });
477
+ writeFileSync(target.path, target.content, "utf8");
478
+ filesWritten.push(target.path);
479
+ }
480
+ return {
481
+ directory,
482
+ recipe_file: join(directory, recipeFileName),
483
+ files_written: filesWritten,
484
+ files_skipped: filesSkipped,
485
+ next_steps: [
486
+ `Edit ${recipeFileName}, then find tools with: oxygen tools search <query>`,
487
+ `Deploy it: oxygen workflows apply --file ./${recipeFileName}`,
488
+ "Dry-run it: oxygen workflows call <workflow-id> --mode dry_run --json",
489
+ ],
490
+ };
491
+ }
492
+ function readPortableWorkflowDefinitionFile(filePath) {
493
+ const absolutePath = resolve(filePath);
494
+ let parsed;
495
+ try {
496
+ parsed = parseJsonValue(readFileSync(absolutePath, "utf8"), "--file");
497
+ assertPortableWorkflowDefinition(parsed);
498
+ }
499
+ catch (error) {
500
+ throw asWorkflowCompileError(error, filePath);
501
+ }
502
+ return parsed;
503
+ }
504
+ function parseWorkflowConnectionBindings(values) {
505
+ const result = {};
506
+ for (const value of values) {
507
+ const separator = value.indexOf("=");
508
+ const bindingId = separator > 0 ? value.slice(0, separator).trim() : "";
509
+ const connectionId = separator > 0 ? value.slice(separator + 1).trim() : "";
510
+ if (!bindingId || !connectionId) {
511
+ throw new OxygenError("invalid_workflow_connection_binding", `Invalid --connection '${value}'. Use <binding_id>=<connection_id>.`, { details: { value }, exitCode: 1 });
512
+ }
513
+ if (result[bindingId]) {
514
+ throw new OxygenError("duplicate_workflow_connection_binding", `Connection binding '${bindingId}' was provided more than once.`, { details: { binding_id: bindingId }, exitCode: 1 });
515
+ }
516
+ result[bindingId] = connectionId;
517
+ }
518
+ return result;
519
+ }
520
+ async function compileWorkflowFile(filePath) {
521
+ try {
522
+ return await loadWorkflowManifestFromFile(filePath);
523
+ }
524
+ catch (error) {
525
+ throw asWorkflowCompileError(error, filePath);
526
+ }
527
+ }
528
+ async function loadWorkflowManifestFromFile(filePath) {
529
+ const absolutePath = resolve(filePath);
530
+ const source = readFileSync(absolutePath, "utf8");
531
+ const extension = extname(absolutePath).toLowerCase();
532
+ if (extension === ".json") {
533
+ const parsed = parseJsonObject(source);
534
+ const manifest = isAnyWorkflowManifest(parsed)
535
+ ? parsed
536
+ : isAnyWorkflowManifest(parsed.manifest)
537
+ ? parsed.manifest
538
+ : null;
539
+ if (!manifest) {
540
+ const portable = parsed.kind === "oxygen-workflow-definition"
541
+ && parsed.definition_version === 1
542
+ && isRecord(parsed.graph);
543
+ throw new OxygenError("invalid_workflow_manifest", portable
544
+ ? `Detected a portable workflow export. Validate it without saving or running anything: oxygen workflows import --file ${filePath} --preflight --json. Workflows lint accepts authored manifests, not import envelopes.`
545
+ : "Workflow JSON must be a manifest or { manifest } object.", {
546
+ details: {
547
+ file: filePath,
548
+ ...(portable
549
+ ? {
550
+ detected_format: "oxygen-workflow-definition",
551
+ next_command: `oxygen workflows import --file ${filePath} --preflight --json`,
552
+ }
553
+ : {}),
554
+ },
555
+ exitCode: 1,
556
+ });
557
+ }
558
+ if (isRecipeManifest(manifest))
559
+ return manifest;
560
+ // A v2 graph is nodes+edges, so the v1 linter would reject it with
561
+ // `invalid_manifest_version` — route it to its own linter instead. Without
562
+ // this the web canvas could author a graph the CLI could not apply, and the
563
+ // CLI is the primary surface.
564
+ if (isWorkflowGraphManifest(manifest)) {
565
+ const compiled = basename(absolutePath).endsWith(".workflow.json")
566
+ ? {
567
+ ...manifest,
568
+ created_at: new Date().toISOString(),
569
+ source_hash: hashPortableWorkflowGraphManifest(manifest),
570
+ }
571
+ : manifest;
572
+ assertWorkflowGraphManifest(compiled);
573
+ return compiled;
574
+ }
575
+ assertWorkflowManifest(manifest);
576
+ return manifest;
577
+ }
578
+ if (extension !== ".js" && extension !== ".mjs" && extension !== ".ts") {
579
+ throw new OxygenError("invalid_workflow_file", "Workflow file must be .js, .mjs, .ts, or .json.", {
580
+ details: { file: filePath },
581
+ exitCode: 1,
582
+ });
583
+ }
584
+ const recipeManifest = await tryCompileRecipeFile(absolutePath, source);
585
+ if (recipeManifest)
586
+ return recipeManifest;
587
+ const mod = extension === ".ts"
588
+ ? await importTranspiledWorkflowModule(absolutePath, source)
589
+ : await importJavaScriptWorkflowModule(absolutePath, source);
590
+ const exported = mod.default ?? mod.workflow ?? mod.manifest;
591
+ if (isWorkflowDefinition(exported)) {
592
+ return compileWorkflowDefinition(exported, {
593
+ source,
594
+ sourceHash: createHash("sha256").update(source).digest("hex"),
595
+ });
596
+ }
597
+ if (isWorkflowManifest(exported)) {
598
+ assertWorkflowManifest(exported);
599
+ return exported;
600
+ }
601
+ throw new OxygenError("invalid_workflow_file", "Workflow module must export a workflow definition, recipe, or manifest.", {
602
+ details: { file: filePath },
603
+ exitCode: 1,
604
+ });
605
+ }
606
+ async function tryCompileRecipeFile(absolutePath, source) {
607
+ if (!referencesRecipeSdk(source))
608
+ return null;
609
+ const esbuild = await import("esbuild");
610
+ const result = await esbuild.build({
611
+ entryPoints: [absolutePath],
612
+ bundle: true,
613
+ write: false,
614
+ format: "esm",
615
+ platform: "node",
616
+ target: "node22",
617
+ nodePaths: RECIPE_ESBUILD_NODE_PATHS,
618
+ minify: false,
619
+ sourcemap: false,
620
+ legalComments: "none",
621
+ logLevel: "silent",
622
+ metafile: true,
623
+ });
624
+ const out = result.outputFiles?.[0];
625
+ if (!out) {
626
+ throw new OxygenError("invalid_workflow_file", "esbuild produced no output for the recipe.", {
627
+ details: { file: absolutePath },
628
+ exitCode: 1,
629
+ });
630
+ }
631
+ const bundle = out.text;
632
+ try {
633
+ assertRecipeBundleSafe(bundle);
634
+ }
635
+ catch (error) {
636
+ throw new OxygenError("unsafe_recipe_bundle", error instanceof Error ? error.message : "Durable recipe bundle is unsafe.", {
637
+ details: { file: absolutePath },
638
+ exitCode: 1,
639
+ });
640
+ }
641
+ let recipe;
642
+ try {
643
+ recipe = await loadRecipeFromBundle(bundle, absolutePath);
644
+ }
645
+ catch (error) {
646
+ if (error instanceof OxygenError)
647
+ throw error;
648
+ throw new OxygenError("invalid_recipe_definition", error instanceof Error ? error.message : "Recipe definition is invalid.", {
649
+ details: { file: absolutePath },
650
+ exitCode: 1,
651
+ });
652
+ }
653
+ const sourceHash = createHash("sha256").update(bundle).digest("hex");
654
+ const manifest = buildRecipeManifest({
655
+ workflowId: recipe.id,
656
+ workflowName: recipe.name,
657
+ ...(recipe.status ? { status: recipe.status } : {}),
658
+ ...(recipe.trigger ? { trigger: recipe.trigger } : {}),
659
+ ...(recipe.inputSchema ? { inputSchema: recipe.inputSchema } : {}),
660
+ ...(recipe.visualPlan ? { visualPlan: recipe.visualPlan } : {}),
661
+ bundle,
662
+ toolsUsed: recipe.tools,
663
+ sourceHash,
664
+ });
665
+ return manifest;
666
+ }
667
+ function referencesRecipeSdk(source) {
668
+ return /["']@oxygen\/recipe-sdk["']/.test(source);
669
+ }
670
+ // The workflow tailer is the one bespoke wait surface: it POSTs (rather than
671
+ // GETs a run by id), reads the run nested under the response envelope, and
672
+ // carries the workflow/run/deep-link URLs from that envelope into its terminal
673
+ // output. The shared waitForCliRun loop drives polling; this config injects the
674
+ // workflow-specific fetch, terminal shape, and queued-no-worker timeout hint.
675
+ function waitForWorkflowRun(runId, options) {
676
+ // Captured per poll so shapeTerminal can read the envelope-level URLs while
677
+ // fetchRun returns the nested run (where the status lives).
678
+ let latestEnvelope = {};
679
+ return waitForCliRun({
680
+ runId,
681
+ requestedTimeoutSeconds: options.timeoutSeconds,
682
+ requestedIntervalSeconds: options.intervalSeconds,
683
+ defaultTimeoutSeconds: WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS,
684
+ defaultIntervalSeconds: WORKFLOW_TAIL_DEFAULT_INTERVAL_SECONDS,
685
+ fetchRun: async () => {
686
+ latestEnvelope = await requestOxygen("/api/cli/workflows/run", {
687
+ method: "POST",
688
+ readOnly: true,
689
+ body: { run_id: runId },
690
+ });
691
+ return isRecord(latestEnvelope.run) ? latestEnvelope.run : latestEnvelope;
692
+ },
693
+ isTerminal: isTerminalWorkflowRunStatus,
694
+ shapeTerminal: (run, status, polls, elapsedMs) => {
695
+ const workflowUrl = readRecordString(latestEnvelope, "workflowUrl");
696
+ const runUrl = readRecordString(latestEnvelope, "runUrl");
697
+ const webUrl = readRecordString(latestEnvelope, "web_url");
698
+ const deepLink = readRecordString(latestEnvelope, "deepLink");
699
+ // `waiting` stops the poll but the run is NOT over — it resumes on its own
700
+ // at ready_at. Say so, and say when, or the caller reads `terminal: true`
701
+ // and concludes the run died.
702
+ const parked = status === "waiting";
703
+ const resumeAt = parked ? readRecordString(run, "readyAt") ?? readRecordString(run, "ready_at") : null;
704
+ return {
705
+ run,
706
+ workflowRunId: readRecordString(run, "id") ?? runId,
707
+ status,
708
+ terminal: !parked,
709
+ ...(parked
710
+ ? {
711
+ parked: true,
712
+ ...(resumeAt ? { resumes_at: resumeAt } : {}),
713
+ message: resumeAt
714
+ ? `Run is parked until ${resumeAt} and resumes automatically — no action needed.`
715
+ : "Run is parked and resumes automatically — no action needed.",
716
+ }
717
+ : {}),
718
+ polls,
719
+ elapsedMs,
720
+ ...(workflowUrl ? { workflowUrl } : {}),
721
+ ...(runUrl ? { runUrl } : {}),
722
+ ...(webUrl ? { web_url: webUrl } : {}),
723
+ ...(deepLink ? { deepLink } : {}),
724
+ };
725
+ },
726
+ timeoutCode: "workflow_tail_timeout",
727
+ timeoutMessage: "Timed out waiting for workflow run to finish.",
728
+ timeoutDetailIdKey: "workflow_run_id",
729
+ timeoutExtraDetails: (latestRun, status) => {
730
+ const worker = latestRun && isRecord(latestRun.worker) ? latestRun.worker : null;
731
+ const queuedWithoutWorker = status === "queued"
732
+ && worker !== null
733
+ && worker.active === null
734
+ && worker.lastClaim === null;
735
+ return queuedWithoutWorker
736
+ ? {
737
+ worker_status: "queued_no_worker",
738
+ guidance: "No worker has claimed this workflow run. Check `oxygen worker queue-stats --json` for Postgres-backed worker queue health.",
739
+ }
740
+ : {};
741
+ },
742
+ });
743
+ }
744
+ function workflowTemplateActionBody(templateId, options) {
745
+ const inputJson = readOption(options.inputJson);
746
+ const maxCredits = readPositiveNumber(options.maxCredits);
747
+ return {
748
+ template_id: templateId,
749
+ input: inputJson ? parseJsonObject(inputJson) : {},
750
+ ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
751
+ ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
752
+ ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
753
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
754
+ ...(options.approved ? { approved: true } : {}),
755
+ };
756
+ }
757
+ // OXY-481: the deprecated `workflows templates {search,describe,preflight,apply}`
758
+ // commands route through the canonical blueprint API. Map the legacy arg names
759
+ // (template_id -> slug, --input-json -> inputs) and fold the old top-level
760
+ // max_credits into inputs.max_credits, where seed blueprints read their cap.
761
+ function workflowTemplateBlueprintBody(templateId, options, includeWorkflowOverrides = false) {
762
+ const inputJson = readOption(options.inputJson);
763
+ const inputs = inputJson ? parseJsonObject(inputJson) : {};
764
+ const maxCredits = readPositiveNumber(options.maxCredits);
765
+ if (maxCredits !== undefined) {
766
+ inputs.max_credits = maxCredits;
767
+ }
768
+ const body = { slug: templateId, inputs };
769
+ if (includeWorkflowOverrides) {
770
+ const workflowId = readOption(options.workflowId);
771
+ if (workflowId)
772
+ body.workflow_id = workflowId;
773
+ const workflowName = readOption(options.workflowName);
774
+ if (workflowName)
775
+ body.workflow_name = workflowName;
776
+ }
777
+ return body;
778
+ }
779
+ function prepareWorkflowCliOutput(value, options) {
780
+ const normalized = normalizeWorkflowRunErrors(value);
781
+ return (options.includeBundle ? normalized : stripWorkflowBundles(normalized));
782
+ }
783
+ function stripWorkflowBundles(value) {
784
+ if (Array.isArray(value))
785
+ return value.map((entry) => stripWorkflowBundles(entry));
786
+ if (!isRecord(value))
787
+ return value;
788
+ const shouldStripBundle = typeof value.bundle === "string"
789
+ && (value.runtime === "durable"
790
+ || value.bundle_format === "esm"
791
+ || (typeof value.compiler_version === "string" && value.compiler_version.startsWith("oxygen-recipes-")));
792
+ const output = {};
793
+ for (const [key, entry] of Object.entries(value)) {
794
+ if (shouldStripBundle && key === "bundle")
795
+ continue;
796
+ output[key] = stripWorkflowBundles(entry);
797
+ }
798
+ return output;
799
+ }
800
+ function normalizeWorkflowRunErrors(value) {
801
+ if (Array.isArray(value))
802
+ return value.map((entry) => normalizeWorkflowRunErrors(entry));
803
+ if (!isRecord(value))
804
+ return value;
805
+ const output = {};
806
+ for (const [key, entry] of Object.entries(value)) {
807
+ output[key] = normalizeWorkflowRunErrors(entry);
808
+ }
809
+ if (output.status === "failed" && (output.error === null || output.error === undefined) && output.lastError !== null && output.lastError !== undefined) {
810
+ output.error = output.lastError;
811
+ }
812
+ if ((output.error === null || output.error === undefined) && isRecord(output.run) && output.run.error !== undefined) {
813
+ output.error = output.run.error;
814
+ }
815
+ return output;
816
+ }
817
+ function isTerminalWorkflowRunStatus(status) {
818
+ // Terminal here means "stop polling", not "the run is over".
819
+ //
820
+ // 'awaiting_approval' pauses the run indefinitely for a human decision (lease
821
+ // cleared, excluded from claim + lease sweep), so it must stop the tail and
822
+ // surface the approval rather than poll forever.
823
+ //
824
+ // 'waiting' is the same shape with a clock instead of a human: the run is
825
+ // parked until `ready_at` — typically a provider's own quota reset, which can
826
+ // be ~20 hours out. Polling that is pointless; the tail stops and reports the
827
+ // resume time (see readWorkflowRunPauseReason).
828
+ return status === "completed" || status === "failed" || status === "canceled"
829
+ || status === "awaiting_approval" || status === "waiting";
830
+ }
831
+ function workflowWebhookDeliveriesPath(options) {
832
+ const params = new URLSearchParams();
833
+ const triggerId = readOption(options.triggerId);
834
+ const workflowId = readOption(options.workflowId);
835
+ const outcome = readOption(options.outcome);
836
+ const limit = readPositiveInt(options.limit);
837
+ if (triggerId)
838
+ params.set("trigger_id", triggerId);
839
+ if (workflowId)
840
+ params.set("workflow_id", workflowId);
841
+ if (outcome)
842
+ params.set("outcome", outcome);
843
+ if (limit)
844
+ params.set("limit", String(limit));
845
+ const query = params.toString();
846
+ return `/api/cli/workflows/webhooks/deliveries${query ? `?${query}` : ""}`;
847
+ }
848
+ export function registerWorkflowsFamily(program, handleAsyncAction) {
849
+ const workflowRunReadbackHelp = `
850
+ Stored run values:
851
+ oxygen workflows runs --run-id <run_id> --projection value --path /runs/0/output --json
852
+ Canonical graph output is a steps map keyed by node id.
853
+ Read the observed cost receipt with oxygen workflows run <run_id> --json.
854
+ For large values, follow next_value_cursor with --value-cursor and the same selection.
855
+ Append value_json fragments in offset order until next_value_cursor is null, then parse the combined JSON.
856
+ `;
857
+ program
858
+ .command("workflows")
859
+ .description("Durable workflow automation commands.")
860
+ .addHelpText("after", `
861
+ Authoring guide:
862
+ Install or refresh the focused product skill:
863
+ oxygen skills install --skill oxygen-workflow-authoring
864
+
865
+ Web creation chooser:
866
+ Open /workspaces/<workspace-id>/workflows and click Create workflow.
867
+ Choose Blank workflow, Workflow template (one workflow), Blueprint
868
+ (a multi-resource motion), or Import definition. The chooser opens on the
869
+ collection page; it has no separate public URL.
870
+ Guide: https://oxygen-agent.com/docs/execution/workflows
871
+
872
+ Fastest editable graph:
873
+ 1. oxygen workflows events list --search "<outcome>" --kind builtin --json
874
+ 2. oxygen workflows init --id my-workflow
875
+ 3. oxygen workflows schema --subject graph --json
876
+ 4. oxygen workflows lint --file my-workflow.workflow.json --phase draft --json
877
+ 5. oxygen workflows apply --file my-workflow.workflow.json --draft --json
878
+ 6. oxygen workflows lint --file my-workflow.workflow.json --phase publish --json
879
+
880
+ Draft apply saves an inert editable revision: it costs 0 credits, calls no graph
881
+ nodes, creates no run, and publishes nothing. After publish lint is clean, apply
882
+ without --draft publishes the revision; calling or enabling it remains a separate
883
+ execution/authorization step.
884
+
885
+ Run completion:
886
+ Calls without --preview enqueue asynchronously. Use the returned data.run.id as <run_id>:
887
+ oxygen workflows tail <run_id>
888
+ Preview calls return data.preview and create no run.
889
+ ${workflowRunReadbackHelp}
890
+ `)
891
+ .addCommand(new Command("templates")
892
+ .description("Deprecated aliases for `oxygen blueprints` — these route through the canonical blueprint surface (built-in seed templates plus this workspace's saved blueprints).")
893
+ .addCommand(addCompactListReadFlags(new Command("search")
894
+ .description("Deprecated alias for `oxygen blueprints list`. Searches Oxygen blueprints (seed templates plus saved blueprints).")
895
+ .argument("[query]", "Search text.")
896
+ .option("--tag <tag>", "Filter by blueprint tag.")
897
+ .option("--json", "Print a JSON envelope.")
898
+ .action(async (query, options, command) => {
899
+ await handleAsyncAction("workflows templates search", options, () => {
900
+ const params = new URLSearchParams();
901
+ params.set("query", query ?? "");
902
+ if (readOption(options.tag))
903
+ params.set("tag", readOption(options.tag) ?? "");
904
+ appendCompactListReadQuery(params, command, "blueprints");
905
+ return requestOxygen(`/api/cli/blueprints?${params.toString()}`);
906
+ });
907
+ }), "blueprints"))
908
+ .addCommand(new Command("describe")
909
+ .description("Deprecated alias for `oxygen blueprints describe`.")
910
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
911
+ .option("--json", "Print a JSON envelope.")
912
+ .action(async (templateId, options) => {
913
+ await handleAsyncAction("workflows templates describe", options, () => requestOxygen("/api/cli/blueprints/get", {
914
+ method: "POST",
915
+ body: { slug: templateId },
916
+ }));
917
+ }))
918
+ .addCommand(new Command("preflight")
919
+ .description("Deprecated alias for `oxygen blueprints preflight`. Validates inputs and previews effects without running.")
920
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
921
+ .requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
922
+ .option("--mode <mode>", "Deprecated and ignored: blueprints validate without a run mode.")
923
+ .option("--max-credits <credits>", "Credit ceiling; folded into inputs.max_credits.")
924
+ .option("--json", "Print a JSON envelope.")
925
+ .action(async (templateId, options) => {
926
+ await handleAsyncAction("workflows templates preflight", options, () => requestOxygen("/api/cli/blueprints/preflight", {
927
+ method: "POST",
928
+ body: workflowTemplateBlueprintBody(templateId, options),
929
+ }));
930
+ }))
931
+ .addCommand(new Command("apply")
932
+ .description("Deprecated alias for `oxygen blueprints apply`. Creates a disabled workflow (plus its tables, columns, and prompts) from a blueprint.")
933
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
934
+ .requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
935
+ .option("--workflow-id <workflow_id>", "Select an existing workflow by database UUID or manifest id/slug; an unmatched value creates a separate workflow.")
936
+ .option("--workflow-name <workflow_name>", "Override the resulting workflow name.")
937
+ .option("--mode <mode>", "Deprecated and ignored: the workflow is created disabled.")
938
+ .option("--max-credits <credits>", "Credit ceiling; folded into inputs.max_credits.")
939
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
940
+ .option("--json", "Print a JSON envelope.")
941
+ .action(async (templateId, options) => {
942
+ await handleAsyncAction("workflows templates apply", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/blueprints/apply", {
943
+ method: "POST",
944
+ body: workflowTemplateBlueprintBody(templateId, options, true),
945
+ }), options));
946
+ }))
947
+ .addCommand(new Command("run")
948
+ .description("Removed: returns a typed 410 deprecation. Use `oxygen blueprints apply <slug>` then `oxygen workflows call <workflow>`.")
949
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
950
+ .requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
951
+ .requiredOption("--mode <mode>", "Execution mode: smoke_test, dry_run, or live.")
952
+ .option("--workflow-id <workflow_id>", "Workflow id to create or update.")
953
+ .option("--workflow-name <workflow_name>", "Workflow display name.")
954
+ .option("--max-credits <credits>", "Required credit ceiling for paid live work.")
955
+ .option("--approved", "Required for live template runs with side effects.")
956
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
957
+ .option("--json", "Print a JSON envelope.")
958
+ .action(async (templateId, options) => {
959
+ await handleAsyncAction("workflows templates run", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/templates/run", {
960
+ method: "POST",
961
+ body: workflowTemplateActionBody(templateId, options),
962
+ }), options));
963
+ })))
964
+ .addCommand(new Command("schema")
965
+ .description("Print workflow JSON schemas.")
966
+ .option("--subject <subject>", "Schema subject: all, apply, call, event, trigger, manifest, graph, or definition.")
967
+ .option("--json", "Print a JSON envelope.")
968
+ .action(async (options) => {
969
+ await handleAsyncAction("workflows schema", options, () => requestOxygen(`/api/cli/workflows/schema?subject=${encodeURIComponent(options.subject ?? "all")}`));
970
+ }))
971
+ .addCommand(new Command("init")
972
+ .description("Create a local editable workflow graph. Use --format recipe only for the legacy durable-recipe authoring path.")
973
+ .option("--dir <path>", "Directory to scaffold into. Defaults to the current directory.")
974
+ .option("--id <id>", "Workflow id. Defaults to 'my-workflow' (or 'my-recipe' with --format recipe).")
975
+ .option("--name <name>", "Workflow display name. Defaults to 'My workflow' (or 'My recipe' with --format recipe).")
976
+ .addOption(new Option("--format <format>", "Authoring format: graph (default) or legacy recipe.").choices(["graph", "recipe"]).default("graph"))
977
+ .option("--force", "Overwrite existing files instead of skipping them.")
978
+ .option("--json", "Print a JSON envelope.")
979
+ .action(async (options) => {
980
+ await handleAsyncAction("workflows init", options, async () => scaffoldWorkflowProject(options));
981
+ }))
982
+ .addCommand(new Command("lint")
983
+ .description("Compile and validate an authored workflow file against the same draft/publish readiness contract as the web editor and apply. Saves nothing, runs nothing, calls no provider, and spends no credits. Portable exports use `workflows import --preflight` because their destination connection bindings are part of validation.")
984
+ .requiredOption("--file <path>", "Workflow module or manifest JSON file.")
985
+ .addOption(new Option("--phase <phase>", "Validation phase: publish (default) or draft.").choices(["draft", "publish"]).default("publish"))
986
+ .option("--approved", "Validate that revision-bound autonomous authority is present; grants nothing because lint is read-only.")
987
+ .option("--max-credits <n>", "Candidate positive per-delivery ceiling; omit to validate the plan default.")
988
+ .option("--json", "Print a JSON envelope.")
989
+ .action(async (options) => {
990
+ await handleAsyncAction("workflows lint", options, async () => {
991
+ const manifest = await compileWorkflowFile(options.file);
992
+ const maxCredits = readPositiveNumber(options.maxCredits);
993
+ return requestOxygen("/api/cli/workflows/lint", {
994
+ method: "POST",
995
+ body: {
996
+ manifest,
997
+ phase: options.phase ?? "publish",
998
+ ...(options.approved ? { approved: true } : {}),
999
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1000
+ },
1001
+ });
1002
+ });
1003
+ }))
1004
+ .addCommand(new Command("apply")
1005
+ .description("Compile and save a workflow definition; --draft appends an editable revision without publishing it. Apply costs 0 credits, calls no graph nodes, creates no run, and preserves disabled status; call or enable separately to execute.")
1006
+ .requiredOption("--file <path>", "Workflow module or manifest JSON file.")
1007
+ .option("--approved", "Authorize autonomous tool calls for this revision.")
1008
+ .option("--max-credits <n>", "Optional explicit positive ceiling for each autonomous delivery; omit to use the plan-tier default.")
1009
+ .option("--draft", "Save without going live: keeps the running version serving every trigger. Publish later by applying again without --draft.")
1010
+ .option("--review", "Show what publishing this would change against the version now serving triggers — added, removed and modified steps, what leaves Oxygen, the accounts used, and whether it needs standing authority. Writes nothing.")
1011
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1012
+ .option("--json", "Print a JSON envelope.")
1013
+ .action(async (options) => {
1014
+ await handleAsyncAction("workflows apply", options, async () => {
1015
+ const manifest = await compileWorkflowFile(options.file);
1016
+ const maxCredits = readPositiveNumber(options.maxCredits);
1017
+ const data = prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/apply", {
1018
+ method: "POST",
1019
+ body: {
1020
+ manifest,
1021
+ // Sent only when drafting. Omitting it entirely keeps the request
1022
+ // byte-identical to what every previous CLI sent, so behaviour
1023
+ // against an older server is unchanged rather than merely equivalent.
1024
+ ...(options.draft ? { publish: false } : {}),
1025
+ ...(options.review ? { review: true } : {}),
1026
+ ...(options.approved ? { approved: true } : {}),
1027
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1028
+ },
1029
+ }), options);
1030
+ writeScheduleWarnings(data);
1031
+ if (!options.json)
1032
+ writeWorkflowPlanLimitNotices(data);
1033
+ return data;
1034
+ });
1035
+ }))
1036
+ .addCommand(new Command("export")
1037
+ .description("Export one graph-native workflow as a portable, editable definition with source account ids and trigger authority removed.")
1038
+ .argument("<workflow>", "Workflow id, slug, or name.")
1039
+ .option("--out <path>", "Write only the portable definition JSON to this path.")
1040
+ .option("--force", "Overwrite --out if it already exists.")
1041
+ .option("--json", "Print a JSON envelope.")
1042
+ .action(async (workflow, options) => {
1043
+ await handleAsyncAction("workflows export", options, async () => {
1044
+ const data = await requestOxygen("/api/cli/workflows/export", { method: "POST", body: { workflow } });
1045
+ const outPath = readOption(options.out);
1046
+ if (!outPath)
1047
+ return data;
1048
+ const absolutePath = resolve(outPath);
1049
+ if (existsSync(absolutePath) && !options.force) {
1050
+ throw new OxygenError("workflow_export_file_exists", `Refusing to overwrite '${absolutePath}'. Pass --force to replace it.`, { details: { path: absolutePath }, exitCode: 1 });
1051
+ }
1052
+ writeFileSync(absolutePath, `${JSON.stringify(data.definition, null, 2)}\n`, "utf8");
1053
+ return { ...data, written_to: absolutePath };
1054
+ });
1055
+ }))
1056
+ .addCommand(new Command("import")
1057
+ .description("Preflight or install a portable workflow definition. Imports are create-only and always disabled.")
1058
+ .requiredOption("--file <path>", "Portable .oxygen-workflow.json file from workflows export.")
1059
+ .option("--preflight", "Validate destination accounts and collisions without creating anything.")
1060
+ .option("--workflow-id <id>", "Override the destination workflow id.")
1061
+ .option("--workflow-name <name>", "Override the destination workflow name.")
1062
+ .option("--webhook-trigger-id <id>", "Override the regenerated destination webhook trigger id.")
1063
+ .option("--connection <binding_id=connection_id>", "Bind one exported account requirement to an active destination connection (repeatable).", collectMultiple, [])
1064
+ .option("--json", "Print a JSON envelope.")
1065
+ .action(async (options) => {
1066
+ await handleAsyncAction(options.preflight ? "workflows import preflight" : "workflows import", options, async () => {
1067
+ const definition = readPortableWorkflowDefinitionFile(options.file);
1068
+ const connectionBindings = parseWorkflowConnectionBindings(options.connection ?? []);
1069
+ return requestOxygen(options.preflight
1070
+ ? "/api/cli/workflows/import/preflight"
1071
+ : "/api/cli/workflows/import", {
1072
+ method: "POST",
1073
+ body: {
1074
+ definition,
1075
+ connection_bindings: connectionBindings,
1076
+ ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
1077
+ ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
1078
+ ...(readOption(options.webhookTriggerId)
1079
+ ? { webhook_trigger_id: readOption(options.webhookTriggerId) }
1080
+ : {}),
1081
+ },
1082
+ });
1083
+ });
1084
+ }))
1085
+ .addCommand(new Command("list")
1086
+ .description("List workflow automations.")
1087
+ .addOption(new Option("--include-archived", "Deprecated no-op retained for compatibility.").hideHelp())
1088
+ .option("--tag <tag>", "Only workflows carrying this workspace tag (see `oxygen tags list`).")
1089
+ .option("--node-testable", "Only canonical graph workflows, the ones `workflows call --node` can scope a test to. Legacy recipes and v1 step lists own their own execution order and are excluded.")
1090
+ .option("--search <text>", "Match on name, slug, trigger event, or an integration the workflow uses — so \"meeting\" or \"hubspot\" finds it without knowing what someone named it.")
1091
+ .option("--limit <n>", "Maximum workflows to list. Defaults to 200; hard cap is 1000.")
1092
+ .option("--all", "List every workflow instead of the most-recently-updated window.")
1093
+ .option("--json", "Print a JSON envelope.")
1094
+ .addHelpText("after", "\nEach row carries `format` (graph, recipe or steps) and `nodeTestable`, so you can tell which workflows support single-step testing without opening them.\nLists the 200 most recently updated workflows by default; `capped` in the JSON envelope says when there are more.\n")
1095
+ .action(async (options) => {
1096
+ await handleAsyncAction("workflows list", options, async () => {
1097
+ const params = new URLSearchParams();
1098
+ const tag = readOption(options.tag);
1099
+ if (tag)
1100
+ params.set("tag", tag);
1101
+ if (options.nodeTestable)
1102
+ params.set("node_testable", "true");
1103
+ const search = readOption(options.search);
1104
+ if (search)
1105
+ params.set("search", search);
1106
+ const limit = readPositiveInt(options.limit);
1107
+ if (limit !== undefined)
1108
+ params.set("limit", String(limit));
1109
+ if (options.all)
1110
+ params.set("all", "true");
1111
+ const qs = params.toString() ? `?${params.toString()}` : "";
1112
+ const data = await requestOxygen(`/api/cli/workflows${qs}`);
1113
+ if (!options.json) {
1114
+ writeDisabledWorkflowNotices(data);
1115
+ writeListCapNotice(data, "workflow(s)");
1116
+ }
1117
+ return data;
1118
+ });
1119
+ }))
1120
+ .addCommand(new Command("get")
1121
+ .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval and `draftRevision` when a saved revision is newer than the published one. The top-level `status` is the live state; `manifest.workflow.status` is what the revision was authored with and does not change on enable or disable.")
1122
+ .argument("<workflow>", "Workflow id, slug, or name.")
1123
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1124
+ .option("--json", "Print a JSON envelope.")
1125
+ .action(async (workflow, options) => {
1126
+ await handleAsyncAction("workflows get", options, async () => {
1127
+ const data = await requestOxygen("/api/cli/workflows/get", {
1128
+ method: "POST",
1129
+ readOnly: true,
1130
+ body: { workflow },
1131
+ });
1132
+ if (!options.json) {
1133
+ writeDisabledWorkflowNotices(data);
1134
+ writeWorkflowPlanLimitNotices(data);
1135
+ }
1136
+ return prepareWorkflowCliOutput(data, options);
1137
+ });
1138
+ }))
1139
+ .addCommand(new Command("tag")
1140
+ .description("Replace a workflow's workspace tags (Tags primitive; \"\" clears). Tags link the workflow to sequences, tables, and wiki pages carrying the same tag — browse with `oxygen tags get <tag>`. Org metadata only: no new revision is cut.")
1141
+ .argument("<workflow>", "Workflow id, slug, or name.")
1142
+ .requiredOption("--tags <csv>", "Comma-separated tags; \"\" clears them.")
1143
+ .option("--json", "Print a JSON envelope.")
1144
+ .action(async (workflow, options) => {
1145
+ await handleAsyncAction("workflows tag", options, () => requestOxygen("/api/cli/workflows/tags", {
1146
+ method: "POST",
1147
+ body: { workflow, tags: readCsvOption(options.tags) },
1148
+ }));
1149
+ }))
1150
+ .addCommand(new Command("duplicate")
1151
+ .description("Duplicate a workflow automation as a disabled copy by default.")
1152
+ .argument("<workflow>", "Source workflow id, slug, or name.")
1153
+ .option("--name <name>", "Display name for the duplicated workflow. Defaults to '<source> Copy'.")
1154
+ .option("--workflow-id <workflow_id>", "Workflow id for the duplicated workflow. Defaults to a unique '<source>-copy' id.")
1155
+ .option("--trigger-id <trigger_id>", "Webhook trigger id for duplicated webhook workflows.")
1156
+ .option("--status <status>", "active or disabled. Defaults to disabled.")
1157
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1158
+ .option("--json", "Print a JSON envelope.")
1159
+ .action(async (workflow, options) => {
1160
+ await handleAsyncAction("workflows duplicate", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/duplicate", {
1161
+ method: "POST",
1162
+ body: {
1163
+ workflow,
1164
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
1165
+ ...(readOption(options.workflowId) ? { new_workflow_id: readOption(options.workflowId) } : {}),
1166
+ ...(readOption(options.triggerId) ? { trigger_id: readOption(options.triggerId) } : {}),
1167
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
1168
+ },
1169
+ }), options));
1170
+ }))
1171
+ .addCommand(new Command("call")
1172
+ .description("Run a workflow — or preview it first. `--preview` returns data.preview without creating a run: it shows every step that leaves Oxygen, the accounts it would use, and what one run costs. Calls without --preview enqueue asynchronously without simulating the trigger; use the returned data.run.id as <run_id> in `oxygen workflows tail <run_id>`.")
1173
+ .argument("[workflow]", "Workflow id, slug, or name.")
1174
+ .option("--workflow <workflow>", "Workflow id, slug, or name.")
1175
+ .option("--workflow-id <workflow_id>", "Workflow id or slug.")
1176
+ .option("--workflow-name <workflow_name>", "Workflow name.")
1177
+ .option("--input-json <json>", "Workflow input object in every mode, including smoke-test. Defaults to {}.")
1178
+ .option("--mode <mode>", "Execution mode. smoke-test/dry-run: 0 credits, no paid provider calls or external writes; internal reads still execute. live: may spend/write and requires approval + cap. Not needed with --preview, which runs nothing.")
1179
+ .option("--idempotency-key <key>", "Optional idempotency key.")
1180
+ .option("--max-credits <n>", "Required credit ceiling for live calls.")
1181
+ .option("--approved", "Required for live calls after inspecting a dry run.")
1182
+ .option("--revision <n>", "Run a specific saved version instead of the live one. Dry-run and smoke-test only: publish a version to run it live.")
1183
+ .addOption(new Option("--test-scope <workflow|node>", "Create a durable non-live whole-workflow or node test; node requires --node.").choices(["workflow", "node"]))
1184
+ .option("--node <node_id>", "Test one node: runs that node plus only the predecessors it needs, from the same saved graph. Dry-run and smoke-test only.")
1185
+ .option("--preview", "Show what a live run of this exact version would do — every step that leaves Oxygen, the accounts it would use, and the minimum number of billable steps and credits it would cost — without running anything. AI and enrichment steps bill per row and can cost more than that floor. Needs no --mode: it always describes a live run.")
1186
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1187
+ .option("--json", "Print a JSON envelope.")
1188
+ .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every execution mode creates an inspectable Workflow run record; --preview creates none.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
1189
+ .addHelpText("after", workflowRunReadbackHelp)
1190
+ .action(async (workflowArg, options) => {
1191
+ const maxCredits = readPositiveNumber(options.maxCredits);
1192
+ const revisionVersion = readPositiveNumber(options.revision);
1193
+ await handleAsyncAction("workflows call", options, async () => {
1194
+ // --mode stopped being a commander requiredOption so that --preview,
1195
+ // which executes nothing, no longer forces the caller to pick a mode
1196
+ // the server then discards. A real call still needs one. Thrown
1197
+ // INSIDE handleAsyncAction so it prints the CLI's error envelope and
1198
+ // exits 2 like any other usage error — thrown outside, it escaped as
1199
+ // an unhandled rejection and showed the user a Node stack trace.
1200
+ if (!options.preview && !readOption(options.mode)) {
1201
+ throw new OxygenError("invalid_request", "--mode is required. Use smoke-test, dry-run, or live. To see what a live run would do without running it, use --preview — it runs nothing and needs no mode.", { exitCode: 2 });
1202
+ }
1203
+ const idempotencyKey = readOption(options.idempotencyKey);
1204
+ return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
1205
+ method: "POST",
1206
+ ...(options.preview ? { previewOnly: true } : {}),
1207
+ ...(idempotencyKey ? { idempotencyKey } : {}),
1208
+ body: {
1209
+ workflow: readOption(workflowArg) ?? readOption(options.workflow),
1210
+ ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
1211
+ ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
1212
+ input: options.inputJson ? parseJsonObject(options.inputJson) : {},
1213
+ ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
1214
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
1215
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1216
+ ...(options.approved ? { approved: true } : {}),
1217
+ ...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
1218
+ ...(readOption(options.testScope) ? { test_scope: readOption(options.testScope) } : {}),
1219
+ ...(readOption(options.node) ? { test_node_id: readOption(options.node) } : {}),
1220
+ ...(options.preview ? { preview: true } : {}),
1221
+ },
1222
+ }), options);
1223
+ });
1224
+ }))
1225
+ .addCommand(new Command("webhooks")
1226
+ .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`. Senders authenticate by sending the trigger's shared secret in the `x-oxygen-workflow-secret` header; a missing or wrong secret is recorded as a `rejected` delivery that starts no run and costs nothing.")
1227
+ .addCommand(new Command("rotate")
1228
+ .description("Replace a workflow webhook's shared secret. Previews by default; the old sender secret stops working immediately after approval.")
1229
+ .argument("<workflow>", "Webhook workflow id, slug, or name.")
1230
+ .option("--approved", "Confirm replacement after reviewing the preview. The new secret is shown once.")
1231
+ .option("--json", "Print a JSON envelope.")
1232
+ .action((workflow, options) => handleAsyncAction("workflows webhooks rotate", options, () => requestOxygen("/api/cli/workflows/webhooks/rotate", {
1233
+ method: "POST",
1234
+ previewOnly: !options.approved,
1235
+ body: {
1236
+ workflow,
1237
+ ...(options.approved ? { approved: true } : {}),
1238
+ },
1239
+ }))))
1240
+ .addCommand(new Command("replay")
1241
+ .description("Create a durable, inspectable run in dry_run mode from one verified outcome=ran delivery's exact original revision and payload. Never resends the webhook or adds an inbound delivery-history row; uses 0 credits, no paid provider calls, and no external writes. Internal reads and outbound HTTP GETs can still execute.")
1242
+ .argument("<delivery_id>", "Verified outcome=ran delivery UUID from `oxygen workflows webhooks deliveries`.")
1243
+ .option("--request-key <key>", "Idempotency key. Reusing the same delivery/key returns the existing replay run; it never creates a second run.")
1244
+ .option("--json", "Print a JSON envelope.")
1245
+ .action((deliveryId, options) => handleAsyncAction("workflows webhooks replay", options, () => requestOxygen("/api/cli/workflows/webhooks/deliveries/replay", {
1246
+ method: "POST",
1247
+ body: {
1248
+ delivery_id: deliveryId,
1249
+ request_key: readOption(options.requestKey) ?? randomUUID(),
1250
+ },
1251
+ }))))
1252
+ .addCommand(new Command("deliveries")
1253
+ .description("List inbound workflow webhook deliveries: what arrived, whether it verified, and the run it started. Stored headers never include `x-oxygen-workflow-secret`, so its absence does not show whether a rejected sender's secret was missing or wrong. Replays never append here; only a verified outcome=ran delivery can be replayed.")
1254
+ .argument("[trigger_id]", "Optional webhook trigger name, such as lead-created.")
1255
+ .option("--workflow-id <id>", "Filter to this workflow's deliveries, including rejected and blocked ones that started no run.")
1256
+ .option("--outcome <outcome>", "Filter by rejected (sender authentication failed), blocked (authenticated, no run), or ran.")
1257
+ .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
1258
+ .option("--json", "Print a JSON envelope.")
1259
+ .action((triggerId, options) => handleAsyncAction("workflows webhooks deliveries", options, () => requestOxygen(workflowWebhookDeliveriesPath({
1260
+ triggerId,
1261
+ workflowId: options.workflowId,
1262
+ outcome: options.outcome,
1263
+ limit: options.limit,
1264
+ }))))))
1265
+ .addCommand(new Command("revisions")
1266
+ .description("Saved revision history for one workflow, newest first — includes unpublished drafts and published versions; use --include-manifest to inspect either.")
1267
+ .argument("[workflow]", "Workflow id, slug, or name.")
1268
+ .option("--workflow <workflow>", "Workflow id, slug, or name.")
1269
+ .option("--limit <n>", "Maximum revisions to return. Defaults to 50.")
1270
+ .option("--include-manifest", "Include each revision's full manifest.")
1271
+ .option("--json", "Print a JSON envelope.")
1272
+ .action(async (workflowArg, options) => {
1273
+ await handleAsyncAction("workflows revisions", options, () => {
1274
+ const workflow = readOption(workflowArg) ?? readOption(options.workflow);
1275
+ const query = new URLSearchParams();
1276
+ if (workflow)
1277
+ query.set("workflow", workflow);
1278
+ const limit = readPositiveInt(options.limit);
1279
+ if (limit)
1280
+ query.set("limit", String(limit));
1281
+ if (options.includeManifest)
1282
+ query.set("include_manifest", "true");
1283
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1284
+ return requestOxygen(`/api/cli/workflows/revisions${suffix}`);
1285
+ });
1286
+ })
1287
+ .addCommand(new Command("restore")
1288
+ .description("Re-publish an earlier version as the newest one. Append-only: restoring v3 applies v3's manifest as v9 rather than rewinding, so the restore is itself undoable and history stays a record of what happened. It clears the same gates a fresh apply does — lint, tool effects, cron cadence — so an autonomous tool-bearing workflow needs --approved (and a per-delivery --max-credits, or the plan default) to arm again.")
1289
+ .argument("[workflow]", "Workflow id, slug, or name.")
1290
+ .requiredOption("--version <n>", "The revision version to re-publish (see `oxygen workflows revisions`).")
1291
+ .option("--workflow <workflow>", "Workflow id, slug, or name.")
1292
+ .option("--approved", "Required to re-arm an autonomous tool-bearing workflow.")
1293
+ .option("--max-credits <n>", "Per-delivery credit ceiling for the restored revision. Omit to bind the plan-tier default.")
1294
+ .option("--json", "Print a JSON envelope.")
1295
+ .action(async (workflowArg, options) => {
1296
+ const maxCredits = readPositiveNumber(options.maxCredits);
1297
+ await handleAsyncAction("workflows revisions restore", options, () => requestOxygen("/api/cli/workflows/revisions/restore", {
1298
+ method: "POST",
1299
+ body: {
1300
+ workflow: readOption(workflowArg) ?? readOption(options.workflow),
1301
+ version: Number(readOption(options.version)),
1302
+ ...(options.approved ? { approved: true } : {}),
1303
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1304
+ },
1305
+ }));
1306
+ })))
1307
+ .addCommand(new Command("events")
1308
+ .description("Workflow event trigger utilities.")
1309
+ .addCommand(new Command("list")
1310
+ .description("Search and filter every registered app-native and connected event this workspace can use as a workflow trigger, including setup-required events.")
1311
+ .option("--search <text>", "Match labels, catalog ids, descriptions, endpoints, filters, or payload fields.")
1312
+ .option("--source <source>", "Match one event source exactly, such as sequencer or crm.")
1313
+ .option("--kind <kind>", "Match builtin, integration, or existing events.")
1314
+ .option("--json", "Print a JSON envelope.")
1315
+ .addHelpText("after", `
1316
+ Cost: 0 credits · read-only.
1317
+
1318
+ Use each returned source, event, and filters array together. Curated presets can
1319
+ share one underlying source/event pair and differ only by filters.
1320
+
1321
+ This is the trigger-ready workspace catalog. oxygen integrations events list
1322
+ instead shows raw provider webhook definitions and the subscriptions that feed
1323
+ connected entries into this catalog.
1324
+
1325
+ Kinds:
1326
+ builtin Curated Oxygen preset with labels, filters, and payload fields.
1327
+ integration Event from an active workspace integration subscription.
1328
+ existing Raw source/event retained from an existing workflow trigger;
1329
+ no preset filter is required.
1330
+
1331
+ Readiness:
1332
+ Events that need setup remain selectable and draft-saveable. Their readiness
1333
+ reason and exact setup action are returned here; publish/enable stays blocked
1334
+ until the prerequisite is ready.
1335
+
1336
+ Example trigger:
1337
+ {"type":"event","source":"crm","event":"activity_created",
1338
+ "filters":[{"path":"activity_type","op":"eq","value":"meeting_booked"}]}
1339
+
1340
+ Full trigger schema: oxygen workflows schema --subject trigger --json
1341
+ `)
1342
+ .action(async (options) => {
1343
+ try {
1344
+ const query = new URLSearchParams();
1345
+ const search = readOption(options.search);
1346
+ const source = readOption(options.source);
1347
+ const kind = readOption(options.kind);
1348
+ if (search)
1349
+ query.set("search", search);
1350
+ if (source)
1351
+ query.set("source", source);
1352
+ if (kind)
1353
+ query.set("kind", kind);
1354
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1355
+ const data = await requestOxygen(`/api/cli/workflows/events/catalog${suffix}`);
1356
+ if (options.json)
1357
+ writeJson(success("workflows events list", data));
1358
+ else
1359
+ process.stdout.write(formatWorkspaceEventsList(data));
1360
+ }
1361
+ catch (error) {
1362
+ emitCliFailure("workflows events list", error);
1363
+ }
1364
+ }))
1365
+ .addCommand(new Command("get")
1366
+ .description("Get one curated workspace-event preset and its ready-to-copy trigger object.")
1367
+ .argument("<catalog_id>", "Exact catalog id returned by workflows events list.")
1368
+ .option("--json", "Print a JSON envelope.")
1369
+ .addHelpText("after", "\nCost: 0 credits · read-only. Integration/existing entries have no curated catalog id; filter them with workflows events list.\n")
1370
+ .action(async (catalogId, options) => {
1371
+ try {
1372
+ const data = await requestOxygen(`/api/cli/workflows/events/catalog?catalog_id=${encodeURIComponent(catalogId)}`);
1373
+ if (options.json)
1374
+ writeJson(success("workflows events get", data));
1375
+ else
1376
+ process.stdout.write(formatWorkspaceEventDetail(data));
1377
+ }
1378
+ catch (error) {
1379
+ emitCliFailure("workflows events get", error);
1380
+ }
1381
+ }))
1382
+ .addCommand(new Command("emit")
1383
+ .description("Emit a normalized workflow event and enqueue matching event-triggered workflows.")
1384
+ .requiredOption("--source <source>", "Event source, such as instantly, calendar, hubspot, or oxygen.notetaker.")
1385
+ .requiredOption("--event <event>", "Event type, such as email.reply_received.")
1386
+ .requiredOption("--payload-json <json>", "Normalized event payload object.")
1387
+ .option("--raw-payload-json <json>", "Optional raw provider payload object.")
1388
+ .option("--headers-json <json>", "Optional delivery headers object.")
1389
+ .option("--external-event-id <id>", "Provider event id for idempotency and inspection.")
1390
+ .option("--idempotency-key <key>", "Optional explicit event idempotency key.")
1391
+ .requiredOption("--mode <mode>", "Execution mode: live, dry-run (dry_run), or smoke-test (smoke_test).")
1392
+ .option("--max-credits <n>", "Required per-delivery credit ceiling for live event emission.")
1393
+ .option("--approved", "Required for live event emission after inspecting a dry run.")
1394
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1395
+ .option("--json", "Print a JSON envelope.")
1396
+ .action(async (options) => {
1397
+ await handleAsyncAction("workflows events emit", options, async () => {
1398
+ const rawPayloadJson = readOption(options.rawPayloadJson);
1399
+ const headersJson = readOption(options.headersJson);
1400
+ const maxCredits = readPositiveNumber(options.maxCredits);
1401
+ return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/events/emit", {
1402
+ method: "POST",
1403
+ body: {
1404
+ source: readOption(options.source),
1405
+ event: readOption(options.event),
1406
+ payload: parseJsonObject(options.payloadJson),
1407
+ ...(rawPayloadJson ? { raw_payload: parseJsonObject(rawPayloadJson) } : {}),
1408
+ ...(headersJson ? { headers: parseJsonObject(headersJson) } : {}),
1409
+ ...(readOption(options.externalEventId) ? { external_event_id: readOption(options.externalEventId) } : {}),
1410
+ ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
1411
+ ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
1412
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1413
+ ...(options.approved ? { approved: true } : {}),
1414
+ },
1415
+ }), options);
1416
+ });
1417
+ })))
1418
+ .addCommand(addContractFlags(new Command("runs")
1419
+ .description("List stored workflow runs. Optional windows preserve complete public values without executing a workflow."), WORKFLOW_RUN_LIST_CONTRACT, WORKFLOW_RUN_LIST_FLAG_OVERLAY)
1420
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1421
+ .option("--json", "Print a JSON envelope.")
1422
+ .addHelpText("after", "\nFor complete bounded history use --projection summary/full; echo nextCursor with the same filters. Unqualified legacy reads issue a continuation only with an explicit --limit. Value windows require --run-id and reauthorize the exact filtered public row.\n")
1423
+ .action(async (options) => {
1424
+ await handleAsyncAction("workflows runs", options, async () => {
1425
+ const args = readWorkflowRunListFlags(options);
1426
+ const query = workflowRunListQueryParams(args);
1427
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1428
+ const data = await requestOxygen(`/api/cli/workflows/runs${suffix}`);
1429
+ // Opt-in windows are lossless public native receipts. Legacy bundle
1430
+ // stripping/error aliases still apply only to the unqualified read.
1431
+ return ["projection", "path", "value_cursor", "max_chars"].some(key => args[key] !== undefined)
1432
+ ? data : prepareWorkflowCliOutput(data, options);
1433
+ });
1434
+ }))
1435
+ .addCommand(new Command("failures")
1436
+ .description("List failed workflow runs and trigger scheduler failures.")
1437
+ .option("--limit <n>", "Maximum failures per group. Defaults to 25; server cap is 100.")
1438
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1439
+ .option("--json", "Print a JSON envelope.")
1440
+ .action(async (options) => {
1441
+ await handleAsyncAction("workflows failures", options, async () => {
1442
+ const query = new URLSearchParams();
1443
+ const limit = readPositiveInt(options.limit);
1444
+ if (limit)
1445
+ query.set("limit", String(limit));
1446
+ if (options.includeBundle)
1447
+ query.set("include_bundle", "true");
1448
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1449
+ return prepareWorkflowCliOutput(await requestOxygen(`/api/cli/workflows/failures${suffix}`), options);
1450
+ });
1451
+ }))
1452
+ .addCommand(new Command("run")
1453
+ .description("Read one workflow run, including waiting and awaiting_approval pause state. This command changes nothing.")
1454
+ .argument("<run_id>", "Workflow run UUID returned as data.run.id by workflows call/tail; not the separate provenance run id.")
1455
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1456
+ .option("--json", "Print a JSON envelope.")
1457
+ .addHelpText("after", "\nWebhook replay: for a webhook-origin run, find its verified delivery with `oxygen workflows webhooks deliveries --workflow-id <workflow-uuid> --outcome ran`, then use `oxygen workflows webhooks replay <delivery-id>`. The replay response and run metadata retain the source delivery/run lineage.\n")
1458
+ .action(async (runId, options) => {
1459
+ await handleAsyncAction("workflows run", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/run", {
1460
+ method: "POST",
1461
+ readOnly: true,
1462
+ body: { run_id: runId },
1463
+ }), options));
1464
+ }))
1465
+ .addCommand(new Command("retry")
1466
+ .description("Preview retrying a failed run from its first failed step. Completed steps stay preserved; ambiguous external writes are refused.")
1467
+ .argument("<run_id>", "Failed workflow run UUID.")
1468
+ .option("--approved", "Execute the reviewed retry plan. Without this flag, returns a read-only preview.")
1469
+ .option("--max-credits <n>", "Required new positive credit ceiling when retrying a live run.")
1470
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1471
+ .option("--json", "Print a JSON envelope.")
1472
+ .action(async (runId, options) => {
1473
+ await handleAsyncAction("workflows retry", options, async () => {
1474
+ const maxCredits = readPositiveNumber(options.maxCredits);
1475
+ return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/retry", {
1476
+ method: "POST",
1477
+ previewOnly: !options.approved,
1478
+ body: {
1479
+ run_id: runId,
1480
+ ...(options.approved ? { approved: true } : {}),
1481
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1482
+ },
1483
+ }), options);
1484
+ });
1485
+ }))
1486
+ .addCommand(new Command("tail")
1487
+ .description("Poll until a workflow finishes or parks in waiting/awaiting_approval; clock waits resume automatically.")
1488
+ .argument("<run_id>", "Workflow run UUID returned as data.run.id by workflows call; distinct from data.run.runId.")
1489
+ .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
1490
+ .option("--interval-seconds <n>", "Polling interval. Defaults to 2.")
1491
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
1492
+ .option("--json", "Print a JSON envelope.")
1493
+ .addHelpText("after", workflowRunReadbackHelp)
1494
+ .action(async (runId, options) => {
1495
+ await handleAsyncAction("workflows tail", options, async () => prepareWorkflowCliOutput(await waitForWorkflowRun(runId, options), options));
1496
+ }))
1497
+ .addCommand(new Command("cancel")
1498
+ .description("Cancel a queued or running workflow run.")
1499
+ .argument("<run_id>", "Workflow run UUID.")
1500
+ .option("--preview", "Show what cancelling would and would not undo — steps in flight, and any external write already dispatched that cancelling cannot recall. Cancels nothing.")
1501
+ .option("--json", "Print a JSON envelope.")
1502
+ .addHelpText("after", "\nCancelling stops further steps; it cannot recall a step already dispatched to a provider. Use --preview first when the run may be mid-write.\n")
1503
+ .action(async (runId, options) => {
1504
+ await handleAsyncAction("workflows cancel", options, () => requestOxygen("/api/cli/workflows/cancel", {
1505
+ method: "POST",
1506
+ ...(options.preview ? { previewOnly: true } : {}),
1507
+ body: {
1508
+ run_id: runId,
1509
+ ...(options.preview ? { preview: true } : {}),
1510
+ },
1511
+ }));
1512
+ }))
1513
+ .addCommand(new Command("approvals")
1514
+ .description("List workflow runs awaiting a human approval decision (the mid-run approval inbox).")
1515
+ .option("--status <status>", "Filter: pending (default), approved, rejected, expired, or all.")
1516
+ .option("--run-id <run_id>", "Only approvals for this workflow run.")
1517
+ .option("--limit <n>", "Maximum approvals to return. Defaults to 50.")
1518
+ .option("--json", "Print a JSON envelope.")
1519
+ .action(async (options) => {
1520
+ await handleAsyncAction("workflows approvals", options, () => {
1521
+ const query = new URLSearchParams();
1522
+ if (readOption(options.status))
1523
+ query.set("status", readOption(options.status) ?? "");
1524
+ if (readOption(options.runId))
1525
+ query.set("run_id", readOption(options.runId) ?? "");
1526
+ const limit = readPositiveInt(options.limit);
1527
+ if (limit)
1528
+ query.set("limit", String(limit));
1529
+ const suffix = query.toString() ? `?${query.toString()}` : "";
1530
+ return requestOxygen(`/api/cli/workflows/approvals${suffix}`);
1531
+ });
1532
+ }))
1533
+ .addCommand(new Command("resume")
1534
+ .description("Approve or reject a paused mid-run workflow approval, resuming the run.")
1535
+ .argument("<run_id>", "Workflow run UUID.")
1536
+ .requiredOption("--decision <decision>", "approve or reject.")
1537
+ .option("--step-id <step_id>", "Approval step id, when a run has more than one pending gate.")
1538
+ .option("--plan-hash <plan_hash>", "Reviewed plan hash; the decision is refused if the plan changed since review.")
1539
+ .option("--json", "Print a JSON envelope.")
1540
+ .action(async (runId, options) => {
1541
+ await handleAsyncAction("workflows resume", options, () => requestOxygen("/api/cli/workflows/resume", {
1542
+ method: "POST",
1543
+ body: {
1544
+ run_id: runId,
1545
+ decision: readOption(options.decision),
1546
+ ...(readOption(options.stepId) ? { step_id: readOption(options.stepId) } : {}),
1547
+ ...(readOption(options.planHash) ? { plan_hash: readOption(options.planHash) } : {}),
1548
+ },
1549
+ }));
1550
+ }))
1551
+ .addCommand(new Command("enable")
1552
+ .description("Enable one or more workflow automations and their current triggers. Prints the schedule's projected automation-action burn; refuses a cron cadence the plan's monthly allowance cannot sustain. Pass several workflows to enable them in one bulk call.")
1553
+ .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
1554
+ .option("--reason <text>", "Why you are enabling it. Recorded on the workflow and shown to the workspace.")
1555
+ .option("--approved", "Authorize autonomous tool calls for each current revision.")
1556
+ .option("--max-credits <n>", "Optional explicit positive ceiling for each autonomous delivery; omit to use the plan-tier default.")
1557
+ .option("--json", "Print a JSON envelope.")
1558
+ .action(async (workflows, options) => {
1559
+ const maxCredits = readPositiveNumber(options.maxCredits);
1560
+ if (workflows.length === 1) {
1561
+ await handleAsyncAction("workflows enable", options, async () => {
1562
+ const data = await requestOxygen("/api/cli/workflows/enable", {
1563
+ method: "POST",
1564
+ body: {
1565
+ workflow: workflows[0],
1566
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
1567
+ ...(options.approved ? { approved: true } : {}),
1568
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1569
+ },
1570
+ });
1571
+ writeAutomationProjection(data);
1572
+ writeScheduleWarnings(data);
1573
+ return data;
1574
+ });
1575
+ return;
1576
+ }
1577
+ try {
1578
+ const data = await requestOxygen("/api/cli/workflows/enable", {
1579
+ method: "POST",
1580
+ body: {
1581
+ workflows,
1582
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
1583
+ ...(options.approved ? { approved: true } : {}),
1584
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1585
+ },
1586
+ });
1587
+ if (options.json) {
1588
+ writeJson(success("workflows enable", data));
1589
+ }
1590
+ else {
1591
+ writeBulkWorkflowResults(data, "enabled");
1592
+ }
1593
+ writeScheduleWarnings(data);
1594
+ }
1595
+ catch (error) {
1596
+ emitCliFailure("workflows enable", error);
1597
+ }
1598
+ }))
1599
+ .addCommand(new Command("disable")
1600
+ .description("Disable one or more workflow automations and their current triggers. Records who disabled it, from where, and why — the workspace sees it on the workflow. Pass several workflows to disable them in one bulk call.")
1601
+ .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
1602
+ .option("--reason <text>", "Why you are disabling it. Recorded on the workflow and shown to the workspace.")
1603
+ .option("--json", "Print a JSON envelope.")
1604
+ .action(async (workflows, options) => {
1605
+ if (workflows.length === 1) {
1606
+ await handleAsyncAction("workflows disable", options, () => requestOxygen("/api/cli/workflows/disable", {
1607
+ method: "POST",
1608
+ body: {
1609
+ workflow: workflows[0],
1610
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
1611
+ },
1612
+ }));
1613
+ return;
1614
+ }
1615
+ try {
1616
+ const data = await requestOxygen("/api/cli/workflows/disable", {
1617
+ method: "POST",
1618
+ body: {
1619
+ workflows,
1620
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
1621
+ },
1622
+ });
1623
+ if (options.json) {
1624
+ writeJson(success("workflows disable", data));
1625
+ }
1626
+ else {
1627
+ writeBulkWorkflowResults(data, "disabled");
1628
+ }
1629
+ }
1630
+ catch (error) {
1631
+ emitCliFailure("workflows disable", error);
1632
+ }
1633
+ }))
1634
+ .addCommand(new Command("delete")
1635
+ .description("Preview or permanently delete one or more workflow automations. Deletion disarms the workflow and removes its definition, revisions, triggers, and workflow-scoped budget policies; historical run and usage records remain inspectable. Run without --yes to preview, then repeat with --yes to approve.")
1636
+ .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
1637
+ .addOption(new Option("--purge", "Deprecated no-op retained for compatibility.").hideHelp())
1638
+ .option("--yes", "Approve the previewed permanent deletion.")
1639
+ .option("--reason <text>", "Why you are deleting it. Attached to the deletion request.")
1640
+ .option("--json", "Print a JSON envelope.")
1641
+ .action(async (workflows, options) => {
1642
+ try {
1643
+ const single = workflows.length === 1;
1644
+ const data = await requestOxygen("/api/cli/workflows/delete", {
1645
+ method: "POST",
1646
+ previewOnly: !options.yes,
1647
+ body: {
1648
+ ...(single ? { workflow: workflows[0] } : { workflows }),
1649
+ ...(options.yes ? { approved: true } : {}),
1650
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
1651
+ },
1652
+ });
1653
+ if (options.json) {
1654
+ writeJson(success("workflows delete", data));
1655
+ return;
1656
+ }
1657
+ if (single) {
1658
+ const workflow = isRecord(data.workflow) ? data.workflow : null;
1659
+ const slug = workflow && typeof workflow.slug === "string" ? workflow.slug : workflows[0];
1660
+ process.stdout.write(`deleted ${slug}\n`);
1661
+ writeWorkflowActionTail(data);
1662
+ }
1663
+ else {
1664
+ writeBulkWorkflowResults(data, "deleted");
1665
+ }
1666
+ }
1667
+ catch (error) {
1668
+ emitCliFailure("workflows delete", error);
1669
+ }
1670
+ }))
1671
+ .addCommand(new Command("rename")
1672
+ .description("Rename a workflow automation's display name only — its id and slug are unchanged, and the next `workflows apply` from source overwrites the name.")
1673
+ .argument("<workflow>", "Workflow id, slug, or name.")
1674
+ .argument("<name>", "New display name.")
1675
+ .option("--json", "Print a JSON envelope.")
1676
+ .action(async (workflow, name, options) => {
1677
+ try {
1678
+ const data = await requestOxygen("/api/cli/workflows/rename", {
1679
+ method: "POST",
1680
+ body: { workflow, name },
1681
+ });
1682
+ if (options.json) {
1683
+ writeJson(success("workflows rename", data));
1684
+ return;
1685
+ }
1686
+ const renamed = isRecord(data.workflow) ? data.workflow : null;
1687
+ const slug = renamed && typeof renamed.slug === "string" ? renamed.slug : workflow;
1688
+ const displayName = renamed && typeof renamed.name === "string" ? renamed.name : name;
1689
+ process.stdout.write(`renamed ${slug} to "${displayName}"\n`);
1690
+ writeWorkflowActionTail(data);
1691
+ }
1692
+ catch (error) {
1693
+ emitCliFailure("workflows rename", error);
1694
+ }
1695
+ }))
1696
+ .addCommand(new Command("mcp")
1697
+ .description("Expose hosted Workflows as dynamic MCP tools (oxygen_workflow_<slug>). For reusable table Functions, use oxygen functions.")
1698
+ .addCommand(new Command("enable")
1699
+ .description("Publish an active workflow as a callable MCP tool so any MCP client can run it. The published tool appears to MCP clients as oxygen_workflow_<slug>.")
1700
+ .argument("<workflow>", "Workflow id, slug, or name.")
1701
+ .option("--display-name <name>", "Display name for the published MCP tool.")
1702
+ .option("--description <text>", "Description for the published MCP tool.")
1703
+ .option("--json", "Print a JSON envelope.")
1704
+ .action(async (workflow, options) => {
1705
+ await handleAsyncAction("workflows mcp enable", options, async () => {
1706
+ const data = await requestOxygen("/api/cli/workflows/mcp", {
1707
+ method: "POST",
1708
+ body: {
1709
+ workflow,
1710
+ enabled: true,
1711
+ ...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
1712
+ ...(readOption(options.description) ? { description: readOption(options.description) } : {}),
1713
+ },
1714
+ });
1715
+ // Surface the resolved MCP tool name. Prefer the server's, fall
1716
+ // back to the canonical builder on the server-resolved slug.
1717
+ const slug = data?.workflow?.slug;
1718
+ const toolName = data?.tool_name
1719
+ ?? (typeof slug === "string" ? workflowMcpToolName(slug) : null);
1720
+ if (toolName) {
1721
+ // MCP tool names are capped at MAX_MCP_TOOL_NAME_LENGTH. Over the
1722
+ // limit the flag is set but the tool is SKIPPED in tools/list until
1723
+ // the slug is shortened — always warn (stderr, JSON stays clean).
1724
+ // Under it, confirm the callable name in human output only (the
1725
+ // JSON envelope already carries tool_name for --json callers).
1726
+ if (toolName.length > MAX_MCP_TOOL_NAME_LENGTH) {
1727
+ process.stderr.write(`warning: "${toolName}" is ${toolName.length} chars (limit ${MAX_MCP_TOOL_NAME_LENGTH}) — the workflow is enabled for MCP but will not appear in tools/list until its slug is shortened.\n`);
1728
+ }
1729
+ else if (!options.json) {
1730
+ process.stderr.write(`Published as MCP tool ${toolName} — call it from any MCP client.\n`);
1731
+ }
1732
+ }
1733
+ return data;
1734
+ });
1735
+ }))
1736
+ .addCommand(new Command("disable")
1737
+ .description("Unpublish a workflow's MCP tool. The workflow stays active and callable via `oxygen workflows call`.")
1738
+ .argument("<workflow>", "Workflow id, slug, or name.")
1739
+ .option("--json", "Print a JSON envelope.")
1740
+ .action(async (workflow, options) => {
1741
+ await handleAsyncAction("workflows mcp disable", options, () => requestOxygen("/api/cli/workflows/mcp", {
1742
+ method: "POST",
1743
+ body: { workflow, enabled: false },
1744
+ }));
1745
+ }))
1746
+ .addCommand(new Command("list")
1747
+ .description("List workflows currently published as MCP tools.")
1748
+ .option("--json", "Print a JSON envelope.")
1749
+ .action(async (options) => {
1750
+ await handleAsyncAction("workflows mcp list", options, () => requestOxygen("/api/cli/workflows/mcp-callable"));
1751
+ })));
1752
+ }