quoxcli 0.4.0 → 0.5.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 (175) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * quox connect <url>: point a profile at a QuoxCORE server on another machine and sign in (ONBOARD-P1).
3
+ *
4
+ * Probes <url>/health, and when the server answers with its own certificate shows its SHA256 fingerprint and,
5
+ * after a yes (--trust-cert, or the y/N), stores that certificate for THAT PROFILE ONLY. It is never the global
6
+ * NODE_EXTRA_CA_CERTS and TLS checks are never turned off. Then hands to `quox -p <profile> login`.
7
+ * One implementation with the TUI's /connect server (lib/quoxon/onboarding/connectServer.js).
8
+ */
9
+ import { announce, announceOnFail } from '../core/announce.js';
10
+ import { QuoxUserError } from '../core/errors.js';
11
+ import { confirmInteractive } from '../core/confirm.js';
12
+ import { profileNameForUrl } from '../core/profileTrust.js';
13
+ import { loadConfig } from '../core/config.js';
14
+
15
+ /** H1: no terminal and no pin: the safer non-interactive choice is the fingerprint pin, --trust-cert the alternative. */
16
+ export const noTerminalTrustMessage = () => 'No terminal to answer y/N here. Read the certificate fingerprint above, then re-run with --fingerprint <sha256> to trust only that exact certificate (safer), or --trust-cert to trust whatever this server presents.';
17
+
18
+ export function registerConnectCommand(program, ctx) {
19
+ program
20
+ .command('connect')
21
+ .description('Connect to a Quox server on another machine: probe it, trust its certificate for one profile, sign in')
22
+ .argument('<url>', 'the server address, for example https://192.0.2.10:3000')
23
+ .option('--profile <name>', 'profile to create or update (default: named from the address; -p also works)')
24
+ .option('--trust-cert', "trust the server's own certificate for this profile without asking (its fingerprint is still printed)")
25
+ .option('--fingerprint <sha256>', "trust the server's own certificate only if its SHA256 fingerprint is exactly this (colons optional); implies --trust-cert for that one certificate")
26
+ .option('--email <email>', 'sign in with this email without a prompt (use with --password-stdin to run with no terminal)')
27
+ .option('--password-stdin', 'read the password from stdin, one line (never an argument)')
28
+ .addHelpText('after', '\nA new QuoxCORE serves its own self-signed certificate. Compare the SHA256 printed here with the one `quox server status` shows on the server before you trust it. The certificate is stored for the one profile; TLS checks are never turned off.')
29
+ .action(announceOnFail('quox', (url) => `connect ${url}`, async (url, options, cmd) => {
30
+ const { connectServer } = await import('../quoxon/onboarding/connectServer.js');
31
+ const root = cmd.parent?.opts?.() || {};
32
+ const profile = options.profile || root.profile || profileNameForUrl(url);
33
+ // H1: the password comes from stdin only, never argv.
34
+ let password = null;
35
+ if (options.passwordStdin) {
36
+ const chunks = [];
37
+ for await (const chunk of process.stdin) chunks.push(chunk);
38
+ password = Buffer.concat(chunks).toString('utf8').split('\n')[0].replace(/\r$/, '');
39
+ if (!password) throw new QuoxUserError('--password-stdin given but stdin was empty. Pipe the password in, for example: printf %s "$PW" | quox connect <url> --email <email> --password-stdin', { exitCode: 2, code: 'empty_password' });
40
+ }
41
+ const email = options.email ? String(options.email).trim() : null;
42
+ const pin = options.fingerprint ? String(options.fingerprint).replace(/[:\s]/g, '').toUpperCase() : null;
43
+ const trust = async (card, peer) => {
44
+ if (pin) {
45
+ const got = String(peer?.chain?.[0]?.fingerprint256 || '').replace(/:/g, '').toUpperCase();
46
+ if (got === pin) return true;
47
+ throw new QuoxUserError(`The certificate ${url} presents has SHA256 ${peer?.chain?.[0]?.fingerprint256 || '(unreadable)'}, not the --fingerprint you gave. Nothing was saved.`, { exitCode: 2, code: 'fingerprint_mismatch' });
48
+ }
49
+ if (options.trustCert) return true;
50
+ if (!process.stdin.isTTY) throw new QuoxUserError(noTerminalTrustMessage(), { exitCode: 2, code: 'no_terminal' });
51
+ return confirmInteractive(`Trust this server's certificate for profile "${profile}" only?`, { flag: '--trust-cert', purpose: 'trust it' });
52
+ };
53
+ // loadConfig, not ctx.config: fresh each time, because the sign-in runs in a child process and writes the file.
54
+ // ctx.connectOverrides is for tests only.
55
+ const r = await connectServer(url, { profile, trust, email, password, loadConfigImpl: loadConfig, ...(ctx.connectOverrides || {}) });
56
+ if (r.action === 'connected') {
57
+ announce('quox', `connected ${url}`, { result: `profile ${profile}` });
58
+ console.log(`Connected as profile "${profile}". Use it with: quox -p ${profile} <command>. Make it your default with: quox config profile use ${profile}`);
59
+ return;
60
+ }
61
+ // H1: no terminal and no way to sign in: say which flags are missing, exit 2 (not 130, which means "cancelled").
62
+ const missing = [!email && '--email <email>', !password && '--password-stdin'].filter(Boolean);
63
+ if (!process.stdin.isTTY && missing.length && r.action !== 'connected') {
64
+ throw new QuoxUserError(`No terminal to sign in with. Re-run with ${missing.join(' and ')}; nothing was left saved.`, { exitCode: 2, code: 'no_terminal' });
65
+ }
66
+ if (r.action === 'connect-cancelled') { announce('quox', `connect ${url}`, { result: 'cancelled', status: 'warn' }); process.exitCode = 130; return; }
67
+ throw new QuoxUserError(`Could not connect to ${url}. See the message above; nothing was left saved.`, { exitCode: 1, code: 'connect_failed' });
68
+ }));
69
+ }
@@ -325,14 +325,12 @@ export function registerDevCommands(program, ctx) {
325
325
  printInfo(`Terms hash: ${offer.termsSha256} (presentation ${offer.presentationVersion})`);
326
326
 
327
327
  if (!options.iAccept) {
328
- const { default: inquirer } = await import('inquirer');
329
- const ans = await inquirer.prompt([{
330
- type: 'confirm',
331
- name: 'confirm',
332
- message: `Do you accept these terms, on behalf of your organisation, for exactly this pack content?`,
333
- default: false,
334
- }]);
335
- if (!ans.confirm) {
328
+ const { confirmInteractive } = await import('../core/confirm.js');
329
+ const accepted = await confirmInteractive(
330
+ `Do you accept these terms, on behalf of your organisation, for exactly this pack content?`,
331
+ { flag: '--i-accept', purpose: 'accept them as yourself' },
332
+ );
333
+ if (!accepted) {
336
334
  printInfo('Not accepted. Nothing was recorded.');
337
335
  return;
338
336
  }
@@ -8,6 +8,9 @@
8
8
  */
9
9
 
10
10
  import chalk from 'chalk';
11
+ import { existsSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
11
14
  import { SERVICE_PORTS } from '../core/config.js';
12
15
  import { emitCliEvent } from '../core/aee.js';
13
16
  import { resolveCliVersion, checkCliDrift } from '../core/version.js';
@@ -20,7 +23,7 @@ const CHECKS = [
20
23
  { name: 'QuoxFlow', service: 'quoxflow', port: 3200, path: '/health' },
21
24
  { name: 'Files service', service: 'files', port: 3102, path: '/health' },
22
25
  // External services
23
- { name: 'QuoxAgent daemon', port: 9847, path: '/health', url: 'http://localhost:9847' },
26
+ { name: 'QuoxAgent daemon', port: 9847, path: '/health', url: 'http://localhost:9847', optionalUnlessEnrolled: true },
24
27
  { name: 'Qdrant vectors', port: 6333, path: '/', url: 'http://localhost:6333' },
25
28
  ];
26
29
 
@@ -40,11 +43,20 @@ async function checkEndpoint(url, path, timeoutMs = 3000) {
40
43
  }
41
44
  }
42
45
 
46
+ /**
47
+ * F2 (ONBOARD-P1): is a QuoxAgent installed/enrolled on THIS machine? Its config is the evidence: the system
48
+ * install writes /etc/quoxagent/quoxagent.yaml, a user install ~/.quoxagent/. Absent both, nothing is enrolled
49
+ * and a daemon that is not running is the normal state of a single-box install, not a critical.
50
+ */
51
+ export function defaultAgentEnrolled({ exists = existsSync, home = homedir() } = {}) {
52
+ return ['/etc/quoxagent/quoxagent.yaml', join(home, '.quoxagent', 'quoxagent.yaml'), join(home, '.quoxagent', 'config.yaml')].some((p) => exists(p));
53
+ }
54
+
43
55
  /**
44
56
  * Every doctor check, in this process: services, auth token, CLI version. Shared by `quox doctor` and the
45
57
  * conversation's /doctor so the two can never disagree. -> [{ name, status, detail?, latency_ms?, ... }].
46
58
  */
47
- export async function runDoctorChecks({ config }) {
59
+ export async function runDoctorChecks({ config, agentEnrolled = defaultAgentEnrolled } = {}) {
48
60
  // Check all services in parallel — per-item try/catch so one failure doesn't lose all results
49
61
  const checks = await Promise.all(
50
62
  CHECKS.map(async (check) => {
@@ -71,6 +83,9 @@ export async function runDoctorChecks({ config }) {
71
83
  }
72
84
  }
73
85
  }
86
+ if (result.status === 'fail' && check.optionalUnlessEnrolled && !agentEnrolled()) {
87
+ result = { status: 'skip', latency_ms: null, detail: 'optional: not running, and no QuoxAgent is enrolled on this machine (install one with the fleet enrol steps if you want a fleet)' };
88
+ }
74
89
  return {
75
90
  name: check.name,
76
91
  port: check.port,
@@ -130,6 +145,7 @@ export function registerDoctorCommand(program, ctx) {
130
145
  program
131
146
  .command('doctor')
132
147
  .description('Run platform health diagnostics')
148
+ .addHelpText('after', '\nAdd --json for machine output: { checks: [...], summary: { passed, warnings, failed } }.')
133
149
  .action(async (opts, cmd) => {
134
150
  const outputFormat = cmd.parent?.opts()?.output || 'table';
135
151
  const isJson = outputFormat === 'json' || cmd.parent?.opts()?.json;
@@ -140,9 +156,10 @@ export function registerDoctorCommand(program, ctx) {
140
156
  const passed = checks.filter(c => c.status === 'pass').length;
141
157
  const warnings = checks.filter(c => c.status === 'warn').length;
142
158
  const failed = checks.filter(c => c.status === 'fail').length;
159
+ const skipped = checks.filter(c => c.status === 'skip').length;
143
160
 
144
161
  if (isJson) {
145
- console.log(JSON.stringify({ checks, summary: { passed, warnings, failed } }, null, 2));
162
+ console.log(JSON.stringify({ checks, summary: { passed, warnings, failed, skipped } }, null, 2));
146
163
  return;
147
164
  }
148
165
 
@@ -151,6 +168,7 @@ export function registerDoctorCommand(program, ctx) {
151
168
  for (const c of checks) {
152
169
  const icon = c.status === 'pass' ? chalk.green(' ✓')
153
170
  : c.status === 'warn' ? chalk.yellow(' ⚠')
171
+ : c.status === 'skip' ? chalk.dim(' ·')
154
172
  : chalk.red(' ✗');
155
173
 
156
174
  const latency = c.latency_ms != null ? chalk.dim(` (${c.latency_ms}ms)`) : '';
@@ -165,6 +183,7 @@ export function registerDoctorCommand(program, ctx) {
165
183
  if (passed) parts.push(chalk.green(`${passed} passed`));
166
184
  if (warnings) parts.push(chalk.yellow(`${warnings} warning${warnings > 1 ? 's' : ''}`));
167
185
  if (failed) parts.push(chalk.red(`${failed} critical`));
186
+ if (skipped) parts.push(chalk.dim(`${skipped} optional, not set up`));
168
187
  console.log(` ${parts.join(' ')}`);
169
188
  console.log('');
170
189
 
@@ -53,7 +53,8 @@ export function registerEvidenceCommands(program, ctx) {
53
53
 
54
54
  evidence
55
55
  .command('status')
56
- .description('Evidence stores (AEE, WARD, VOLT): size, rows, time span, growth, archive and backup, read-only')
56
+ .description('Evidence stores (AEE, WARD, VOLT): size, rows, time span, growth, archive and backup, read-only. Instance admin only')
57
+ .addHelpText('after', '\nInstance admin only: an org owner or admin who is not an instance admin gets a 403 (exit 4).')
57
58
  .action(async () => {
58
59
  auth.requireAuth();
59
60
  const ep = resolveEndpoint('evidence.status');
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { formatDetail, printInfo, printWarning } from '../core/output.js';
25
- import { events, line as glyphLine, printEvent } from '../core/quoxglyph.js';
25
+ import { events, gripeFiledLine, line as glyphLine, printEvent } from '../core/quoxglyph.js';
26
26
  import { resolveEndpoint } from '../api/endpoints.js';
27
27
  import { resolveOrgId } from './helpers.js';
28
28
 
@@ -50,20 +50,28 @@ export function registerGripeCommands(program, { client, auth }) {
50
50
  return;
51
51
  }
52
52
  printEvent(glyphLine('quox', 'filing gripe via GRIPE'));
53
- const data = await askGripe({ client, complaint, repo: options.repo, label: options.label, yes: !!options.yes, orgId });
54
-
55
- if (program.opts().output === 'json') {
56
- formatDetail(data, { format: 'json' });
57
- return;
58
- }
59
-
60
- const r = await settleGripe({ client, data, complaint, options, orgId });
53
+ // GRIPE-QUEUE: a timeout / network / 5xx saves the gripe locally instead of losing it (/gripes in quox lists it).
54
+ const { gripeQueue, fileOrQueue } = await import('../quoxon/gripeQueue.js');
55
+ let rawData = null;
56
+ const f = await fileOrQueue({
57
+ text: complaint, repo: options.repo || null, queue: gripeQueue(), queueable: !!options.yes,
58
+ run: async (key) => {
59
+ const o = { ...options, idempotencyKey: key, queueRetryable: true };
60
+ rawData = await askGripe({ client, complaint, repo: options.repo, label: options.label, yes: !!options.yes, orgId });
61
+ if (program.opts().output === 'json') return { json: true };
62
+ return settleGripe({ client, data: rawData, complaint, options: o, orgId });
63
+ },
64
+ });
65
+ if (!f.ok && f.queued) { printWarning(`${f.saved} (${f.message})`); process.exitCode = 1; return; }
66
+ if (!f.ok) throw new Error(f.message);
67
+ if (f.json) { formatDetail(rawData, { format: 'json' }); return; }
68
+ const r = f;
61
69
  console.log('');
62
70
  if (r.superseded) printInfo(SUPERSEDED_MESSAGE);
63
71
  else console.log(r.response || '(no response)');
64
72
  console.log('');
65
73
  for (const n of r.notes) {
66
- if (n.kind === 'created') printEvent(events.created('quox', 'issue', n.url));
74
+ if (n.kind === 'created') { printEvent(gripeFiledLine(n.url, complaint)); console.log(n.url); }
67
75
  else if (n.kind === 'info') printInfo(n.text);
68
76
  else printWarning(n.text);
69
77
  }
@@ -117,7 +125,7 @@ export async function settleGripe({ client, data, complaint, options = {}, orgId
117
125
  const notes = [];
118
126
  let url = null;
119
127
  const det = async () => {
120
- const r = await fileDeterministically({ client, complaint, options, orgId, quiet: true });
128
+ const r = await fileDeterministically({ client, complaint, options, orgId, quiet: true, throwRetryable: !!options.queueRetryable });
121
129
  notes.push(...r.notes); if (r.url) url = r.url;
122
130
  return r;
123
131
  };
@@ -181,7 +189,7 @@ async function promptConfirmPublic(repo) {
181
189
  } finally { rl.close(); }
182
190
  }
183
191
 
184
- export async function fileDeterministically({ client, complaint, options, orgId, quiet = false, confirmPublic = null }) {
192
+ export async function fileDeterministically({ client, complaint, options, orgId, quiet = false, confirmPublic = null, throwRetryable = false }) {
185
193
  const notes = [];
186
194
  const say = (n) => { notes.push(n); if (quiet) return; if (n.kind === 'created') printEvent(events.created('quox', 'issue', n.url)); else if (n.kind === 'info') printInfo(n.text); else printWarning(n.text); };
187
195
  const repoArg = options.repo || 'quoxai/quox';
@@ -201,9 +209,10 @@ export async function fileDeterministically({ client, complaint, options, orgId,
201
209
  owner,
202
210
  repo,
203
211
  title,
204
- body: `## Gripe\n${complaint}\n\n## Context\n- Filed deterministically by \`quox gripe --yes\` (the GRIPE agent run did not file; quox#478 guarantee path)\n- Priority: minor / off-task observation\n\n## Suggested Action\nInvestigate and fix if confirmed.`,
212
+ body: `${options.idempotencyKey ? `<!-- quox-gripe-key: ${options.idempotencyKey} -->\n` : ''}## Gripe\n${complaint}\n\n## Context\n- Filed deterministically by \`quox gripe --yes\` (the GRIPE agent run did not file; quox#478 guarantee path)\n- Priority: minor / off-task observation\n\n## Suggested Action\nInvestigate and fix if confirmed.`,
205
213
  labels: [options.label || 'fleet/qa'],
206
214
  ...(orgId ? { org_id: orgId } : {}),
215
+ ...(options.idempotencyKey ? { idempotency_key: options.idempotencyKey } : {}),
207
216
  ...(confirmed ? { confirm_public: true } : {}),
208
217
  },
209
218
  });
@@ -216,6 +225,8 @@ export async function fileDeterministically({ client, complaint, options, orgId,
216
225
  say({ kind: 'warn', text: 'Deterministic file returned no issue URL; treat as NOT filed until you can see the issue.' });
217
226
  return { filed: false, notes };
218
227
  } catch (err) {
228
+ // GRIPE-QUEUE: a timeout / network / 5xx is "Quox did not answer", not a refusal; the caller queues it.
229
+ if (throwRetryable && (err?.status == null || Number(err.status) >= 500)) throw err;
219
230
  say({ kind: 'warn', text: `Not filed: the GRIPE run did not file and the deterministic fallback failed too (${err.message}).` });
220
231
  return { filed: false, error: err.message, notes };
221
232
  }
@@ -9,6 +9,8 @@ import { attachWalkthrough } from './walkthrough.js';
9
9
  import { getApprovalContext, formatApprovalContext, approvalListColumns } from '../core/approvalContext.js';
10
10
  import { QuoxUserError } from '../core/errors.js';
11
11
  import { resolutionFromItem, alreadyResolvedError } from '../core/inboxResolution.js';
12
+ import { withStepUp } from '../core/stepUpPrompt.js';
13
+ import { announce, announceFailure } from '../core/announce.js';
12
14
  import chalk from 'chalk';
13
15
 
14
16
  export function registerInboxCommands(program, ctx) {
@@ -55,7 +57,7 @@ export function registerInboxCommands(program, ctx) {
55
57
  inbox
56
58
  .command('list')
57
59
  .description('List inbox items')
58
- .option('--status <status>', 'filter: one status, a comma list (resolved,cancelled,expired), or all (includes answered approvals). Default: active only')
60
+ .option('--status <status>', 'filter: one status, a comma list (resolved,cancelled,expired,withdrawn), or all (includes answered approvals). Default: active only')
59
61
  .option('--decision <decision>', 'with resolved/all: approved or rejected')
60
62
  .option('--page <n>', 'page number (newest first)')
61
63
  .option('--priority <priority>', 'filter: low, normal, high, urgent')
@@ -78,7 +80,15 @@ export function registerInboxCommands(program, ctx) {
78
80
  columns: ['id', 'kind', 'priority', 'title', 'source', 'reason', 'status', 'created_at'],
79
81
  headers: ['ID', 'Kind', 'Priority', 'Title', 'Source', 'Reason', 'Status', 'Created'],
80
82
  rows: items,
81
- }, { format: program.opts().output });
83
+ }, {
84
+ format: program.opts().output,
85
+ emptyMessage: 'Nothing waiting for you. Try `--status all` for answered items, or `--awaiting-human` for approvals waiting on a human org-wide.',
86
+ });
87
+ const limit = Number(options.limit);
88
+ if (program.opts().output !== 'json' && items.length > 0 && Number.isFinite(limit) && items.length >= limit) {
89
+ const total = typeof data.total === 'number' ? data.total : null;
90
+ console.log(`showing ${items.length}${total && total > items.length ? ` of ${total}` : ''} · --limit to see more`);
91
+ }
82
92
  });
83
93
 
84
94
  inbox
@@ -116,9 +126,13 @@ export function registerInboxCommands(program, ctx) {
116
126
  auth.requireAuth();
117
127
  const orgId = resolveOrgId(auth, program);
118
128
  const format = program.opts().output;
129
+ // DS-010 / #1335: a step-up sink answers 403 step_up_required; ask the person for their PIN (hidden, TTY only) and retry once.
119
130
  const post = (itemId) => {
120
131
  const ep = resolveEndpoint('inbox.respond', { orgId, itemId });
121
- return client.post(ep.path, { service: ep.service, body: { action: options.action, comment: options.comment } });
132
+ return withStepUp(
133
+ (headers) => client.post(ep.path, { service: ep.service, headers, body: { action: options.action, comment: options.comment } }),
134
+ { client, orgId, io: ctx.stepUpIO, what: 'This approval' },
135
+ );
122
136
  };
123
137
  // Resolve an approvalId to its card (server-side on a current server, else the client scan). When
124
138
  // this profile cannot see the card, still ask the server: it knows WHY (an agent login, another
@@ -161,6 +175,26 @@ export function registerInboxCommands(program, ctx) {
161
175
  } catch { return null; }
162
176
  }
163
177
 
178
+ inbox
179
+ .command('unread')
180
+ .description('Mark inbox items unread again (undo a mark-as-read), for you only')
181
+ .argument('<itemId...>', 'one or more inbox item IDs')
182
+ .action(async (ids) => {
183
+ auth.requireAuth();
184
+ const orgId = resolveOrgId(auth, program);
185
+ let done = 0;
186
+ for (const id of ids) {
187
+ try {
188
+ await client.patch(`${resolveEndpoint('inbox.get', { orgId, itemId: encodeURIComponent(id) }).path}/read`, { service: resolveEndpoint('inbox.get', { orgId, itemId: id }).service, body: { is_read: false } });
189
+ done++;
190
+ } catch (err) {
191
+ announceFailure('inbox', `unread ${id}`, err);
192
+ throw err.status === 404 ? notFound(id, err) : err;
193
+ }
194
+ }
195
+ announce('inbox', `marked ${done} unread`, { result: ids.length === 1 ? ids[0] : `${done} items` });
196
+ });
197
+
164
198
  inbox
165
199
  .command('stats')
166
200
  .description('Show inbox statistics')
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `quox inboxq`: InboxQ email namespace. Today: `inboxq watch`, awaited-mail
3
+ * alerts (collector: /api/v1/inboxq/watches, org resolved server-side).
4
+ *
5
+ * `quox inbox` is the HITL approvals inbox and `quox watch` is fleet
6
+ * monitoring; neither is touched here.
7
+ */
8
+
9
+ import { formatOutput } from '../core/output.js';
10
+ import { QuoxApiError, QuoxError } from '../core/errors.js';
11
+ import { resolveEndpoint } from '../api/endpoints.js';
12
+
13
+ const ZONES = ['focus', 'fyi', 'auto', 'screened'];
14
+ const CHANNELS = ['hitl', 'lucid', 'telegram', 'discord', 'call', 'sms'];
15
+ const NEEDS_DESTINATION = ['telegram', 'discord', 'call', 'sms'];
16
+
17
+ const isJson = (program) => program.opts().output === 'json';
18
+ const printJson = (d) => console.log(JSON.stringify(d, null, 2));
19
+
20
+ async function call(fn) {
21
+ try {
22
+ return await fn();
23
+ } catch (e) {
24
+ if (e instanceof QuoxApiError && e.status === 404) throw new QuoxError('Watch not found.', 1);
25
+ throw e;
26
+ }
27
+ }
28
+
29
+ export function describeMatch(match = {}) {
30
+ const parts = [];
31
+ if (match.from) parts.push(`from~${match.from}`);
32
+ if (match.subject) parts.push(`subject~${match.subject}`);
33
+ if (match.text) parts.push(`text~${match.text}`);
34
+ if (match.zone) parts.push(`zone=${match.zone}`);
35
+ return parts.join(' ') || '(any)';
36
+ }
37
+
38
+ export function parseNotify(csv) {
39
+ const list = String(csv ?? 'hitl').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
40
+ if (list.length === 0) throw new QuoxError(`--notify needs at least one of: ${CHANNELS.join(', ')}`, 2);
41
+ const bad = list.filter((c) => !CHANNELS.includes(c));
42
+ if (bad.length) throw new QuoxError(`Unknown --notify channel: ${bad.join(', ')}. Use: ${CHANNELS.join(', ')}`, 2);
43
+ return [...new Set(list)];
44
+ }
45
+
46
+ export function registerInboxqCommands(program, ctx) {
47
+ const { client, auth } = ctx;
48
+
49
+ const inboxq = program
50
+ .command('inboxq')
51
+ .description('InboxQ email: awaited-mail watches (not the HITL approvals inbox, see `quox inbox`)');
52
+
53
+ const watch = inboxq
54
+ .command('watch')
55
+ .description('Awaited-mail alerts: get told when a matching email arrives');
56
+
57
+ watch
58
+ .command('add')
59
+ .description('Create a watch. At least one of --from, --subject, --text, --zone is required')
60
+ .option('--account <id>', 'mail account id; omit to watch across all your mailboxes')
61
+ .requiredOption('--label <text>', 'short name for this watch')
62
+ .option('--from <addr>', 'sender contains this text')
63
+ .option('--subject <text>', 'subject contains this text')
64
+ .option('--text <keyword>', 'message text contains this keyword')
65
+ .option('--zone <zone>', `only mail triaged into this zone (${ZONES.join('|')})`)
66
+ .option('--notify <csv>', `alert channels, comma list of ${CHANNELS.join('|')}`, 'hitl')
67
+ .action(async (options) => {
68
+ auth.requireAuth();
69
+ const match = {};
70
+ if (options.from) match.from = options.from;
71
+ if (options.subject) match.subject = options.subject;
72
+ if (options.text) match.text = options.text;
73
+ if (options.zone) {
74
+ if (!ZONES.includes(options.zone)) throw new QuoxError(`--zone must be one of: ${ZONES.join(', ')}`, 2);
75
+ match.zone = options.zone;
76
+ }
77
+ if (Object.keys(match).length === 0) {
78
+ throw new QuoxError('Give at least one of --from, --subject, --text or --zone, or the watch would match nothing in particular.', 2);
79
+ }
80
+ const notify = parseNotify(options.notify);
81
+ const ep = resolveEndpoint('inboxq.watches.create');
82
+ const data = await call(() => client.post(ep.path, {
83
+ service: ep.service,
84
+ body: { accountId: options.account, label: options.label, match, notify },
85
+ }));
86
+ if (isJson(program)) return printJson(data);
87
+ const w = data.watch || {};
88
+ console.log(`Watch ${w.id} created: "${options.label}" watches ${describeMatch(match)}; alerts via ${notify.join(', ')}.`);
89
+ const needs = notify.filter((c) => NEEDS_DESTINATION.includes(c));
90
+ if (needs.length) {
91
+ console.log(`Note: ${needs.join(', ')} need a destination configured, otherwise they will report unconfigured. hitl and lucid work out of the box.`);
92
+ }
93
+ });
94
+
95
+ watch
96
+ .command('list')
97
+ .description('List watches')
98
+ .option('--account <id>', 'only watches for this mail account')
99
+ .option('--json', 'print JSON')
100
+ .action(async (options) => {
101
+ auth.requireAuth();
102
+ const ep = resolveEndpoint('inboxq.watches.list');
103
+ const data = await call(() => client.get(ep.path, { service: ep.service }));
104
+ let watches = data.watches || [];
105
+ if (options.account) watches = watches.filter((w) => w.accountId === options.account);
106
+ if (options.json || isJson(program)) return printJson({ ...data, watches });
107
+ if (watches.length === 0) return console.log('No watches.');
108
+ formatOutput({
109
+ columns: ['id', 'label', 'matches', 'notify', 'active', 'hits'],
110
+ headers: ['ID', 'Label', 'Matches', 'Notify', 'Active', 'Hits'],
111
+ rows: watches.map((w) => ({
112
+ id: w.id,
113
+ label: w.label,
114
+ matches: describeMatch(w.match),
115
+ notify: (w.notify || []).join(','),
116
+ active: w.active ? 'yes' : 'no',
117
+ hits: w.hitCount ?? 0,
118
+ })),
119
+ }, { format: program.opts().output });
120
+ });
121
+
122
+ for (const [name, active, past] of [['pause', false, 'paused'], ['resume', true, 'resumed']]) {
123
+ watch
124
+ .command(name)
125
+ .description(`${name === 'pause' ? 'Pause' : 'Resume'} a watch`)
126
+ .argument('<watchId>', 'watch id')
127
+ .action(async (watchId) => {
128
+ auth.requireAuth();
129
+ const ep = resolveEndpoint('inboxq.watches.active', { watchId });
130
+ const data = await call(() => client.post(ep.path, { service: ep.service, body: { active } }));
131
+ if (isJson(program)) return printJson(data);
132
+ console.log(`Watch ${watchId} ${past}.`);
133
+ });
134
+ }
135
+
136
+ watch
137
+ .command('rm')
138
+ .description('Delete a watch')
139
+ .argument('<watchId>', 'watch id')
140
+ .action(async (watchId) => {
141
+ auth.requireAuth();
142
+ const ep = resolveEndpoint('inboxq.watches.delete', { watchId });
143
+ const data = await call(() => client.delete(ep.path, { service: ep.service }));
144
+ if (isJson(program)) return printJson(data);
145
+ console.log(`Watch ${watchId} deleted.`);
146
+ });
147
+ }
@@ -7,8 +7,9 @@
7
7
  * quox kuma down [slug] only monitors that are not up (exit 3 when any)
8
8
  * quox kuma uptime [slug] 24h/30d uptime table, worst first
9
9
  * quox kuma refresh [slug] ask the collector to re-scrape the status page(s)
10
- * quox kuma monitors list|get|add|edit|pause|resume|remove manage monitors (writes need org admin + human approval)
10
+ * quox kuma monitors list|get|add|edit|pause|resume|wire-alerts|remove manage monitors (writes need org admin + human approval)
11
11
  * quox kuma incidents [--open] Kuma alerts that became Quox incidents (NQ-KUMA-4)
12
+ * quox kuma incidents show <id> one incident, its diagnosis and the Wazuh picture for its host (WAZ P7)
12
13
  * quox kuma hooks list|create|delete per-org signed alert hook Kuma posts to (org admin)
13
14
  * quox kuma diagnose <incidentId>|--monitor <id> why is it down: outside-in + governed host checks (NQ-KUMA-5)
14
15
  * quox kuma sla [--month|--from --to] | list | verify <id|file> signed, verifiable uptime reports (NQ-KUMA-7)
@@ -443,11 +444,17 @@ export function registerKumaCommands(program, ctx) {
443
444
  if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
444
445
  console.log(glyphLine('kuma', 'monitors list', { result: `${data.monitors.length} monitors${data.kumaVersion ? ` · Kuma ${data.kumaVersion}` : ''}`, status: 'ok' }));
445
446
  if (!data.monitors.length) return;
446
- const rows = [['ID', 'ACTIVE', 'TYPE', 'NAME', 'TARGET', 'INTERVAL']];
447
+ const rows = [['ID', 'ACTIVE', 'TYPE', 'NAME', 'TARGET', 'INTERVAL', 'ALERTS']];
448
+ let noAlert = 0;
447
449
  for (const m of data.monitors) {
448
- rows.push([m.id ?? '-', m.active === null || m.active === undefined ? '-' : (m.active ? 'yes' : 'no'), m.type || '-', m.name || '-', targetOf(m), m.interval ? `${m.interval}s` : '-']);
450
+ let alerts;
451
+ if (!Array.isArray(m.alerts)) alerts = '?';
452
+ else if (!m.alerts.length) { alerts = '⚠ NONE'; noAlert += 1; }
453
+ else alerts = m.alerts.map((a) => (a && a.name) || a).join(',');
454
+ rows.push([m.id ?? '-', m.active === null || m.active === undefined ? '-' : (m.active ? 'yes' : 'no'), m.type || '-', m.name || '-', targetOf(m), m.interval ? `${m.interval}s` : '-', alerts]);
449
455
  }
450
456
  console.log(pad(rows).join('\n'));
457
+ if (noAlert) console.log(`${noAlert} monitor(s) have no Quox alert, so outages on them are not seen. Fix: quox kuma monitors wire-alerts <id...> (each waits for a human approval)`);
451
458
  }));
452
459
 
453
460
  monitors
@@ -490,6 +497,12 @@ export function registerKumaCommands(program, ctx) {
490
497
  if (!/^\d+$/.test(String(v))) throw new KumaFail(`quox kuma ${verb}: the id must be a whole number, got "${v}"`);
491
498
  return Number(v);
492
499
  };
500
+ /** A monitor added with alerts [] is invisible to Quox incident capture: say so. */
501
+ const warnNoAlert = (d) => {
502
+ if (d && d.status !== 'awaiting_approval' && Array.isArray(d.alerts) && d.alerts.length === 0) {
503
+ console.log(`⚠ ${d.name || 'The new monitor'}${d.id ? ` (id ${d.id})` : ''} has no Quox alert, so outages on it are not seen. Fix: quox kuma monitors wire-alerts ${d.id ?? '<id>'}`);
504
+ }
505
+ };
493
506
  const awaitingLine = (id) => `◐ awaiting approval ${id}: a person approves it in the inbox; check with quox approvals result ${id}`;
494
507
  /** Run a governed kuma_* tool as the attached agent and report it like a human write. Returns the tool result. */
495
508
  async function agentTool(agentName, verb, tool, input, okLine) {
@@ -529,6 +542,7 @@ export function registerKumaCommands(program, ctx) {
529
542
  let data;
530
543
  try { data = await client.post(ep.path, { service: ep.service, body: { monitor } }); } catch (err) { throw monFail('add', ep, err); }
531
544
  reportWrite('add', ep, data, (d) => `added ${d.name || monitor.name}${d.id ? ` (id ${d.id})` : ''}`);
545
+ if (!jsonOut()) warnNoAlert(data);
532
546
  } catch (err) { fail(err); }
533
547
  });
534
548
 
@@ -581,6 +595,24 @@ export function registerKumaCommands(program, ctx) {
581
595
  } catch (err) { fail(err); }
582
596
  });
583
597
 
598
+ monitors
599
+ .command('wire-alerts')
600
+ .description('Attach the default Quox alert to monitors that have none, so their outages are seen (org admin; one human approval per monitor)')
601
+ .argument('<id...>', 'monitor id(s)')
602
+ .action(async (ids) => {
603
+ try {
604
+ auth.requireAuth();
605
+ const agentName = await actingAgent();
606
+ for (const id of ids) {
607
+ if (agentName) { await agentTool(agentName, 'monitors wire-alerts', 'kuma_monitor_wire_alerts', { id: wholeId('monitors wire-alerts', id) }, () => `wired Quox alerts on monitor ${id}`); continue; }
608
+ const ep = resolveEndpoint('mon.kuma.monitors.wire-alerts', { id });
609
+ let data;
610
+ try { data = await client.post(ep.path, { service: ep.service, body: {} }); } catch (err) { throw monFail('wire-alerts', ep, err, id); }
611
+ reportWrite('wire-alerts', ep, data, (d) => `wired Quox alerts on ${d.name || `monitor ${id}`}`);
612
+ }
613
+ } catch (err) { fail(err); }
614
+ });
615
+
584
616
  monitors
585
617
  .command('remove')
586
618
  .description('Delete a monitor (org admin; held for human approval)')
@@ -614,12 +646,20 @@ export function registerKumaCommands(program, ctx) {
614
646
  return new KumaFail(`${head}: ${msg}`);
615
647
  };
616
648
 
649
+ // WAZ P7: `quox kuma incidents show <id>` is positional words on this ONE leaf (not a subcommand), so the list verb
650
+ // keeps its leaf-policy row for chat and the matrix; `show <id>` prints one incident with the Wazuh picture.
617
651
  kuma
618
652
  .command('incidents')
619
- .description('Kuma alerts that became Quox incidents: when down, when back, how long')
653
+ .description('Kuma alerts that became Quox incidents: when down, when back, how long. `incidents show <id>`: one incident with its diagnosis and the Wazuh agent + open Wazuh incidents for the same host')
654
+ .argument('[words...]', 'show <incidentId> for one incident')
620
655
  .option('--open', 'only outages still going')
621
656
  .option('--limit <n>', 'max rows (default 50, max 200)')
622
- .action(guard('incidents', async (options) => {
657
+ .action(guard('incidents', async (words, options) => {
658
+ const w = Array.isArray(words) ? words : [];
659
+ if (w.length) {
660
+ if (w[0] !== 'show' || w.length !== 2) throw new KumaFail(`quox kuma incidents: unexpected "${w.join(' ')}". Use \`quox kuma incidents [--open]\` or \`quox kuma incidents show <incidentId>\`.`);
661
+ return showIncident(w[1]);
662
+ }
623
663
  auth.requireAuth();
624
664
  const ep = resolveEndpoint('mon.kuma.incidents.list', { open: !!options.open, limit: options.limit });
625
665
  let data;
@@ -657,6 +697,38 @@ export function registerKumaCommands(program, ctx) {
657
697
  }
658
698
  }));
659
699
 
700
+ // WAZ P7: one incident with its diagnosis and the Wazuh picture for the host (attached when the outage opened).
701
+ async function showIncident(incidentId) {
702
+ auth.requireAuth();
703
+ const ep = resolveEndpoint('mon.kuma.incidents.get', { id: incidentId });
704
+ let data;
705
+ try { data = await client.get(ep.path, { service: ep.service }); } catch (err) {
706
+ const status = err && (err.status ?? err.statusCode);
707
+ if (status === 404) throw new KumaFail(`quox kuma incidents show: ${ep.method} ${ep.path} failed with HTTP 404: no such incident (${incidentId}) in this org. List them with \`quox kuma incidents\`.`);
708
+ throw new KumaFail(`quox kuma incidents show: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}: ${(err && err.message) || err}`);
709
+ }
710
+ if (!data || data.success === false || data.error || !data.incident) {
711
+ throw new KumaFail(`quox kuma incidents show: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no incident'}`);
712
+ }
713
+ if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
714
+ const i = data.incident;
715
+ console.log(glyphLine('kuma', 'incidents show', { result: `${i.id} · ${i.open ? 'DOWN' : 'resolved'}`, status: i.open ? 'warn' : 'ok' }));
716
+ const d = i.diagnosis;
717
+ const w = i.wazuh;
718
+ const rows = [
719
+ ['Monitor', `${i.monitor_name || '-'} (monitor ${i.monitor_id ?? '-'})`],
720
+ ['Target', i.target || '-'],
721
+ ['State', i.open ? 'down' : (i.close_reason === 'monitor_deleted' ? 'closed: monitor deleted' : 'resolved')],
722
+ ['Opened', i.opened_at || '-'],
723
+ ['Closed', i.closed_at || '-'],
724
+ ['Events', i.events ?? '-'],
725
+ ['Last message', i.last_msg || '-'],
726
+ ['Why', d && d.summary ? `${d.summary} [${d.verdict}]` : 'not diagnosed yet (quox kuma diagnose ' + i.id + ')'],
727
+ ['Wazuh', w && w.summary ? w.summary : 'not checked (Quox attaches the Wazuh picture when an outage opens and Wazuh is configured)'],
728
+ ];
729
+ console.log(pad(rows).join('\n'));
730
+ }
731
+
660
732
  const printDiagnosis = (d, verb) => {
661
733
  console.log(glyphLine('kuma', verb, { result: d.verdict, status: d.verdict === 'not_reproducible' ? 'warn' : 'fail' }));
662
734
  console.log(d.summary);
@@ -1019,6 +1091,7 @@ export function registerKumaCommands(program, ctx) {
1019
1091
  'monitors edit': 'quox kuma monitors edit 4 --interval 60',
1020
1092
  'monitors pause': 'quox kuma monitors pause 4',
1021
1093
  'monitors resume': 'quox kuma monitors resume 4',
1094
+ 'monitors wire-alerts': 'quox kuma monitors wire-alerts 4 7',
1022
1095
  'monitors remove': 'quox kuma monitors remove 4',
1023
1096
  incidents: 'quox kuma incidents --open',
1024
1097
  diagnose: 'quox kuma diagnose --monitor 7',