quoxcli 0.6.0 → 0.6.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 (83) hide show
  1. package/CHANGELOG.md +18 -3
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/index.js +2 -0
  5. package/lib/api/endpoints/lens.js +34 -0
  6. package/lib/api/endpoints/lucid.js +2 -1
  7. package/lib/api/endpoints/mon.js +6 -7
  8. package/lib/api/endpoints/v1.js +57 -0
  9. package/lib/commands/admin.js +86 -0
  10. package/lib/commands/approvals.js +5 -1
  11. package/lib/commands/calendar.js +27 -0
  12. package/lib/commands/chat.js +1 -1
  13. package/lib/commands/fleet.js +164 -0
  14. package/lib/commands/fleetRecipe.js +174 -0
  15. package/lib/commands/lens.js +268 -0
  16. package/lib/commands/lucidGoals.js +36 -2
  17. package/lib/commands/mail.js +21 -2
  18. package/lib/commands/monitoring.js +5 -1
  19. package/lib/commands/plan.js +36 -1
  20. package/lib/commands/research.js +78 -0
  21. package/lib/commands/settings.js +7 -1
  22. package/lib/commands/wazuh.js +9 -0
  23. package/lib/commands/wazuhIntake.js +11 -3
  24. package/lib/core/commandRegistrars.js +1 -0
  25. package/lib/core/version.js +15 -2
  26. package/lib/dragon/select.js +2 -2
  27. package/lib/quoxon/app.js +1 -1
  28. package/lib/quoxon/commands.js +1 -1
  29. package/lib/quoxon/dragonCompanion.js +24 -12
  30. package/lib/quoxon/main.js +1 -1
  31. package/lib/quoxon/onboarding/view.js +1 -1
  32. package/lib/quoxon/slash/cliLeaves.json +19 -0
  33. package/lib/quoxon/whatsnew.jsonl +1 -0
  34. package/package.json +1 -1
  35. package/vendor/dragonlab/SOURCE +3 -3
  36. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  37. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  38. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  39. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  40. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  41. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  42. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  43. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  44. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  45. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  46. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  47. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  48. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  49. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  50. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  51. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  52. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  53. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  54. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  55. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  56. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  57. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  58. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  59. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  60. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  61. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  62. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  63. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  64. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  65. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  66. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  67. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  68. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  69. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  70. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  71. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  72. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  73. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  74. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  75. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  76. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  77. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  78. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  79. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  80. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  81. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  82. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  83. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
package/CHANGELOG.md CHANGED
@@ -4,11 +4,26 @@ User-visible changes to the `quoxcli` package. Items marked *(unproven)* ship in
4
4
  the package and pass their tests, but nobody has run them end to end from the
5
5
  published package yet.
6
6
 
7
+ ## 0.6.1 (2026-11-10)
8
+
9
+ - `quox --version` and `quox doctor` no longer report another project's git commit. Installed
10
+ under nvm, 0.6.0 printed `0.6.0 (bab86d5, detached)`: `bab86d5` was nvm's own commit,
11
+ because `~/.nvm` is a git clone. Git details now show only when the CLI runs from its own
12
+ source checkout; a package install prints the bare version. *(driven live: the 0.6.0
13
+ npm install patched with this fix printed `0.6.0`)*
14
+ - New: `quox lens panel` runs several QuoxLens critiques over one target, repeated, and keeps the
15
+ findings that recur across runs. *(unproven from the package; landed by another session the
16
+ same day)*
17
+ - `quox lens apply` now waits past the 5 minute model ceiling instead of the 30 second client
18
+ default. *(unproven from the package)*
19
+
7
20
  ## 0.6.0 (2026-11-10)
8
21
 
9
- Nothing below has been run end to end from the published 0.6.0 package yet: treat every
10
- item as *(unproven from the package)*. Items marked *(driven live)* were driven against a
11
- live instance from the source tree before release.
22
+ Driven from the published 0.6.0 package on 2026-11-10 (npm install -g, Node under nvm):
23
+ `notes ls`, `doctor` (9 of 9), and the full seal round trip (`notes seal init`, `status`,
24
+ `new`, `open` by id, `print` then `open --armor-file`; the server copy held no plaintext).
25
+ Everything else below has not been run from the package yet. Items marked *(driven live)*
26
+ were driven against a live instance from the source tree before release.
12
27
 
13
28
  ### New commands
14
29
  - `quox notes seal convert <id>` and `quox notes seal purge <id>` (human sessions only):
@@ -175,4 +175,25 @@ export const admin = {
175
175
  path: () => `/api/admin/stats`,
176
176
  tags: ['live-safe'],
177
177
  },
178
+ // AQ-9H LADON-P1B: the scratch-org registry (fixture mail/calendar providers load only for a marked org, and
179
+ // only on a QUOX_FIXTURE_PROVIDERS=1 deployment). Collector routes, human instance admin only, enforced
180
+ // server-side by requireInstanceAdminMw (services/collector/server.js).
181
+ 'admin.scratch_orgs.list': {
182
+ method: 'GET',
183
+ service: 'collector',
184
+ path: () => `/api/v1/admin/scratch-orgs`,
185
+ tags: ['live-safe'],
186
+ },
187
+ 'admin.scratch_orgs.mark': {
188
+ method: 'PUT',
189
+ service: 'collector',
190
+ path: ({ orgId }) => `/api/v1/admin/scratch-orgs/${encodeURIComponent(orgId)}`,
191
+ tags: ['admin-write'],
192
+ },
193
+ 'admin.scratch_orgs.unmark': {
194
+ method: 'DELETE',
195
+ service: 'collector',
196
+ path: ({ orgId }) => `/api/v1/admin/scratch-orgs/${encodeURIComponent(orgId)}`,
197
+ tags: ['admin-write'],
198
+ },
178
199
  };
@@ -38,6 +38,14 @@ export const calendarq = {
38
38
  path: () => `/api/v1/calendarq/changes`,
39
39
  tags: ['live-safe'],
40
40
  },
41
+ // AQ-9H LADON-P1B: create a seeded FIXTURE calendar connection (scratch orgs on a fixture-enabled
42
+ // deployment only; the collector's gate decides). Google connects through the OAuth flow, not here.
43
+ 'calendarq.connections.create': {
44
+ method: 'POST',
45
+ service: 'collector',
46
+ path: () => `/api/v1/calendarq/connections`,
47
+ tags: ['live-write'],
48
+ },
41
49
  'calendarq.sync': {
42
50
  method: 'POST',
43
51
  service: 'collector',
@@ -62,6 +62,7 @@ import { awareness } from './awareness.js';
62
62
  import { router } from './router.js';
63
63
  import { mind } from './mind.js';
64
64
  import { lucid } from './lucid.js';
65
+ import { lens } from './lens.js';
65
66
  import { loops } from './loops.js';
66
67
  import { away } from './away.js';
67
68
  import { presence } from './presence.js';
@@ -156,6 +157,7 @@ export const ENDPOINTS = {
156
157
  ...router,
157
158
  ...mind,
158
159
  ...lucid,
160
+ ...lens,
159
161
  ...loops,
160
162
  ...away,
161
163
  ...presence,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 'lens.*' endpoints: QuoxLens (`quox lens`).
3
+ * Collector routes under /api/v1/lens (services/collector/routes/quoxlens.js), mounted behind
4
+ * requireServiceOrUserAuth + requirePluginEntitlement('quoxlens') (402 LICENSE_REQUIRED).
5
+ *
6
+ * See cli/lib/api/endpoints/index.js for the fragment convention.
7
+ */
8
+
9
+ export const lens = {
10
+ 'lens.list': {
11
+ method: 'GET',
12
+ service: 'collector',
13
+ path: () => `/api/v1/lens/lenses`,
14
+ tags: ['live-safe'],
15
+ },
16
+ 'lens.get': {
17
+ method: 'GET',
18
+ service: 'collector',
19
+ path: ({ id }) => `/api/v1/lens/lenses/${encodeURIComponent(id)}`,
20
+ tags: ['live-safe'],
21
+ },
22
+ 'lens.apply': {
23
+ method: 'POST',
24
+ service: 'collector',
25
+ path: () => `/api/v1/lens/apply`,
26
+ tags: ['live-write'],
27
+ },
28
+ 'lens.panel': {
29
+ method: 'POST',
30
+ service: 'collector',
31
+ path: () => `/api/v1/lens/panel`,
32
+ tags: ['live-write'],
33
+ },
34
+ };
@@ -97,9 +97,10 @@ export const lucid = {
97
97
  'lucid.world.archive': { method: 'POST', service: 'collector', path: ({ id }) => `/api/v1/lucid/intelligence/world/${encodeURIComponent(id)}/archive`, tags: ['live-write'] },
98
98
  'lucid.world.restore': { method: 'POST', service: 'collector', path: ({ id }) => `/api/v1/lucid/intelligence/world/${encodeURIComponent(id)}/restore`, tags: ['live-write'] },
99
99
  'lucid.world.history': { method: 'GET', service: 'collector', path: ({ id }) => `/api/v1/lucid/intelligence/world/${encodeURIComponent(id)}/history`, tags: ['live-safe'] },
100
- // LUCID-INTELLIGENCE LI-2 shadow insights (read-only).
100
+ // LUCID-INTELLIGENCE LI-2 shadow insights (read-only, except a person deciding a proposed link).
101
101
  'lucid.insights.list': { method: 'GET', service: 'collector', path: () => `/api/v1/lucid/intelligence/insights`, tags: ['live-safe'] },
102
102
  'lucid.insights.status': { method: 'GET', service: 'collector', path: () => `/api/v1/lucid/intelligence/insights/status`, tags: ['live-safe'] },
103
+ 'lucid.insights.decide': { method: 'POST', service: 'collector', path: ({ id, goalId }) => `/api/v1/lucid/intelligence/insights/${encodeURIComponent(id)}/links/${encodeURIComponent(goalId)}`, tags: ['live-write'] },
103
104
  'lucid.insights.get': { method: 'GET', service: 'collector', path: ({ id }) => `/api/v1/lucid/intelligence/insights/${encodeURIComponent(id)}`, tags: ['live-safe'] },
104
105
  // LUCID-INTELLIGENCE LI-3 brief (read-only, never moves a cursor).
105
106
  'lucid.brief': { method: 'GET', service: 'collector', path: () => `/api/v1/lucid/intelligence/brief`, tags: ['live-safe'] },
@@ -6,13 +6,12 @@
6
6
  * collector-direct (no org segment in the URL — same shape as
7
7
  * `traces.*`/`aee.*`):
8
8
  *
9
- * - Docker fleet (`/docker/*`) — VERIFIED at the handlers (server.js
10
- * ~7232-7401): these routes have NO auth middleware applied at all, and
11
- * no org filtering — a bare fleet-wide scan/cache read across every
12
- * Docker host the collector can reach. The CLI still sends a bearer
13
- * (harmless) and still gates `quox mon docker *` behind
14
- * `auth.requireAuth()` for a consistent logged-in CLI UX, but the
15
- * backend itself would serve these to an unauthenticated caller.
9
+ * - Docker fleet (`/docker/*`) — the instance operator's fleet. Every
10
+ * route needs a login (requireServiceOrUserAuth). The reads run
11
+ * instanceFleetRouteGate: the operator org or an instance admin sees the
12
+ * fleet, any other org gets an honest empty list (or 404 for one
13
+ * container). The start/stop/restart action goes through executeTool and
14
+ * is refused for any other org (VULCAN FND_01M71D6KXP402M7CE1NGH0GVVZ).
16
15
  * - Wazuh (`/api/v1/wazuh/*`) — gated by `requireServiceOrUserAuth()`.
17
16
  * Reads (manager/status, agents, alerts, vulnerabilities,
18
17
  * deploy-history) are not org-filtered by the Wazuh API itself (the org
@@ -67,6 +67,38 @@ export const v1 = {
67
67
  path: () => `/api/v1/fleet/ping`,
68
68
  tags: ['admin-write'],
69
69
  },
70
+ // BASTION-JANUS P3 — `quox fleet recipe list|show|add|remove|run`: the org's governed fleet
71
+ // recipe library. run hands the rendered recipe to the collector's ssh_exec gate (one card).
72
+ 'v1.fleet.recipes.list': {
73
+ method: 'GET',
74
+ service: 'collector',
75
+ path: () => `/api/v1/fleet/recipes`,
76
+ tags: ['live-safe'],
77
+ },
78
+ 'v1.fleet.recipes.get': {
79
+ method: 'GET',
80
+ service: 'collector',
81
+ path: ({ name }) => `/api/v1/fleet/recipes/${encodeURIComponent(name)}`,
82
+ tags: ['live-safe'],
83
+ },
84
+ 'v1.fleet.recipes.create': {
85
+ method: 'POST',
86
+ service: 'collector',
87
+ path: () => `/api/v1/fleet/recipes`,
88
+ tags: ['admin-write'],
89
+ },
90
+ 'v1.fleet.recipes.delete': {
91
+ method: 'DELETE',
92
+ service: 'collector',
93
+ path: ({ name }) => `/api/v1/fleet/recipes/${encodeURIComponent(name)}`,
94
+ tags: ['admin-write'],
95
+ },
96
+ 'v1.fleet.recipes.run': {
97
+ method: 'POST',
98
+ service: 'collector',
99
+ path: () => `/api/v1/fleet/recipes/run`,
100
+ tags: ['live-write'],
101
+ },
70
102
  // DEVSUITE-HUB Phase 2 — `quox dev enable` (see lib/commands/dev.js).
71
103
  // Constructs + signs + delivers a quoxagent dev.CapabilityProfile onto a
72
104
  // target host via Bastion. See services/collector/server.js's
@@ -202,6 +234,31 @@ export const v1 = {
202
234
  path: () => `/api/v1/build-status`,
203
235
  tags: ['live-safe'],
204
236
  },
237
+ // HYPERION-NEXT P10: fleet hardware health (bare-metal IPMI / RAID / SMART), `quox fleet hw`.
238
+ 'v1.fleet.hw.list': {
239
+ method: 'GET',
240
+ service: 'collector',
241
+ path: () => `/api/v1/fleet/hw`,
242
+ tags: ['live-safe'],
243
+ },
244
+ 'v1.fleet.hw.events': {
245
+ method: 'GET',
246
+ service: 'collector',
247
+ path: () => `/api/v1/fleet/hw/events`,
248
+ tags: ['live-safe'],
249
+ },
250
+ 'v1.fleet.hw.get': {
251
+ method: 'GET',
252
+ service: 'collector',
253
+ path: ({ host }) => `/api/v1/fleet/hw/${encodeURIComponent(host)}`,
254
+ tags: ['live-safe'],
255
+ },
256
+ 'v1.fleet.hw.refresh': {
257
+ method: 'POST',
258
+ service: 'collector',
259
+ path: () => `/api/v1/fleet/hw/refresh`,
260
+ tags: ['live-write'],
261
+ },
205
262
  'v1.gpu.nodes.list': {
206
263
  method: 'GET',
207
264
  service: 'collector',
@@ -407,4 +407,90 @@ export function registerAdminCommands(program, ctx) {
407
407
  const data = await client.get(resolveEndpoint('admin.retention.stats.list').path, { service: resolveEndpoint('admin.retention.stats.list').service });
408
408
  formatDetail(data.stats || data, { format: program.opts().output, title: 'Retention Stats' });
409
409
  });
410
+
411
+ // AQ-9H LADON-P1B (plan v6.2 A9, owner decision S9): the scratch-org registry. Fixture mail and calendar
412
+ // providers load only for an org marked here, and only on a deployment that sets QUOX_FIXTURE_PROVIDERS=1.
413
+ // The collector refuses every caller but a human instance admin (requireInstanceAdminMw), so this verb is a
414
+ // human step: an agent or service key gets a 403.
415
+ const scratchOrgs = admin
416
+ .command('scratch-orgs')
417
+ .description('Scratch orgs: only a human instance admin can mark an org, which enables fixture mail and calendar providers for that org only');
418
+
419
+ scratchOrgs
420
+ .command('list')
421
+ .description('List the orgs marked scratch (human instance admin)')
422
+ .action(async () => {
423
+ auth.requireAuth();
424
+ const ep = resolveEndpoint('admin.scratch_orgs.list');
425
+ let data;
426
+ try {
427
+ data = await client.get(ep.path, { service: ep.service });
428
+ } catch (err) {
429
+ announceFailure('quox', 'admin scratch-orgs list', err);
430
+ throw err;
431
+ }
432
+ const orgs = data.orgs || [];
433
+ announce('quox', 'admin scratch-orgs list', data.corrupt
434
+ ? { result: 'registry unreadable', status: 'warn' }
435
+ : { result: `${orgs.length} scratch org(s)` });
436
+ if (program.opts().output === 'json') {
437
+ console.log(JSON.stringify(data, null, 2));
438
+ return;
439
+ }
440
+ if (data.corrupt) {
441
+ console.error(`Scratch-org registry is unreadable, so NO org is treated as scratch: ${data.error || 'unknown error'}`);
442
+ process.exitCode = 1;
443
+ }
444
+ formatOutput({
445
+ columns: ['orgId', 'markedBy', 'markedAt', 'note'],
446
+ headers: ['Org', 'Marked by', 'Marked at', 'Note'],
447
+ rows: orgs.map((o) => ({ orgId: o.orgId, markedBy: o.markedBy || '-', markedAt: o.markedAt || '-', note: o.note || '' })),
448
+ }, { format: program.opts().output, raw: orgs, emptyMessage: 'No org is marked scratch.' });
449
+ if (program.opts().output !== 'quiet' && program.opts().output !== 'csv') {
450
+ console.log(data.fixtureProvidersEnv
451
+ ? 'Fixture providers are enabled on this deployment (QUOX_FIXTURE_PROVIDERS=1).'
452
+ : 'Fixture providers are off on this deployment (QUOX_FIXTURE_PROVIDERS is not 1), so marking has no effect yet.');
453
+ }
454
+ });
455
+
456
+ scratchOrgs
457
+ .command('mark')
458
+ .description('Mark an org scratch so fixture providers load for that org only (human instance admin; audited)')
459
+ .argument('<orgId>', 'the org id to mark')
460
+ .option('--note <text>', 'why this org is scratch (stored with the mark, 200 characters max)')
461
+ .action(async (orgId, opts) => {
462
+ auth.requireAuth();
463
+ const ep = resolveEndpoint('admin.scratch_orgs.mark', { orgId });
464
+ let data;
465
+ try {
466
+ data = await client.put(ep.path, { service: ep.service, body: opts.note ? { note: opts.note } : {} });
467
+ } catch (err) {
468
+ announceFailure('quox', `admin scratch-orgs mark ${orgId}`, err);
469
+ throw err;
470
+ }
471
+ announce('quox', `admin scratch-orgs mark ${orgId}`, { result: 'marked scratch' });
472
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
473
+ printSuccess(`Org ${orgId} is marked scratch. Fixture providers load for this org only, on a deployment with QUOX_FIXTURE_PROVIDERS=1.`);
474
+ });
475
+
476
+ scratchOrgs
477
+ .command('unmark')
478
+ .description('Remove the scratch mark from an org, so fixture providers no longer load for it (human instance admin; audited)')
479
+ .argument('<orgId>', 'the org id to unmark')
480
+ .action(async (orgId) => {
481
+ auth.requireAuth();
482
+ const ep = resolveEndpoint('admin.scratch_orgs.unmark', { orgId });
483
+ let data;
484
+ try {
485
+ data = await client.delete(ep.path, { service: ep.service });
486
+ } catch (err) {
487
+ announceFailure('quox', `admin scratch-orgs unmark ${orgId}`, err);
488
+ throw err;
489
+ }
490
+ announce('quox', `admin scratch-orgs unmark ${orgId}`, { result: data.removed ? 'unmarked' : 'was not marked' });
491
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
492
+ printSuccess(data.removed
493
+ ? `Org ${orgId} is no longer scratch. Fixture providers no longer load for it.`
494
+ : `Org ${orgId} was not marked scratch. Nothing changed.`);
495
+ });
410
496
  }
@@ -142,7 +142,11 @@ export function registerApprovalsCommands(program, ctx) {
142
142
  body.patterns = options.pattern || [];
143
143
  }
144
144
  const ep = resolveEndpoint('v1.approvals.grants.create');
145
- const data = await client.post(ep.path, { service: ep.service, body });
145
+ // VULCAN: a standing grant pre-authorises ssh_exec for days, so the server asks for a step-up PIN (scope override).
146
+ const data = await withStepUp(
147
+ (headers) => client.post(ep.path, { service: ep.service, headers, body }),
148
+ { client, orgId: auth.getOrgId?.(program.opts().org) || null, scope: 'override', io: ctx.stepUpIO, what: 'Creating a standing grant' },
149
+ );
146
150
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
147
151
  const g = data.grant || {};
148
152
  announce('approvals', `grant created ${g.tool || 'ssh_exec'}@${g.host || options.host}`, { result: g.grant_id });
@@ -28,6 +28,33 @@ export function registerCalendarCommands(program, ctx) {
28
28
  .command('calendar')
29
29
  .description('Calendar-Q: connected calendars, agenda, coverage, sync');
30
30
 
31
+ // ── connections add (AQ-9H LADON-P1B: fixture only) ─────────────────────────────
32
+
33
+ const connections = calendar
34
+ .command('connections')
35
+ .description('Calendar connections (Google connects through the OAuth flow; add here is fixture only)');
36
+
37
+ connections
38
+ .command('add')
39
+ .description('Connect a seeded fixture calendar for scratch-org proof runs (--provider fixture --seed <name>). The server decides: it refuses unless this deployment enables fixture providers and a platform admin marked the org scratch. Google calendars connect through the OAuth flow, not this command.')
40
+ .requiredOption('--provider <name>', 'calendar provider; only fixture is accepted here')
41
+ .option('--seed <name>', 'fixture seed to load (e.g. scratch-office-day)')
42
+ .option('--run <id>', 'proof run id substituted into the seed ({{run}})')
43
+ .action(async (options) => {
44
+ auth.requireAuth();
45
+ if (options.provider !== 'fixture') {
46
+ throw new Error(`--provider ${options.provider} is not connected here. Only --provider fixture is; Google calendars connect through the OAuth flow.`);
47
+ }
48
+ if (!options.seed) throw new Error('--seed <name> is required with --provider fixture (e.g. --seed scratch-office-day).');
49
+ const body = { provider: 'fixture', seed: options.seed };
50
+ if (options.run) body.run = options.run;
51
+ const ep = resolveEndpoint('calendarq.connections.create');
52
+ const data = await client.post(ep.path, { service: ep.service, body });
53
+ if (isJsonOutput(program)) return printJson(data);
54
+ const conn = data.connection || data;
55
+ printSuccess(`Connected fixture calendar ${conn.resourceLabel || ''} (${conn.id}) from seed ${options.seed}.`);
56
+ });
57
+
31
58
  // ── status ───────────────────────────────────────────────────────────
32
59
 
33
60
  calendar
@@ -221,7 +221,7 @@ async function awaitTurn(client, turnId) {
221
221
  }
222
222
 
223
223
  /** One-shot (non-streaming) chat via POST /chat/claude, kept server side as a pollable turn. */
224
- async function sendOneShot(client, { message, agent, sessionId, resumeTurn }) {
224
+ export async function sendOneShot(client, { message, agent, sessionId, resumeTurn }) {
225
225
  let data;
226
226
  if (resumeTurn) {
227
227
  data = await awaitTurn(client, resumeTurn);
@@ -10,6 +10,51 @@ import { formatOutput, formatDetail, printSuccess, printWarning, printInfo } fro
10
10
  import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
11
11
  import { parseJSON } from './helpers.js';
12
12
  import { resolveEndpoint } from '../api/endpoints.js';
13
+ import { registerFleetRecipeCommands } from './fleetRecipe.js';
14
+
15
+ /** HYPERION-NEXT P10: one `quox fleet hw` summary row. */
16
+ function formatHwRow(h) {
17
+ const c = h.components || {};
18
+ return {
19
+ host: h.host,
20
+ overall: h.overall,
21
+ fans: c.fans || '-', psu: c.psu || '-', memory: c.memory || '-', cpu: c.cpu || '-',
22
+ raid: c.raid || '-', disks: c.disks || '-', temps: c.temps || '-',
23
+ findings: h.findings ?? 0,
24
+ checked: h.checked_at ? h.checked_at.replace('T', ' ').slice(0, 16) : '-',
25
+ };
26
+ }
27
+
28
+ /**
29
+ * When a finding was seen. Event findings carry the earliest and latest REAL dated event (an undated "Pre-Init"
30
+ * SEL entry has none); a run over several days shows both ends, and no date is said plainly, never invented.
31
+ */
32
+ function hwWhen(f) {
33
+ const first = f.first_seen ? f.first_seen.slice(0, 10) : null;
34
+ const last = f.last_seen ? f.last_seen.slice(0, 10) : null;
35
+ if (first && last && last !== first) return ` (first ${first}, last ${last})`;
36
+ if (first) return ` (since ${first})`;
37
+ if (f.basis === 'events' && !/date not recorded/.test(f.title || '')) return ' (date not recorded by the BMC)';
38
+ return '';
39
+ }
40
+
41
+ /** HYPERION-NEXT P10: `quox fleet hw <host>`: every finding with what it means and what to do. */
42
+ function printHwHost(r, baseline) {
43
+ console.log(`${r.host || '?'} ${String(r.overall || 'unknown').toUpperCase()} ${r.model || ''} checked ${r.checked_at || '-'}`);
44
+ const c = r.components || {};
45
+ console.log(Object.keys(c).map((k) => `${k} ${c[k]}`).join(' '));
46
+ if (r.dimms) console.log(`memory: ${r.dimms.count} modules, ${r.dimms.total_gb} GB${baseline ? ` (baseline ${baseline.count} modules, ${baseline.total_gb} GB)` : ''}`);
47
+ if (r.power_restore_policy) console.log(`power restore policy: ${r.power_restore_policy}`);
48
+ const findings = r.findings || [];
49
+ if (!findings.length) console.log('\nNo findings.');
50
+ for (const f of findings) {
51
+ console.log(`\n[${f.severity}] ${f.component}: ${f.title}${hwWhen(f)}`);
52
+ console.log(` What it means: ${f.meaning}`);
53
+ console.log(` What to do: ${f.action}`);
54
+ for (const e of (f.evidence || []).slice(0, 3)) console.log(` evidence: ${e}`);
55
+ }
56
+ for (const g of r.gaps || []) printWarning(`not checked: ${g}`);
57
+ }
13
58
 
14
59
  /**
15
60
  * Map a raw /api/v1/gpu/nodes row to the flat shape `fleet gpu`'s table
@@ -95,6 +140,7 @@ export const DEFAULT_CODEX_MODEL = 'gpt-5.4';
95
140
 
96
141
  /** Tools whose input schema has a `host` field (collector reads input.host, not body.host_id). */
97
142
  const TOOLS_READING_INPUT_HOST = new Set(['ssh_exec']);
143
+ const HW_HOST_RE = /^(?:[A-Za-z0-9][A-Za-z0-9_-]{0,62}|\d{1,3}(?:\.\d{1,3}){3})$/; // same shape the collector's fleet hw routes accept
98
144
 
99
145
  /** Locates the vendored (bwrap-capable) codex binary on the host. */
100
146
  export const CODEX_VENDOR_FIND_COMMAND =
@@ -635,6 +681,121 @@ export function registerFleetCommands(program, ctx) {
635
681
  }, { format: program.opts().output, emptyMessage: 'No GPU nodes enrolled yet.' });
636
682
  });
637
683
 
684
+ // HYPERION-NEXT P10: bare-metal hardware health (fans, power supplies, temperatures, memory, CPU, RAID, disks).
685
+ const hw = fleet
686
+ .command('hw')
687
+ .description('Hardware health of the bare-metal hosts (fans, PSUs, memory, CPU, RAID, disks); give a host for its findings')
688
+ .argument('[host]', 'one host: list its findings with what they mean and what to do')
689
+ .action(async (host) => {
690
+ auth.requireAuth();
691
+ const json = program.opts().output === 'json';
692
+ if (host) {
693
+ const ep = resolveEndpoint('v1.fleet.hw.get', { host });
694
+ const data = await client.get(ep.path, { service: ep.service });
695
+ if (json) { formatDetail(data, { format: 'json' }); return; }
696
+ printHwHost(data.report || {}, data.baseline);
697
+ return;
698
+ }
699
+ const ep = resolveEndpoint('v1.fleet.hw.list');
700
+ const data = await client.get(ep.path, { service: ep.service });
701
+ if (json) { formatDetail(data, { format: 'json' }); return; }
702
+ formatOutput({
703
+ columns: ['host', 'overall', 'fans', 'psu', 'memory', 'cpu', 'raid', 'disks', 'temps', 'findings', 'checked'],
704
+ headers: ['Host', 'Overall', 'Fans', 'PSU', 'Memory', 'CPU', 'RAID', 'Disks', 'Temps', 'Findings', 'Checked'],
705
+ rows: (data.hosts || []).map(formatHwRow),
706
+ }, { format: program.opts().output, emptyMessage: data.no_fleet_for_org ? (data.message || 'This organisation has no fleet hosts on this Quox instance.') : 'No hardware reports yet. Run: quox fleet hw refresh' });
707
+ const o = data.overall || {};
708
+ if ((data.hosts || []).length) printInfo(`${o.fault || 0} fault, ${o.warn || 0} warn, ${o.ok || 0} ok, ${o.unknown || 0} unknown. Details: quox fleet hw <host>`);
709
+ if (data.poller) printInfo(`Poller: ${data.poller.armed ? `on, every ${Math.round((data.poller.interval_ms || 0) / 60000)} min` : 'off (FLEET_HW_POLL=1 turns it on)'}${data.poller.state && data.poller.state.last_outcome ? `, last tick ${data.poller.state.last_outcome}` : ''}`);
710
+ if (data.alerts) printInfo(`Alerts: ${data.alerts.enabled ? 'on, one inbox notification per new fault' : `off (${data.alerts.env_flag || 'FLEET_HW_ALERTS'}=1 turns them on)`}`);
711
+ });
712
+
713
+ hw
714
+ .command('events')
715
+ .description('Hardware event log (IPMI SEL) entries across the bare-metal hosts')
716
+ .option('--since <window>', 'only events since this window (30m, 12h, 1d, 2w) or ISO time', '1d')
717
+ .option('--host <host>', 'one host only')
718
+ .option('--severity <level>', 'fault, warn or info')
719
+ .option('--limit <n>', 'most events to return (max 1000)', '200')
720
+ .action(async (options) => {
721
+ auth.requireAuth();
722
+ const ep = resolveEndpoint('v1.fleet.hw.events');
723
+ const query = { since: options.since, limit: options.limit };
724
+ if (options.host) query.host = options.host;
725
+ if (options.severity) query.severity = options.severity;
726
+ const data = await client.get(ep.path, { service: ep.service, query });
727
+ if (program.opts().output === 'json') { formatDetail(data, { format: 'json' }); return; }
728
+ formatOutput({
729
+ columns: ['when', 'host', 'severity', 'sensor', 'event'],
730
+ headers: ['When', 'Host', 'Severity', 'Sensor', 'Event'],
731
+ rows: (data.events || []).map((e) => ({
732
+ when: e.ts ? e.ts.replace('T', ' ').slice(0, 19) : (e.raw_time || '-'),
733
+ host: e.host,
734
+ severity: e.severity,
735
+ sensor: e.sensor || '-',
736
+ event: [e.event, e.direction, e.detail].filter(Boolean).join(' | '),
737
+ })),
738
+ }, { format: program.opts().output, emptyMessage: `No hardware events since ${options.since}.` });
739
+ });
740
+
741
+ hw
742
+ .command('refresh')
743
+ .description('Check the bare-metal hardware now (read-only IPMI, RAID and SMART reads) and store a fresh report')
744
+ .argument('[host]', 'one host only (default: every bare-metal host)')
745
+ .action(async (host) => {
746
+ auth.requireAuth();
747
+ const ep = resolveEndpoint('v1.fleet.hw.refresh');
748
+ const data = await client.post(ep.path, { service: ep.service, body: host ? { hosts: [host] } : {}, timeout: 600000 });
749
+ if (program.opts().output === 'json') { formatDetail(data, { format: 'json' }); return; }
750
+ formatOutput({
751
+ columns: ['host', 'overall', 'faults', 'warns', 'top'],
752
+ headers: ['Host', 'Overall', 'Faults', 'Warnings', 'Top finding'],
753
+ rows: (data.hosts || []).map((h) => ({ host: h.host, overall: h.overall, faults: h.faults ?? 0, warns: h.warns ?? 0, top: (h.findings && h.findings[0] && h.findings[0].title) || '-' })),
754
+ }, { format: program.opts().output, emptyMessage: 'No bare-metal host answered.' });
755
+ for (const u of data.unreachable || []) printWarning(`${u.host}: no hardware answer (${u.error})`);
756
+ printInfo(`${(data.hosts || []).length} host(s) checked, ${data.reads || 0} read(s), ${data.failures || 0} failure(s). Collection ${data.collection_id || '-'}`);
757
+ });
758
+
759
+ // HYPERION-NEXT P10 slice 2: blink the chassis identify light so a person can find the server in the rack.
760
+ // An ACTION on the BMC, never card-free: it goes through ssh_exec, where `ipmitool chassis identify <n>` is not an
761
+ // allowlisted read, so the call is held for a human approval card like any other fleet write.
762
+ hw
763
+ .command('identify')
764
+ .description('Blink a host\'s chassis identify light so it can be found in the rack (held for approval)')
765
+ .argument('<host>', 'the bare-metal host')
766
+ .option('--seconds <n>', 'how long the light blinks, 30 to 600 seconds', '120')
767
+ .option('--reason <text>', 'why; shown on the approval card')
768
+ .option('--org <id>', 'org ID (overrides active org; required for QUOX_SERVICE_KEY automation callers unless QUOX_ORG_ID is set)')
769
+ .action(async (host, options) => {
770
+ auth.requireAuth();
771
+ if (!HW_HOST_RE.test(String(host))) throw new Error(`not a safe short hostname: ${JSON.stringify(String(host).slice(0, 40))}`);
772
+ const raw = String(options.seconds);
773
+ const seconds = /^\d{1,3}$/.test(raw) ? Number(raw) : NaN;
774
+ if (!(seconds >= 30 && seconds <= 600)) throw new Error(`--seconds must be a whole number from 30 to 600 (got ${JSON.stringify(raw.slice(0, 20))})`);
775
+ const body = {
776
+ tool_name: 'ssh_exec',
777
+ input: { host, command: `ipmitool chassis identify ${seconds}` },
778
+ reason: options.reason ? String(options.reason) : `Blink the identify light on ${host} for ${seconds} seconds so someone can find it in the rack`,
779
+ };
780
+ const orgId = auth.getOrgId(options.org || process.env.QUOX_ORG_ID);
781
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
782
+ throw new Error('QUOX_SERVICE_KEY is set but no org could be resolved. Set QUOX_ORG_ID=<org> for automation callers, or pass --org <id>.');
783
+ }
784
+ if (orgId) body.org_id = orgId;
785
+ const ep = resolveEndpoint('v1.tools.execute');
786
+ const data = await client.post(ep.path, { service: ep.service, body, timeout: 120000 });
787
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); }
788
+ const heldId = data && data.pending && (data.approvalId || (data._approval && data._approval.id));
789
+ const failed = (data && data.success === false) || (data && data.result && data.result.success === false);
790
+ if (program.opts().output !== 'json') {
791
+ if (data && data.pending) printInfo(`Held for approval (${heldId || 'pending'}). Once approved, the identify light on ${host} blinks for ${seconds} seconds.`);
792
+ else if (failed) printWarning(`The identify light on ${host} could not be turned on: ${(data && (data.error || (data.result && data.result.error))) || 'the command failed'}`);
793
+ else console.log(`The identify light on ${host} is blinking for ${seconds} seconds.`);
794
+ }
795
+ if (heldId) console.error(`after approval run: quox approvals result ${heldId}`);
796
+ if (failed && !(data && data.pending)) process.exitCode = 1;
797
+ });
798
+
638
799
  fleet
639
800
  .command('tools')
640
801
  .description('List available fleet tools')
@@ -716,6 +877,9 @@ export function registerFleetCommands(program, ctx) {
716
877
  if (failed && !(data && data.pending)) process.exitCode = 1;
717
878
  });
718
879
 
880
+ // BASTION-JANUS P3: the governed fleet recipe library (lib/commands/fleetRecipe.js).
881
+ registerFleetRecipeCommands(fleet, { client, auth, program });
882
+
719
883
  fleet
720
884
  .command('host-update <hostId>')
721
885
  .description('Update a host in the Bastion registry (link/clear its bootstrap credential)')