quoxcli 0.4.0 → 0.6.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 (199) hide show
  1. package/CHANGELOG.md +110 -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/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -8,18 +8,19 @@
8
8
  */
9
9
 
10
10
  import { resolveEndpoint } from '../api/endpoints.js';
11
- import { capabilityLines, findCapability, offerFor } from '../quoxon/capabilities.js';
11
+ import { capabilityLines, findCapability, offerFor, needsSetupNames } from '../quoxon/capabilities.js';
12
12
 
13
13
  export function registerCapabilitiesCommands(program, ctx) {
14
14
  const { client, auth } = ctx;
15
15
  program
16
16
  .command('capabilities [name]')
17
17
  .description('What this Quox can do for your org: ready, needs setup, not available, planned')
18
+ .addHelpText('after', '\nAdd --json for the full list (id, name, availability, reason, setup, permissions_ok).')
18
19
  .action(async (name) => {
19
20
  auth.requireAuth();
20
21
  const ep = resolveEndpoint('v1.capabilities');
21
22
  const data = await client.get(ep.path, { service: ep.service });
22
- if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
23
+ if (program.opts().output === 'json') { console.log(JSON.stringify({ needs_setup: needsSetupNames(data?.capabilities), ...data }, null, 2)); return; }
23
24
  const res = { ok: Array.isArray(data?.capabilities), capabilities: data?.capabilities, error: 'unexpected response' };
24
25
  if (name) {
25
26
  const c = findCapability(name, res);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import chalk from 'chalk';
6
6
  import { formatDetail } from '../core/output.js';
7
+ import { eventLine, announceMuted } from '../core/announce.js';
7
8
  import { resolveEndpoint } from '../api/endpoints.js';
8
9
  import { existsSync } from 'node:fs';
9
10
  import { lastConversation, saveLastConversation } from './chatState.js';
@@ -70,6 +71,14 @@ function printTrace(ev) {
70
71
  if (f) process.stderr.write((TONE_PAINT[f.tone] || chalk.cyan)(f.text) + '\n');
71
72
  }
72
73
 
74
+ /** A turn's quox_event frame as its QuoxGlyph line on stderr (stdout stays the answer); silent under --json/--quiet. */
75
+ export function printQuoxEvent(ev) {
76
+ if (announceMuted()) return null;
77
+ const q = eventLine(ev);
78
+ if (q) process.stderr.write(`${q}\n`);
79
+ return q;
80
+ }
81
+
73
82
  function generateQueryId() {
74
83
  return `CLI_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
75
84
  }
@@ -81,6 +90,7 @@ function generateQueryId() {
81
90
  export async function consumeStream(client, { message, agent, sessionId, context }, { onAgentLabel, trace = false, companionPath, companionDisplay } = {}) {
82
91
  const queryId = generateQueryId();
83
92
  const body = buildStreamQuery({ queryId, message, agent, sessionId, context });
93
+ body.turn_id = queryId; // an explicit turn id selects the v1 event stream: tool and quox_event frames only flow with it
84
94
 
85
95
  let text = '';
86
96
  let resultSessionId = sessionId;
@@ -145,6 +155,10 @@ export async function consumeStream(client, { message, agent, sessionId, context
145
155
  write('\n');
146
156
  break;
147
157
 
158
+ case 'quox_event':
159
+ printQuoxEvent(event);
160
+ break;
161
+
148
162
  case 'error':
149
163
  throw new Error(event.error || 'Stream error');
150
164
 
@@ -361,6 +375,7 @@ export function registerChatCommands(program, ctx) {
361
375
  });
362
376
  spinner.stop();
363
377
  noteConversation(convId);
378
+ for (const qe of Array.isArray(data.quox_events) ? data.quox_events : []) printQuoxEvent(qe); // the server's per-turn Quox effects, when it returns them
364
379
 
365
380
  if (program.opts().output === 'json') {
366
381
  console.log(JSON.stringify({ ...data, conversation_id: convId || null }, null, 2));
@@ -29,6 +29,7 @@ export {
29
29
  */
30
30
  export function parseClaudeArgs(raw) {
31
31
  let noTmux = false;
32
+ let force = false;
32
33
  let as;
33
34
  let slot;
34
35
  const args = [];
@@ -38,6 +39,7 @@ export function parseClaudeArgs(raw) {
38
39
  if (past) { args.push(a); continue; }
39
40
  if (a === '--') { past = true; args.push(a); continue; }
40
41
  if (a === '--no-tmux') noTmux = true;
42
+ else if (a === '--force') force = true;
41
43
  else if ((a === '--as' || a === '--slot') && raw[i + 1] && !raw[i + 1].startsWith('-')) {
42
44
  if (a === '--as') as = raw[i + 1]; else slot = raw[i + 1];
43
45
  i++;
@@ -72,7 +74,7 @@ export function parseClaudeArgs(raw) {
72
74
  }
73
75
  }
74
76
  if (name !== undefined && !validateName(name)) name = undefined;
75
- return { args, name, resume, noTmux, as, slot };
77
+ return { args, name, resume, noTmux, ...(force ? { force } : {}), as, slot };
76
78
  }
77
79
 
78
80
  /**
@@ -151,6 +153,7 @@ quox's own flags (consumed here, not passed to claude):
151
153
  --as <agent> start already attached to a Quox agent
152
154
  --slot <name> start already holding a dev slot
153
155
  --no-tmux run claude directly, without tmux
156
+ --force with --resume: start a second copy even if that conversation is already running
154
157
  -n, --name <n> name the session (also names the tmux session; claude's own flag)
155
158
  --resume <n> resume the named session (claude's own flag)
156
159
  -h, --help show this help
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Compliance commands: scheduled compliance report jobs.
3
+ *
4
+ * `compliance jobs list` calls GET /compliance/jobs (services/collector/server.js),
5
+ * the same route the compliance_list_jobs agent tool uses. Jobs can carry an
6
+ * optional name (set via the compliance_schedule_job tool / POST body `name`).
7
+ *
8
+ * `compliance reports list|show` read GET /compliance/reports and /compliance/reports/:id, the
9
+ * routes compliance_get_reports reads (org-scoped by the caller's session). The ORG column is
10
+ * always shown: a row with no org (org_id null) is printed as `none`, never hidden.
11
+ */
12
+
13
+ import { formatOutput, formatDetail } from '../core/output.js';
14
+ import { resolveOrgId } from './helpers.js';
15
+ import { resolveEndpoint } from '../api/endpoints.js';
16
+
17
+ export function registerComplianceCommands(program, ctx) {
18
+ const { client, auth } = ctx;
19
+
20
+ const compliance = program
21
+ .command('compliance')
22
+ .description('Compliance: generated reports and scheduled report jobs');
23
+
24
+ const jobs = compliance
25
+ .command('jobs')
26
+ .description('Scheduled compliance report jobs');
27
+
28
+ jobs
29
+ .command('list')
30
+ .description('List scheduled compliance report jobs (with NAME)')
31
+ .option('--status <status>', 'filter by status (active|paused|error)')
32
+ .action(async (options) => {
33
+ auth.requireAuth();
34
+ const orgId = resolveOrgId(auth, program);
35
+ const ep = resolveEndpoint('compliance.jobs.list');
36
+ const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
37
+ let rows = data.jobs || [];
38
+ if (options.status) rows = rows.filter((j) => j.status === options.status);
39
+ formatOutput({
40
+ columns: ['id', 'name', 'format', 'schedule_interval', 'status', 'next_run'],
41
+ headers: ['ID', 'NAME', 'Format', 'Interval', 'Status', 'Next run'],
42
+ rows: rows.map((j) => ({ ...j, name: j.name || '-' })),
43
+ }, { format: program.opts().output, emptyMessage: 'No compliance jobs found.', raw: rows });
44
+ });
45
+
46
+ const reports = compliance
47
+ .command('reports')
48
+ .description('Generated compliance reports (read only)');
49
+
50
+ const scoreOf = (r) => (r.summary && typeof r.summary === 'object' && r.summary.overallScore != null ? r.summary.overallScore : '-');
51
+
52
+ reports
53
+ .command('list')
54
+ .description('List generated compliance reports: framework, period, score, sampled evidence, ORG')
55
+ .option('--format <format>', 'only this framework (soc2, hipaa, gdpr, iso27001, euAiAct, iso42001, nistAiRmf)')
56
+ .option('--limit <n>', 'max rows', '20')
57
+ .action(async (options) => {
58
+ auth.requireAuth();
59
+ const orgId = resolveOrgId(auth, program);
60
+ const ep = resolveEndpoint('compliance.reports.list');
61
+ const query = { org_id: orgId, limit: String(options.limit || 20) };
62
+ if (options.format) query.format = options.format;
63
+ const data = await client.get(ep.path, { service: ep.service, query });
64
+ let rows = data.reports || [];
65
+ // the route does not filter by format, so the flag is applied here too
66
+ if (options.format) rows = rows.filter((r) => r.format === options.format);
67
+ formatOutput({
68
+ columns: ['id', 'format', 'generated_at', 'period_days', 'score', 'status', 'sample', 'org'],
69
+ headers: ['ID', 'Format', 'Generated', 'Days', 'Score', 'Status', 'Evidence sample', 'ORG'],
70
+ rows: rows.map((r) => ({
71
+ ...r,
72
+ period_days: r.period_days ?? '-',
73
+ score: scoreOf(r),
74
+ status: (r.summary && r.summary.overallStatus) || '-',
75
+ sample: (r.summary && Array.isArray(r.summary.evidenceSample) && r.summary.evidenceSample.length) ? r.summary.evidenceSample.join(' ') : '-',
76
+ org: r.org_id || 'none',
77
+ })),
78
+ }, { format: program.opts().output, emptyMessage: 'No compliance reports found.', raw: rows });
79
+ });
80
+
81
+ reports
82
+ .command('show <id>')
83
+ .description('Show one generated report: framework, period, score, per-control status, sampled evidence')
84
+ .action(async (id) => {
85
+ auth.requireAuth();
86
+ const orgId = resolveOrgId(auth, program);
87
+ const ep = resolveEndpoint('compliance.reports.get', { id });
88
+ const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
89
+ const r = data.report || data;
90
+ const fmt = program.opts().output;
91
+ if (fmt && fmt !== 'table') { formatDetail(r, { format: fmt }); return; }
92
+ const s = (r.summary && typeof r.summary === 'object') ? r.summary : {};
93
+ const body = (r.report_data && typeof r.report_data === 'object') ? r.report_data : {};
94
+ const lines = [
95
+ `Report ${r.id}`,
96
+ `Framework ${s.framework || body.framework || r.format}`,
97
+ `Org ${r.org_id || 'none'}`,
98
+ `Period ${r.period_start || '-'} .. ${r.period_end || '-'} (${r.period_days ?? '-'} days)`,
99
+ `Score ${s.overallScore ?? body.overallScore ?? '-'} status ${s.overallStatus || body.overallStatus || '-'}`,
100
+ `Evidence ${Array.isArray(s.evidenceSample) && s.evidenceSample.length ? s.evidenceSample.join(' ') : '-'}`,
101
+ ];
102
+ const sections = body.sections && typeof body.sections === 'object' ? body.sections : {};
103
+ for (const [key, sec] of Object.entries(sections)) {
104
+ lines.push('', `${key} ${sec && sec.name ? sec.name : ''}`.trimEnd());
105
+ for (const c of (sec && Array.isArray(sec.controls) ? sec.controls : [])) lines.push(` ${String(c.id || '-').padEnd(10)} ${String(c.status || '-').padEnd(16)} ${c.name || ''}`);
106
+ }
107
+ for (const l of lines) console.log(l);
108
+ });
109
+ }
@@ -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
+ }
@@ -408,6 +408,8 @@ export function registerContactsCommands(program, ctx) {
408
408
  if (isJson(program)) return printJson(d);
409
409
  console.log(`channel: ${d.channel || 'none allowed'}`);
410
410
  for (const a of d.alternatives || []) console.log(` ${a.channel}: ${a.decision}${(a.reasons || []).length ? ` (${a.reasons.join(', ')})` : ''}`);
411
+ if (d.evaluated_for) console.log(`for: ${d.evaluated_for === 'agent' ? 'an agent' : 'you (a person)'}`);
412
+ if ((d.quiet_hours_for_agents || []).length) console.log(`quiet hours: an agent reaching them by ${d.quiet_hours_for_agents.join(', ')} now needs approval`);
411
413
  announce('contacts best-channel', d.channel || 'none allowed');
412
414
  });
413
415
 
@@ -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')