@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,1419 @@
1
+ import { asPayloadRecord } from "../cli-output.js";
2
+ import { parseJsonObject, readNonNegativeInt, readPositiveInt, readRecordString } from "../cli-values.js";
3
+ import { collectRepeatable, readFileIfPresent, readPositiveNumber, readPublishingPostText, readPublishingTextFile, splitCommaList } from "../command-support.js";
4
+ import { addContractFlags, readContractFlags } from "../contract-flags.js";
5
+ import { requestOxygen } from "../http-client.js";
6
+ import { defaultApiUrl } from "../credentials.js";
7
+ import { oxygenDocsUrl } from "../help.js";
8
+ import { isRecord, readOption } from "../util.js";
9
+ import { OxygenError } from "@oxygen/shared/cli-result";
10
+ import { publishingAmplificationListQueryContract, publishingAmplificationActionsQueryContract, publishingNotesQueryContract, publishingRevisionsQueryContract, publishingAnalyticsQueryContract, publishingReadQueryParams } from "@oxygen/shared/contracts/publishing-reads";
11
+ import { Command } from "commander";
12
+ function buildPublishingPostsListPath(options) {
13
+ const query = new URLSearchParams();
14
+ const filters = {
15
+ status: options.status,
16
+ approval_status: options.approvalStatus,
17
+ tag: options.tag,
18
+ provider: options.provider,
19
+ sender_account_id: options.sender,
20
+ cursor: options.cursor,
21
+ limit: options.limit,
22
+ order: options.order,
23
+ };
24
+ for (const [key, value] of Object.entries(filters)) {
25
+ const read = readOption(value);
26
+ if (read)
27
+ query.set(key, read);
28
+ }
29
+ const suffix = query.toString();
30
+ return suffix ? `/api/cli/publishing/posts?${suffix}` : "/api/cli/publishing/posts";
31
+ }
32
+ function buildPublishingMentionsResolveBody(options) {
33
+ const text = readPublishingPostText(options, false);
34
+ const identifiers = (options.identifier ?? [])
35
+ .map((value) => readOption(value))
36
+ .filter((value) => Boolean(value));
37
+ const kind = readOption(options.kind);
38
+ if (kind && kind !== "person" && kind !== "company") {
39
+ throw new OxygenError("invalid_request", "--kind must be person or company.", { exitCode: 1 });
40
+ }
41
+ if (!text && identifiers.length === 0) {
42
+ throw new OxygenError("invalid_request", "Pass --text/--text-file or at least one --identifier.", { exitCode: 1 });
43
+ }
44
+ const source = readOption(options.source);
45
+ if (source && source !== "sender" && source !== "scraper") {
46
+ throw new OxygenError("invalid_request", "--source must be sender or scraper.", { exitCode: 1 });
47
+ }
48
+ if ((source ?? "sender") === "sender" && !readOption(options.account)) {
49
+ throw new OxygenError("invalid_request", "--account is required when resolving through a sender.", { exitCode: 1 });
50
+ }
51
+ return {
52
+ ...(options.account ? { account: options.account } : {}),
53
+ ...(source ? { source } : {}),
54
+ ...(options.approved ? { approved: true } : {}),
55
+ ...(options.maxCredits ? { max_credits: Number(options.maxCredits) } : {}),
56
+ ...(text ? { text } : {}),
57
+ ...(identifiers.length > 0 ? {
58
+ identifiers: identifiers.map((value) => ({
59
+ value,
60
+ ...(kind ? { kind } : {}),
61
+ ...(options.asMention ? { as_mention: true } : {}),
62
+ })),
63
+ } : {}),
64
+ };
65
+ }
66
+ function buildPublishingPostCreateBody(options) {
67
+ const status = resolvePublishingCreateStatus(options);
68
+ const body = {
69
+ content_text: readPublishingPostText(options, true),
70
+ publish_at: options.publishAt,
71
+ status,
72
+ approved: options.approved === true,
73
+ };
74
+ const sender = readOption(options.sender);
75
+ const provider = readOption(options.provider);
76
+ const channel = readOption(options.channel);
77
+ const providerConnection = readOption(options.providerConnection);
78
+ const title = readOption(options.title);
79
+ const timezone = readOption(options.timezone);
80
+ const ugcParticipationId = readOption(options.ugcParticipationId);
81
+ const idempotencyKey = readOption(options.idempotencyKey);
82
+ const content = buildPublishingContent(options);
83
+ if (provider)
84
+ body.provider = provider;
85
+ if (channel)
86
+ body.channel = channel;
87
+ if (sender)
88
+ body.sender_account_id = sender;
89
+ if (providerConnection)
90
+ body.provider_connection_id = providerConnection;
91
+ if (title)
92
+ body.title = title;
93
+ if (timezone)
94
+ body.timezone = timezone;
95
+ if (idempotencyKey)
96
+ body.idempotency_key = idempotencyKey;
97
+ if (options.tags !== undefined)
98
+ body.tags = splitCommaList(options.tags);
99
+ if (ugcParticipationId) {
100
+ if (options.approved) {
101
+ throw new OxygenError("ugc_draft_required", "A program-bound personal post must be saved unapproved. Remove --approved, then review it in Publishing → UGC.", { exitCode: 1 });
102
+ }
103
+ body.ugc_participation_id = ugcParticipationId;
104
+ }
105
+ const xContent = buildPublishingXContent(options, { forMerge: false });
106
+ const firstComment = readPublishingFirstComment(options);
107
+ if (content || xContent || firstComment) {
108
+ body.content = { ...(content ?? {}), ...(xContent ?? {}), ...(firstComment ? { first_comment: firstComment } : {}) };
109
+ }
110
+ return body;
111
+ }
112
+ /**
113
+ * `--first-comment` / `--clear-first-comment`: the comment to set, null to remove
114
+ * it (update only), or undefined when neither flag was passed. The API refuses a
115
+ * first comment on a provider that cannot post one (publishing_first_comment_unsupported),
116
+ * judging the post's stored provider too, so the CLI does not keep its own list.
117
+ */
118
+ function readPublishingFirstComment(options) {
119
+ if (options.firstComment !== undefined && options.clearFirstComment) {
120
+ throw new OxygenError("invalid_request", "Choose --first-comment or --clear-first-comment, not both.", { exitCode: 2 });
121
+ }
122
+ if (options.clearFirstComment)
123
+ return null;
124
+ if (options.firstComment === undefined)
125
+ return undefined;
126
+ const text = options.firstComment.trim();
127
+ if (!text) {
128
+ throw new OxygenError("invalid_request", "--first-comment needs the comment text. To remove a post's first comment, use --clear-first-comment on `publishing posts update`.", { exitCode: 2 });
129
+ }
130
+ return text;
131
+ }
132
+ /**
133
+ * The X flags as content keys (see the API's X content contract). On update they
134
+ * become a `content_merge` so the rest of the stored content is kept; there
135
+ * `--reply-settings everyone` removes the key.
136
+ */
137
+ function buildPublishingXContent(options, mode) {
138
+ const content = {};
139
+ const threadPosts = options.threadPost ?? [];
140
+ const threadMedia = parseKeyedPairs(options.threadMedia ?? [], "--thread-media", "position=media_id");
141
+ if (threadMedia.length > 0 && threadPosts.length === 0) {
142
+ throw new OxygenError("invalid_request", "--thread-media needs the thread's posts: add --thread-post for each post after the first.", { exitCode: 2 });
143
+ }
144
+ if (threadPosts.length > 0) {
145
+ const thread = threadPosts.map((text) => ({ text }));
146
+ for (const [position, mediaId] of threadMedia) {
147
+ const index = Number(position) - 2;
148
+ if (!Number.isInteger(index) || index < 0 || index >= thread.length) {
149
+ throw new OxygenError("invalid_request", `--thread-media ${position}=... names no thread post; positions run 2 to ${thread.length + 1}.`, { exitCode: 2 });
150
+ }
151
+ const entry = thread[index];
152
+ entry.media_asset_ids = [...(entry.media_asset_ids ?? []), mediaId];
153
+ }
154
+ content.thread = thread;
155
+ }
156
+ const pollOptions = options.pollOption ?? [];
157
+ const pollDuration = readOption(options.pollDuration);
158
+ if (pollDuration && pollOptions.length === 0) {
159
+ throw new OxygenError("invalid_request", "--poll-duration needs the poll's options: add --poll-option 2-4 times.", { exitCode: 2 });
160
+ }
161
+ if (pollOptions.length > 0) {
162
+ content.poll = { options: pollOptions, duration_minutes: pollDuration ? parsePollDurationMinutes(pollDuration) : 1440 };
163
+ }
164
+ const replySettings = readOption(options.replySettings);
165
+ if (replySettings) {
166
+ const map = { everyone: null, following: "following", mentioned: "mentionedUsers", subscribers: "subscribers" };
167
+ if (!(replySettings in map)) {
168
+ throw new OxygenError("invalid_request", "--reply-settings must be everyone, following, mentioned, or subscribers.", { exitCode: 2 });
169
+ }
170
+ const value = map[replySettings];
171
+ if (value)
172
+ content.reply_settings = value;
173
+ else if (mode.forMerge)
174
+ content.reply_settings = null;
175
+ }
176
+ const altText = parseKeyedPairs(options.altText ?? [], "--alt-text", "media_id=text");
177
+ if (altText.length > 0)
178
+ content.media_alt_text = Object.fromEntries(altText);
179
+ const photoTags = (options.photoTag ?? []).map((name) => name.trim().replace(/^@/, "")).filter(Boolean);
180
+ if (photoTags.length > 0)
181
+ content.media_tagged_usernames = photoTags;
182
+ return Object.keys(content).length > 0 ? content : null;
183
+ }
184
+ /** `key=value` pairs from a repeatable flag; the value may itself contain `=`. */
185
+ function parseKeyedPairs(entries, flag, shape) {
186
+ return entries.map((entry) => {
187
+ const at = entry.indexOf("=");
188
+ const key = at > 0 ? entry.slice(0, at).trim() : "";
189
+ const value = at > 0 ? entry.slice(at + 1) : "";
190
+ if (!key || !value.trim()) {
191
+ throw new OxygenError("invalid_request", `${flag} takes ${shape}.`, { exitCode: 2 });
192
+ }
193
+ return [key, value];
194
+ });
195
+ }
196
+ /** `30m`, `6h`, `2d`, or a bare number of minutes. */
197
+ function parsePollDurationMinutes(value) {
198
+ const match = /^(\d+)\s*([mhd]?)$/i.exec(value.trim());
199
+ if (!match) {
200
+ throw new OxygenError("invalid_request", "--poll-duration takes a number of minutes or 30m, 6h, 2d.", { exitCode: 2 });
201
+ }
202
+ const unit = match[2].toLowerCase();
203
+ return Number(match[1]) * (unit === "d" ? 1440 : unit === "h" ? 60 : 1);
204
+ }
205
+ function buildPublishingPostUpdateBody(options) {
206
+ const body = {};
207
+ const sender = readOption(options.sender);
208
+ const provider = readOption(options.provider);
209
+ const channel = readOption(options.channel);
210
+ const providerConnection = readOption(options.providerConnection);
211
+ const title = readOption(options.title);
212
+ const text = readPublishingPostText(options, false);
213
+ const publishAt = readOption(options.publishAt);
214
+ const timezone = readOption(options.timezone);
215
+ const status = readOption(options.status);
216
+ const content = buildPublishingContent(options);
217
+ if (provider)
218
+ body.provider = provider;
219
+ if (channel)
220
+ body.channel = channel;
221
+ if (sender)
222
+ body.sender_account_id = sender;
223
+ if (providerConnection)
224
+ body.provider_connection_id = providerConnection;
225
+ if (title)
226
+ body.title = title;
227
+ if (text !== null)
228
+ body.content_text = text;
229
+ if (publishAt)
230
+ body.publish_at = publishAt;
231
+ if (timezone)
232
+ body.timezone = timezone;
233
+ if (status)
234
+ body.status = status;
235
+ if (content)
236
+ body.content = content;
237
+ const merge = readOption(options.contentMergeJson);
238
+ const xContent = buildPublishingXContent(options, { forMerge: true });
239
+ // A merge key, so the post's media and other content survive the change.
240
+ const firstComment = readPublishingFirstComment(options);
241
+ if (merge || xContent || firstComment !== undefined) {
242
+ body.content_merge = {
243
+ ...(merge ? parseJsonObject(merge) : {}),
244
+ ...(xContent ?? {}),
245
+ // Clearing also nulls the legacy camelCase key the worker still falls back to.
246
+ ...(firstComment === null ? { first_comment: null, firstComment: null } : {}),
247
+ ...(typeof firstComment === "string" ? { first_comment: firstComment } : {}),
248
+ };
249
+ }
250
+ if (options.confirmNotPublished)
251
+ body.confirm_not_published = true;
252
+ return body;
253
+ }
254
+ /**
255
+ * The LinkedIn video cover flags: a media id, a local file uploaded first, or
256
+ * (update only) a clear. Sent as the top-level `video_thumbnail_media_asset_id`,
257
+ * which the server merges into content, so the post's attachments are untouched.
258
+ */
259
+ async function withPublishingVideoThumbnailOption(body, options) {
260
+ const mediaId = readOption(options.videoThumbnail);
261
+ const filePath = readOption(options.videoThumbnailFile);
262
+ const chosen = [mediaId, filePath, options.clearVideoThumbnail ? "clear" : undefined].filter(Boolean);
263
+ if (chosen.length > 1) {
264
+ throw new OxygenError("invalid_request", "Choose one of --video-thumbnail, --video-thumbnail-file, or --clear-video-thumbnail.", { exitCode: 2 });
265
+ }
266
+ if (options.clearVideoThumbnail)
267
+ return { ...body, video_thumbnail_media_asset_id: null };
268
+ if (mediaId)
269
+ return { ...body, video_thumbnail_media_asset_id: mediaId };
270
+ if (!filePath)
271
+ return body;
272
+ const contentType = publishingMediaContentTypeForPath(filePath);
273
+ if (contentType !== "image/jpeg" && contentType !== "image/png") {
274
+ throw new OxygenError("invalid_request", "--video-thumbnail-file must be a .jpg, .jpeg, or .png image.", { exitCode: 2 });
275
+ }
276
+ const uploaded = await uploadPublishingMediaFile(filePath, { contentType, scheduledPostId: null });
277
+ const media = uploaded.media && typeof uploaded.media === "object" ? uploaded.media : {};
278
+ const id = readRecordString(media, "id");
279
+ if (!id)
280
+ throw new OxygenError("publishing_media_upload_failed", "The cover image uploaded but no media id came back.", { exitCode: 1 });
281
+ return { ...body, video_thumbnail_media_asset_id: id };
282
+ }
283
+ /** Extension → MIME for the types Publishing accepts; anything else needs --content-type. */
284
+ function publishingMediaContentTypeForPath(path) {
285
+ const extension = path.toLowerCase().split(".").pop() ?? "";
286
+ const types = {
287
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp",
288
+ mp4: "video/mp4", m4v: "video/x-m4v", mov: "video/quicktime", webm: "video/webm",
289
+ pdf: "application/pdf",
290
+ };
291
+ return types[extension] ?? null;
292
+ }
293
+ /**
294
+ * Presign, PUT the file straight to object storage, then have the server verify
295
+ * it — the three hops the web composer makes. The body is a file-backed Blob, so
296
+ * a large video streams from disk instead of being read into memory.
297
+ */
298
+ async function uploadPublishingMediaFile(filePath, options) {
299
+ const { openAsBlob } = await import("node:fs");
300
+ const { basename } = await import("node:path");
301
+ const contentType = options.contentType ?? publishingMediaContentTypeForPath(filePath);
302
+ if (!contentType) {
303
+ throw new OxygenError("invalid_request", "Could not tell the file type from its extension. Pass --content-type, such as video/mp4 or image/png.", { details: { path: filePath }, exitCode: 2 });
304
+ }
305
+ const file = await openAsBlob(filePath, { type: contentType });
306
+ const ticket = await requestOxygen("/api/cli/publishing/media/upload-url", {
307
+ method: "POST",
308
+ body: {
309
+ file_name: basename(filePath),
310
+ content_type: contentType,
311
+ byte_length: file.size,
312
+ ...(options.scheduledPostId ? { scheduled_post_id: options.scheduledPostId } : {}),
313
+ },
314
+ });
315
+ const uploadUrl = readRecordString(ticket, "upload_url");
316
+ const mediaId = isRecord(ticket.media) ? readRecordString(ticket.media, "id") : null;
317
+ if (!uploadUrl || !mediaId) {
318
+ throw new OxygenError("publishing_media_upload_failed", "The server did not return an upload URL and media ID for that file.", { exitCode: 1 });
319
+ }
320
+ const headers = ticket.upload_headers && typeof ticket.upload_headers === "object"
321
+ ? Object.fromEntries(Object.entries(ticket.upload_headers).map(([key, value]) => [key, String(value)]))
322
+ : { "content-type": contentType };
323
+ const put = await fetch(uploadUrl, { method: "PUT", headers, body: file, signal: AbortSignal.timeout(15 * 60_000) });
324
+ if (!put.ok) {
325
+ throw new OxygenError("publishing_media_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
326
+ }
327
+ return await requestOxygen(`/api/cli/publishing/media/${encodeURIComponent(mediaId)}/uploaded`, { method: "POST", body: {} });
328
+ }
329
+ function buildPublishingMediaUploadUrlBody(options) {
330
+ const fileName = readOption(options.fileName);
331
+ const contentType = readOption(options.contentType);
332
+ const byteLength = readPositiveInt(options.byteLength);
333
+ if (!fileName) {
334
+ throw new OxygenError("invalid_request", "--file-name is required.", { exitCode: 1 });
335
+ }
336
+ if (!contentType) {
337
+ throw new OxygenError("invalid_request", "--content-type is required.", { exitCode: 1 });
338
+ }
339
+ if (!byteLength) {
340
+ throw new OxygenError("invalid_request", "--byte-length must be a positive integer.", { exitCode: 1 });
341
+ }
342
+ const body = {
343
+ file_name: fileName,
344
+ content_type: contentType,
345
+ byte_length: byteLength,
346
+ };
347
+ const scheduledPost = readOption(options.scheduledPost);
348
+ const metadataJson = readOption(options.metadataJson);
349
+ if (scheduledPost)
350
+ body.scheduled_post_id = scheduledPost;
351
+ if (metadataJson)
352
+ body.metadata = parseJsonObject(metadataJson);
353
+ return body;
354
+ }
355
+ function buildPublishingMediaUploadedBody(options) {
356
+ const sha256 = readOption(options.sha256);
357
+ return sha256 ? { sha256 } : {};
358
+ }
359
+ function buildPublishingContent(options) {
360
+ const contentJson = readOption(options.contentJson);
361
+ const composioAction = readOption(options.composioAction);
362
+ if (!contentJson && !composioAction)
363
+ return null;
364
+ const content = contentJson ? parseJsonObject(contentJson) : {};
365
+ if (composioAction) {
366
+ const existing = content.composio && typeof content.composio === "object" && !Array.isArray(content.composio)
367
+ ? content.composio
368
+ : {};
369
+ content.composio = { ...existing, action_slug: composioAction };
370
+ }
371
+ return content;
372
+ }
373
+ function resolvePublishingCreateStatus(options) {
374
+ const status = readOption(options.status);
375
+ if (readOption(options.ugcParticipationId)) {
376
+ if (status && status !== "draft") {
377
+ throw new OxygenError("ugc_draft_required", "--ugc-participation-id only creates an unapproved draft; omit --status or pass --status draft.", { exitCode: 1 });
378
+ }
379
+ return "draft";
380
+ }
381
+ if (options.draft === true && status && status !== "draft") {
382
+ throw new OxygenError("conflicting_flags", "Pass either --draft or --status, not both.", { exitCode: 1 });
383
+ }
384
+ return options.draft === true ? "draft" : status ?? "scheduled";
385
+ }
386
+ // --source-text / --source-file for the AI draft templates that rewrite a source
387
+ // (summarize, repurpose, podcast_clip, hashtags). Mirrors readPublishingPostText.
388
+ function readPublishingSourceText(options) {
389
+ const inlineText = readOption(options.sourceText);
390
+ const sourceFile = readOption(options.sourceFile);
391
+ if (inlineText && sourceFile) {
392
+ throw new OxygenError("conflicting_flags", "Pass either --source-text or --source-file, not both.", {
393
+ exitCode: 1,
394
+ });
395
+ }
396
+ if (sourceFile)
397
+ return readPublishingTextFile(sourceFile, "--source-file");
398
+ return inlineText?.trim() ? inlineText : null;
399
+ }
400
+ function buildPublishingPostsDraftBody(options) {
401
+ const body = {
402
+ template: options.template,
403
+ max_credits: readPositiveNumber(options.maxCredits),
404
+ };
405
+ const topic = readOption(options.topic);
406
+ const channels = readOption(options.channels);
407
+ const variants = readPositiveInt(options.variants);
408
+ const sourceText = readPublishingSourceText(options);
409
+ const sourceUrl = readOption(options.sourceUrl);
410
+ if (topic)
411
+ body.topic = topic;
412
+ if (channels) {
413
+ body.channels = channels.split(",").map((channel) => channel.trim()).filter(Boolean);
414
+ }
415
+ if (variants !== undefined)
416
+ body.variants = variants;
417
+ // Commander's --no-ground sets ground=false; the default (true) is the API's too,
418
+ // so only the explicit opt-out is worth sending.
419
+ if (options.ground === false)
420
+ body.ground = false;
421
+ if (sourceText)
422
+ body.source_text = sourceText;
423
+ if (sourceUrl)
424
+ body.source_url = sourceUrl;
425
+ return body;
426
+ }
427
+ function buildPublishingDraftsListPath(options) {
428
+ const query = new URLSearchParams();
429
+ const kind = readOption(options.kind);
430
+ const status = readOption(options.status);
431
+ const limit = readOption(options.limit);
432
+ if (kind)
433
+ query.set("kind", kind);
434
+ if (status)
435
+ query.set("status", status);
436
+ if (limit)
437
+ query.set("limit", limit);
438
+ const suffix = query.toString();
439
+ return suffix ? `/api/cli/publishing/drafts?${suffix}` : "/api/cli/publishing/drafts";
440
+ }
441
+ function buildPublishingDraftsAcceptBody(options) {
442
+ const body = {
443
+ variant_index: readNonNegativeInt(options.variant) ?? 0,
444
+ };
445
+ const publishAt = readOption(options.publishAt);
446
+ const channel = readOption(options.channel);
447
+ const sender = readOption(options.sender);
448
+ const providerConnection = readOption(options.providerConnection);
449
+ const timezone = readOption(options.timezone);
450
+ if (publishAt)
451
+ body.publish_at = publishAt;
452
+ if (channel)
453
+ body.channel = channel;
454
+ if (sender)
455
+ body.sender_account_id = sender;
456
+ if (providerConnection)
457
+ body.provider_connection_id = providerConnection;
458
+ if (timezone)
459
+ body.timezone = timezone;
460
+ return body;
461
+ }
462
+ function buildPublishingTagsBody(options) {
463
+ const add = splitCommaList(options.add);
464
+ const remove = splitCommaList(options.remove);
465
+ if (add.length === 0 && remove.length === 0) {
466
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
467
+ }
468
+ return {
469
+ ...(add.length > 0 ? { add } : {}),
470
+ ...(remove.length > 0 ? { remove } : {}),
471
+ };
472
+ }
473
+ // A list value goes out comma-separated (`accounts=a,b`), which every list param
474
+ // in the publishing routes reads.
475
+ function buildPublishingAnalyticsPath(base, params) {
476
+ const query = new URLSearchParams();
477
+ const lists = [];
478
+ for (const [key, value] of Object.entries(params)) {
479
+ if (typeof value === "string") {
480
+ if (value)
481
+ query.set(key, value);
482
+ }
483
+ else if (value && value.length > 0) {
484
+ lists.push(`${encodeURIComponent(key)}=${value.map(encodeURIComponent).join(",")}`);
485
+ }
486
+ }
487
+ const suffix = [query.toString(), ...lists].filter(Boolean).join("&");
488
+ return suffix ? `${base}?${suffix}` : base;
489
+ }
490
+ function publishingReadQueryPath(path, contract, options) {
491
+ const [base = path, existing] = path.split("?", 2);
492
+ const query = new URLSearchParams(existing);
493
+ for (const [key, value] of publishingReadQueryParams(contract, readContractFlags(options, contract)))
494
+ query.set(key, value);
495
+ return query.size ? `${base}?${query}` : base;
496
+ }
497
+ const PUBLISHING_ANALYTICS_ACCOUNT_HELP = "An account key from the summary's accounts list, or a LinkedIn sender id from `oxygen senders list`. Repeatable.";
498
+ /** Repeated and/or comma-separated `--account` values, trimmed and de-duplicated. */
499
+ function readPublishingAnalyticsAccounts(values) {
500
+ return [...new Set((values ?? []).flatMap((value) => splitCommaList(value)))];
501
+ }
502
+ // Content analytics sums every posting profile unless --account names some. When
503
+ // more than one posted, the last line a human reads says how to see one of them;
504
+ // stderr, so stdout stays the payload (the writeDryRunNotice split).
505
+ function writePublishingAnalyticsAccountHint(data) {
506
+ const payload = asPayloadRecord(data);
507
+ const accounts = Array.isArray(payload?.accounts) ? payload.accounts : [];
508
+ const first = asPayloadRecord(accounts[0]);
509
+ if (accounts.length < 2 || typeof first?.key !== "string")
510
+ return;
511
+ const channel = asPayloadRecord(payload?.scope)?.channel;
512
+ const channelFlag = typeof channel === "string" && channel !== "linkedin" ? ` --channel ${channel}` : "";
513
+ const example = typeof first.name === "string" ? `, e.g. ${first.name}` : "";
514
+ process.stderr.write(`Totals combine ${accounts.length} accounts. Scope to one${example}: oxygen publishing analytics summary${channelFlag} --account ${first.key}\n`);
515
+ }
516
+ function buildPublishingCommentsListPath(options) {
517
+ const view = readOption(options.view);
518
+ const status = readOption(options.status);
519
+ if (view && status) {
520
+ throw new OxygenError("conflicting_flags", "Pass either --view or --status, not both.", { exitCode: 1 });
521
+ }
522
+ return buildPublishingAnalyticsPath("/api/cli/publishing/comments", {
523
+ view,
524
+ status,
525
+ post_id: readOption(options.post),
526
+ assignee: readOption(options.assignee),
527
+ channel: readOption(options.channel),
528
+ cursor: readOption(options.cursor),
529
+ limit: readOption(options.limit),
530
+ });
531
+ }
532
+ function readPublishingCommentText(text, file, label) {
533
+ if (readOption(text) && readOption(file)) {
534
+ throw new OxygenError("conflicting_flags", `Pass either --${label} or --${label}-file, not both.`, {
535
+ exitCode: 1,
536
+ });
537
+ }
538
+ const value = readOption(file)
539
+ ? readPublishingTextFile(readOption(file) ?? "", `--${label}-file`)
540
+ : readOption(text);
541
+ if (!value?.trim()) {
542
+ throw new OxygenError("invalid_request", `Pass --${label} or --${label}-file.`, { exitCode: 1 });
543
+ }
544
+ return value.trim();
545
+ }
546
+ // `--file` is the whole import: a .csv is posted verbatim as `csv` (the API owns
547
+ // the parse, so the CLI and the web importer can never disagree about a quoted
548
+ // comma); a .json is posted as `rows`. Dry-run is the DEFAULT — a bare
549
+ // `publishing import --file posts.csv` lints and reports, and only --approved
550
+ // flips dry_run off, so a bulk write into the queue is never one typo away.
551
+ function buildPublishingImportBody(options) {
552
+ const file = readOption(options.file);
553
+ if (!file)
554
+ throw new OxygenError("invalid_request", "Pass --file <path.csv|.json>.", { exitCode: 1 });
555
+ if (options.dryRun === true && options.approved === true) {
556
+ throw new OxygenError("conflicting_flags", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
557
+ }
558
+ const contents = readPublishingTextFile(file, "--file");
559
+ const tags = splitCommaList(options.tags);
560
+ const isJson = file.trim().toLowerCase().endsWith(".json");
561
+ return {
562
+ ...(isJson ? { rows: readPublishingImportRows(contents) } : { csv: contents }),
563
+ dry_run: options.approved !== true,
564
+ ...(tags.length > 0 ? { tags } : {}),
565
+ };
566
+ }
567
+ function readPublishingImportRows(contents) {
568
+ let parsed;
569
+ try {
570
+ parsed = JSON.parse(contents);
571
+ }
572
+ catch {
573
+ throw new OxygenError("invalid_json", "--file is not valid JSON.", { exitCode: 1 });
574
+ }
575
+ if (Array.isArray(parsed))
576
+ return parsed;
577
+ const rows = parsed?.rows;
578
+ if (Array.isArray(rows))
579
+ return rows;
580
+ throw new OxygenError("invalid_request", "Expected a JSON array of rows, or an object with a `rows` array.", { exitCode: 1 });
581
+ }
582
+ // Amplification create and post boost both arm REAL public writes from other
583
+ // people's connected accounts and both spend credits, so the CLI refuses (exit 7)
584
+ // before the request rather than relying on the API to bounce it. Same gate the
585
+ // LinkedIn `posts delete` path uses: an irreversible public action never happens
586
+ // because a flag was forgotten.
587
+ function requirePublishingApprovalAndCap(approved, maxCredits, action, details) {
588
+ if (approved !== true) {
589
+ throw new OxygenError("approval_required", `${action} makes real public engagement from the participants' connected accounts and spends credits. Re-run with --approved --max-credits <n>.`, { details, exitCode: 7 });
590
+ }
591
+ const cap = readPositiveNumber(maxCredits);
592
+ if (cap === undefined) {
593
+ throw new OxygenError("max_credits_required", `${action} spends credits, so it needs an explicit ceiling. Re-run with --max-credits <n>.`, { details, exitCode: 7 });
594
+ }
595
+ return cap;
596
+ }
597
+ /** The LinkedIn-restriction acknowledgement every arming amplification call needs. */
598
+ function requireAmplificationRiskAcknowledged(acknowledged, action, details) {
599
+ if (acknowledged === true)
600
+ return;
601
+ throw new OxygenError("amplification_risk_acknowledgement_required", `${action} runs automated engagement from connected LinkedIn accounts, which LinkedIn may warn, restrict or close. Pacing reduces that risk but does not remove it. Re-run with --acknowledge-risk.`, { details: { ...details, risk_acknowledged: false }, exitCode: 7 });
602
+ }
603
+ function buildPublishingAmplificationCreateBody(options) {
604
+ const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Creating an amplification policy", { name: options.name, scope: options.scope });
605
+ requireAmplificationRiskAcknowledged(options.acknowledgeRisk, "Creating an amplification policy", { name: options.name, scope: options.scope });
606
+ const senders = splitCommaList(options.senders);
607
+ const actions = splitCommaList(options.actions);
608
+ if (senders.length === 0)
609
+ throw new OxygenError("invalid_request", "Pass --senders.", { exitCode: 1 });
610
+ if (actions.length === 0)
611
+ throw new OxygenError("invalid_request", "Pass --actions.", { exitCode: 1 });
612
+ return {
613
+ name: options.name,
614
+ scope_kind: options.scope,
615
+ participant_sender_ids: senders,
616
+ actions,
617
+ max_credits_per_cycle: maxCredits,
618
+ approved: true,
619
+ risk_acknowledged: true,
620
+ ...publishingAmplificationScope(options),
621
+ ...publishingAmplificationTunables(options),
622
+ };
623
+ }
624
+ function publishingAmplificationScope(options) {
625
+ const tag = readOption(options.tag);
626
+ const post = readOption(options.post);
627
+ return {
628
+ ...(tag ? { scope_tag: tag } : {}),
629
+ ...(post ? { scope_post_id: post } : {}),
630
+ };
631
+ }
632
+ function publishingAmplificationTunables(options) {
633
+ const maxActions = readPositiveInt(options.maxActionsPerPost);
634
+ const commentPool = readPublishingCommentPool(options);
635
+ const reactionMin = readNonNegativeInt(options.reactionDelayMin);
636
+ const reactionMax = readNonNegativeInt(options.reactionDelayMax);
637
+ const commentMin = readNonNegativeInt(options.commentDelayMin);
638
+ const commentMax = readNonNegativeInt(options.commentDelayMax);
639
+ return {
640
+ ...(options.metadataJson !== undefined ? { metadata: parseJsonObject(readFileIfPresent(options.metadataJson)) } : {}),
641
+ ...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
642
+ ...(commentPool ? { comment_source: "pool", comment_pool: commentPool } : {}),
643
+ ...(reactionMin !== undefined ? { reaction_delay_min_seconds: reactionMin } : {}),
644
+ ...(reactionMax !== undefined ? { reaction_delay_max_seconds: reactionMax } : {}),
645
+ ...(commentMin !== undefined ? { comment_delay_min_seconds: commentMin } : {}),
646
+ ...(commentMax !== undefined ? { comment_delay_max_seconds: commentMax } : {}),
647
+ };
648
+ }
649
+ // Comments are prose: a comma-separated flag would split them mid-sentence. The
650
+ // pool is a repeatable --comment-pool flag, or a file of one comment per line.
651
+ function readPublishingCommentPool(options) {
652
+ const file = readOption(options.commentPoolFile);
653
+ const inline = Array.isArray(options.commentPool)
654
+ ? options.commentPool.map((entry) => entry.trim()).filter(Boolean)
655
+ : [];
656
+ if (file && inline.length > 0) {
657
+ throw new OxygenError("conflicting_flags", "Pass either --comment-pool or --comment-pool-file, not both.", {
658
+ exitCode: 1,
659
+ });
660
+ }
661
+ if (file) {
662
+ const lines = readPublishingTextFile(file, "--comment-pool-file")
663
+ .split("\n")
664
+ .map((line) => line.trim())
665
+ .filter(Boolean);
666
+ if (lines.length === 0) {
667
+ throw new OxygenError("invalid_request", "--comment-pool-file has no comments.", { exitCode: 1 });
668
+ }
669
+ return lines;
670
+ }
671
+ return inline.length > 0 ? inline : null;
672
+ }
673
+ function buildPublishingAmplificationUpdateBody(options) {
674
+ const name = readOption(options.name);
675
+ const senders = splitCommaList(options.senders);
676
+ const actions = splitCommaList(options.actions);
677
+ const maxCredits = readPositiveNumber(options.maxCredits);
678
+ return {
679
+ ...(name ? { name } : {}),
680
+ ...(senders.length > 0 ? { participant_sender_ids: senders } : {}),
681
+ ...(actions.length > 0 ? { actions } : {}),
682
+ ...(maxCredits !== undefined ? { max_credits_per_cycle: maxCredits } : {}),
683
+ ...(options.acknowledgeRisk === true ? { risk_acknowledged: true } : {}),
684
+ ...publishingAmplificationTunables(options),
685
+ };
686
+ }
687
+ function buildPublishingBoostBody(postId, options) {
688
+ const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Boosting a post", { post_id: postId });
689
+ requireAmplificationRiskAcknowledged(options.acknowledgeRisk, "Boosting a post", {
690
+ post_id: postId,
691
+ });
692
+ const senders = splitCommaList(options.senders);
693
+ const actions = splitCommaList(options.actions);
694
+ if (senders.length === 0)
695
+ throw new OxygenError("invalid_request", "Pass --senders.", { exitCode: 1 });
696
+ if (actions.length === 0)
697
+ throw new OxygenError("invalid_request", "Pass --actions.", { exitCode: 1 });
698
+ const maxActions = readPositiveInt(options.maxActionsPerPost);
699
+ const commentPool = readPublishingCommentPool(options);
700
+ return {
701
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
702
+ senders,
703
+ actions,
704
+ max_credits: maxCredits,
705
+ approved: true,
706
+ risk_acknowledged: true,
707
+ ...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
708
+ ...(commentPool ? { comment_pool: commentPool } : {}),
709
+ };
710
+ }
711
+ function buildPublishingIdeaBody(options) {
712
+ const topic = readOption(options.topic);
713
+ const channels = splitCommaList(options.channels);
714
+ return {
715
+ kind: "idea",
716
+ text: readPublishingPostText(options, true),
717
+ ...(topic ? { topic } : {}),
718
+ ...(channels.length > 0 ? { channels } : {}),
719
+ };
720
+ }
721
+ export function registerPublishingFamily(program, handleAsyncAction) {
722
+ program
723
+ .command("publishing")
724
+ .description(`Social publishing, performance, and public-comment operations. Start Community triage with \`oxygen publishing comments list --view unanswered --json\`. Teammates engaging on the workspace's own posts is \`oxygen publishing amplification --help\` (created disabled; enabling needs --approved and --acknowledge-risk). Docs: ${oxygenDocsUrl(defaultApiUrl(), "/execution/publishing")}`)
725
+ .addCommand(new Command("mentions")
726
+ .description("Resolve LinkedIn identities for a publish-faithful post preview.")
727
+ .addCommand(new Command("resolve")
728
+ .description("Resolve @handles, LinkedIn profile/company links, provider ids, and URNs — through a connected sender (notify=false, free) or the managed scraper (--source scraper, paid, no sender/window needed).")
729
+ .option("--account <account>", "LinkedIn sender id, connection id, or Unipile account id. Required unless --source scraper.")
730
+ .option("--source <source>", "Resolution source: sender (default, free) or scraper (managed cookieless lookup; fetch current cost from tools get/dry-run; needs --approved --max-credits).")
731
+ .option("--approved", "Approve the paid scraper lookups (required with --source scraper).")
732
+ .option("--max-credits <n>", "Credit ceiling for scraper lookups (required with --source scraper).")
733
+ .option("--text <text>", "Post text containing @identifiers or LinkedIn URLs.")
734
+ .option("--text-file <path>", "Read post text from a local file.")
735
+ .option("--identifier <value...>", "Explicit provider id, public identifier, LinkedIn URL, or URN (repeat values after the flag).")
736
+ .option("--kind <kind>", "Type for explicit identifiers: person or company.")
737
+ .option("--as-mention", "Preview explicit identifiers as mentions instead of plain identity references.")
738
+ .option("--json", "Print a JSON envelope.")
739
+ .action(async (options) => {
740
+ await handleAsyncAction("publishing mentions resolve", options, () => requestOxygen("/api/cli/publishing/mentions/resolve", {
741
+ method: "POST",
742
+ body: buildPublishingMentionsResolveBody(options),
743
+ }));
744
+ })))
745
+ .addCommand(new Command("posts")
746
+ .description("Manage scheduled Publishing posts.")
747
+ .addCommand(new Command("list")
748
+ .description("List scheduled posts by publish time, oldest first; pass --order newest for the latest first. Follow next_cursor for more than one page.")
749
+ .option("--status <status>", "Filter by draft, scheduled, queued, publishing, published, failed, or canceled.")
750
+ .option("--approval-status <status>", "Filter by draft, needs_approval, approved, or rejected.")
751
+ .option("--tag <tag>", "Only posts carrying this workspace tag.")
752
+ .option("--provider <provider>", "Filter by provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
753
+ .option("--sender <sender_account_id>", "Only posts belonging to this connected sender in the active workspace.")
754
+ .option("--order <order>", "oldest (default) or newest publish time first.")
755
+ .option("--cursor <cursor>", "Continue from next_cursor with the same filters and order.")
756
+ .option("--limit <n>", "Maximum posts to return.")
757
+ .option("--json", "Print a JSON envelope.")
758
+ .action(async (options) => {
759
+ await handleAsyncAction("publishing posts list", options, () => requestOxygen(buildPublishingPostsListPath(options)));
760
+ }))
761
+ .addCommand(new Command("create")
762
+ .description("Create a personal Publishing post. Use --ugc-participation-id to save it into one program as an unapproved draft; otherwise it defaults to scheduled and publishes only after approval.")
763
+ .requiredOption("--publish-at <iso>", "When the post publishes, e.g. 2026-10-06T09:00:00+02:00. With Z or an offset it is that exact instant; without one (2026-10-06T09:00) it is wall-clock time in --timezone.")
764
+ .option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube. Defaults to linkedin. LinkedIn publishes through --sender; every other provider publishes through a connected account (--provider-connection), which the worker needs before it can deliver.")
765
+ .option("--channel <channel>", "Publishing channel override. Defaults to provider.")
766
+ .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
767
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers. Find it with `integrations list` (each integration's connection); create one with `integrations connect <provider>` (an OAuth redirect URL, or Settings > Connections on the web).")
768
+ .option("--title <title>", "Internal title for the queue.")
769
+ .option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
770
+ .option("--text-file <path>", "Read post text from a local file.")
771
+ .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. X options have their own flags (--thread-post, --poll-option, --reply-settings, --alt-text, --photo-tag); raw X API keys can still ride in composio.arguments.")
772
+ .option("--first-comment <text>", "Comment OXYGEN posts right after the post, e.g. the link (LinkedIn, X, Instagram, YouTube). Wins over first_comment in --content-json.")
773
+ .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
774
+ .option("--timezone <tz>", "IANA zone (Europe/Berlin) the post is shown in and a --publish-at without an offset is read in. Defaults to the LinkedIn sender's own timezone when --sender is set, else UTC.")
775
+ .option("--status <status>", "draft or scheduled. Defaults to scheduled. A draft sits outside the queue until `publishing posts approve` schedules and arms it in one step; scheduled is queued for its publish time and still waits for approval.")
776
+ .option("--draft", "Create as a draft instead of scheduled.")
777
+ .option("--ugc-participation-id <id>", "Also enroll this personal post in one active creator participation. It is always saved as an unapproved draft; omit --approved.")
778
+ .option("--approved", "Mark an ordinary post approved for the scheduler. Invalid with --ugc-participation-id.")
779
+ .option("--idempotency-key <key>", "Reuse one key when retrying the same post creation to avoid a duplicate.")
780
+ .option("--tags <csv>", "Workspace tags to attach to the new post.")
781
+ .option("--thread-post <text>", "X: text of the next post in a thread (posts 2, 3, ... in order). Repeatable.", collectRepeatable, [])
782
+ .option("--thread-media <position=media_id>", "X: attach an uploaded media id to thread post N (2 or later), e.g. 2=<media_id>. Repeatable.", collectRepeatable, [])
783
+ .option("--poll-option <text>", "X: a poll option on the first post (2-4 options, 25 characters each). Repeatable.", collectRepeatable, [])
784
+ .option("--poll-duration <duration>", "X: how long the poll runs, e.g. 30m, 6h, 2d (5 minutes to 7 days; default 1d).")
785
+ .option("--reply-settings <who>", "X: who can reply: everyone, following, mentioned, or subscribers.")
786
+ .option("--alt-text <media_id=text>", "X: image or GIF description for screen readers (up to 1,000 characters). Repeatable.", collectRepeatable, [])
787
+ .option("--photo-tag <username>", "X: tag an account in the first post's images (up to 10). Repeatable.", collectRepeatable, [])
788
+ .option("--video-thumbnail <media_id>", "LinkedIn video cover: an uploaded JPEG or PNG media id (`publishing media upload`). Needs a video in media_asset_ids.")
789
+ .option("--video-thumbnail-file <path>", "Upload a local JPEG or PNG and use it as the LinkedIn video cover.")
790
+ .option("--json", "Print a JSON envelope.")
791
+ .action(async (options) => {
792
+ await handleAsyncAction("publishing posts create", options, async () => requestOxygen("/api/cli/publishing/posts", {
793
+ method: "POST",
794
+ body: await withPublishingVideoThumbnailOption(buildPublishingPostCreateBody(options), options),
795
+ }));
796
+ }))
797
+ .addCommand(new Command("get")
798
+ .description("Get one scheduled post with the sender account it publishes from (id, name, profile URL), resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id, once `publishing posts approve` has resolved the tags (before approval it is absent). Open web_url to preview the post as it will look on LinkedIn or X.")
799
+ .argument("<post_id>", "Scheduled post id.")
800
+ .option("--json", "Print a JSON envelope.")
801
+ .action(async (postId, options) => {
802
+ await handleAsyncAction("publishing posts get", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`));
803
+ }))
804
+ .addCommand(new Command("update")
805
+ .description("Update an editable draft, scheduled, or failed post. Content edits revoke approval. Text edits keep mention_previews. A cached repair must use the preview's existing verified publicIdentifier: update text, raw, identifier and offsets, preserving identity fields. For another company handle, resolve again; changing text cannot verify a different page.")
806
+ .argument("<post_id>", "Scheduled post id.")
807
+ .option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
808
+ .option("--channel <channel>", "Publishing channel override.")
809
+ .option("--sender <sender_account_id>", "LinkedIn sender account id.")
810
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
811
+ .option("--title <title>", "Internal title for the queue.")
812
+ .option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
813
+ .option("--text-file <path>", "Read post text from a local file.")
814
+ .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. This replaces the existing content object; use --content-merge-json or the X flags to change single keys.")
815
+ .option("--first-comment <text>", "Set the comment OXYGEN posts right after the post, keeping the rest of its content. Revokes approval on an approved post.")
816
+ .option("--clear-first-comment", "Remove the first comment. Revokes approval on an approved post.")
817
+ .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
818
+ .option("--publish-at <iso>", "When the post publishes. With Z or an offset it is that exact instant; without one it is wall-clock time in --timezone, else the post's own timezone.")
819
+ .option("--timezone <tz>", "IANA zone the post is shown in and a --publish-at without an offset is read in. Unchanged when omitted.")
820
+ .option("--status <status>", "draft or scheduled. Setting scheduled does not itself approve the post.")
821
+ .option("--thread-post <text>", "X: text of the next post in a thread (posts 2, 3, ... in order). Repeatable.", collectRepeatable, [])
822
+ .option("--thread-media <position=media_id>", "X: attach an uploaded media id to thread post N (2 or later), e.g. 2=<media_id>. Repeatable.", collectRepeatable, [])
823
+ .option("--poll-option <text>", "X: a poll option on the first post (2-4 options, 25 characters each). Repeatable.", collectRepeatable, [])
824
+ .option("--poll-duration <duration>", "X: how long the poll runs, e.g. 30m, 6h, 2d (5 minutes to 7 days; default 1d).")
825
+ .option("--reply-settings <who>", "X: who can reply: everyone, following, mentioned, or subscribers.")
826
+ .option("--alt-text <media_id=text>", "X: image or GIF description for screen readers (up to 1,000 characters). Repeatable.", collectRepeatable, [])
827
+ .option("--photo-tag <username>", "X: tag an account in the first post's images (up to 10). Repeatable.", collectRepeatable, [])
828
+ .option("--content-merge-json <json>", "Merge content keys; null removes a key, e.g. '{\"poll\":null}'. Arrays, including mention_previews, are replaced whole: carry every entry you want to keep.")
829
+ .option("--video-thumbnail <media_id>", "Set the LinkedIn video cover to an uploaded JPEG or PNG media id (`publishing media upload`). The post needs a video attached.")
830
+ .option("--video-thumbnail-file <path>", "Upload a local JPEG or PNG and set it as the LinkedIn video cover. To pick a frame from the video instead, open the post's web_url and use Cover.")
831
+ .option("--clear-video-thumbnail", "Remove the LinkedIn video cover.")
832
+ .option("--confirm-not-published", "Only after you checked the account: the post OXYGEN could not confirm is NOT there. Unlocks a post that failed with publishing_effect_outcome_uncertain; if the post is live, do not use it or it publishes twice.")
833
+ .option("--json", "Print a JSON envelope.")
834
+ .action(async (postId, options) => {
835
+ await handleAsyncAction("publishing posts update", options, async () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`, {
836
+ method: "PATCH",
837
+ body: await withPublishingVideoThumbnailOption(buildPublishingPostUpdateBody(options), options),
838
+ }));
839
+ }))
840
+ .addCommand(new Command("approve")
841
+ .description("Approve a draft or scheduled post; drafts become scheduled for their saved publish time. Costs 0 credits. LinkedIn checks person handles and page IDs before arming. Current resolved previews with handle mismatches fail before lookups. Company handles are rechecked through the selected sender and server identity cache; free provider reads may be needed even with stored previews. Fix tags with `publishing posts update` and matching mention_previews. queued_at records first arming; publish_at controls publication. Program drafts require UGC approval.")
842
+ .argument("<post_id>", "Draft or scheduled post id.")
843
+ .option("--json", "Print a JSON envelope.")
844
+ .action(async (postId, options) => {
845
+ await handleAsyncAction("publishing posts approve", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/approve`, {
846
+ method: "POST",
847
+ }));
848
+ }))
849
+ .addCommand(new Command("cancel")
850
+ .description("Cancel a draft, scheduled, queued, or failed post and revoke its approval. Use `publishing posts uncancel` to reopen it as an unapproved draft; approve it again before it can publish.")
851
+ .argument("<post_id>", "Scheduled post id.")
852
+ .option("--json", "Print a JSON envelope.")
853
+ .action(async (postId, options) => {
854
+ await handleAsyncAction("publishing posts cancel", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/cancel`, {
855
+ method: "POST",
856
+ }));
857
+ }))
858
+ .addCommand(new Command("retry")
859
+ .description("Move a failed post back to scheduled for another worker attempt. A post that failed with publishing_effect_outcome_uncertain stays locked until you check the account and pass --confirm-not-published.")
860
+ .argument("<post_id>", "Scheduled post id.")
861
+ .option("--confirm-not-published", "Only after you checked the account: the post OXYGEN could not confirm is NOT there. Unlocks a post that failed with publishing_effect_outcome_uncertain; if the post is live, do not use it or it publishes twice.")
862
+ .option("--json", "Print a JSON envelope.")
863
+ .action(async (postId, options) => {
864
+ await handleAsyncAction("publishing posts retry", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/retry`, {
865
+ method: "POST",
866
+ ...(options.confirmNotPublished ? { body: { confirm_not_published: true } } : {}),
867
+ }));
868
+ }))
869
+ .addCommand(new Command("uncancel")
870
+ .description("Reopen a canceled post as an unapproved draft, keeping its publish time and its attempt history. Cancelling revoked approval, so approve it again to re-arm it for that time — approving a draft schedules and arms it in one step. This is cancel's inverse, NOT revision rollback: to roll the text back to an earlier revision use `publishing posts restore`.")
871
+ .argument("<post_id>", "Scheduled post id.")
872
+ .option("--json", "Print a JSON envelope.")
873
+ .action(async (postId, options) => {
874
+ await handleAsyncAction("publishing posts uncancel", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/uncancel`, {
875
+ method: "POST",
876
+ }));
877
+ }))
878
+ .addCommand(new Command("delete")
879
+ .description("Permanently delete the OXYGEN copy and its settled history, keeping the live platform post intact. Supports never-attempted draft/scheduled/queued posts, settled canceled unpublished posts, and settled published copies. Published deletions stay excluded from background discovery. Cancel failed or re-armed posts first. In-flight publishing, replies, amplification, and active amplification spend accounting remain protected. Live LinkedIn deletion is a separate action using `oxygen posts delete`.")
880
+ .argument("<post_id>", "Scheduled post id.")
881
+ .option("--json", "Print a JSON envelope.")
882
+ .action(async (postId, options) => {
883
+ await handleAsyncAction("publishing posts delete", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`, {
884
+ method: "DELETE",
885
+ }));
886
+ }))
887
+ .addCommand(new Command("draft")
888
+ .description("Draft posts with AI, grounded in the workspace Knowledge Graph. PAID (one AI call) — requires --max-credits. Writes drafts only: nothing is scheduled or published until you accept a draft and approve the post.")
889
+ .requiredOption("--template <key>", "Draft template: summarize, repurpose, changelog, feature_launch, case_study, personal_story, startup_advice, company_milestone, hiring, fundraise, hook_generator, popular_questions, tech_stack, podcast_clip, or hashtags.")
890
+ .requiredOption("--max-credits <n>", "Credit cap for the AI call (required — this is a paid action).")
891
+ .option("--topic <topic>", "What the post is about. Required by most templates.")
892
+ .option("--channels <list>", "Comma-separated target channels: linkedin, x, instagram, tiktok, facebook, youtube.")
893
+ .option("--variants <n>", "How many angles to draft (1-5). Defaults to 3.")
894
+ .option("--no-ground", "Skip Knowledge-Graph page retrieval. The canonical brand voice still applies.")
895
+ .option("--source-text <text>", "Source text for summarize, repurpose, podcast_clip, and hashtags.")
896
+ .option("--source-file <path>", "Read the source text from a local file.")
897
+ .option("--source-url <url>", "Source URL to reference.")
898
+ .option("--json", "Print a JSON envelope.")
899
+ .action(async (options) => {
900
+ await handleAsyncAction("publishing posts draft", options, () => requestOxygen("/api/cli/publishing/posts/draft", {
901
+ method: "POST",
902
+ body: buildPublishingPostsDraftBody(options),
903
+ }));
904
+ }))
905
+ .addCommand(new Command("review")
906
+ .description("Pre-publish review of a post: deterministic channel lint (free, and blocking at approve) plus an AI voice/claims check against the Knowledge Graph. PAID (one AI call) — requires --max-credits. AI findings never block approval.")
907
+ .argument("<post_id>", "Scheduled post id.")
908
+ .requiredOption("--max-credits <n>", "Credit cap for the AI review call (required — this is a paid action).")
909
+ .option("--json", "Print a JSON envelope.")
910
+ .action(async (postId, options) => {
911
+ await handleAsyncAction("publishing posts review", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/review`, {
912
+ method: "POST",
913
+ body: { max_credits: readPositiveNumber(options.maxCredits) },
914
+ }));
915
+ }))
916
+ .addCommand(new Command("boost")
917
+ .description("Boost ONE post: your teammates' connected accounts react to and comment on it, once. These are REAL public LinkedIn writes from their accounts and they SPEND CREDITS — refused (exit 7) without --approved, --max-credits and --acknowledge-risk. Creates a post-scoped policy that is already enabled and plans its actions immediately. For a standing rule across many posts, use `publishing amplification create`.")
918
+ .argument("<post_id>", "Scheduled post id.")
919
+ .option("--name <name>", "Name the post-scoped amplification policy.")
920
+ .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
921
+ .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
922
+ .option("--max-actions-per-post <n>", "Hard cap on how many actions are planned (at most 12).")
923
+ .option("--max-credits <n>", "Credit ceiling for the boost (required — this is a paid action).")
924
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
925
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
926
+ .option("--approved", "Confirm the real public engagement. Without it the command refuses and nothing is planned.")
927
+ .option("--acknowledge-risk", "Acknowledge that LinkedIn may warn, restrict or close accounts used for automated engagement. Required.")
928
+ .option("--json", "Print a JSON envelope.")
929
+ .action(async (postId, options) => {
930
+ await handleAsyncAction("publishing posts boost", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/boost`, {
931
+ method: "POST",
932
+ body: buildPublishingBoostBody(postId, options),
933
+ }));
934
+ }))
935
+ .addCommand(addContractFlags(new Command("revisions"), publishingRevisionsQueryContract)
936
+ .description("List a post's content history: newest-first revisions with their number, title, text, author, and time.")
937
+ .argument("<post_id>", "Scheduled post id.")
938
+ .option("--json", "Print a JSON envelope.")
939
+ .action(async (postId, options) => {
940
+ await handleAsyncAction("publishing posts revisions", options, () => requestOxygen(publishingReadQueryPath(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/revisions`, publishingRevisionsQueryContract, options)));
941
+ }))
942
+ .addCommand(new Command("restore")
943
+ .description("Roll a post's TEXT back to an earlier revision (see `publishing posts revisions`). The current text is snapshotted as a new revision first, so a restore is itself undoable. Refused once the post is publishing, published, or canceled — to bring a canceled post back, use `publishing posts uncancel`.")
944
+ .argument("<post_id>", "Scheduled post id.")
945
+ .requiredOption("--revision <n>", "Revision number to restore.")
946
+ .option("--json", "Print a JSON envelope.")
947
+ .action(async (postId, options) => {
948
+ await handleAsyncAction("publishing posts restore", options, () => {
949
+ const revision = readPositiveInt(options.revision);
950
+ if (revision === undefined) {
951
+ throw new OxygenError("invalid_request", "Pass --revision <n>.", { exitCode: 1 });
952
+ }
953
+ return requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/revisions/${revision}/restore`, { method: "POST" });
954
+ });
955
+ }))
956
+ .addCommand(addContractFlags(new Command("notes"), publishingNotesQueryContract)
957
+ .description("List the internal review notes on a post. Notes are workspace-only — they are never published.")
958
+ .argument("<post_id>", "Scheduled post id.")
959
+ .option("--json", "Print a JSON envelope.")
960
+ .action(async (postId, options) => {
961
+ await handleAsyncAction("publishing posts notes", options, () => requestOxygen(publishingReadQueryPath(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments`, publishingNotesQueryContract, options)));
962
+ }))
963
+ .addCommand(new Command("note")
964
+ .description("Leave an internal review note on a post, or reply to one with --parent. Never published.")
965
+ .argument("<post_id>", "Scheduled post id.")
966
+ .option("--text <text>", "The note.")
967
+ .option("--text-file <path>", "Read the note from a local file.")
968
+ .option("--parent <comment_id>", "Reply to this note.")
969
+ .option("--json", "Print a JSON envelope.")
970
+ .action(async (postId, options) => {
971
+ await handleAsyncAction("publishing posts note", options, () => {
972
+ const parent = readOption(options.parent);
973
+ return requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments`, {
974
+ method: "POST",
975
+ body: {
976
+ body: readPublishingPostText(options, true),
977
+ ...(parent ? { parent_comment_id: parent } : {}),
978
+ },
979
+ });
980
+ });
981
+ }))
982
+ .addCommand(new Command("resolve-note")
983
+ .description("Resolve a review note, or reopen it.")
984
+ .argument("<post_id>", "Scheduled post id.")
985
+ .requiredOption("--comment <comment_id>", "Note id from `publishing posts notes`.")
986
+ .option("--reopen", "Reopen the review note instead of resolving it.")
987
+ .option("--json", "Print a JSON envelope.")
988
+ .action(async (postId, options) => {
989
+ await handleAsyncAction("publishing posts resolve-note", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments/${encodeURIComponent(options.comment)}/resolve`, { method: "POST", body: { resolved: options.reopen !== true } }));
990
+ })))
991
+ .addCommand(new Command("drafts")
992
+ .description("Review the AI post-draft queue before anything reaches the publish queue.")
993
+ .addCommand(new Command("list")
994
+ .description("List post drafts. Defaults to the open queue (queued + edited).")
995
+ .option("--kind <kind>", "Filter by ai or idea.")
996
+ .option("--status <list>", "Comma-separated statuses: queued, edited, accepted, rejected.")
997
+ .option("--limit <n>", "Maximum drafts to return.")
998
+ .option("--json", "Print a JSON envelope.")
999
+ .action(async (options) => {
1000
+ await handleAsyncAction("publishing drafts list", options, () => requestOxygen(buildPublishingDraftsListPath(options)));
1001
+ }))
1002
+ .addCommand(new Command("accept")
1003
+ .description("Accept a draft variant into the publish queue. The post is created needs-approval — approve it separately before it can publish.")
1004
+ .argument("<draft_id>", "Post draft id.")
1005
+ .option("--variant <i>", "Which variant to accept (0-based). Defaults to 0.")
1006
+ .option("--publish-at <iso>", "ISO date-time to schedule the post for; without an offset it is wall-clock time in --timezone. Defaults to now (still needs approval).")
1007
+ .option("--channel <channel>", "Channel override. Defaults to the draft's first target channel.")
1008
+ .option("--sender <sender_account_id>", "LinkedIn sender account id.")
1009
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
1010
+ .option("--timezone <tz>", "IANA zone the post is shown in and a --publish-at without an offset is read in. Defaults to UTC.")
1011
+ .option("--json", "Print a JSON envelope.")
1012
+ .action(async (draftId, options) => {
1013
+ await handleAsyncAction("publishing drafts accept", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}/accept`, {
1014
+ method: "POST",
1015
+ body: buildPublishingDraftsAcceptBody(options),
1016
+ }));
1017
+ }))
1018
+ .addCommand(new Command("reject")
1019
+ .description("Reject a draft. The reason is filed to the Knowledge Graph so the next drafts learn from it.")
1020
+ .argument("<draft_id>", "Post draft id.")
1021
+ .option("--reason <reason>", "Why this draft was rejected.")
1022
+ .option("--json", "Print a JSON envelope.")
1023
+ .action(async (draftId, options) => {
1024
+ await handleAsyncAction("publishing drafts reject", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}/reject`, {
1025
+ method: "POST",
1026
+ body: readOption(options.reason) ? { reason: readOption(options.reason) } : {},
1027
+ }));
1028
+ }))
1029
+ .addCommand(new Command("edit")
1030
+ .description("Rewrite one variant's text. The first edit snapshots the AI's original copy, so accepting it teaches the Knowledge Graph what you changed.")
1031
+ .argument("<draft_id>", "Post draft id.")
1032
+ .option("--variant <i>", "Which variant to edit (0-based). Defaults to 0.")
1033
+ .option("--text <text>", "The replacement post text.")
1034
+ .option("--text-file <path>", "Read the replacement text from a local file.")
1035
+ .option("--json", "Print a JSON envelope.")
1036
+ .action(async (draftId, options) => {
1037
+ await handleAsyncAction("publishing drafts edit", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}`, {
1038
+ method: "PATCH",
1039
+ body: {
1040
+ variant_index: readNonNegativeInt(options.variant) ?? 0,
1041
+ text: readPublishingPostText(options, true),
1042
+ },
1043
+ }));
1044
+ })))
1045
+ .addCommand(new Command("media")
1046
+ .description("Manage Publishing media uploads.")
1047
+ .addCommand(new Command("upload")
1048
+ .description("Upload a local image, video, or PDF in one step and print its media id. Attach it with --content-json '{\"media_asset_ids\":[...]}', or use a JPEG/PNG as a LinkedIn video cover with `publishing posts update --video-thumbnail`.")
1049
+ .argument("<path>", "Local file path.")
1050
+ .option("--content-type <type>", "MIME type when the file extension does not say it, such as video/mp4.")
1051
+ .option("--scheduled-post <post_id>", "Optional provenance association; it does not attach the file to the post.")
1052
+ .option("--json", "Print a JSON envelope.")
1053
+ .action(async (filePath, options) => {
1054
+ await handleAsyncAction("publishing media upload", options, () => uploadPublishingMediaFile(filePath, {
1055
+ contentType: readOption(options.contentType) ?? null,
1056
+ scheduledPostId: readOption(options.scheduledPost) ?? null,
1057
+ }));
1058
+ }))
1059
+ .addCommand(new Command("upload-url")
1060
+ .description("Create a Publishing media asset and presigned upload URL with required upload headers.")
1061
+ .requiredOption("--file-name <name>", "Original media filename.")
1062
+ .requiredOption("--content-type <type>", "Media MIME type, such as image/png or video/mp4; use the returned upload_headers for PUT.")
1063
+ .requiredOption("--byte-length <bytes>", "Exact media byte length.")
1064
+ .option("--scheduled-post <post_id>", "Optional provenance association; also set the post content.media_asset_ids to attach it.")
1065
+ .option("--metadata-json <json>", "Optional metadata object to store with the media asset.")
1066
+ .option("--json", "Print a JSON envelope.")
1067
+ .action(async (options) => {
1068
+ await handleAsyncAction("publishing media upload-url", options, () => requestOxygen("/api/cli/publishing/media/upload-url", {
1069
+ method: "POST",
1070
+ body: buildPublishingMediaUploadUrlBody(options),
1071
+ }));
1072
+ }))
1073
+ .addCommand(new Command("uploaded")
1074
+ .description("Verify an uploaded Publishing media object and mark the media asset uploaded.")
1075
+ .argument("<media_id>", "Publishing media asset id.")
1076
+ .option("--sha256 <hex>", "Optional SHA-256 digest of the uploaded bytes.")
1077
+ .option("--json", "Print a JSON envelope.")
1078
+ .action(async (mediaId, options) => {
1079
+ await handleAsyncAction("publishing media uploaded", options, () => requestOxygen(`/api/cli/publishing/media/${encodeURIComponent(mediaId)}/uploaded`, {
1080
+ method: "POST",
1081
+ body: buildPublishingMediaUploadedBody(options),
1082
+ }));
1083
+ })))
1084
+ .addCommand(new Command("tags")
1085
+ .description("Apply unified workspace tags to scheduled posts; tags are never published.")
1086
+ .addCommand(new Command("set")
1087
+ .description("Add and/or remove workspace tags on a post.")
1088
+ .argument("<post_id>", "Scheduled post id.")
1089
+ .option("--add <tags>", "Comma-separated workspace tags to add.")
1090
+ .option("--remove <tags>", "Comma-separated workspace tags to remove.")
1091
+ .option("--json", "Print a JSON envelope.")
1092
+ .action(async (postId, options) => {
1093
+ await handleAsyncAction("publishing tags set", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/tags`, {
1094
+ method: "POST",
1095
+ body: buildPublishingTagsBody(options),
1096
+ }));
1097
+ })))
1098
+ .addCommand(new Command("comments")
1099
+ .description("Publishing Community for public comments on every recent post owned by a connected LinkedIn account, including posts created directly on LinkedIn. Start with `oxygen publishing comments list --view unanswered --json`. These are network conversations, not workspace review notes. The local queue targets a six-hour background discovery + comment poll; provider quota/backoff can delay it. Read freshness, empty_queue_is_current and guidance before trusting an empty queue. automatic_retry_at reports a future retry; next_sync_at alone does not confirm one. An unconfirmed provider read can pause refresh; contact support when guidance says to. No manual retry is available. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
1100
+ .addCommand(new Command("list")
1101
+ .description("List the Publishing Community work queue, oldest first, with polling freshness/backoff even when empty. Defaults to the Unanswered view: needs_reply, draft, awaiting_approval, and action_unavailable. All covers every lifecycle state on published owned posts within 30 days plus UGC-linked posts, not lifetime LinkedIn history. Completed coverage counts earlier successful polls and can overlap failed coverage after a later error. Read-only: no provider call, 0 credits.")
1102
+ .option("--view <view>", "Community view: unanswered (default) or all; all is bounded to recent owned posts.")
1103
+ .option("--status <status>", "Advanced exact-state filter: needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable. Cannot be combined with --view.")
1104
+ .option("--post <post_id>", "Only comments on one scheduled Publishing post.")
1105
+ .option("--assignee <actor>", "Only one actor id; pass me for your own assignments.")
1106
+ .option("--channel <channel>", "Social channel. Defaults to linkedin.")
1107
+ .option("--cursor <cursor>", "Opaque next_cursor from the previous page.")
1108
+ .option("--limit <n>", "Maximum comments (default 50; maximum 100). Counts, scope and freshness accompany every page; use 1 for a compact metadata read.")
1109
+ .option("--json", "Print a JSON envelope.")
1110
+ .action(async (options) => {
1111
+ await handleAsyncAction("publishing comments list", options, () => requestOxygen(buildPublishingCommentsListPath(options)));
1112
+ }))
1113
+ .addCommand(new Command("get")
1114
+ .description("Get one stored public comment with its provider thread and immutable reply-action history. Read-only: no provider call, 0 credits.")
1115
+ .argument("<comment_id>", "OXYGEN public comment id.")
1116
+ .option("--json", "Print a JSON envelope.")
1117
+ .action(async (commentId, options) => {
1118
+ await handleAsyncAction("publishing comments get", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}`));
1119
+ }))
1120
+ .addCommand(new Command("update")
1121
+ .description("Save local draft, notes, assignment, or triage state. Nothing is posted publicly and no credits are used.")
1122
+ .argument("<comment_id>", "OXYGEN public comment id.")
1123
+ .option("--draft <text>", "Save a reply draft locally.")
1124
+ .option("--draft-file <path>", "Read the local reply draft from a file.")
1125
+ .option("--notes <text>", "Workspace-only operator notes.")
1126
+ .option("--assignee <actor_id>", "Assign to a workspace actor id; pass an empty string to clear.")
1127
+ .option("--status <status>", "Set needs_reply, draft, resolved, ignored, or spam.")
1128
+ .option("--json", "Print a JSON envelope.")
1129
+ .action(async (commentId, options) => {
1130
+ await handleAsyncAction("publishing comments update", options, () => {
1131
+ const hasDraft = Boolean(readOption(options.draft) || readOption(options.draftFile));
1132
+ if (!hasDraft && options.notes === undefined && options.assignee === undefined && options.status === undefined) {
1133
+ throw new OxygenError("invalid_request", "Pass --draft/--draft-file, --notes, --assignee, or --status.", { exitCode: 1 });
1134
+ }
1135
+ return requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}`, {
1136
+ method: "PATCH",
1137
+ body: {
1138
+ ...(hasDraft
1139
+ ? { draft_body: readPublishingCommentText(options.draft, options.draftFile, "draft") }
1140
+ : {}),
1141
+ ...(options.notes !== undefined ? { notes: options.notes } : {}),
1142
+ ...(options.assignee !== undefined
1143
+ ? { assignee_actor_id: readOption(options.assignee) ?? null }
1144
+ : {}),
1145
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
1146
+ },
1147
+ });
1148
+ });
1149
+ }))
1150
+ .addCommand(new Command("reply")
1151
+ .description("Preview an exact public reply and return its action_id + content_hash. Makes no provider call, posts nothing, and uses 0 credits.")
1152
+ .argument("<comment_id>", "OXYGEN public comment id.")
1153
+ .option("--text <text>", "Reply text to preview.")
1154
+ .option("--text-file <path>", "Read reply text from a file.")
1155
+ .option("--idempotency-key <key>", "Stable key for safely recreating the same preview.")
1156
+ .option("--json", "Print a JSON envelope.")
1157
+ .action(async (commentId, options) => {
1158
+ await handleAsyncAction("publishing comments reply preview", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}/reply-actions`, {
1159
+ method: "POST",
1160
+ body: {
1161
+ body: readPublishingCommentText(options.text, options.textFile, "text"),
1162
+ ...(readOption(options.idempotencyKey)
1163
+ ? { idempotency_key: readOption(options.idempotencyKey) }
1164
+ : {}),
1165
+ },
1166
+ }));
1167
+ }))
1168
+ .addCommand(new Command("approve")
1169
+ .description("Post one exact previewed reply publicly. A REAL LinkedIn write; refused without --approved and the preview's content hash.")
1170
+ .argument("<action_id>", "Reply action id returned by `publishing comments reply`.")
1171
+ .requiredOption("--content-hash <sha256>", "Exact content_hash returned by the preview.")
1172
+ .option("--approved", "Approve this exact public reply.")
1173
+ .option("--json", "Print a JSON envelope.")
1174
+ .action(async (actionId, options) => {
1175
+ await handleAsyncAction("publishing comments reply approve", options, () => {
1176
+ if (options.approved !== true) {
1177
+ throw new OxygenError("approval_required", "Public replies require --approved after inspecting the exact preview.", { exitCode: 7 });
1178
+ }
1179
+ return requestOxygen(`/api/cli/publishing/comment-replies/${encodeURIComponent(actionId)}/approve`, {
1180
+ method: "POST",
1181
+ body: { content_hash: options.contentHash, approved: true },
1182
+ });
1183
+ });
1184
+ }))
1185
+ .addCommand(new Command("resolve")
1186
+ .description("Resolve, ignore, mark spam, or reopen one local public-comment work item. No provider write, 0 credits.")
1187
+ .argument("<comment_id>", "OXYGEN public comment id.")
1188
+ .option("--resolution <status>", "resolved (default), ignored, spam, or needs_reply.")
1189
+ .option("--json", "Print a JSON envelope.")
1190
+ .action(async (commentId, options) => {
1191
+ await handleAsyncAction("publishing comments resolve", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}/resolve`, {
1192
+ method: "POST",
1193
+ body: { resolution: readOption(options.resolution) ?? "resolved" },
1194
+ }));
1195
+ })))
1196
+ .addCommand(new Command("analytics")
1197
+ .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Read freshness and guidance for delayed refresh; an unconfirmed provider read can pause it. No manual provider retry is available.")
1198
+ .addCommand(new Command("summary")
1199
+ .description("Workspace LinkedIn or X analytics. LinkedIn includes owned posts discovered from the provider; X currently includes posts published through OXYGEN. Uses each post's latest stored snapshot; snapshotted_posts (legacy measured_posts) counts sync passes, while coverage counts non-null values. Read-only: no provider call, 0 credits.")
1200
+ .option("--channel <channel>", "Channel to summarize: linkedin (default) or x.")
1201
+ .option("--range <range>", "Publication window: 7d, 30d (default), 90d, or 365d.")
1202
+ .option("--from <date>", "Custom inclusive UTC start date.")
1203
+ .option("--to <date>", "Custom inclusive UTC end date.")
1204
+ .option("--limit <n>", "Top posts to return. Defaults to 10, maximum 50.")
1205
+ .option("--sort <metric>", "Rank top posts by engagements (default; reactions + comments + reposts), impressions, reactions, comments, or reposts. `leader` reports the top post's lead over the runner-up and the median.")
1206
+ .option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
1207
+ .option("--json", "Print a JSON envelope.")
1208
+ .action(async (options) => {
1209
+ const accounts = readPublishingAnalyticsAccounts(options.account);
1210
+ let data;
1211
+ await handleAsyncAction("publishing analytics summary", options, async () => {
1212
+ data = await requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
1213
+ channel: readOption(options.channel),
1214
+ range: readOption(options.range),
1215
+ from: readOption(options.from),
1216
+ to: readOption(options.to),
1217
+ limit: readOption(options.limit),
1218
+ sort: readOption(options.sort),
1219
+ accounts,
1220
+ }));
1221
+ return data;
1222
+ });
1223
+ if (!options.json && accounts.length === 0)
1224
+ writePublishingAnalyticsAccountHint(data);
1225
+ }))
1226
+ .addCommand(new Command("timeseries")
1227
+ .description("Impressions and engagements EARNED per day across all tracked posts (not only posts published in the range), with reactions, comments and reposts also reported on their own, whether each is growing or shrinking (against the previous period, or when tracking began inside the range, the second half of its tracked days against the first; `trend.basis` says which and `trend.compared` shows both values), and follower change per LinkedIn account. Days without a tracked post are null, never zero; `estimated` days spread a gain across an unread gap of up to three days. Read-only: no provider call, 0 credits.")
1228
+ .option("--channel <channel>", "Channel: linkedin (default) or x.")
1229
+ .option("--range <range>", "Window: 7d, 30d (default), 90d, or 365d.")
1230
+ .option("--from <date>", "Custom inclusive start date.")
1231
+ .option("--to <date>", "Custom inclusive end date.")
1232
+ .option("--tz <zone>", "IANA time zone for day boundaries, e.g. Europe/Berlin. Defaults to UTC.")
1233
+ .option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
1234
+ .option("--json", "Print a JSON envelope.")
1235
+ .action(async (options) => {
1236
+ await handleAsyncAction("publishing analytics timeseries", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/timeseries", {
1237
+ channel: readOption(options.channel),
1238
+ range: readOption(options.range),
1239
+ from: readOption(options.from),
1240
+ to: readOption(options.to),
1241
+ tz: readOption(options.tz),
1242
+ accounts: readPublishingAnalyticsAccounts(options.account),
1243
+ })));
1244
+ }))
1245
+ .addCommand(addContractFlags(new Command("post"), publishingAnalyticsQueryContract)
1246
+ .description("One post's stored daily metric series, totals, growth curve, 24h/7d benchmarks against the workspace median, and earned-media value. EMV is impressions-derived; absent impressions return a reason instead of a made-up number. Read-only: no provider call, 0 credits.")
1247
+ .argument("<post_id>", "Scheduled post id.")
1248
+ .option("--since <date>", "ISO date to start the series from.")
1249
+ .option("--json", "Print a JSON envelope.")
1250
+ .action(async (postId, options) => {
1251
+ await handleAsyncAction("publishing analytics post", options, () => requestOxygen(publishingReadQueryPath(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) }), publishingAnalyticsQueryContract, options)));
1252
+ }))
1253
+ .addCommand(addContractFlags(new Command("account"), publishingAnalyticsQueryContract)
1254
+ .description("Stored per-account metric series (followers) for connected LinkedIn and X publishing accounts, read once a day in the background. Each account reports its snapshot freshness; stale values remain available. Read-only: no provider call, 0 credits.")
1255
+ .option("--provider <provider>", "Channel to read: linkedin or x. Omit for every connected channel.")
1256
+ .option("--since <date>", "ISO date to start the series from.")
1257
+ .option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
1258
+ .option("--json", "Print a JSON envelope.")
1259
+ .action(async (options) => {
1260
+ await handleAsyncAction("publishing analytics account", options, () => requestOxygen(publishingReadQueryPath(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/account", {
1261
+ provider: readOption(options.provider),
1262
+ since: readOption(options.since),
1263
+ accounts: readPublishingAnalyticsAccounts(options.account),
1264
+ }), publishingAnalyticsQueryContract, options)));
1265
+ }))
1266
+ .addCommand(new Command("emv")
1267
+ .description("Set the workspace CPM used for earned-media value, per channel. EMV stays null on any channel whose provider does not expose impressions.")
1268
+ .requiredOption("--cpm-json <json>", "Per-channel CPM object, e.g. '{\"linkedin\":30,\"x\":12}'.")
1269
+ .option("--json", "Print a JSON envelope.")
1270
+ .action(async (options) => {
1271
+ await handleAsyncAction("publishing analytics emv", options, () => requestOxygen("/api/cli/publishing/analytics/emv", {
1272
+ method: "POST",
1273
+ body: { cpm: parseJsonObject(options.cpmJson ?? "") },
1274
+ }));
1275
+ }))
1276
+ .addHelpText("after", "\nExamples (stored snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel linkedin --range 30d --sort impressions --json\n oxygen publishing analytics timeseries --channel linkedin --range 30d --tz Europe/Berlin --json\n\nTotals, leader and trends combine every posting account; `accounts` lists them and `--account <key>` counts only the ones you name. `top_posts[].account` names each post's author.\nWhy a metric is null: every null carries a reason in `null_reasons`:\n not_exposed_by_provider the rail OXYGEN currently reads through never returns it (`capabilities.metrics.<metric>.reason` names the limit)\n not_synced_yet no background snapshot has landed yet\n not_returned_by_provider snapshots exist but the provider left it out (LinkedIn shows impressions only to the post's author)\nSee also: the oxygen-publishing product skill (`oxygen skills get oxygen-publishing --file references/analytics.md`) for reading LinkedIn performance.\n"))
1277
+ .addCommand(new Command("import")
1278
+ .description("Bulk-load scheduled posts from a .csv or .json file. Dry-run by DEFAULT: it parses, lints, and reports invalid rows while writing nothing. Pass --approved to actually write. Imported posts land as drafts that still need approval — import never approves and never publishes. A row whose idempotency_key was already used is skipped, so re-importing the same file cannot duplicate the queue.")
1279
+ .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }). Columns: content_text, publish_at, and optionally sender_account_id, provider, timezone, title, first_comment, tags, idempotency_key. A publish_at without Z or an offset is wall-clock time in the row's timezone, else its sender's, else UTC.")
1280
+ .option("--dry-run", "Parse, lint, and report without writing. Default.")
1281
+ .option("--approved", "Write the parsed rows into the queue as needs-approval drafts.")
1282
+ .option("--tags <tags>", "Comma-separated workspace tags for every imported post.")
1283
+ .option("--json", "Print a JSON envelope.")
1284
+ .action(async (options) => {
1285
+ await handleAsyncAction("publishing import", options, () => requestOxygen("/api/cli/publishing/import", {
1286
+ method: "POST",
1287
+ body: buildPublishingImportBody(options),
1288
+ }));
1289
+ }))
1290
+ .addCommand(new Command("amplification")
1291
+ .description("Standing auto-engagement policies: the sender accounts you name react to and comment on every post in scope, paced and capped. Default OFF — a new policy is created disabled, and arming it is a separate, deliberate call.")
1292
+ .addCommand(addContractFlags(new Command("list"), publishingAmplificationListQueryContract)
1293
+ .description("List amplification policies with their scope, caps, and enabled state.")
1294
+ .option("--json", "Print a JSON envelope.")
1295
+ .action(async (options) => {
1296
+ await handleAsyncAction("publishing amplification list", options, () => requestOxygen(publishingReadQueryPath("/api/cli/publishing/amplification", publishingAmplificationListQueryContract, options)));
1297
+ }))
1298
+ .addCommand(new Command("create")
1299
+ .description("Create an amplification policy. It arms REAL public engagement from other people's connected accounts and, once enabled, draws credits each cycle (creating it spends nothing), so it is refused (exit 7) without --approved, --max-credits and --acknowledge-risk — that grant is recorded with who/when/what scope. The policy is created DISABLED: run `amplification enable` when you actually want it to run.")
1300
+ .requiredOption("--name <name>", "Policy name.")
1301
+ .requiredOption("--scope <kind>", "What it amplifies: all, tag, or post.")
1302
+ .option("--tag <tag>", "Workspace tag to scope to (with --scope tag).")
1303
+ .option("--post <post_id>", "Post to scope to (with --scope post).")
1304
+ .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
1305
+ .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
1306
+ .option("--max-actions-per-post <n>", "Hard cap on actions per post (at most 12).")
1307
+ .option("--max-credits <n>", "Hard credit cap per cycle (required — this policy spends credits).")
1308
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
1309
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
1310
+ .option("--metadata-json <json-or-file>", "Policy metadata; {} clears it on update.")
1311
+ .option("--reaction-delay-min <seconds>", "Lower bound of the randomized reaction delay.")
1312
+ .option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
1313
+ .option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
1314
+ .option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
1315
+ .option("--approved", "Grant the standing permission. Without it the command refuses and creates nothing.")
1316
+ .option("--acknowledge-risk", "Acknowledge that LinkedIn may warn, restrict or close accounts used for automated engagement. Required.")
1317
+ .option("--json", "Print a JSON envelope.")
1318
+ .action(async (options) => {
1319
+ await handleAsyncAction("publishing amplification create", options, () => requestOxygen("/api/cli/publishing/amplification", {
1320
+ method: "POST",
1321
+ body: buildPublishingAmplificationCreateBody(options),
1322
+ }));
1323
+ }))
1324
+ .addCommand(new Command("get")
1325
+ .description("Get one policy: scope, participants, caps, pacing, enabled state, and standing approval.")
1326
+ .argument("<policy_id>", "Amplification policy id.")
1327
+ .option("--json", "Print a JSON envelope.")
1328
+ .action(async (policyId, options) => {
1329
+ await handleAsyncAction("publishing amplification get", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`));
1330
+ }))
1331
+ .addCommand(new Command("update")
1332
+ .description("Update a policy's participants, actions, caps, or pacing. Tightening a cap takes effect on the next cycle.")
1333
+ .argument("<policy_id>", "Amplification policy id.")
1334
+ .option("--name <name>", "Policy name.")
1335
+ .option("--senders <ids>", "Comma-separated connected sender account ids.")
1336
+ .option("--actions <list>", "Comma-separated actions: reaction, comment.")
1337
+ .option("--max-actions-per-post <n>", "Hard cap on actions per post (at most 12).")
1338
+ .option("--max-credits <n>", "Hard credit cap per cycle.")
1339
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable.", collectRepeatable, [])
1340
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
1341
+ .option("--metadata-json <json-or-file>", "Policy metadata; {} clears it on update.")
1342
+ .option("--reaction-delay-min <seconds>", "Lower bound of the randomized reaction delay.")
1343
+ .option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
1344
+ .option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
1345
+ .option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
1346
+ .option("--acknowledge-risk", "Record a fresh LinkedIn-restriction risk acknowledgement on the policy.")
1347
+ .option("--json", "Print a JSON envelope.")
1348
+ .action(async (policyId, options) => {
1349
+ await handleAsyncAction("publishing amplification update", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
1350
+ method: "PATCH",
1351
+ body: buildPublishingAmplificationUpdateBody(options),
1352
+ }));
1353
+ }))
1354
+ .addCommand(new Command("enable")
1355
+ .description("Arm a policy. From here the worker engages on every post in scope from the participants' real accounts, within the caps — so this refuses (exit 7) without --approved and --acknowledge-risk. Nothing else in the policy changes.")
1356
+ .argument("<policy_id>", "Amplification policy id.")
1357
+ .option("--approved", "Confirm arming the standing public engagement. Without it nothing is armed.")
1358
+ .option("--acknowledge-risk", "Acknowledge that LinkedIn may warn, restrict or close accounts used for automated engagement. Required.")
1359
+ .option("--json", "Print a JSON envelope.")
1360
+ .action(async (policyId, options) => {
1361
+ await handleAsyncAction("publishing amplification enable", options, () => {
1362
+ if (options.approved !== true) {
1363
+ throw new OxygenError("approval_required", "Enabling a policy starts real public engagement from the participants' connected accounts. Re-run with --approved.", { details: { policy_id: policyId }, exitCode: 7 });
1364
+ }
1365
+ requireAmplificationRiskAcknowledged(options.acknowledgeRisk, "Enabling a policy", {
1366
+ policy_id: policyId,
1367
+ });
1368
+ return requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/enable`, { method: "POST", body: { risk_acknowledged: true } });
1369
+ });
1370
+ }))
1371
+ .addCommand(new Command("disable")
1372
+ .description("Disarm a policy and skip every action it had already planned. Always allowed — the off switch is never gated.")
1373
+ .argument("<policy_id>", "Amplification policy id.")
1374
+ .option("--json", "Print a JSON envelope.")
1375
+ .action(async (policyId, options) => {
1376
+ await handleAsyncAction("publishing amplification disable", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/disable`, {
1377
+ method: "POST",
1378
+ }));
1379
+ }))
1380
+ .addCommand(new Command("delete")
1381
+ .description("Delete a policy.")
1382
+ .argument("<policy_id>", "Amplification policy id.")
1383
+ .option("--json", "Print a JSON envelope.")
1384
+ .action(async (policyId, options) => {
1385
+ await handleAsyncAction("publishing amplification delete", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
1386
+ method: "DELETE",
1387
+ }));
1388
+ }))
1389
+ .addCommand(addContractFlags(new Command("actions"), publishingAmplificationActionsQueryContract)
1390
+ .description("The policy's action ledger: what each participant account did (or is about to do) on which post, with status and cost.")
1391
+ .argument("<policy_id>", "Amplification policy id.")
1392
+ .option("--status <status>", "Filter by planned, dispatched, failed, or skipped.")
1393
+ .option("--json", "Print a JSON envelope.")
1394
+ .action(async (policyId, options) => {
1395
+ await handleAsyncAction("publishing amplification actions", options, () => requestOxygen(publishingReadQueryPath(buildPublishingAnalyticsPath(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/actions`, { status: readOption(options.status) }), publishingAmplificationActionsQueryContract, options)));
1396
+ })))
1397
+ .addCommand(new Command("ideas")
1398
+ .description("The content backlog: ideas parked for later, with no publish time and no AI spend.")
1399
+ .addCommand(new Command("add")
1400
+ .description("File an idea into the backlog. Free — no AI call, nothing scheduled. Turn one into a post later with `publishing drafts accept --publish-at`.")
1401
+ .option("--text <text>", "The idea.")
1402
+ .option("--text-file <path>", "Read the idea from a local file.")
1403
+ .option("--topic <topic>", "Optional topic tag.")
1404
+ .option("--channels <list>", "Comma-separated channels this idea is meant for.")
1405
+ .option("--json", "Print a JSON envelope.")
1406
+ .action(async (options) => {
1407
+ await handleAsyncAction("publishing ideas add", options, () => requestOxygen("/api/cli/publishing/drafts", {
1408
+ method: "POST",
1409
+ body: buildPublishingIdeaBody(options),
1410
+ }));
1411
+ }))
1412
+ .addCommand(new Command("list")
1413
+ .description("List the idea backlog.")
1414
+ .option("--limit <n>", "Maximum ideas to return.")
1415
+ .option("--json", "Print a JSON envelope.")
1416
+ .action(async (options) => {
1417
+ await handleAsyncAction("publishing ideas list", options, () => requestOxygen(buildPublishingDraftsListPath({ ...options, kind: "idea" })));
1418
+ })));
1419
+ }