quoxcli 0.2.0 → 0.3.1

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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -26,6 +26,46 @@ export function buildStreamQuery({ queryId, message, agent, sessionId, context }
26
26
  return body;
27
27
  }
28
28
 
29
+ /**
30
+ * TUI-DIVIDER: `quox chat --trace` prints the turn's protocol events as they arrive, so the
31
+ * per-layer AOCL record (`aocl.layer`: id, name, result, ms) is visible in the CLI before any
32
+ * UI animates it. Returns { text, tone } or null for an event --trace does not show.
33
+ * tone: ok | warn | fail | accent (no grey: every tone is a real colour).
34
+ */
35
+ const RESULT_TONE = { pass: 'ok', gated: 'warn', blocked: 'fail', bypass: 'accent' };
36
+ const fmtTraceMs = (ms) => {
37
+ const n = Number(ms);
38
+ if (!Number.isFinite(n)) return '';
39
+ return n < 10 ? `${Math.round(n * 10) / 10} ms` : `${Math.round(n)} ms`;
40
+ };
41
+ export function formatTraceEvent(ev) {
42
+ if (!ev || typeof ev !== 'object') return null;
43
+ switch (ev.type) {
44
+ case 'turn.started':
45
+ return { text: `▸ turn.started ${[ev.provider, ev.model].filter(Boolean).join(' ')}`, tone: 'accent' };
46
+ case 'aocl.layer': {
47
+ const result = String(ev.result || 'pass');
48
+ const line = `▤ aocl.layer ${String(ev.id || '?').padEnd(4)}${String(ev.name || '').padEnd(10)}${result.padEnd(8)}${fmtTraceMs(ev.ms).padStart(8)} ${ev.phase || ''}${ev.reason ? ` ${ev.reason}` : ''}`;
49
+ return { text: line, tone: RESULT_TONE[result] || 'accent' };
50
+ }
51
+ case 'aocl.summary':
52
+ return { text: `▤ aocl.summary ${ev.accounted ?? '?'}/${ev.total ?? '?'} accounted · ${ev.passed ?? 0} passed · ${ev.bypassed ?? 0} bypassed · ${ev.rejected ?? 0} rejected${ev.ms != null ? ` · ${fmtTraceMs(ev.ms)}` : ''}`, tone: ev.rejected ? 'fail' : 'ok' };
53
+ case 'aocl.verdict':
54
+ return { text: `▤ aocl.verdict ${ev.layer || 'L8'} ${ev.verdict}${ev.reason ? `: ${ev.reason}` : ''}`, tone: ev.verdict === 'blocked' ? 'fail' : 'warn' };
55
+ case 'trace':
56
+ return { text: `· trace ${ev.protocol || ''} ${ev.label || ''}${ev.detail ? ` ${ev.detail}` : ''}`, tone: 'accent' };
57
+ case 'turn.finished':
58
+ return { text: `▸ turn.finished ${ev.status || ''}${ev.usage?.elapsed_ms != null ? ` · ${ev.usage.elapsed_ms} ms` : ''}`, tone: ev.status === 'completed' ? 'ok' : 'fail' };
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ const TONE_PAINT = { ok: chalk.green, warn: chalk.yellow, fail: chalk.red, accent: chalk.cyan };
64
+ function printTrace(ev) {
65
+ const f = formatTraceEvent(ev);
66
+ if (f) process.stderr.write((TONE_PAINT[f.tone] || chalk.cyan)(f.text) + '\n');
67
+ }
68
+
29
69
  function generateQueryId() {
30
70
  return `CLI_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
31
71
  }
@@ -34,7 +74,7 @@ function generateQueryId() {
34
74
  * Consume a chat SSE stream, printing chunks as they arrive.
35
75
  * Returns { text, sessionId, agentUsed } once the stream completes.
36
76
  */
37
- export async function consumeStream(client, { message, agent, sessionId, context }, { onAgentLabel } = {}) {
77
+ export async function consumeStream(client, { message, agent, sessionId, context }, { onAgentLabel, trace = false } = {}) {
38
78
  const queryId = generateQueryId();
39
79
  const body = buildStreamQuery({ queryId, message, agent, sessionId, context });
40
80
 
@@ -50,7 +90,19 @@ export async function consumeStream(client, { message, agent, sessionId, context
50
90
  onAgentLabel?.(agentUsed);
51
91
  };
52
92
 
53
- for await (const event of client.stream(resolveEndpoint('chat.stream').path, { service: resolveEndpoint('chat.stream').service, body })) {
93
+ const iter = client.stream(resolveEndpoint('chat.stream').path, { service: resolveEndpoint('chat.stream').service, body, untilClose: Boolean(trace) })[Symbol.asyncIterator]();
94
+ while (true) {
95
+ let step;
96
+ try {
97
+ step = await iter.next();
98
+ } catch (err) {
99
+ // A refusal before the stream opened carries the layers it really evaluated.
100
+ if (trace && Array.isArray(err?.body?.aocl_layers)) for (const l of err.body.aocl_layers) printTrace({ type: 'aocl.layer', ...l });
101
+ throw err;
102
+ }
103
+ if (step.done) break;
104
+ const event = step.value;
105
+ if (trace) printTrace(event);
54
106
  switch (event.type) {
55
107
  case 'connected':
56
108
  break;
@@ -120,6 +172,7 @@ export function registerChatCommands(program, ctx) {
120
172
  .option('--agent <name>', 'route to a specific agent (e.g. sentinel, cipher, nova)')
121
173
  .option('--no-stream', 'disable streaming output (one-shot request/response)')
122
174
  .option('--conversation <id>', 'continue a conversation (session id)')
175
+ .option('--trace', 'print the turn\'s protocol events (per-layer AOCL, traces, summary) to stderr as they arrive')
123
176
  .action(async (message, options) => {
124
177
  auth.requireAuth();
125
178
  const streaming = options.stream !== false;
@@ -161,6 +214,7 @@ export function registerChatCommands(program, ctx) {
161
214
  sessionId,
162
215
  }, {
163
216
  onAgentLabel: (agentUsed) => process.stdout.write(chalk.green(`${(agentUsed || currentAgent).toUpperCase()}: `)),
217
+ trace: Boolean(options.trace),
164
218
  });
165
219
  if (result.sessionId) sessionId = result.sessionId;
166
220
  console.log();
@@ -190,6 +244,7 @@ export function registerChatCommands(program, ctx) {
190
244
  sessionId: options.conversation,
191
245
  }, {
192
246
  onAgentLabel: () => {}, // no label prefix in single-shot streaming; just print tokens
247
+ trace: Boolean(options.trace),
193
248
  });
194
249
  } catch (err) {
195
250
  console.error(chalk.red(`Error: ${err.message}`));
@@ -3,6 +3,7 @@
3
3
  * lives in core/governedSession.js; this file holds argument parsing and the
4
4
  * commander wiring. argv arrays only; user input never reaches a shell string.
5
5
  */
6
+ import { markForwardsRawArgs } from '../core/global-flags.js';
6
7
  import * as gs from '../core/governedSession.js';
7
8
  import { validateName, PROGRAMS } from '../core/governedSession.js';
8
9
  import { runClaudeLs } from '../core/claudeLs.js';
@@ -178,8 +179,8 @@ export function registerClaudeCommands(program, ctx, over) {
178
179
  // `quox claude <args>` behaves exactly like `claude <args>`: every arg is
179
180
  // forwarded verbatim. Only --no-tmux is quox's own. Management lives in
180
181
  // `quox sessions` so it can never collide with claude's own arguments.
181
- program
182
- .command('claude')
182
+ markForwardsRawArgs(program
183
+ .command('claude'), 'forwards argv after the verb verbatim')
183
184
  .description('Run Claude Code (same arguments as claude) in a tmux session that survives sleep and drops')
184
185
  .argument('[args...]', 'passed to claude exactly as given (quox adds only --no-tmux, --as, --slot)')
185
186
  .addHelpText('after', `
@@ -205,8 +206,8 @@ Claude's own --agent is untouched and passes through to claude.`)
205
206
  const parsed = parseClaudeArgs(rest);
206
207
  process.exitCode = runClaude(parsed, over);
207
208
  });
208
- program
209
- .command('codex')
209
+ markForwardsRawArgs(program
210
+ .command('codex'), 'forwards argv after the verb verbatim')
210
211
  .description('Run Codex (same arguments as codex) in a tmux session that survives sleep and drops')
211
212
  .argument('[args...]', 'passed to codex exactly as given (quox adds only --no-tmux)')
212
213
  .helpOption(false)
@@ -218,8 +219,8 @@ Claude's own --agent is untouched and passes through to claude.`)
218
219
  const parsed = parseCodexArgs(i >= 0 ? raw.slice(i + 1) : []);
219
220
  process.exitCode = runCodex(parsed, over);
220
221
  });
221
- program
222
- .command('gemini')
222
+ markForwardsRawArgs(program
223
+ .command('gemini'), 'forwards argv after the verb verbatim')
223
224
  .description('Run Gemini CLI (same arguments as gemini) in a tmux session that survives sleep and drops')
224
225
  .argument('[args...]', 'passed to gemini exactly as given (quox adds only --no-tmux)')
225
226
  .helpOption(false)
@@ -230,8 +231,8 @@ Claude's own --agent is untouched and passes through to claude.`)
230
231
  const i = raw.indexOf('gemini', 2);
231
232
  process.exitCode = runGemini(parseGeminiArgs(i >= 0 ? raw.slice(i + 1) : []), over);
232
233
  });
233
- program
234
- .command('wrap')
234
+ markForwardsRawArgs(program
235
+ .command('wrap'), 'forwards argv after the verb verbatim')
235
236
  .description('Run any program in a governed tmux session: quox wrap <program> [args...]')
236
237
  .argument('<program>', 'command name on PATH')
237
238
  .argument('[args...]', 'passed to the program exactly as given')
@@ -270,6 +271,31 @@ Claude's own --agent is untouched and passes through to claude.`)
270
271
  const json = !!o.json || program.opts().output === 'json';
271
272
  process.exitCode = await runSessionsOverview({ json, watch: !!o.watch, all: !!o.all }, { ...so, fetchIndex });
272
273
  });
274
+ // `quox active [sessions|slots|agents]`: one short summary of who is doing what (QUOXON-1208).
275
+ program.command('active [filter]')
276
+ .description('Who is doing what: one line per active worker, grouped working / waiting on you / idle; filter: sessions, slots, agents')
277
+ .option('--json', 'print the summary as JSON')
278
+ .option('--all', 'show every worker (default: top 10 per group)')
279
+ .option('--lines', 'the older one-line-per-worker layout with state and age columns')
280
+ .action(async (filter, o) => {
281
+ const so = (over && over.sessionsOverview) || {};
282
+ const fetchIndex = so.fetchIndex || (async () => {
283
+ const { client, auth } = ctx;
284
+ auth.requireAuth();
285
+ const ep = resolveEndpoint('v1.sessions.index');
286
+ const org = orgOrNull(auth);
287
+ return client.get(ep.path, { service: ep.service, retries: 0, ...(org ? { query: { org_id: org } } : {}) });
288
+ });
289
+ const json = !!o.json || program.opts().output === 'json';
290
+ const { runActive } = await import('../core/activeSummary.js');
291
+ const fetchSlots = so.fetchSlots || (async () => {
292
+ const { client, auth } = ctx;
293
+ const ep = resolveEndpoint('v1.slots.list');
294
+ const r = await client.get(ep.path, { service: ep.service, retries: 0 });
295
+ return Array.isArray(r) ? r : (r?.slots || r?.data || []);
296
+ });
297
+ process.exitCode = await runActive({ filter: filter || null, json, all: !!o.all, lines: !!o.lines }, { ...so, fetchIndex, fetchSlots });
298
+ });
273
299
  sessions.command('heartbeat <tmuxSession>', { hidden: true })
274
300
  .description('Heartbeat one wrapped tmux session to the org session index (started by quox claude)')
275
301
  .option('--loop', 'keep beating until the tmux session ends')
@@ -107,6 +107,7 @@ export function registerContactsCommands(program, ctx) {
107
107
  const q = textQuery ?? options.q;
108
108
  if (q) query.q = q;
109
109
  if (options.status) query.status = options.status;
110
+ if (options.relationship) query.relationship = options.relationship;
110
111
  if (options.limit !== undefined) query.limit = Number(options.limit);
111
112
  const ep = resolveEndpoint('contactsq.contacts.list');
112
113
  const data = await call(() => client.get(ep.path, { service: ep.service, query }));
@@ -186,6 +187,7 @@ export function registerContactsCommands(program, ctx) {
186
187
  .description('List contacts')
187
188
  .option('--q <text>', 'name filter')
188
189
  .option('--status <s>', 'active | candidate | archived')
190
+ .option('--relationship <r>', 'primary relationship, e.g. customer | supplier | partner')
189
191
  .option('--limit <n>', 'max contacts')
190
192
  .action((options) => list(options));
191
193
 
@@ -373,6 +375,53 @@ export function registerContactsCommands(program, ctx) {
373
375
  announce('contacts brief', `${d.candidates_to_review} candidates, ${d.duplicates} duplicates`);
374
376
  });
375
377
 
378
+ contacts
379
+ .command('timeline <id>')
380
+ .description('Interaction history of a contact, newest first: surface, direction, outcome and time (refs only, no message bodies)')
381
+ .option('--limit <n>', 'max entries', '20')
382
+ .action(async (id, options) => {
383
+ auth.requireAuth();
384
+ const ep = resolveEndpoint('contactsq.timeline', { id });
385
+ const d = await call(() => client.get(ep.path, { service: ep.service, query: { limit: options.limit } }));
386
+ if (isJson(program)) return printJson(d);
387
+ const rows = d.timeline || [];
388
+ if (!rows.length) console.log('No interactions recorded.');
389
+ for (const t of rows) console.log(`${t.occurred_at || ''} ${t.surface || ''} ${t.direction || ''} ${t.outcome || ''}${t.agent_id ? ` (agent ${t.agent_id})` : ''}`);
390
+ announce('contacts timeline', `${rows.length} entries`);
391
+ });
392
+
393
+ contacts
394
+ .command('best-channel <id>')
395
+ .description('Which channel (voice, email or sms) may be used to reach a contact for a purpose, with every alternative and its reason codes. Read-only; never shows a number or address')
396
+ .option('--purpose <purpose>', 'service, transactional, marketing or research', 'service')
397
+ .action(async (id, options) => {
398
+ auth.requireAuth();
399
+ const ep = resolveEndpoint('contactsq.best_channel', { id });
400
+ const d = await call(() => client.get(ep.path, { service: ep.service, query: { purpose: options.purpose } }));
401
+ if (isJson(program)) return printJson(d);
402
+ console.log(`channel: ${d.channel || 'none allowed'}`);
403
+ for (const a of d.alternatives || []) console.log(` ${a.channel}: ${a.decision}${(a.reasons || []).length ? ` (${a.reasons.join(', ')})` : ''}`);
404
+ announce('contacts best-channel', d.channel || 'none allowed');
405
+ });
406
+
407
+ contacts
408
+ .command('count')
409
+ .description('How many contacts: the total (people and organisations, not archived or erased) with counts by kind, status and relationship. --kind narrows to person or organisation')
410
+ .option('--kind <kind>', 'person or organisation')
411
+ .action(async (options) => {
412
+ auth.requireAuth();
413
+ const ep = resolveEndpoint('contactsq.count');
414
+ const query = options.kind ? { kind: options.kind } : undefined;
415
+ const d = await call(() => client.get(ep.path, { service: ep.service, query }));
416
+ if (isJson(program)) return printJson(d);
417
+ const fmt = (o) => Object.entries(o || {}).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
418
+ formatDetail({
419
+ total: d.total, ...(d.kind ? { kind: d.kind } : {}), by_kind: fmt(d.by_kind), by_status: fmt(d.by_status),
420
+ by_relationship: fmt(d.by_relationship), candidates_to_review: d.candidates_to_review, generated_at: d.generated_at,
421
+ });
422
+ announce('contacts count', `${d.total} contacts${d.kind ? ` (${d.kind})` : ''}`);
423
+ });
424
+
376
425
  contacts
377
426
  .command('selftest')
378
427
  .description('Drive the read path (search, find, resolve, best channel, timeline, touchpoint, brief) against a demo contact. Never dials or sends. --agent runs it through the attached agent tool scope')
@@ -16,11 +16,29 @@ export function registerConversationCommands(program, ctx) {
16
16
 
17
17
  conv
18
18
  .command('list')
19
- .description('List conversations')
19
+ .description('List your chat conversations (newest first); --archive lists the imported conversation archive')
20
20
  .option('--limit <n>', 'Max results', '20')
21
- .option('--offset <n>', 'Skip results', '0')
21
+ .option('--offset <n>', 'Skip results (archive only)', '0')
22
+ .option('--archive', 'list the imported conversation archive instead of chat conversations')
22
23
  .action(async (opts) => {
23
24
  auth.requireAuth();
25
+ if (!opts.archive) {
26
+ const ep = resolveEndpoint('conversations.chat.list');
27
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: opts.limit } });
28
+ const rows = data.conversations || [];
29
+ formatOutput({
30
+ columns: ['id', 'title', 'turns', 'agent', 'updated_at'],
31
+ headers: ['ID', 'Title', 'Turns', 'Agent', 'Updated'],
32
+ rows: rows.map((c) => ({
33
+ id: c.id,
34
+ title: (c.title || 'Untitled').slice(0, 50),
35
+ turns: c.turn_count ?? 0,
36
+ agent: c.agent || '-',
37
+ updated_at: c.updated_at ? new Date(c.updated_at).toLocaleString() : '-',
38
+ })),
39
+ }, { format: program.opts().output });
40
+ return;
41
+ }
24
42
  const query = { limit: opts.limit, offset: opts.offset };
25
43
  const data = await client.get(resolveEndpoint('conversations.list').path, { service: resolveEndpoint('conversations.list').service, query });
26
44
  const conversations = data.conversations || data || [];
@@ -39,9 +57,22 @@ export function registerConversationCommands(program, ctx) {
39
57
  conv
40
58
  .command('get')
41
59
  .argument('<id>', 'conversation ID')
42
- .description('Get conversation details')
43
- .action(async (id) => {
60
+ .description('Show a chat conversation\'s turns; --archive reads the imported conversation archive')
61
+ .option('--limit <n>', 'Max turns (chat only)', '50')
62
+ .option('--archive', 'read the imported conversation archive instead of chat conversations')
63
+ .action(async (id, opts) => {
44
64
  auth.requireAuth();
65
+ if (!opts.archive) {
66
+ const ep = resolveEndpoint('conversations.chat.turns', { id });
67
+ const data = await client.get(ep.path, { service: ep.service, query: { limit: opts.limit } });
68
+ const turns = data.turns || [];
69
+ formatOutput({
70
+ columns: ['ts', 'role', 'content'],
71
+ headers: ['Time', 'Role', 'Content'],
72
+ rows: turns.map((t) => ({ ts: t.ts ? new Date(t.ts).toLocaleString() : '-', role: t.role, content: t.content })),
73
+ }, { format: program.opts().output });
74
+ return;
75
+ }
45
76
  const data = await client.get(resolveEndpoint('conversations.get', { id }).path, { service: resolveEndpoint('conversations.get', { id }).service });
46
77
  formatDetail(data.conversation || data, { format: program.opts().output, title: 'Conversation' });
47
78
  });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `quox dashboard [page]`: open the web dashboard already signed in, via a one-time link
3
+ * (10 minutes, single use). Same engine as /dashboard in the Quox client (lib/quoxon/slash/dashboard.js).
4
+ * Over SSH it prints the link and does not open a browser. The link is a credential: it is printed
5
+ * to the terminal only, never logged.
6
+ */
7
+ import { resolveEndpoint } from '../api/endpoints.js';
8
+ import { runDashboard, dashboardLines, PAGES } from '../quoxon/slash/dashboard.js';
9
+ import { supportsHyperlinks } from '../quoxon/markdown.js';
10
+ import { resolveDashboardBase, detectLanIp } from '../quoxon/hostUrls.js';
11
+ import { loadJourney } from '../quoxon/onboarding/journey.js';
12
+
13
+ /**
14
+ * Z2H-R4: the address to print, resolved exactly like /dashboard in the Quox client (api.js
15
+ * dashboardBase): server app URL, else a non-loopback profile URL, else the install finale's URL,
16
+ * else this machine's LAN IP. Over SSH a localhost link only works on the Quox box itself.
17
+ */
18
+ export async function commandDashboardBase({ client, config, journey = loadJourney, lanIp = detectLanIp() } = {}) {
19
+ const profileUrl = config.getActiveProfile?.()?.url || config.getBaseUrl();
20
+ let serverApp = null;
21
+ try { const ep = resolveEndpoint('admin.instance.basics.get'); const r = await client.get(ep.path, { service: ep.service, retries: 0 }); serverApp = r?.effective?.appBaseUrl || null; } catch { serverApp = null; }
22
+ let journeyUrl = null; try { journeyUrl = journey()?.dashboardUrl || null; } catch { journeyUrl = null; }
23
+ return resolveDashboardBase({ serverApp, profileUrl, journeyUrl, lanIp }).url;
24
+ }
25
+
26
+ export function registerDashboardCommands(program, ctx) {
27
+ const { client, auth, config, lanIp = detectLanIp, journey = loadJourney } = ctx;
28
+ program
29
+ .command('dashboard')
30
+ .description(`Open the web dashboard signed in (one-time link, 10 minutes). Pages: ${Object.keys(PAGES).join(', ')}`)
31
+ .argument('[page]', 'dashboard page to land on')
32
+ .action(async (page) => {
33
+ auth.requireAuth();
34
+ const baseUrl = await commandDashboardBase({ client, config, journey, lanIp: lanIp() });
35
+ const r = await runDashboard({ page, baseUrl, mint: () => { const ep = resolveEndpoint('auth.login-link'); return client.post(ep.path, { service: ep.service, body: {}, retries: 0 }); } });
36
+ if (program.opts().output === 'json') {
37
+ console.log(JSON.stringify({ kind: r.kind, url: r.url ?? null, opened: Boolean(r.opened), remote: Boolean(r.remote), error: r.error ?? null }, null, 2));
38
+ } else {
39
+ for (const l of dashboardLines(r, { hyperlinks: supportsHyperlinks() })) console.log(l.text);
40
+ }
41
+ if (r.kind === 'error') process.exitCode = 1;
42
+ });
43
+ }
@@ -15,17 +15,38 @@ export function registerDepartmentCommands(program, ctx) {
15
15
 
16
16
  dept
17
17
  .command('list')
18
- .description('List departments')
19
- .requiredOption('--division <divId>', 'division ID')
18
+ .description('List departments (all divisions unless --division; division ids: quox org divisions list)')
19
+ .option('--division <divId>', 'only this division')
20
20
  .action(async (options) => {
21
21
  auth.requireAuth();
22
22
  const orgId = resolveOrgId(auth, program);
23
- const data = await client.get(resolveEndpoint('divisions.departments.list', { orgId, division: options.division }).path, { service: resolveEndpoint('divisions.departments.list', { orgId, division: options.division }).service });
23
+ const listFor = async (division) => {
24
+ const ep = resolveEndpoint('divisions.departments.list', { orgId, division });
25
+ const data = await client.get(ep.path, { service: ep.service });
26
+ return data.departments || data || [];
27
+ };
28
+ if (options.division) {
29
+ formatOutput({
30
+ columns: ['id', 'name', 'description', 'created_at'],
31
+ headers: ['ID', 'Name', 'Description', 'Created'],
32
+ rows: await listFor(options.division),
33
+ }, { format: program.opts().output });
34
+ return;
35
+ }
36
+ // quox#922: no --division lists every division's departments, so the read
37
+ // needs no id the department group cannot show.
38
+ const divEp = resolveEndpoint('divisions.list', { orgId });
39
+ const divData = await client.get(divEp.path, { service: divEp.service });
40
+ const divisions = divData.divisions || divData || [];
41
+ const rows = [];
42
+ for (const d of divisions) {
43
+ for (const dept of await listFor(d.id)) rows.push({ ...dept, division: d.name || d.id });
44
+ }
24
45
  formatOutput({
25
- columns: ['id', 'name', 'description', 'created_at'],
26
- headers: ['ID', 'Name', 'Description', 'Created'],
27
- rows: data.departments || data,
28
- }, { format: program.opts().output });
46
+ columns: ['id', 'name', 'division', 'description', 'created_at'],
47
+ headers: ['ID', 'Name', 'Division', 'Description', 'Created'],
48
+ rows,
49
+ }, { format: program.opts().output, emptyMessage: 'No departments.' });
29
50
  });
30
51
 
31
52
  dept
@@ -164,10 +164,13 @@ export function registerDevResidentCommands(dev, ctx) {
164
164
  const slotName = resolveSlotName(options, hostId);
165
165
  const sessionName = options.sessionName || defaultSessionName(slotName);
166
166
 
167
+ let released = true;
168
+ let killed = true;
167
169
  try {
168
170
  const releaseEp = resolveEndpoint('v1.slots.release', { name: slotName });
169
171
  await client.post(releaseEp.path, { service: releaseEp.service, body: { org_id: orgId, force: true } });
170
172
  } catch (err) {
173
+ released = false;
171
174
  printWarning(`Releasing ${slotName} failed (continuing): ${err.message}`);
172
175
  }
173
176
 
@@ -175,10 +178,11 @@ export function registerDevResidentCommands(dev, ctx) {
175
178
  const killEp = resolveEndpoint('v1.dev.sessions.kill', { hostId, sessionId: sessionName, byName: true });
176
179
  await client.delete(killEp.path, { service: killEp.service });
177
180
  } catch (err) {
181
+ killed = false;
178
182
  printWarning(`Killing tmux session ${sessionName} on ${hostId} failed (continuing): ${err.message}`);
179
183
  }
180
184
 
181
- return { host_id: hostId, slot_name: slotName, session_name: sessionName };
185
+ return { host_id: hostId, slot_name: slotName, session_name: sessionName, released, killed };
182
186
  }
183
187
 
184
188
  resident
@@ -290,8 +294,16 @@ export function registerDevResidentCommands(dev, ctx) {
290
294
  .action(async (options) => {
291
295
  auth.requireAuth();
292
296
  const report = await releaseAndKill(options);
293
- formatDetail(report, { format: program.opts().output, title: `Resident retired on ${report.host_id}` });
294
- printSuccess(`Resident retired on ${report.host_id}: slot ${report.slot_name} released, session ${report.session_name} killed.`);
297
+ // #1018: only claim what happened, in the card title too; a retire that left the slot held or the session running failed.
298
+ const outcome = report.released && report.killed ? 'Resident retired'
299
+ : report.released || report.killed ? 'Resident retire incomplete' : 'Resident retire failed';
300
+ formatDetail(report, { format: program.opts().output, title: `${outcome} on ${report.host_id}` });
301
+ if (report.released && report.killed) {
302
+ printSuccess(`Resident retired on ${report.host_id}: slot ${report.slot_name} released, session ${report.session_name} killed.`);
303
+ } else {
304
+ printWarning(`Resident NOT fully retired on ${report.host_id}: slot ${report.slot_name} ${report.released ? 'released' : 'NOT released'}, session ${report.session_name} ${report.killed ? 'killed' : 'NOT killed'}.`);
305
+ process.exitCode = 1;
306
+ }
295
307
  });
296
308
 
297
309
  resident
@@ -29,7 +29,7 @@ import { resolveEndpoint } from '../api/endpoints.js';
29
29
  import { resolveOrgId } from './helpers.js';
30
30
  import { loadResidentProfile, expandHome, deriveSupervisorEnv } from '../core/residentAuth.js';
31
31
  import { createInitialPumpState, runPumpOnce } from '../core/residentPump.js';
32
- import { createInitialAlarmState, runWardenPass } from '../core/residentAlarm.js';
32
+ import { createInitialAlarmState, runWardenPass, sendAlarm } from '../core/residentAlarm.js';
33
33
  import {
34
34
  DEFAULT_SETTINGS,
35
35
  DEFAULT_IDLE_RECHECK_SECONDS,
@@ -344,7 +344,17 @@ export function registerDevSupervisorCommands(dev, ctx) {
344
344
  await client.post(ep.path, { service: ep.service, body: { ...fields, slot_name: slotName, org_id: orgId } });
345
345
  };
346
346
 
347
+ // GRIPE-797: one owner alert per auth-expiry episode, same transport
348
+ // order as the warden alarm (tg-send.sh first, slot say fallback).
349
+ const alertOwner = async (text) => {
350
+ const script = ctx.tgSendScriptPath || join(homedir(), '.claude', 'skills', 'telegram-owner', 'tg-send.sh');
351
+ await sendAlarm({
352
+ tgSend: async (session, t) => { await (ctx.execFileAsync || promisify(execFile))('bash', [script, session, t]); },
353
+ postSlotNotice: postReply, text, sessionName: `SUPERVISOR-${hostId}`, log,
354
+ });
355
+ };
347
356
  const commonPollArgs = {
357
+ alertOwner,
348
358
  hostId,
349
359
  slot: slotName,
350
360
  selfIdentity,
@@ -633,6 +643,8 @@ export function registerDevSupervisorCommands(dev, ctx) {
633
643
  }
634
644
  } catch (err) {
635
645
  log('error', `poll failed: ${err.message}`);
646
+ // #1018: a single pass that failed is a failed run, not a quiet success.
647
+ if (options.once) process.exitCode = 1;
636
648
  if (continuous && waitMode) {
637
649
  waitFailures += 1;
638
650
  if (waitFailures >= DEFAULT_WAIT_FAILURE_THRESHOLD) {
@@ -25,14 +25,10 @@ const QDP = process.env.QDP_CLI_PATH
25
25
 
26
26
  /**
27
27
  * Per-verb usage, mirrored from qdp.mjs's own header doc block (bin/qdp.mjs
28
- * in the quox-discord-pro repo). qdp itself has no --help handling of its
29
- * own, and this command hand-rolls arg dispatch (allowUnknownOption +
30
- * helpOption(false), see below) rather than commander subcommands, so
31
- * `--help`/`-h` was previously just another unrecognised arg forwarded
32
- * straight through to qdp — which silently ran the verb instead of showing
33
- * help. Worst case for a mutating verb (bind/unbind/provision): "--help"
34
- * would have actually bound/unbound/provisioned a channel. This map is
35
- * checked BEFORE qdp is ever spawned so that can't happen (quox#603).
28
+ * in the quox-discord-pro repo), shown in each subcommand's --help. qdp has no
29
+ * --help handling of its own; before quox#1077 this command hand-rolled arg
30
+ * dispatch and had to intercept --help itself (quox#603). Commander
31
+ * subcommands now answer --help before any action runs.
36
32
  */
37
33
  const VERB_USAGE = {
38
34
  status: 'qdp status daemon liveness, instances, binding count',
@@ -56,41 +52,81 @@ const VERB_USAGE = {
56
52
  + ' --dry-run print exactly what it WOULD do, change nothing',
57
53
  };
58
54
 
59
- const GENERAL_USAGE = 'Usage: quox discord <verb> [args...]\n\n'
60
- + Object.values(VERB_USAGE).map((u) => ` ${u}`).join('\n')
55
+ /**
56
+ * qdp verbs that only READ (quox#1077). Each was checked against bin/qdp.mjs:
57
+ * status/instances/bindings print config.json, the pid file and the systemd
58
+ * state; gaps GETs the open support-gap inbox items; health reads the pid and
59
+ * systemd state; doctor GETs the control API /health and Discord's channel,
60
+ * webhook and @me endpoints. None issues PUT/POST/PATCH/DELETE or writes a
61
+ * file. bind, unbind and provision PUT/DELETE bindings and create channels.
62
+ */
63
+ export const DISCORD_READ_VERBS = ['status', 'instances', 'bindings', 'gaps', 'health', 'doctor'];
64
+ export const DISCORD_WRITE_VERBS = ['bind', 'unbind', 'provision'];
65
+
66
+ // No "Usage:" line here: the matrix's --help walker (scripts/matrix/cli-tree.mjs) reads one as the command's usage.
67
+ const GENERAL_USAGE = Object.values(VERB_USAGE).map((u) => ` ${u}`).join('\n')
61
68
  + '\n\nRun `quox discord <verb> --help` for a single verb\'s usage.';
62
69
 
63
- function printVerbHelp(verb) {
64
- if (verb && VERB_USAGE[verb]) {
65
- process.stdout.write(`Usage:\n ${VERB_USAGE[verb]}\n`);
66
- } else {
67
- process.stdout.write(`${GENERAL_USAGE}\n`);
70
+ function runQdp(verb, args = []) {
71
+ if (!fs.existsSync(QDP)) {
72
+ process.stderr.write(
73
+ `Discord Pro control CLI (qdp) not found at ${QDP}\n` +
74
+ 'Set QDP_CLI_PATH if it lives elsewhere, or install the quox-discord-pro daemon on this host.\n',
75
+ );
76
+ process.exit(2);
68
77
  }
78
+ const r = spawnSync(process.execPath, [QDP, verb, ...args], { stdio: 'inherit' });
79
+ if (r.error) {
80
+ process.stderr.write(`qdp failed to run: ${r.error.message}\n`);
81
+ process.exit(2);
82
+ }
83
+ process.exit(r.status ?? 2);
69
84
  }
70
85
 
86
+ const VERB_DESC = { unbind: 'remove a channel binding', doctor: 'per-binding view/webhook/liveness check (exits non-zero if any binding is broken)' };
87
+ const firstLine = (verb) => VERB_DESC[verb] || VERB_USAGE[verb].split('\n')[0].replace(/^qdp \S+(?: [<[][^\]>]*[>\]])*\s+/, '').trim();
88
+
89
+ /**
90
+ * One commander subcommand per qdp verb (quox#1077). It used to be a single
91
+ * `discord [args...]` leaf, so the CLI leaf policy could only class the whole
92
+ * group, and it classed it write: a read like `quox discord bindings` was held
93
+ * for approval. Per-verb leaves let reads be read and keep bind/unbind/provision
94
+ * write. Commander's own --help runs before any action, so `--help` can never
95
+ * reach qdp and run a mutating verb (quox#603).
96
+ */
71
97
  export function registerDiscordCommands(program) {
72
- program
73
- .command('discord [args...]')
74
- .description('Quox Discord Pro daemon: status | instances | bindings | bind | unbind | gaps | health')
75
- .allowUnknownOption(true)
76
- .helpOption(false)
77
- .action((args = []) => {
78
- if (args.includes('--help') || args.includes('-h')) {
79
- printVerbHelp(args[0]);
80
- process.exit(0);
81
- }
82
- if (!fs.existsSync(QDP)) {
83
- process.stderr.write(
84
- `Discord Pro control CLI (qdp) not found at ${QDP}\n` +
85
- 'Set QDP_CLI_PATH if it lives elsewhere, or install the quox-discord-pro daemon on this host.\n',
86
- );
87
- process.exit(2);
88
- }
89
- const r = spawnSync(process.execPath, [QDP, ...args], { stdio: 'inherit' });
90
- if (r.error) {
91
- process.stderr.write(`qdp failed to run: ${r.error.message}\n`);
92
- process.exit(2);
93
- }
94
- process.exit(r.status ?? 2);
95
- });
98
+ const discord = program
99
+ .command('discord')
100
+ .description('Quox Discord Pro daemon: status | instances | bindings | gaps | health | doctor | bind | unbind | provision')
101
+ .addHelpText('after', `\nPer-verb usage (each verb shells straight through to qdp):\n${GENERAL_USAGE}`);
102
+
103
+ for (const verb of DISCORD_READ_VERBS) {
104
+ discord
105
+ .command(verb)
106
+ .description(firstLine(verb) || verb)
107
+ .addHelpText('after', `\nqdp usage:\n ${VERB_USAGE[verb]}`)
108
+ .action(() => runQdp(verb));
109
+ }
110
+
111
+ const writeArgs = { bind: '<channelId> <mode> [pairs...]', unbind: '<channelId>', provision: '<agentId> [args...]' };
112
+ for (const verb of DISCORD_WRITE_VERBS) {
113
+ const leaf = discord
114
+ .command(`${verb} ${writeArgs[verb]}`);
115
+ // Only provision has a dry run in qdp. Declaring it here keeps it in --help and lets the
116
+ // root guard refuse `discord bind|unbind --dry-run` instead of binding for real.
117
+ if (verb === 'provision') leaf.option('--dry-run', 'print exactly what it WOULD do, change nothing');
118
+ leaf
119
+ .description(firstLine(verb) || verb)
120
+ .addHelpText('after', `\nqdp usage:\n ${VERB_USAGE[verb]}`)
121
+ // qdp parses its own options (provision --mode/--host/...), so they pass through unchanged.
122
+ .allowUnknownOption(true)
123
+ .action((...a) => {
124
+ const cmd = a[a.length - 1];
125
+ const args = [...cmd.args];
126
+ // --dry-run is also a root quox flag, so commander hands it to the root and it never reaches
127
+ // cmd.args: a requested dry run of provision used to provision for real. Forward it explicitly.
128
+ if (cmd.optsWithGlobals().dryRun && !args.includes('--dry-run')) args.push('--dry-run');
129
+ runQdp(verb, args);
130
+ });
131
+ }
96
132
  }