quoxcli 0.0.0-stage → 0.2.0

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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,280 @@
1
+ /**
2
+ * `quox licence` (alias `license`), GRIPE-715: governed, audited licence
3
+ * operations from the terminal.
4
+ *
5
+ * Phase 1 (instance-local): status reads the lease (never the key), refresh
6
+ * runs the renewal, seats lists/cleans band holders (dry run default).
7
+ *
8
+ * Phase 2 (owner-only, talks to the quox.ai WEBSITE admin API with a separate
9
+ * website session, see core/websiteSession.js): reissue re-bands/extends a
10
+ * licence record, catalogue-sign re-signs the product catalogue. Both are DRY
11
+ * RUN unless --apply. The signing key never leaves the website VM; the CLI
12
+ * never sees, prints or sends key material or licence keys.
13
+ */
14
+
15
+ import { writeFileSync } from 'node:fs';
16
+ import { formatOutput, formatDetail, printSuccess, printInfo, printWarning } from '../core/output.js';
17
+ import { resolveEndpoint } from '../api/endpoints.js';
18
+ import { QuoxValidationError } from '../core/errors.js';
19
+ import {
20
+ DEFAULT_WEBSITE_URL, DEFAULT_WEBSITE_PROFILE, normaliseWebsiteUrl, websiteLogin, websitePost,
21
+ saveWebsiteSession, clearWebsiteSession, getWebsiteSession,
22
+ } from '../core/websiteSession.js';
23
+
24
+ const OWNER_HELP = `
25
+ Owner-only (website admin session, dry run unless --apply):
26
+ website-login log in to the quox.ai admin API once (password is not stored)
27
+ reissue <licenseId> band bare paid ids / add ids, re-signed on the website VM
28
+ catalogue-sign re-sign the product catalogue with the live key
29
+ See the quox-licensing skill (~/.claude/skills/quox-licensing/SKILL.md).
30
+ `;
31
+
32
+ const ID_LIST_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
33
+
34
+ export function parseIdList(value, flag) {
35
+ if (value === undefined) return undefined;
36
+ const ids = String(value).split(',').map((s) => s.trim()).filter(Boolean);
37
+ for (const id of ids) {
38
+ if (!ID_LIST_RE.test(id)) throw new QuoxValidationError(`${flag}: invalid plugin id "${id}"`);
39
+ }
40
+ return ids;
41
+ }
42
+
43
+ export function parseOrgs(value) {
44
+ if (value === undefined) return undefined;
45
+ const n = Number(value);
46
+ if (!Number.isInteger(n) || n < 1 || n > 1000) throw new QuoxValidationError('--orgs must be an integer between 1 and 1000');
47
+ return n;
48
+ }
49
+
50
+ function list(arr) {
51
+ return arr && arr.length ? arr.join(', ') : '(none)';
52
+ }
53
+
54
+ export function registerLicenceCommands(program, ctx) {
55
+ const { client, auth } = ctx;
56
+ const fetchImpl = ctx.websiteFetch || globalThis.fetch;
57
+ const json = () => program.opts().output === 'json';
58
+
59
+ const licence = program
60
+ .command('licence')
61
+ .alias('license')
62
+ .description('Instance licence status, lease refresh and seat bands')
63
+ .addHelpText('after', OWNER_HELP)
64
+ .action(() => { licence.help(); });
65
+
66
+ licence
67
+ .command('status')
68
+ .description('Licence id, plan, lease timestamps, governed count, entitled list with seat usage')
69
+ .option('--plugin <id>', 'limit to one plugin')
70
+ .action(async (opts) => {
71
+ auth.requireAuth();
72
+ const ep = resolveEndpoint('setup.licensing.status');
73
+ const d = await client.get(ep.path, { service: ep.service });
74
+ const bands = d.org_bands || {};
75
+ const pick = (id) => !opts.plugin || id === opts.plugin;
76
+ const entitled = (d.entitlements || []).filter(pick).map((id) => {
77
+ const b = bands[id];
78
+ return { plugin: id, seats: b ? `${b.assigned_count}/${b.allowance}` : 'whole install' };
79
+ });
80
+ const out = {
81
+ licenses: (d.licenses || []).map((l) => ({
82
+ license_id: l.license_id,
83
+ plans: l.plans || [],
84
+ lease_issued_at: l.issued_at,
85
+ lease_expires_at: l.lease_expires_at,
86
+ licence_expires_at: l.expires_at,
87
+ })),
88
+ governed_count: Array.isArray(d.governed) ? d.governed.length : 0,
89
+ org_id: d.org_id || null,
90
+ entitled,
91
+ entitlement_errors: d.entitlement_errors || [],
92
+ renewal_health: d.renewal_health || null,
93
+ };
94
+ if (opts.plugin) out.governed = (d.governed || []).includes(opts.plugin);
95
+ if (json()) { console.log(JSON.stringify(out, null, 2)); return; }
96
+ formatDetail({
97
+ licence_id: out.licenses.map((l) => l.license_id).join(', ') || '(none)',
98
+ plan: out.licenses.flatMap((l) => l.plans).join(', ') || '(none)',
99
+ lease_issued: out.licenses.map((l) => l.lease_issued_at || '?').join(', ') || '(none)',
100
+ lease_expires: out.licenses.map((l) => l.lease_expires_at).join(', ') || '(none)',
101
+ governed: String(out.governed_count),
102
+ org_id: out.org_id || '(instance scoped)',
103
+ entitlement_errors: out.entitlement_errors.length
104
+ ? out.entitlement_errors.map((e) => (e.entitlement ? `${e.entitlement}: ${e.error}` : JSON.stringify(e))).join('; ')
105
+ : '(none)',
106
+ }, { title: 'Licence status' });
107
+ formatOutput({
108
+ columns: ['plugin', 'seats'],
109
+ headers: ['Entitled plugin', 'Seats (used/cap)'],
110
+ rows: entitled,
111
+ }, { format: program.opts().output, emptyMessage: 'No entitlements.' });
112
+ });
113
+
114
+ licence
115
+ .command('refresh')
116
+ .description('Renew the licence lease now; prints old to new lease timestamps and entitlement diff')
117
+ .action(async () => {
118
+ auth.requireAuth();
119
+ const ep = resolveEndpoint('setup.licensing.refresh');
120
+ const d = await client.post(ep.path, { service: ep.service, body: {} });
121
+ if (json()) { console.log(JSON.stringify(d, null, 2)); return; }
122
+ const byId = new Map((d.before || []).map((l) => [l.license_id, l]));
123
+ for (const l of d.after || []) {
124
+ const old = byId.get(l.license_id);
125
+ printInfo(`${l.license_id}: lease expires ${old ? old.lease_expires_at : '(new)'} -> ${l.lease_expires_at}`);
126
+ }
127
+ for (const r of d.results || []) printInfo(`${r.license_id}: ${r.action}${r.reason ? ` (${r.reason})` : ''}`);
128
+ const diff = d.entitlement_diff || { added: [], removed: [] };
129
+ printInfo(`entitlements added: ${diff.added.join(', ') || '(none)'}`);
130
+ printInfo(`entitlements removed: ${diff.removed.join(', ') || '(none)'}`);
131
+ printSuccess('Lease refresh complete.');
132
+ });
133
+
134
+ licence
135
+ .command('seats')
136
+ .description('List a plugin band holders; --release-dead frees non-live holders (dry run unless --yes)')
137
+ .argument('<pluginId>', 'plugin id, e.g. notesq')
138
+ .option('--release-dead', 'release holders that are not live orgs (deleted orgs, user: ids)')
139
+ .option('--yes', 'actually release (otherwise dry run)')
140
+ .action(async (pluginId, opts) => {
141
+ auth.requireAuth();
142
+ const lep = resolveEndpoint('setup.licensing.seats.list', { pluginId });
143
+ const list = await client.get(lep.path, { service: lep.service });
144
+ let rel = null;
145
+ if (opts.releaseDead) {
146
+ const rep = resolveEndpoint('setup.licensing.seats.releaseDead', { pluginId });
147
+ rel = await client.post(rep.path, { service: rep.service, body: { dry_run: !opts.yes } });
148
+ }
149
+ if (json()) { console.log(JSON.stringify({ ...list, release: rel }, null, 2)); return; }
150
+ printInfo(`${pluginId}: ${list.holders.length} holder(s), cap ${list.allowance ?? '(no band)'}`);
151
+ formatOutput({
152
+ columns: ['id', 'name', 'status'],
153
+ headers: ['Holder', 'Org name', 'Status'],
154
+ rows: list.holders.map((h) => ({ id: h.id, name: h.name || '-', status: h.status })),
155
+ }, { format: program.opts().output, emptyMessage: 'No holders.' });
156
+ if (rel) {
157
+ if (rel.dry_run) {
158
+ printInfo(`DRY RUN: would release ${rel.dead.length} non-live holder(s): ${rel.dead.map((d) => d.id).join(', ') || '(none)'}. Re-run with --yes to release.`);
159
+ } else {
160
+ printSuccess(`Released ${rel.released.length} non-live holder(s): ${rel.released.join(', ') || '(none)'}`);
161
+ }
162
+ }
163
+ });
164
+
165
+ // ---------------- Phase 2: owner-only, website admin API ----------------
166
+
167
+ licence
168
+ .command('website-login')
169
+ .description('Log in to the quox.ai website admin API for reissue/catalogue-sign (stores the session cookie only, 0600)')
170
+ .option('--website-profile <name>', 'website session profile', DEFAULT_WEBSITE_PROFILE)
171
+ .option('--url <url>', 'website base URL', DEFAULT_WEBSITE_URL)
172
+ .option('--username <name>', 'website admin username')
173
+ .option('--password-stdin', 'read the password from stdin (one line) instead of prompting')
174
+ .action(async (opts) => {
175
+ normaliseWebsiteUrl(opts.url); // refuse a bad or plain-http URL before asking for a password
176
+ let { username } = opts;
177
+ let password;
178
+ if (opts.passwordStdin) {
179
+ const chunks = [];
180
+ for await (const c of process.stdin) chunks.push(Buffer.from(c));
181
+ password = Buffer.concat(chunks).toString('utf8').split('\n')[0].trim();
182
+ }
183
+ if (!username || !password) {
184
+ const { default: inquirer } = await import('inquirer');
185
+ if (!username) username = (await inquirer.prompt([{ type: 'input', name: 'u', message: 'Website admin username:' }])).u;
186
+ if (!password) password = (await inquirer.prompt([{ type: 'password', name: 'p', message: 'Password:', mask: '*' }])).p;
187
+ }
188
+ if (!username || !password) throw new QuoxValidationError('username and password are required');
189
+ const session = await websiteLogin({ url: opts.url, username, password, fetchImpl });
190
+ password = null;
191
+ saveWebsiteSession(opts.websiteProfile, session);
192
+ if (json()) { console.log(JSON.stringify({ profile: opts.websiteProfile, url: session.url, username: session.username, must_change_password: session.must_change_password }, null, 2)); return; }
193
+ printSuccess(`Website session saved for ${session.username} at ${session.url} (profile ${opts.websiteProfile}, valid ~24h). Password not stored.`);
194
+ if (session.must_change_password) printWarning('This admin must change their password on the website before licence operations will be allowed (HTTP 403 until then).');
195
+ });
196
+
197
+ licence
198
+ .command('website-logout')
199
+ .description('End the website admin session and delete the stored cookie')
200
+ .option('--website-profile <name>', 'website session profile', DEFAULT_WEBSITE_PROFILE)
201
+ .action(async (opts) => {
202
+ if (getWebsiteSession(opts.websiteProfile)) {
203
+ try { await websitePost(opts.websiteProfile, '/api/admin/logout', {}, { fetchImpl }); } catch { /* session may already be dead */ }
204
+ }
205
+ const had = clearWebsiteSession(opts.websiteProfile);
206
+ printSuccess(had ? `Website session ${opts.websiteProfile} removed.` : `No website session ${opts.websiteProfile} was stored.`);
207
+ });
208
+
209
+ licence
210
+ .command('reissue')
211
+ .description('Owner-only: band bare paid ids and add ids on a licence record, re-signed on the website (dry run unless --apply)')
212
+ .argument('<licenseId>', 'licence id, e.g. lic_abc123')
213
+ .option('--orgs <n>', 'org band for every banded/added id (default 10)')
214
+ .option('--add <ids>', 'comma-separated ids to ADD as <id>@orgs=N')
215
+ .option('--band <ids>', 'comma-separated ids already present to FORCE-band')
216
+ .option('--apply', 'actually reissue (otherwise dry run, nothing changes)')
217
+ .option('--website-profile <name>', 'website session profile', DEFAULT_WEBSITE_PROFILE)
218
+ .action(async (licenseId, opts) => {
219
+ const body = { dryRun: !opts.apply };
220
+ const orgs = parseOrgs(opts.orgs);
221
+ if (orgs !== undefined) body.orgs = orgs;
222
+ const add = parseIdList(opts.add, '--add');
223
+ if (add) body.add = add;
224
+ const band = parseIdList(opts.band, '--band');
225
+ if (band) body.band = band;
226
+
227
+ const d = await websitePost(opts.websiteProfile, `/api/admin/licenses/${encodeURIComponent(licenseId)}/reissue`, body, { fetchImpl });
228
+ if (json()) { console.log(JSON.stringify(d, null, 2)); return; }
229
+
230
+ printInfo(`Licence ${d.license_id}${d.customer_email ? ` (${d.customer_email})` : ''}: max_servers ${d.max_servers ?? '?'}, expires ${d.expires_at || 'never'}`);
231
+ const removed = new Set(d.diff?.removed || []);
232
+ const added = new Set(d.diff?.added || []);
233
+ for (const e of d.current || []) console.log(` ${removed.has(e) ? '-' : ' '} ${e}`);
234
+ for (const e of d.next || []) if (added.has(e)) console.log(` + ${e}`);
235
+ if (d.unknown_ids?.length) printWarning(`Not issuable (unknown to the signed catalogue), apply will be refused: ${d.unknown_ids.join(', ')}`);
236
+
237
+ if (!d.changed) { printSuccess('No change needed: the licence already has these entitlements.'); return; }
238
+ if (d.dry_run) { printInfo('DRY RUN: nothing changed. Re-run with --apply to reissue.'); return; }
239
+ printSuccess(`Reissued ${d.license_id}. Audit/backup id: ${d.backup_id}`);
240
+ printInfo('Instances pick this up on their next lease renewal (~6h). To pull it now, on the instance: quox licence refresh');
241
+ });
242
+
243
+ licence
244
+ .command('catalogue-sign')
245
+ .alias('catalog-sign')
246
+ .description('Owner-only: re-sign the product catalogue with the live key on the website (dry run unless --apply)')
247
+ .option('--apply', 'actually sign and publish to the website API (otherwise dry run)')
248
+ .option('--out <path>', 'after --apply, save the signed catalogue (public artifact) here for committing')
249
+ .option('--website-profile <name>', 'website session profile', DEFAULT_WEBSITE_PROFILE)
250
+ .action(async (opts) => {
251
+ if (opts.out && !opts.apply) throw new QuoxValidationError('--out is only meaningful with --apply');
252
+ const d = await websitePost(opts.websiteProfile, '/api/admin/catalog/sign', { dryRun: !opts.apply }, { fetchImpl });
253
+ if (opts.out && d.artifact) writeFileSync(opts.out, JSON.stringify(d.artifact, null, 2) + '\n', 'utf8');
254
+ if (json()) {
255
+ const { artifact, ...rest } = d;
256
+ console.log(JSON.stringify({ ...rest, artifact_saved_to: opts.out && artifact ? opts.out : null }, null, 2));
257
+ return;
258
+ }
259
+ const p = d.published || {};
260
+ formatDetail({
261
+ published: p.exists ? `${p.verified ? 'verified' : 'NOT VERIFIED'}, sha256 ${p.sha256 ? p.sha256.slice(0, 16) : '?'}..., ${p.generated_at || '?'}` : '(none)',
262
+ products: `${p.product_count ?? 0} -> ${d.candidate?.product_count ?? '?'}`,
263
+ governed: `${p.governed_count ?? 0} -> ${d.candidate?.governed_count ?? '?'}`,
264
+ products_added: list(d.diff?.products_added),
265
+ products_removed: list(d.diff?.products_removed),
266
+ products_changed: list(d.diff?.products_changed),
267
+ governed_added: list(d.diff?.governed_added),
268
+ governed_removed: list(d.diff?.governed_removed),
269
+ signing_key: d.signing ? (d.signing.trusted ? `trusted (${d.signing.trusted_key_prefix}...)` : (d.signing.configured ? 'NOT the trusted key: apply will be refused' : 'not configured: apply will be refused')) : '?',
270
+ }, { title: d.dry_run ? 'Catalogue sign (DRY RUN)' : 'Catalogue sign' });
271
+ if (d.diff?.governed_added?.length) {
272
+ printWarning(`Newly governed: ${d.diff.governed_added.join(', ')}. Reissue the owner licence with these ids (quox licence reissue <id> --add ...) and refresh its lease BEFORE instances install this catalogue, or they lock out.`);
273
+ }
274
+ if (!d.changed) { printSuccess('No change: the published catalogue already matches.'); return; }
275
+ if (d.dry_run) { printInfo('DRY RUN: nothing signed. Re-run with --apply to sign.'); return; }
276
+ printSuccess(`Catalogue signed. sha256 ${d.sha256}, governed ${d.governed_count}, audit id ${d.audit_id}`);
277
+ if (opts.out) printInfo(`Signed catalogue saved to ${opts.out}.`);
278
+ printInfo('The website container copy does not survive a rebuild: commit the artifact to quox-website api/data/signed-catalog.json and quox-dashboard services/auth/data/signed-catalog.json (use --out to save it).');
279
+ });
280
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `quox linkedin` — LinkedIn plugin verbs, LI-OVERHAUL P9 (X CLI parity).
3
+ *
4
+ * quox linkedin post "<text>" --as <author_urn> [--visibility PUBLIC|CONNECTIONS] [--link <url>] [--image-url <https url>]
5
+ * quox linkedin comment <post_urn> "<text>" --as <actor_urn>
6
+ * quox linkedin posts <author_urn> [--count <n>]
7
+ * quox linkedin org <org_id>
8
+ *
9
+ * Both write verbs execute the linkedin connector tools through the governed
10
+ * collector endpoint (POST /api/v1/tools/execute -> connector bridge). The
11
+ * bridge gates them on their requiresApproval flag: each files an owner
12
+ * approval card and returns immediately with
13
+ * { pending:true, status:'awaiting_approval', approvalId, inbox_item_id }.
14
+ * The actual LinkedIn API write only happens once the owner approves via the
15
+ * dashboard inbox or `quox inbox respond <id> --action approve`. Reads
16
+ * (posts, org) execute directly; LinkedIn reads carry no per-call dollar
17
+ * cost (unlike X), only LinkedIn's own daily program quotas.
18
+ *
19
+ * URNs are explicit by design: LinkedIn addresses everything by URN
20
+ * (urn:li:person:xxx member, urn:li:organization:12345 org page) and the
21
+ * CLI has no org-scoped plugin-config lookup yet, so --as is required on
22
+ * writes rather than silently guessed.
23
+ */
24
+
25
+ import { formatDetail, printSuccess, printInfo } from '../core/output.js';
26
+ import { resolveEndpoint } from '../api/endpoints.js';
27
+ import { resolveOrgId } from './helpers.js';
28
+
29
+ const MAX_POST_CHARS = 3000;
30
+
31
+ /**
32
+ * Execute one linkedin connector tool via the governed collector endpoint.
33
+ * A pending-approval response is a SUCCESS shape here (the card was filed);
34
+ * only a non-pending success:false is an error.
35
+ */
36
+ async function executeLinkedInTool({ client, auth, program }, toolName, input) {
37
+ auth.requireAuth();
38
+ const orgId = resolveOrgId(auth, program);
39
+ const ep = resolveEndpoint('v1.tools.execute');
40
+ const body = { tool_name: toolName, input, org_id: orgId };
41
+ const data = await client.post(ep.path, { service: ep.service, body });
42
+ if (data && data.success === false && !data.pending) {
43
+ throw new Error(`${toolName} via POST ${ep.path} returned success:false: ${data.error || 'unknown error'}`);
44
+ }
45
+ return data;
46
+ }
47
+
48
+ function printAwaitingApproval(action, data) {
49
+ const itemRef = data.inbox_item_id || data.approvalId || 'unknown';
50
+ printSuccess(`${action} filed for owner approval — inbox item ${itemRef} (awaiting approval). Nothing publishes until an approver says yes.`);
51
+ if (data.inbox_item_id) {
52
+ printInfo(`Review and approve: quox inbox get ${data.inbox_item_id} (or: quox inbox respond ${data.inbox_item_id} --action approve)`);
53
+ }
54
+ }
55
+
56
+ export function registerLinkedInCommands(program, ctx) {
57
+ const { client, auth } = ctx;
58
+
59
+ const linkedin = program
60
+ .command('linkedin')
61
+ .description('LinkedIn publishing: governed, HITL-approved via HITL Inbox');
62
+
63
+ // ── linkedin post ────────────────────────────────────────────────────────
64
+
65
+ linkedin
66
+ .command('post')
67
+ .description('Draft a LinkedIn post. Files an owner approval card and returns immediately; never posts directly.')
68
+ .argument('<text>', `post text, max ${MAX_POST_CHARS} characters`)
69
+ .requiredOption('--as <author_urn>', 'author URN (urn:li:person:xxx or urn:li:organization:12345)')
70
+ .option('--visibility <visibility>', 'PUBLIC or CONNECTIONS', 'PUBLIC')
71
+ .option('--link <url>', 'attach a link/article URL')
72
+ .option('--image-url <url>', 'attach an image from an https URL (max 10MB); uploaded to LinkedIn server-side before the card is filed, so the approver sees the finished attachment')
73
+ .action(async (text, options) => {
74
+ if (text.length > MAX_POST_CHARS) {
75
+ throw new Error(`Text is ${text.length} characters, over the ${MAX_POST_CHARS} character limit.`);
76
+ }
77
+ if (!['PUBLIC', 'CONNECTIONS'].includes(options.visibility)) {
78
+ throw new Error(`--visibility must be PUBLIC or CONNECTIONS, got ${options.visibility}`);
79
+ }
80
+
81
+ const input = { author: options.as, text, visibility: options.visibility };
82
+ if (options.link) input.link_url = options.link;
83
+
84
+ if (options.imageUrl) {
85
+ const media = await executeLinkedInTool({ client, auth, program }, 'linkedin_upload_media', {
86
+ owner: options.as,
87
+ image_url: options.imageUrl,
88
+ });
89
+ const urn = media?.result?.image_urn;
90
+ if (!urn) {
91
+ throw new Error(`Image upload did not return an image URN: ${JSON.stringify(media).slice(0, 200)}`);
92
+ }
93
+ printInfo(`Image uploaded (${media.result.bytes} bytes) — ${urn}`);
94
+ input.media_id = urn;
95
+ }
96
+
97
+ const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_publish_post', input);
98
+
99
+ if (program.opts().output === 'json') {
100
+ formatDetail(data, { format: 'json' });
101
+ return;
102
+ }
103
+ printAwaitingApproval('Post', data);
104
+ });
105
+
106
+ // ── linkedin comment ─────────────────────────────────────────────────────
107
+
108
+ linkedin
109
+ .command('comment')
110
+ .description('Draft a comment on a LinkedIn post. Files an owner approval card and returns immediately; never comments directly.')
111
+ .argument('<post_urn>', 'URN of the post to comment on (e.g. urn:li:share:12345)')
112
+ .argument('<text>', 'comment text')
113
+ .requiredOption('--as <actor_urn>', 'actor URN posting the comment (person or organisation)')
114
+ .action(async (postUrn, text, options) => {
115
+ const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_create_comment', {
116
+ post_urn: postUrn,
117
+ actor: options.as,
118
+ text,
119
+ });
120
+
121
+ if (program.opts().output === 'json') {
122
+ formatDetail(data, { format: 'json' });
123
+ return;
124
+ }
125
+ printAwaitingApproval('Comment', data);
126
+ });
127
+
128
+ // ── linkedin posts (read) ────────────────────────────────────────────────
129
+
130
+ linkedin
131
+ .command('posts')
132
+ .description('List recent posts for a member or organisation (read-only, no approval needed).')
133
+ .argument('<author_urn>', 'author URN (person or organisation)')
134
+ .option('--count <n>', 'number of posts (max 50)', '20')
135
+ .action(async (authorUrn, options) => {
136
+ const count = Math.min(parseInt(options.count, 10) || 20, 50);
137
+ const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_get_posts', {
138
+ author: authorUrn,
139
+ count,
140
+ });
141
+
142
+ if (program.opts().output === 'json') {
143
+ formatDetail(data, { format: 'json' });
144
+ return;
145
+ }
146
+ const posts = data.result || [];
147
+ if (posts.length === 0) {
148
+ printInfo('No posts returned.');
149
+ return;
150
+ }
151
+ for (const p of posts) {
152
+ console.log(`${p.id} ${p.created_at || ''} ${p.lifecycle_state || ''}`);
153
+ console.log(` ${String(p.text || '').replace(/\n/g, '\n ')}`);
154
+ }
155
+ });
156
+
157
+ // ── linkedin org (read) ──────────────────────────────────────────────────
158
+
159
+ linkedin
160
+ .command('org')
161
+ .description('Show a LinkedIn organisation page: name, follower count, website (read-only). Also the cheapest live credential check.')
162
+ .argument('<org_id>', 'numeric organisation id (e.g. 12345)')
163
+ .action(async (orgId) => {
164
+ const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_get_org_info', { org_id: orgId });
165
+
166
+ if (program.opts().output === 'json') {
167
+ formatDetail(data, { format: 'json' });
168
+ return;
169
+ }
170
+ const org = data.result || {};
171
+ printSuccess(`${org.name || 'Unknown'} (urn:li:organization:${org.id})`);
172
+ if (org.vanity_name) printInfo(`linkedin.com/company/${org.vanity_name}`);
173
+ // followers can be null when networkSizes is unavailable — say unknown,
174
+ // never a fake 0.
175
+ printInfo(`Followers: ${org.followers == null ? 'unknown' : org.followers.toLocaleString()}`);
176
+ if (org.website) printInfo(`Website: ${org.website}`);
177
+ });
178
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * LLM routing policy commands: view/set/dry-run-test the org's execution
3
+ * policy (which providers/models agents are allowed to route to).
4
+ *
5
+ * quox llm-policy get|set|test
6
+ *
7
+ * Backend: services/auth/routes/llm-routing-policies.js, mounted at plain
8
+ * `/api` (org-scoped in the URL): GET/PUT `/api/orgs/:orgId/llm-routing-policy`,
9
+ * POST `/api/orgs/:orgId/llm-routing-policy/test`. See
10
+ * cli/lib/api/endpoints/llm-policy.js for the full verified response shapes.
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { formatDetail, printSuccess, printWarning } from '../core/output.js';
15
+ import { resolveOrgId, parseJSON } from './helpers.js';
16
+ import { resolveEndpoint } from '../api/endpoints.js';
17
+
18
+ /**
19
+ * Resolve a policy object from --file or --policy. Throws if neither given
20
+ * or the input isn't a JSON object.
21
+ */
22
+ function resolvePolicy(options) {
23
+ let raw;
24
+ if (options.file) {
25
+ try {
26
+ raw = readFileSync(options.file, 'utf-8');
27
+ } catch (err) {
28
+ throw new Error(`Could not read ${options.file}: ${err.message}`);
29
+ }
30
+ } else if (options.policy !== undefined) {
31
+ raw = options.policy;
32
+ } else {
33
+ throw new Error('Policy input is required. Use --file <path> or --policy <json>.');
34
+ }
35
+
36
+ const policy = parseJSON(raw, options.file ? '--file contents' : '--policy');
37
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
38
+ throw new Error('Policy must be a JSON object.');
39
+ }
40
+ return policy;
41
+ }
42
+
43
+ export function registerLlmPolicyCommands(program, ctx) {
44
+ const { client, auth } = ctx;
45
+
46
+ const llmPolicy = program
47
+ .command('llm-policy')
48
+ .description('View, set, and dry-run-validate the org LLM routing policy');
49
+
50
+ // ── llm-policy get ──────────────────────────────────────────────────────────
51
+
52
+ llmPolicy
53
+ .command('get')
54
+ .description('Show the effective LLM routing policy for the active org')
55
+ .action(async () => {
56
+ auth.requireAuth();
57
+ const orgId = resolveOrgId(auth, program);
58
+ const data = await client.get(resolveEndpoint('llm-policy.get', { orgId }).path, {
59
+ service: resolveEndpoint('llm-policy.get', { orgId }).service,
60
+ });
61
+
62
+ if (data.source === 'seed') {
63
+ printWarning('No org-specific policy saved — showing the built-in seed policy.');
64
+ }
65
+
66
+ formatDetail(data.policy, {
67
+ format: program.opts().output,
68
+ title: `LLM Routing Policy (source: ${data.source})`,
69
+ });
70
+ });
71
+
72
+ // ── llm-policy set ──────────────────────────────────────────────────────────
73
+
74
+ llmPolicy
75
+ .command('set')
76
+ .description('Save a new LLM routing policy for the active org (requires ORG_MANAGE_SETTINGS)')
77
+ .option('--file <path>', 'path to a JSON file containing the policy object')
78
+ .option('--policy <json>', 'inline JSON policy object')
79
+ .action(async (options) => {
80
+ auth.requireAuth();
81
+ const orgId = resolveOrgId(auth, program);
82
+ const policy = resolvePolicy(options);
83
+
84
+ const data = await client.put(resolveEndpoint('llm-policy.set', { orgId }).path, {
85
+ service: resolveEndpoint('llm-policy.set', { orgId }).service,
86
+ body: { policy },
87
+ });
88
+
89
+ printSuccess('LLM routing policy saved.');
90
+ formatDetail(data.policy, { format: program.opts().output });
91
+ });
92
+
93
+ // ── llm-policy test ─────────────────────────────────────────────────────────
94
+
95
+ llmPolicy
96
+ .command('test')
97
+ .description('Dry-run validate a policy without saving it')
98
+ .option('--file <path>', 'path to a JSON file containing the policy object')
99
+ .option('--policy <json>', 'inline JSON policy object')
100
+ .action(async (options) => {
101
+ auth.requireAuth();
102
+ const orgId = resolveOrgId(auth, program);
103
+ const policy = resolvePolicy(options);
104
+
105
+ const data = await client.post(resolveEndpoint('llm-policy.test', { orgId }).path, {
106
+ service: resolveEndpoint('llm-policy.test', { orgId }).service,
107
+ body: { policy },
108
+ });
109
+
110
+ if (data.valid) {
111
+ printSuccess('Policy is valid.');
112
+ } else {
113
+ printWarning(`Policy is invalid (${(data.errors || []).length} error(s)).`);
114
+ }
115
+ formatDetail(data, { format: program.opts().output });
116
+ });
117
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * LLM commands: provider status incl. degraded state and the failover target.
3
+ *
4
+ * quox llm status
5
+ *
6
+ * Backend: collector `GET /chat/status` (same endpoint as `quox chat-status`,
7
+ * key 'chat.status.list'). This is the curated LLM view of it: it surfaces the
8
+ * failover fields shipped with the LLM failover chain work (`degraded`,
9
+ * `degraded_reason`, `degraded_until`, `fallback_hint`) and warns loudly when
10
+ * the primary provider is in a failover cooldown. Routing policy itself lives
11
+ * under `quox llm-policy`.
12
+ */
13
+
14
+ import { formatDetail, printWarning } from '../core/output.js';
15
+ import { resolveEndpoint } from '../api/endpoints.js';
16
+
17
+ export function registerLlmCommands(program, ctx) {
18
+ const { client } = ctx;
19
+
20
+ const llm = program
21
+ .command('llm')
22
+ .description('LLM provider status and failover');
23
+
24
+ llm
25
+ .command('status')
26
+ .description('Show LLM provider status, degraded state, and the failover target')
27
+ .action(async () => {
28
+ // No requireAuth: the route is public on collectors that allowlist it,
29
+ // but the token is attached when logged in (the dev gate requires a JWT).
30
+ const data = await client.get(resolveEndpoint('chat.status.list').path, { service: resolveEndpoint('chat.status.list').service });
31
+
32
+ if (data.degraded) {
33
+ const reason = data.degraded_reason ? ` (${data.degraded_reason})` : '';
34
+ const fallback = data.fallback_hint
35
+ ? ` Failing over to ${data.fallback_hint.provider}:${data.fallback_hint.model}.`
36
+ : '';
37
+ printWarning(`Primary provider degraded${reason}.${fallback}`);
38
+ }
39
+
40
+ formatDetail(data, {
41
+ format: program.opts().output,
42
+ title: 'LLM Status',
43
+ });
44
+ });
45
+ }