@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,779 @@
1
+ import { readNonNegativeInt, readRecordString } from "../cli-values.js";
2
+ import { collectRepeatable, splitCommaList } from "../command-support.js";
3
+ import { addContractFlags, readContractFlags } from "../contract-flags.js";
4
+ import { requestOxygen } from "../http-client.js";
5
+ import { readOption } from "../util.js";
6
+ import { OxygenError } from "@oxygen/shared/cli-result";
7
+ import { INBOX_DRAFTS_READ_CONTRACT } from "@oxygen/shared/contracts/inbox-drafts-read";
8
+ import { Command, Option } from "commander";
9
+ export function registerInboxFamily(program, handleAsyncAction) {
10
+ program.addCommand(new Command("inbox")
11
+ .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read, reply, or start a WhatsApp chat.")
12
+ .addCommand(new Command("list")
13
+ .description("List conversations newest first across email, LinkedIn, and WhatsApp — the merged stream is the default, --channels narrows it, and --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation. Background AI reply triage sets each conversation's status, so triage is a filter, not a script: `--status interested` is positive sales intent, `--status not_interested` is who said no, and `--unanswered` only means the last message is theirs. None of them means a thread needs a reply: interested includes thank-yous and booking confirmations, and neutral can hold real questions. A reply that just arrived can read neutral until it is analyzed (`inbox analyze <id>` classifies one now). In --json, `count` is the rows on this page (follow next_cursor for more); on email, `total_conversations` and the `sidebar_counts` facets are mailbox-wide and do not apply your filters, so never read them as the size of a filtered list.")
14
+ .option("--channel <channel>", "Inbox channel: all (default, the merged stream), email, linkedin, or whatsapp.")
15
+ .option("--channels <list>", "Narrow the merged stream: comma-separated channel groups to include (email,linkedin,whatsapp). Implies --channel all.")
16
+ .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
17
+ .option("--unread", "Only show conversations with unread messages.")
18
+ .option("--unanswered", "Only conversations you have not replied to — the last message in the thread is inbound. Not the same as needing a reply: it keeps thank-yous and automated mail. Cross-channel. Off by default in the CLI and web Unibox: an unfiltered list still shows answered threads. It includes inbound mail no campaign sent — unsolicited or spam — so for replies to your outreach add --sequence-id; the ids and per-campaign counts are in sidebar_counts.byCampaign of the same --json response.")
19
+ // Hidden while the AI inbox triage is dev-only (ADR 0027): production
20
+ // help must not offer a flag its API refuses. Unhide with the flag's
21
+ // production rollout.
22
+ .addOption(new Option("--needs-reply", "Narrower than --unanswered: unanswered threads minus those AI inbox triage judged bulk (newsletters, notifications, mass pitches) or needing no answer. Threads not yet analysed stay in: each row carries `triage_status` (analysed, undecided or not_analysed) next to `triage`, and `needs_reply_counts` sizes the whole list. `inbox analyze <id> --force` analyses one now. Ignored while --search is set. Only where AI inbox triage is enabled; elsewhere the API answers needs_reply_unavailable.").hideHelp())
23
+ .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
24
+ .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
25
+ .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
26
+ .option("--status <keys>", "Comma-separated reply-status keys, set by AI reply triage. System keys: interested, neutral, out_of_office, wrong_person, not_now, not_interested, lost, bounced; your workspace's full list, custom labels included, is `inbox labels list`. Cross-channel — filters email + LinkedIn + WhatsApp by the shared taxonomy.")
27
+ .option("--tag <tags>", "Comma-separated campaign tags — matches email conversations whose campaign (sequence) carries any of these tags. DMs have no campaign link, so a tag filter shows email only.")
28
+ .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
29
+ .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
30
+ .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs — `sequences list` shows each campaign's id). Returns only conversations linked to that campaign; `sidebar_counts.byCampaign` is its size.")
31
+ .option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
32
+ .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
33
+ .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
34
+ .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
35
+ .option("--sender-profile-id <ids>", "Comma-separated sender profile ids (people; `unibox metadata` and `senders profiles list` list them): only conversations on their own inboxes and LinkedIn/WhatsApp accounts. The same `sender_profile_id` key a saved view's --filters takes. Merged stream only; narrow channels with --channels.")
36
+ .addOption(new Option("--sender-profile <ids>", "Alias of --sender-profile-id.").hideHelp())
37
+ .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow. For structured filters use --status or --tag, not a text search.")
38
+ .option("--include-archived", "Include archived conversations.")
39
+ .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
40
+ .option("--cursor <cursor>", "Merged stream only: the previous page's next_cursor — resumes after that row.")
41
+ .option("--no-counts", "channel=all only: skip the sidebar facet counts for a faster paged read (total_conversations/unread_conversations/sidebar_counts omitted from the envelope). Other channels ignore it.")
42
+ .option("--json", "Print a JSON envelope.")
43
+ .action(async (options) => {
44
+ await handleAsyncAction("inbox list", options, () => {
45
+ const params = new URLSearchParams();
46
+ const channel = readOption(options.channel);
47
+ if (channel)
48
+ params.set("channel", channel);
49
+ const account = readOption(options.account);
50
+ if (account)
51
+ params.set("account", account);
52
+ if (options.unread)
53
+ params.set("unread", "true");
54
+ if (options.unanswered)
55
+ params.set("unanswered", "true");
56
+ if (options.needsReply)
57
+ params.set("needs_reply", "true");
58
+ if (options.responsesOnly)
59
+ params.set("responses_only", "true");
60
+ for (const [flag, key] of [
61
+ ["bucket", "bucket"],
62
+ ["segment", "segment"],
63
+ ["channels", "channels"],
64
+ ["status", "status"],
65
+ ["tag", "tag"],
66
+ ["since", "since"],
67
+ ["until", "until"],
68
+ ["sequenceId", "sequence_id"],
69
+ ["provider", "provider"],
70
+ ["domain", "domain"],
71
+ ["excludeDomain", "exclude_domain"],
72
+ ["mailboxId", "mailbox_id"],
73
+ ["senderProfile", "sender_profile_id"],
74
+ ["senderProfileId", "sender_profile_id"],
75
+ ["cursor", "cursor"],
76
+ ]) {
77
+ const value = readOption(options[flag]);
78
+ if (value)
79
+ params.set(key, value);
80
+ }
81
+ const search = readOption(options.search);
82
+ if (search)
83
+ params.set("search", search);
84
+ if (options.includeArchived)
85
+ params.set("include_archived", "true");
86
+ const limit = readOption(options.limit);
87
+ if (limit)
88
+ params.set("limit", limit);
89
+ // Commander's --no-counts sets counts:false; absent leaves the
90
+ // server default (counts included) untouched.
91
+ if (options.counts === false)
92
+ params.set("include_counts", "false");
93
+ const suffix = params.toString();
94
+ return requestOxygen(`/api/cli/inbox${suffix ? `?${suffix}` : ""}`);
95
+ });
96
+ }))
97
+ .addCommand(new Command("get")
98
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp). Opening a LinkedIn or WhatsApp thread may fetch provider history and update the local mirror. WhatsApp also merges provider-confirmed phone/contact identities. This uses no Oxygen credits and sends no message; old conversation references remain usable. Use --stored-only to read the mirror without provider refresh. --sender-account-id retains that participant’s DM privacy window.")
99
+ .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
100
+ .option("--channel <channel>", "Inbox channel: all (default, resolves the id on any channel), email, linkedin, or whatsapp.")
101
+ .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
102
+ .option("--sender-account-id <ids>", "Scope DM history to participant UUIDs, comma-separated.")
103
+ .option("--stored-only", "Read mirrored messages without provider refresh.")
104
+ .option("--json", "Print a JSON envelope.")
105
+ .action(async (conversation, options) => {
106
+ await handleAsyncAction("inbox get", options, () => {
107
+ const params = new URLSearchParams();
108
+ const channel = readOption(options.channel);
109
+ if (channel)
110
+ params.set("channel", channel);
111
+ const messageLimit = readOption(options.messageLimit);
112
+ if (messageLimit)
113
+ params.set("message_limit", messageLimit);
114
+ const senderAccountId = readOption(options.senderAccountId);
115
+ if (senderAccountId)
116
+ params.set("sender_account_id", senderAccountId);
117
+ if (options.storedOnly)
118
+ params.set("sync", "0");
119
+ const suffix = params.toString();
120
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
121
+ });
122
+ }))
123
+ .addCommand(new Command("send")
124
+ .description("Reply into a conversation on its own channel, resolved from the conversation when --channel is omitted: an email thread replies from its own mailbox (Zapbox/native Gmail/Graph, threaded); a WhatsApp thread gets a warm reply; a LinkedIn thread replies via Unipile. Shared LinkedIn threads require --sender-account-id. Sends a real message — requires --approved. Without it, returns a preview.")
125
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
126
+ .requiredOption("--text <message>", "Reply text to send.")
127
+ .option("--channel <channel>", "Optional: email, linkedin, or whatsapp. Resolved from the conversation when omitted.")
128
+ .option("--sender-account-id <id>", "LinkedIn participant UUID from the thread’s senderAccountIds. Required for a shared thread.")
129
+ .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
130
+ .option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
131
+ .option("--attach <path-or-url>", "Email: local PDF, DOCX, XLSX, PPTX, TXT, or CSV path; preview uploads it for 24 hours without sending. LinkedIn: public URL. Repeatable, up to 5 files and 10 MiB total for email.", collectRepeatable, [])
132
+ .option("--json", "Print a JSON envelope.")
133
+ .action(async (conversation, options) => {
134
+ await handleAsyncAction("inbox send", options, async () => {
135
+ let channel = readOption(options.channel);
136
+ const draftId = readOption(options.draftId);
137
+ const attached = (options.attach ?? []).map((value) => value.trim()).filter(Boolean);
138
+ if (attached.length > 5)
139
+ throw new OxygenError("invalid_attachments", "Attach up to five files.", { exitCode: 1 });
140
+ if (attached.length && !channel) {
141
+ const detail = await requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}`);
142
+ channel = readRecordString(detail, "channel");
143
+ }
144
+ const attachments = channel === "linkedin" ? attached.map((url) => ({ url })) : [];
145
+ const attachmentIds = [];
146
+ if (attached.length && channel === "email") {
147
+ const { readFile, stat } = await import("node:fs/promises");
148
+ const { basename } = await import("node:path");
149
+ const { createHash } = await import("node:crypto");
150
+ let totalBytes = 0;
151
+ const base = `/api/cli/inbox/${encodeURIComponent(conversation)}/attachments`;
152
+ for (const filePath of attached) {
153
+ const info = await stat(filePath);
154
+ totalBytes += info.size;
155
+ if (!info.isFile() || info.size < 1 || totalBytes > 10 * 1024 * 1024) {
156
+ throw new OxygenError("attachment_size_invalid", "Email attachments must be files totaling at most 10 MiB.", { exitCode: 1 });
157
+ }
158
+ const bytes = await readFile(filePath);
159
+ const ticket = await requestOxygen(`${base}/upload-url`, {
160
+ method: "POST", body: { name: basename(filePath), byte_length: bytes.byteLength,
161
+ sha256: createHash("sha256").update(bytes).digest("hex") },
162
+ });
163
+ const uploadUrl = readRecordString(ticket, "upload_url");
164
+ const file = ticket.file;
165
+ const fileId = file && typeof file === "object" && !Array.isArray(file)
166
+ ? readRecordString(file, "id") : null;
167
+ if (!uploadUrl || !fileId)
168
+ throw new OxygenError("attachment_upload_failed", "The server did not return a complete upload ticket.", { exitCode: 1 });
169
+ const put = await fetch(uploadUrl, { method: "PUT", headers: {
170
+ "content-type": readRecordString(ticket, "upload_content_type") ?? "application/octet-stream",
171
+ }, body: new Uint8Array(bytes) });
172
+ if (!put.ok)
173
+ throw new OxygenError("attachment_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
174
+ await requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/attachments/${encodeURIComponent(fileId)}/uploaded`, {
175
+ method: "POST", body: {},
176
+ });
177
+ attachmentIds.push(fileId);
178
+ }
179
+ }
180
+ else if (attached.length && channel !== "linkedin") {
181
+ throw new OxygenError("attachments_unsupported", "Files can only be attached to email replies or LinkedIn public URLs.", { exitCode: 1 });
182
+ }
183
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
184
+ method: "POST",
185
+ body: {
186
+ text: readOption(options.text),
187
+ ...(channel ? { channel } : {}),
188
+ ...(options.approved ? { approved: true } : {}),
189
+ ...(draftId ? { draft_id: draftId } : {}),
190
+ ...(readOption(options.senderAccountId) ? { sender_account_id: readOption(options.senderAccountId) } : {}),
191
+ ...(attachments.length > 0 ? { attachments } : {}),
192
+ ...(attachmentIds.length > 0 ? { attachment_ids: attachmentIds } : {}),
193
+ },
194
+ });
195
+ });
196
+ }))
197
+ .addCommand(new Command("start-whatsapp")
198
+ .description("Start a one-to-one WhatsApp chat from the Unibox. Choose a connected sender and either a phone with country code or a CRM person row id. Without --approved, preview the exact recipient and first message. Cold chats use an unofficial linked-device connection and can increase account restriction risk.")
199
+ .requiredOption("--sender <id>", "Connected WhatsApp sender account id.")
200
+ .option("--phone <number>", "Recipient phone with country code, e.g. +14155550142.")
201
+ .option("--person-row-id <id>", "CRM people record row id; uses that person's phone.")
202
+ .requiredOption("--text <message>", "First message to send.")
203
+ .option("--approved", "Approve and send the WhatsApp message. Without this flag, returns a preview.")
204
+ .option("--json", "Print a JSON envelope.")
205
+ .action(async (options) => {
206
+ await handleAsyncAction("inbox start-whatsapp", options, () => requestOxygen("/api/cli/inbox/whatsapp/start", {
207
+ method: "POST",
208
+ body: { sender_account_id: readOption(options.sender),
209
+ ...(options.phone ? { phone: readOption(options.phone) } : {}),
210
+ ...(options.personRowId ? { person_row_id: readOption(options.personRowId) } : {}),
211
+ text: readOption(options.text), ...(options.approved ? { approved: true } : {}) },
212
+ }));
213
+ }))
214
+ .addCommand(new Command("forget-whatsapp-chat")
215
+ .description("Permanently erase a terminal old WhatsApp chat and its messages from OXYGEN. Preview first; --approved confirms the exact chat. --sender and --before guard the scope. This does not delete from WhatsApp; for a connected account find whatsapp.chats_delete with oxygen tools search, inspect tools run --mode dry-run, then use --mode live --approved and verify the provider result.")
216
+ .argument("<conversationId>", "OXYGEN conversation id from inbox list.")
217
+ .requiredOption("--sender <id>", "Original disconnected WhatsApp sender account id.")
218
+ .requiredOption("--before <iso>", "Delete only when the latest message predates this ISO timestamp.")
219
+ .option("--approved", "Approve permanent removal from OXYGEN after reviewing the preview.")
220
+ .option("--json", "Print a JSON envelope.")
221
+ .action(async (conversationId, options) => {
222
+ await handleAsyncAction("inbox forget-whatsapp-chat", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversationId)}/forget-whatsapp`, {
223
+ method: "POST",
224
+ body: { sender_account_id: readOption(options.sender), before: readOption(options.before),
225
+ approved: options.approved === true },
226
+ }));
227
+ }))
228
+ .addCommand(new Command("delete-message")
229
+ .description("Delete your sent LinkedIn message for everyone within 60 minutes. This cannot be undone. Omit --approved for a preview; show it before approving. Other channels and inbound messages are unsupported.")
230
+ .argument("<conversationId>", "Oxygen conversation id from inbox get.")
231
+ .argument("<messageId>", "Oxygen message id from inbox get.")
232
+ .option("--approved", "Approve permanent deletion of this exact message after reviewing the preview.")
233
+ .option("--json", "Print a JSON envelope.")
234
+ .action(async (conversationId, messageId, options) => {
235
+ await handleAsyncAction("inbox delete-message", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/delete`, {
236
+ method: "POST",
237
+ body: { approved: options.approved === true },
238
+ }));
239
+ }))
240
+ .addCommand(new Command("mark-read")
241
+ .description("Mark visible messages in a conversation as read (LinkedIn by default; use --channel email or whatsapp for those channels). Sender filters retain the selected DM history window.")
242
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
243
+ .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
244
+ .option("--sender-account-id <ids>", "Scope DM read state to participant UUIDs, comma-separated.")
245
+ .option("--sender-profile-id <ids>", "Scope DM read state to sender profile UUIDs, comma-separated.")
246
+ .option("--json", "Print a JSON envelope.")
247
+ .action(async (conversation, options) => {
248
+ await handleAsyncAction("inbox mark-read", options, () => {
249
+ const channel = readOption(options.channel);
250
+ const params = new URLSearchParams();
251
+ const senderAccountId = readOption(options.senderAccountId), senderProfileId = readOption(options.senderProfileId);
252
+ if (senderAccountId)
253
+ params.set("sender_account_id", senderAccountId);
254
+ if (senderProfileId)
255
+ params.set("sender_profile_id", senderProfileId);
256
+ const suffix = params.toString();
257
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/read${suffix ? `?${suffix}` : ""}`, {
258
+ method: "POST",
259
+ body: channel ? { channel } : {},
260
+ });
261
+ });
262
+ }))
263
+ .addCommand(new Command("mark-all-read")
264
+ .description("Mark EVERY conversation matching the filters as read — the whole filtered set across email + LinkedIn + WhatsApp, not just the conversations on the page you can see. Two phases: WITHOUT --yes it only previews (returns the unread count those filters would mark and changes nothing); WITH --yes it marks them read. The filter flags below are the same ones `inbox list` takes, so the scope is exactly what `inbox list --channel all` would show — run that first to see it. Large sets are marked in batches: when the response comes back with has_more true, re-run the same command to continue. Free — no credits, no external writes.")
265
+ .option("--channels <list>", "Comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
266
+ .option("--segment <segment>", "Which folder to sweep: all (the default), primary, others, sent, warmup, or dmarc. all and primary are cross-channel; others, sent, warmup and dmarc are email-only folders. NOTE the default is not 'everything': all is the merged reply stream, so it already excludes warmup mail, DMARC reports, bounced threads, and threads you have only sent to — a bare run leaves those untouched; sweep them with --segment warmup / dmarc / sent / others. primary is narrower still: it also drops the negative status tier (not_now, not_interested, lost, bounced).")
267
+ .option("--status <keys>", "Comma-separated status keys (e.g. interested,meeting_booked). Cross-channel.")
268
+ .option("--tag <tags>", "Comma-separated campaign tags — matches email conversations whose campaign (sequence) carries any of these tags. DMs have no campaign link, so a tag filter scopes the sweep to email.")
269
+ .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs — get the id from `sequences get <slug>`).")
270
+ .option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
271
+ .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
272
+ .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
273
+ .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
274
+ .option("--sender-account-id <ids>", "DMs only: comma-separated OXYGEN sender UUIDs from `oxygen senders list --json` (not LinkedIn member ids).")
275
+ .option("--sender-profile-id <ids>", "Comma-separated sender profile ids (people; `unibox metadata` lists them): only their own inboxes and LinkedIn/WhatsApp accounts, as in `inbox list --sender-profile-id`.")
276
+ .addOption(new Option("--sender-profile <ids>", "Alias of --sender-profile-id.").hideHelp())
277
+ .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
278
+ .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
279
+ .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
280
+ .option("--include-archived", "Also mark archived conversations read.")
281
+ .option("--unanswered", "Only sweep conversations you have not replied to (the last message is inbound) — the same filter as `inbox list --unanswered`, so the scope is exactly that list.")
282
+ // Hidden with `inbox list --needs-reply`; see there.
283
+ .addOption(new Option("--needs-reply", "Only sweep the `inbox list --needs-reply` set (where AI inbox triage is enabled). Ignored while --search is set.").hideHelp())
284
+ .option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
285
+ .option("--json", "Print a JSON envelope.")
286
+ .action(async (options) => {
287
+ await handleAsyncAction("inbox mark-all-read", options, () => {
288
+ // The sweep is always cross-channel: `channel=all` selects the
289
+ // merged stream `inbox list --channel all` reads, and --channels
290
+ // narrows it from there. The filters ride on the query string in
291
+ // the identical vocabulary as that GET — same parser server-side,
292
+ // so the sweep means exactly what the list means.
293
+ const params = new URLSearchParams();
294
+ params.set("channel", "all");
295
+ for (const [flag, key] of [
296
+ ["channels", "channels"],
297
+ ["segment", "segment"],
298
+ ["status", "status"],
299
+ ["tag", "tag"],
300
+ ["sequenceId", "sequence_id"],
301
+ ["provider", "provider"],
302
+ ["domain", "domain"],
303
+ ["excludeDomain", "exclude_domain"],
304
+ ["mailboxId", "mailbox_id"],
305
+ ["senderAccountId", "sender_account_id"],
306
+ ["senderProfile", "sender_profile_id"],
307
+ ["senderProfileId", "sender_profile_id"],
308
+ ["since", "since"],
309
+ ["until", "until"],
310
+ ["search", "search"],
311
+ ]) {
312
+ const value = readOption(options[flag]);
313
+ if (value)
314
+ params.set(key, value);
315
+ }
316
+ if (options.includeArchived)
317
+ params.set("include_archived", "true");
318
+ // Booleans need their own line: the tuple loop above only walks
319
+ // the string-valued options.
320
+ if (options.unanswered)
321
+ params.set("unanswered", "true");
322
+ if (options.needsReply)
323
+ params.set("needs_reply", "true");
324
+ return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
325
+ method: "POST",
326
+ previewOnly: !options.yes,
327
+ // Approval rides in the body: a bodyless POST reads as
328
+ // approved=false server-side, i.e. a preview that mutates
329
+ // nothing. --yes (not --approved) because this spends no
330
+ // credits — see MUTATING_VERBS in command-manifest.ts.
331
+ body: options.yes ? { approved: true } : {},
332
+ });
333
+ });
334
+ }))
335
+ .addCommand(new Command("analyze")
336
+ .description("Run analysis on a conversation now: status + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound conversations.")
337
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
338
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
339
+ .option("--force", "Re-analyze even if the conversation was already classified (an explicit paid re-classification; spends one managed credit). Without it, an already-analyzed email is a free no-op. DMs always re-analyze.")
340
+ .option("--json", "Print a JSON envelope.")
341
+ .action(async (conversation, options) => {
342
+ await handleAsyncAction("inbox analyze", options, () => {
343
+ const channel = readOption(options.channel) ?? "email";
344
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/analyze`, {
345
+ method: "POST",
346
+ body: { channel, ...(options.force ? { force: true } : {}) },
347
+ });
348
+ });
349
+ }))
350
+ .addCommand(new Command("archive")
351
+ .description("Archive a conversation (triage it out of the inbox). --unarchive restores it. --channel linkedin/whatsapp archives a DM.")
352
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
353
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
354
+ .option("--unarchive", "Restore the conversation instead of archiving it.")
355
+ .option("--json", "Print a JSON envelope.")
356
+ .action(async (conversation, options) => {
357
+ await handleAsyncAction("inbox archive", options, () => {
358
+ const channel = readOption(options.channel) ?? "email";
359
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/archive`, {
360
+ method: "POST",
361
+ body: { channel, archived: !options.unarchive },
362
+ });
363
+ });
364
+ }))
365
+ .addCommand(new Command("sync")
366
+ .description("Force a backstop inbox sync from Unipile for the active LinkedIn and WhatsApp sender accounts (pulls recent chats + messages into the unibox). Email mailboxes are not synced here: they are polled automatically every worker tick, so email replies reach `inbox list` on their own.")
367
+ .option("--account <id>", "Force-sync one sender account (sender id, connection id, or Unipile account id).")
368
+ .option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
369
+ .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
370
+ .option("--json", "Print a JSON envelope.")
371
+ .action(async (options) => {
372
+ await handleAsyncAction("inbox sync", options, () => {
373
+ const body = {};
374
+ const account = readOption(options.account);
375
+ if (account)
376
+ body.account = account;
377
+ const chatLimit = readOption(options.chatLimit);
378
+ if (chatLimit)
379
+ body.chat_limit = Number(chatLimit);
380
+ const messageLimit = readOption(options.messageLimit);
381
+ if (messageLimit)
382
+ body.message_limit = Number(messageLimit);
383
+ return requestOxygen("/api/cli/inbox/sync", { method: "POST", body });
384
+ });
385
+ }))
386
+ .addCommand(new Command("backfill")
387
+ .description("Opt-in, LinkedIn only: pull a LinkedIn account's OLDER message history into the unibox. A SLOW, durable background drip walks each conversation's thread strictly backward — one page per worker tick under a dedicated conservative read budget — so a long history fills in over many days and never burns the account's limits. New messages keep arriving in real time via webhooks; this only backfills old history. --cancel stops an armed drip. No messages are sent and no Oxygen credits are charged.")
388
+ .option("--account <ref>", "Sender account to backfill (sender id, connection id, or Unipile account id). Omit to backfill every active LinkedIn account.")
389
+ .option("--conversation <ref>", "Scope to a single conversation (conversation id or Unipile chat id).")
390
+ .option("--cancel", "Cancel the active history backfill drip for --account (or every LinkedIn account) instead of arming one. Messages already mirrored stay.")
391
+ .option("--json", "Print a JSON envelope.")
392
+ .action(async (options) => {
393
+ await handleAsyncAction("inbox backfill", options, () => {
394
+ const account = readOption(options.account);
395
+ const conversation = readOption(options.conversation);
396
+ if (options.cancel) {
397
+ if (conversation) {
398
+ throw new OxygenError("invalid_request", "--cancel stops a sender's whole drip; it cannot be combined with --conversation. Pass --account (or nothing) with --cancel.", { exitCode: 1 });
399
+ }
400
+ return requestOxygen("/api/cli/linkedin/inbox/backfill/cancel", {
401
+ method: "POST",
402
+ body: { ...(account ? { account } : {}) },
403
+ });
404
+ }
405
+ return requestOxygen("/api/cli/linkedin/inbox/backfill", {
406
+ method: "POST",
407
+ body: {
408
+ ...(account ? { account } : {}),
409
+ ...(conversation ? { conversation } : {}),
410
+ },
411
+ });
412
+ });
413
+ }))
414
+ .addCommand(new Command("status")
415
+ .description("Set a conversation's status (the Instantly-style tier). A manual override that locks out AI re-classification. --channel linkedin/whatsapp sets a DM's status.")
416
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
417
+ .argument("<status>", "A status label key (e.g. interested, meeting_booked, won, not_interested).")
418
+ .option("--source <manual|ai>", "Classification provenance; manual locks out AI reclassification.")
419
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
420
+ .option("--json", "Print a JSON envelope.")
421
+ .action(async (conversation, status, options) => {
422
+ await handleAsyncAction("inbox status", options, () => {
423
+ const channel = readOption(options.channel) ?? "email";
424
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/status`, {
425
+ method: "POST",
426
+ body: { status, channel, ...(readOption(options.source) ? { source: readOption(options.source) } : {}) },
427
+ });
428
+ });
429
+ }))
430
+ .addCommand(new Command("tag")
431
+ .description("Add/remove workspace tags on a conversation (Tags primitive — the same vocabulary as sequences, tables, posts; see `oxygen tags list`). Delta semantics: --add unions, --remove subtracts, remove wins on conflict. Internal metadata, never sent to the counterpart. --channel linkedin/whatsapp tags a DM conversation.")
432
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
433
+ .option("--add <tags>", "Comma-separated tags to add.")
434
+ .option("--remove <tags>", "Comma-separated tags to remove.")
435
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
436
+ .option("--json", "Print a JSON envelope.")
437
+ .action(async (conversation, options) => {
438
+ await handleAsyncAction("inbox tag", options, () => {
439
+ const add = splitCommaList(options.add);
440
+ const remove = splitCommaList(options.remove);
441
+ if (add.length === 0 && remove.length === 0) {
442
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
443
+ }
444
+ const channel = readOption(options.channel) ?? "email";
445
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/tags`, {
446
+ method: "POST",
447
+ body: {
448
+ ...(add.length > 0 ? { add } : {}),
449
+ ...(remove.length > 0 ? { remove } : {}),
450
+ channel,
451
+ },
452
+ });
453
+ });
454
+ }))
455
+ .addCommand(new Command("rescan")
456
+ .description("Re-queue conversations for AI re-classification (status + drafted reply). Without --yes, previews the counts that would be re-flipped. With --yes, the worker re-classifies the pending rows (free for opt-outs, low-tier model otherwise, credit-capped).")
457
+ .option("--channel <channel>", "Inbox channel: all (default), email, linkedin, or whatsapp.")
458
+ .option("--yes", "Execute the re-queue. Without this flag, returns a preview of the counts only.")
459
+ .option("--json", "Print a JSON envelope.")
460
+ .action(async (options) => {
461
+ await handleAsyncAction("inbox rescan", options, () => {
462
+ const channel = readOption(options.channel) ?? "all";
463
+ return requestOxygen("/api/cli/inbox/rescan", {
464
+ method: "POST",
465
+ body: { channel, confirm: Boolean(options.yes) },
466
+ });
467
+ });
468
+ }))
469
+ .addCommand(new Command("bulk")
470
+ .description("Bulk-apply one triage action — set-status / archive / unarchive — across up to 100 hand-picked conversations (the ids `inbox list` returns) in one call. Without --yes, previews the matched/would-change counts (no mutation). With --yes, applies it. --channel scopes the ids (email default; run once per channel).")
471
+ .requiredOption("--action <action>", "set-status, archive, or unarchive.")
472
+ .requiredOption("--ids <ids>", "Comma-separated conversation ids (the uuids `inbox list` returns). Max 100.")
473
+ .option("--status <key>", "Required for --action set-status: the status label key to set (e.g. interested).")
474
+ .option("--channel <channel>", "Inbox channel the ids belong to: email (default), linkedin, or whatsapp.")
475
+ .option("--yes", "Apply the action. Without this flag, returns a preview of the counts only.")
476
+ .option("--json", "Print a JSON envelope.")
477
+ .action(async (options) => {
478
+ await handleAsyncAction("inbox bulk", options, () => {
479
+ // CLI uses hyphenated `set-status` for ergonomics; the API wants `set_status`.
480
+ const action = (readOption(options.action) ?? "").replace(/-/g, "_");
481
+ const channel = readOption(options.channel) ?? "email";
482
+ const status = readOption(options.status);
483
+ const ids = (readOption(options.ids) ?? "")
484
+ .split(",")
485
+ .map((id) => id.trim())
486
+ .filter(Boolean);
487
+ return requestOxygen("/api/cli/inbox/bulk", {
488
+ method: "POST",
489
+ previewOnly: !options.yes,
490
+ body: {
491
+ action,
492
+ channel,
493
+ conversation_ids: ids,
494
+ ...(status ? { status } : {}),
495
+ ...(options.yes ? { approved: true } : {}),
496
+ },
497
+ });
498
+ });
499
+ }))
500
+ .addCommand(new Command("views")
501
+ .description("Saved Unibox views: named filter states the whole team shares (a sender, channels, statuses). Each member picks their own default, which the Unibox opens on.")
502
+ .addCommand(new Command("list")
503
+ .description("List the team's saved views; is_default marks your own default.")
504
+ .option("--json", "Print a JSON envelope.")
505
+ .action(async (options) => {
506
+ await handleAsyncAction("inbox views list", options, () => requestOxygen("/api/cli/inbox/views"));
507
+ }))
508
+ .addCommand(new Command("create")
509
+ .description("Save a view for the team.")
510
+ .requiredOption("--name <name>", "View name, unique in the workspace (up to 80 characters).")
511
+ .option("--filters <url-or-query>", "A Unibox URL or its query string, e.g. 'sender_profile_id=<id>&channels=linkedin&status=interested'. Keys are the `inbox list` filters (sender_profile_id is --sender-profile-id); a view without segment opens on the Primary tab, segment=all on every conversation. Omit for every conversation.")
512
+ .option("--default", "Also make it your own default view.")
513
+ .option("--json", "Print a JSON envelope.")
514
+ .action(async (options) => {
515
+ await handleAsyncAction("inbox views create", options, () => requestOxygen("/api/cli/inbox/views", {
516
+ method: "POST",
517
+ body: {
518
+ name: readOption(options.name),
519
+ ...(readOption(options.filters) ? { filters: readOption(options.filters) } : {}),
520
+ ...(options.default === true ? { default: true } : {}),
521
+ },
522
+ }));
523
+ }))
524
+ .addCommand(new Command("update")
525
+ .description("Rename, re-filter or reorder a view, or set it as (or stop using it as) your own default.")
526
+ .argument("<view>", "View id (from `inbox views list`).")
527
+ .option("--name <name>", "New name.")
528
+ .option("--filters <url-or-query>", "Replace the view's filters: a Unibox URL or its query string (the `inbox list` filter keys; without segment the view opens on the Primary tab).")
529
+ .option("--position <n>", "Zero-based order in the team's list.")
530
+ .option("--default", "Make it your own default view.")
531
+ .option("--no-default", "Stop using it as your default view (you open on the built-in view).")
532
+ .option("--json", "Print a JSON envelope.")
533
+ .action(async (view, options) => {
534
+ await handleAsyncAction("inbox views update", options, () => requestOxygen(`/api/cli/inbox/views/${encodeURIComponent(view)}`, {
535
+ method: "PATCH",
536
+ body: {
537
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
538
+ ...(readOption(options.filters) !== null ? { filters: readOption(options.filters) } : {}),
539
+ ...(readOption(options.position) !== null ? { position: readNonNegativeInt(options.position) } : {}),
540
+ ...(typeof options.default === "boolean" ? { default: options.default } : {}),
541
+ },
542
+ }));
543
+ }))
544
+ .addCommand(new Command("delete")
545
+ .description("Delete a view for the whole team. Anyone using it as their default opens on the built-in view.")
546
+ .argument("<view>", "View id (from `inbox views list`).")
547
+ .option("--json", "Print a JSON envelope.")
548
+ .action(async (view, options) => {
549
+ await handleAsyncAction("inbox views delete", options, () => requestOxygen(`/api/cli/inbox/views/${encodeURIComponent(view)}`, { method: "DELETE" }));
550
+ })))
551
+ .addCommand(new Command("labels")
552
+ .description("Manage inbox status labels (the Instantly-style system set + custom labels).")
553
+ .addCommand(new Command("list")
554
+ .description("List status labels.")
555
+ .option("--include-archived", "Include archived custom labels.")
556
+ .option("--json", "Print a JSON envelope.")
557
+ .action(async (options) => {
558
+ await handleAsyncAction("inbox labels list", options, () => requestOxygen(`/api/cli/inbox/labels${options.includeArchived ? "?include_archived=true" : ""}`));
559
+ }))
560
+ .addCommand(new Command("create")
561
+ .description("Create a custom status label.")
562
+ .argument("<name>", "Display name.")
563
+ .option("--key <key>", "Stable custom status key; defaults to the name slug.")
564
+ .option("--icon <name>", "Label icon; an empty string clears it.")
565
+ .option("--sort-order <n>", "Numeric position; 0 is valid.")
566
+ .option("--color <hex>", "Hex color (e.g. #2a78d6). Workspace palette: #2a78d6 #eb6834 #1baf7a #eda100 #e87ba4 #008300 #4a3aa7 #e34948 #0e9dc4 #9a4fd0.")
567
+ .option("--bucket <bucket>", "primary (default) or others.")
568
+ .option("--ai-settable", "Let the AI reply classifier assign this label.")
569
+ .option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
570
+ .option("--json", "Print a JSON envelope.")
571
+ .action(async (name, options) => {
572
+ await handleAsyncAction("inbox label create", options, () => {
573
+ const color = readOption(options.color);
574
+ const bucket = readOption(options.bucket);
575
+ const description = readOption(options.description);
576
+ return requestOxygen("/api/cli/inbox/labels", {
577
+ method: "POST",
578
+ body: {
579
+ name,
580
+ ...(color ? { color } : {}),
581
+ ...(options.icon !== undefined ? { icon: options.icon } : {}),
582
+ ...(options.sortOrder !== undefined ? { sort_order: Number(options.sortOrder) } : {}),
583
+ ...(readOption(options.key) ? { key: readOption(options.key) } : {}),
584
+ ...(bucket ? { bucket } : {}),
585
+ ...(options.aiSettable ? { ai_settable: true } : {}),
586
+ ...(description ? { description } : {}),
587
+ },
588
+ });
589
+ });
590
+ }))
591
+ .addCommand(new Command("update")
592
+ .description("Update a status label's name, color, bucket, description, or (custom labels only) ai-settable flag.")
593
+ .argument("<key>", "The label key.")
594
+ .option("--name <name>", "New display name.")
595
+ .option("--icon <name>", "Label icon; an empty string clears it.")
596
+ .option("--sort-order <n>", "Numeric position; 0 is valid.")
597
+ .option("--color <hex>", "New hex color.")
598
+ .option("--bucket <bucket>", "New bucket (primary or others).")
599
+ .option("--ai-settable", "Let the AI classifier assign this label (custom labels only).")
600
+ .option("--no-ai-settable", "Stop the AI classifier assigning this label (custom labels only).")
601
+ .option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
602
+ .option("--json", "Print a JSON envelope.")
603
+ .action(async (key, options) => {
604
+ await handleAsyncAction("inbox label update", options, () => {
605
+ const name = readOption(options.name);
606
+ const color = readOption(options.color);
607
+ const bucket = readOption(options.bucket);
608
+ const description = readOption(options.description);
609
+ return requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
610
+ method: "PATCH",
611
+ body: {
612
+ ...(name ? { name } : {}),
613
+ ...(color ? { color } : {}),
614
+ ...(options.icon !== undefined ? { icon: options.icon } : {}),
615
+ ...(options.sortOrder !== undefined ? { sort_order: Number(options.sortOrder) } : {}),
616
+ ...(readOption(options.key) ? { key: readOption(options.key) } : {}),
617
+ ...(bucket ? { bucket } : {}),
618
+ ...(options.aiSettable !== undefined ? { ai_settable: options.aiSettable } : {}),
619
+ ...(description ? { description } : {}),
620
+ },
621
+ });
622
+ });
623
+ }))
624
+ .addCommand(new Command("delete")
625
+ .description("Archive a custom status label (system labels and in-use labels are rejected).")
626
+ .argument("<key>", "The custom label key.")
627
+ .option("--json", "Print a JSON envelope.")
628
+ .action(async (key, options) => {
629
+ await handleAsyncAction("inbox label archive", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, { method: "DELETE" }));
630
+ }))
631
+ .addCommand(new Command("restore")
632
+ .description("Restore an archived custom status label under its original key.")
633
+ .argument("<key>", "The archived custom label key.")
634
+ .option("--json", "Print a JSON envelope.")
635
+ .action(async (key, options) => {
636
+ await handleAsyncAction("inbox label restore", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
637
+ method: "PATCH",
638
+ body: { archived: false },
639
+ }));
640
+ })))
641
+ .addCommand(new Command("drafts")
642
+ .description("The AI reply-agent draft queue (the approve-before-send review queue).")
643
+ .addCommand(addContractFlags(new Command("list")
644
+ .description("List drafts awaiting review (queued + edited by default), across email + LinkedIn.")
645
+ .option("--json", "Print a JSON envelope."), INBOX_DRAFTS_READ_CONTRACT, {
646
+ status: { valueName: "<keys>", help: "Comma-separated draft statuses (queued,edited,approved,sent,rejected).", parse: readOption },
647
+ channel: { valueName: "<channel>", help: "Filter to one channel: email or linkedin.", parse: readOption },
648
+ sequence_id: { valueName: "<id>", help: "Filter to one campaign (sequence) id (email drafts only).", parse: readOption },
649
+ limit: { valueName: "<n>", help: "Maximum drafts to return.", parse: readOption },
650
+ conversation_id: { valueName: "<id>", parse: readOption },
651
+ draft_id: { valueName: "<id>", parse: readOption },
652
+ })
653
+ .action(async (options) => {
654
+ await handleAsyncAction("inbox drafts list", options, () => {
655
+ const query = readContractFlags(options, INBOX_DRAFTS_READ_CONTRACT, {
656
+ status: { parse: readOption }, channel: { parse: readOption },
657
+ sequence_id: { parse: readOption }, limit: { parse: readOption },
658
+ conversation_id: { parse: readOption }, draft_id: { parse: readOption },
659
+ });
660
+ const params = new URLSearchParams();
661
+ for (const [key, value] of Object.entries(query)) {
662
+ if (value !== undefined && value !== null)
663
+ params.set(key, String(value));
664
+ }
665
+ const suffix = params.toString();
666
+ return requestOxygen(`/api/cli/inbox/drafts${suffix ? `?${suffix}` : ""}`);
667
+ });
668
+ }))
669
+ .addCommand(new Command("edit")
670
+ .description("Edit an open draft's body before approval. Does not send.")
671
+ .argument("<draft>", "The draft id.")
672
+ .requiredOption("--text <message>", "The new draft body.")
673
+ .option("--json", "Print a JSON envelope.")
674
+ .action(async (draft, options) => {
675
+ await handleAsyncAction("inbox draft update", options, () => requestOxygen(`/api/cli/inbox/drafts/${encodeURIComponent(draft)}`, {
676
+ method: "PATCH",
677
+ body: { text: readOption(options.text) },
678
+ }));
679
+ }))
680
+ .addCommand(new Command("reject")
681
+ .description("Reject an open draft so it leaves the queue. Does not send.")
682
+ .argument("<draft>", "The draft id.")
683
+ .option("--reason <text>", "Optional reason.")
684
+ .option("--json", "Print a JSON envelope.")
685
+ .action(async (draft, options) => {
686
+ await handleAsyncAction("inbox draft reject", options, () => {
687
+ const reason = readOption(options.reason);
688
+ return requestOxygen(`/api/cli/inbox/drafts/${encodeURIComponent(draft)}/reject`, {
689
+ method: "POST",
690
+ body: reason ? { reason } : {},
691
+ });
692
+ });
693
+ })))
694
+ .addCommand(new Command("reply-agent")
695
+ .description("The draft-only AI reply agent (the 'AI Sales Agent'): auto-classifies + auto-drafts replies into the review queue. It NEVER auto-sends. Roster + run history: `oxygen agent get inbox-reply-drafts`; web home: /agents/inbox-reply-drafts.")
696
+ .addCommand(new Command("get")
697
+ .description("Show the reply-agent config.")
698
+ .option("--json", "Print a JSON envelope.")
699
+ .action(async (options) => {
700
+ await handleAsyncAction("inbox reply-agent get", options, () => requestOxygen("/api/cli/inbox/reply-agent"));
701
+ }))
702
+ .addCommand(new Command("set")
703
+ .description("Configure the reply agent (enable, persona/tone, targets). It drafts into the queue; a human approves+sends. Configuring is free (no credits); once enabled, each draft the agent writes consumes credits.")
704
+ .option("--enabled", "Enable the agent.")
705
+ .option("--disabled", "Disable the agent.")
706
+ .option("--persona <text>", "Who the agent writes as — drafts open in this voice. Richest results name role + company + what you sell, e.g. \"Jonas, founder of Acme (dev-tools for CI caching)\".")
707
+ .option("--tone <text>", "How drafts should read, e.g. \"warm, concise, low-pressure\".")
708
+ .option("--instructions <text>", "Extra drafting instructions.")
709
+ .option("--signature <text>", "Signature appended to drafts.")
710
+ .option("--target-statuses <keys>", "Comma-separated statuses to draft for (empty = all draftable).")
711
+ .option("--target-sequence-ids <ids>", "Comma-separated campaign ids to draft for (empty = all).")
712
+ .option("--channels <list>", "Comma-separated channels to draft for: a non-empty subset of email, linkedin (default email). LinkedIn drafts are short + signature-free.")
713
+ .option("--max-drafts-per-day <n>", "Per-day draft cap.")
714
+ .option("--json", "Print a JSON envelope.")
715
+ .action(async (options) => {
716
+ await handleAsyncAction("inbox reply-agent set", options, () => {
717
+ const body = {};
718
+ if (options.enabled)
719
+ body.enabled = true;
720
+ if (options.disabled)
721
+ body.enabled = false;
722
+ // Presence (flag passed) — not readOption — decides inclusion, so an
723
+ // absent flag leaves the field untouched rather than clearing it.
724
+ const opts = options;
725
+ for (const [flag, key] of [
726
+ ["persona", "persona"],
727
+ ["tone", "tone"],
728
+ ["instructions", "instructions"],
729
+ ["signature", "signature"],
730
+ ]) {
731
+ if (opts[flag] !== undefined)
732
+ body[key] = opts[flag];
733
+ }
734
+ if (options.targetStatuses !== undefined) {
735
+ body.target_statuses = options.targetStatuses.split(",").map((s) => s.trim()).filter(Boolean);
736
+ }
737
+ if (options.targetSequenceIds !== undefined) {
738
+ body.target_sequence_ids = options.targetSequenceIds.split(",").map((s) => s.trim()).filter(Boolean);
739
+ }
740
+ if (options.channels !== undefined) {
741
+ body.channels = options.channels.split(",").map((c) => c.trim()).filter(Boolean);
742
+ }
743
+ if (options.maxDraftsPerDay !== undefined)
744
+ body.max_drafts_per_day = Number(options.maxDraftsPerDay);
745
+ return requestOxygen("/api/cli/inbox/reply-agent", { method: "POST", body });
746
+ });
747
+ })))
748
+ .addCommand(new Command("auto-tagger")
749
+ .description("The auto-tagging agent (the 'AI Inbox Tagger'): classifies each new inbound conversation against your workspace tag allowlist and auto-applies matches — additive, never re-adds a tag you removed, zero extra model calls (rides the analysis pass). Roster + run history: `oxygen agent get inbox-auto-tagger`; web home: /agents/inbox-auto-tagger.")
750
+ .addCommand(new Command("get")
751
+ .description("Show the auto-tagger config (enabled, tag allowlist, channels).")
752
+ .option("--json", "Print a JSON envelope.")
753
+ .action(async (options) => {
754
+ await handleAsyncAction("inbox auto-tagger get", options, () => requestOxygen("/api/cli/inbox/auto-tagger"));
755
+ }))
756
+ .addCommand(new Command("set")
757
+ .description("Update the auto-tagger. --tags replaces the whole allowlist (max 50; `--tags \"\"` clears, which pauses tagging while keeping the agent enabled).")
758
+ .option("--enabled", "Enable the agent.")
759
+ .option("--disabled", "Disable the agent.")
760
+ .option("--tags <tags>", "Comma-separated tag allowlist the classifier may apply (replaces the whole set).")
761
+ .option("--channels <list>", "Comma-separated channels to tag: a non-empty subset of email, linkedin, whatsapp (default all three).")
762
+ .option("--json", "Print a JSON envelope.")
763
+ .action(async (options) => {
764
+ await handleAsyncAction("inbox auto-tagger set", options, () => {
765
+ const body = {};
766
+ if (options.enabled)
767
+ body.enabled = true;
768
+ if (options.disabled)
769
+ body.enabled = false;
770
+ if (options.tags !== undefined) {
771
+ body.tags = options.tags.split(",").map((tag) => tag.trim()).filter(Boolean);
772
+ }
773
+ if (options.channels !== undefined) {
774
+ body.channels = options.channels.split(",").map((c) => c.trim()).filter(Boolean);
775
+ }
776
+ return requestOxygen("/api/cli/inbox/auto-tagger", { method: "POST", body });
777
+ });
778
+ }))));
779
+ }