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
@@ -8,9 +8,23 @@
8
8
  import { resolveEndpoint } from '../api/endpoints.js';
9
9
 
10
10
  import { attachWalkthrough } from './walkthrough.js';
11
+ import { resolveOrgId } from './helpers.js';
12
+ import { confirmInteractive, askTyped } from '../core/confirm.js';
13
+ import { QuoxUserError } from '../core/errors.js';
14
+ import { announce, announceFailure, announceOnFail } from '../core/announce.js';
15
+ import { withStepUp } from '../core/stepUpPrompt.js';
16
+ import { formatOutput, printSuccess } from '../core/output.js';
17
+ import { needsPin } from '../quoxon/approvalsView.js';
18
+ import { describeInboxItem, approvalSourceLabel } from '../quoxon/events.js';
19
+ import { runCallMe, statusLines } from '../core/callMe.js';
20
+ import { isDestructive, destructiveObject } from '../quoxon/destructive.js';
21
+ import { otherOrgsWaiting, crossOrgLines, clientForTarget } from '../core/crossOrgApprovals.js';
11
22
 
12
23
  export function registerApprovalsCommands(program, ctx) {
13
- const { client, auth } = ctx;
24
+ const { client: baseClient, auth, clientForOrg } = ctx;
25
+ // The client for the org a verb targets: --org another org (a verb in CROSS_ORG, see core/org-override.js) uses that org's own token.
26
+ const clientFor = (orgId) => clientForTarget({ client: baseClient, clientForOrg, sessionOrgId: auth.getOrgId(), orgId });
27
+ const client = baseClient; // `result`, grants and the rest stay on the session org
14
28
 
15
29
  const approvals = program
16
30
  .command('approvals')
@@ -39,6 +53,10 @@ export function registerApprovalsCommands(program, ctx) {
39
53
  const via = data.decided_via && data.decided_via.channel ? ` via ${data.decided_via.channel}` : '';
40
54
  console.log(`decided by ${who} (${data.decided_by.actor_type || 'user'})${via} at ${data.decided_at || 'unknown time'}`);
41
55
  }
56
+ if (data.human_decision) {
57
+ const hd = { yes: 'yes (a person approved it with a verified step-up PIN)', no: 'no (not human-verified: denied, withdrawn, expired, approved by an agent, or approved without a verified PIN)', pending: 'pending' }[data.human_decision] || data.human_decision;
58
+ console.log(`human decision: ${hd}`);
59
+ }
42
60
  if (data.result === null || data.result === undefined) {
43
61
  if (data.status === 'pending') console.log('Still pending: a human has not decided yet.');
44
62
  else if (data.status === 'resuming') console.log('Approved, executing. Re-run shortly.');
@@ -53,6 +71,26 @@ export function registerApprovalsCommands(program, ctx) {
53
71
  else console.log(JSON.stringify(r, null, 2));
54
72
  });
55
73
 
74
+ // Call-me: the switch for the ApprovalsQ watcher that phones a human when a high-risk
75
+ // approval sits unanswered. It never approves anything; the call only asks the human.
76
+ approvals
77
+ .command('call-me [action]')
78
+ .description('Phone me when a high-risk approval sits unanswered: status (default), on or off (human org admin only)')
79
+ .option('--after <minutes>', 'ring after this many minutes unanswered (1-1440)')
80
+ .option('--quiet <HH:MM-HH:MM>', 'quiet hours, no calls in this window (e.g. 22:00-08:00)')
81
+ .action(announceOnFail('quox', 'approvals call-me', async (action, options) => {
82
+ auth.requireAuth();
83
+ const act = action || 'status';
84
+ if (act === 'status' && (options.after || options.quiet)) {
85
+ throw new QuoxUserError('--after and --quiet change the policy: use `quox approvals call-me on --after 10` (or off).', { code: 'bad_args' });
86
+ }
87
+ const { data, changed } = await runCallMe(client, { action: act, after: options.after, quiet: options.quiet });
88
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
89
+ const p = (data && data.policy) || {};
90
+ announce('quox', 'approvals call-me', { result: `${changed ? 'set ' : ''}${p.enabled ? `ON after ${p.age_threshold_minutes} min` : 'OFF'}` });
91
+ for (const l of statusLines(data)) console.log(l);
92
+ }));
93
+
56
94
  // Fleet standing grants: an org admin pre-approves ONE host + an anchored command
57
95
  // shape + a VMID range until an expiry, for ssh_exec. Every use still lands in
58
96
  // approvals history with an AEE envelope; anything outside the grant files HITL.
@@ -61,7 +99,7 @@ export function registerApprovalsCommands(program, ctx) {
61
99
  .description('Standing grants: org-admin pre-approved fleet command lanes (ssh_exec)');
62
100
 
63
101
  grants
64
- .command('list')
102
+ .command('list', { isDefault: true })
65
103
  .description('List active standing grants (--all includes revoked and expired)')
66
104
  .option('--all', 'include revoked and expired grants')
67
105
  .action(async (options) => {
@@ -74,7 +112,7 @@ export function registerApprovalsCommands(program, ctx) {
74
112
  for (const g of rows) {
75
113
  const state = g.revoked_at ? `revoked ${g.revoked_at}` : `expires ${g.expires_at}`;
76
114
  const range = g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : '';
77
- console.log(`${g.grant_id} ${g.tool}@${g.host}${range} ${g.preset || `${g.patterns.length} patterns`} uses ${g.use_count} ${state}`);
115
+ console.log(`${g.tool} on ${g.host}${range} ${g.preset || `${(g.patterns || []).length} patterns`} uses ${g.use_count} ${state} [${g.grant_id}]`);
78
116
  console.log(` reason: ${g.reason} (by ${g.created_by})`);
79
117
  }
80
118
  });
@@ -107,6 +145,7 @@ export function registerApprovalsCommands(program, ctx) {
107
145
  const data = await client.post(ep.path, { service: ep.service, body });
108
146
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
109
147
  const g = data.grant || {};
148
+ announce('approvals', `grant created ${g.tool || 'ssh_exec'}@${g.host || options.host}`, { result: g.grant_id });
110
149
  console.log(`Created ${g.grant_id}: ${g.tool}@${g.host}${g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : ''}, expires ${g.expires_at}`);
111
150
  console.log(`Revoke any time: quox approvals grants revoke ${g.grant_id}`);
112
151
  });
@@ -119,6 +158,157 @@ export function registerApprovalsCommands(program, ctx) {
119
158
  const ep = resolveEndpoint('v1.approvals.grants.revoke', { id: grantId });
120
159
  const data = await client.post(ep.path, { service: ep.service, body: {} });
121
160
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
161
+ announce('approvals', `grant revoked ${grantId}`);
122
162
  console.log(`Revoked ${data.grant?.grant_id || grantId} at ${data.grant?.revoked_at || 'now'}.`);
123
163
  });
164
+
165
+ // ---- list | approve | deny: the verb people expect, over the SAME server path as `quox inbox respond`.
166
+ const OPEN_QUERY = { status: 'open', kind: 'approval', limit: 200 };
167
+ const listOpen = async (orgId, status, cl = client) => {
168
+ const ep = resolveEndpoint('inbox.list', { orgId });
169
+ const data = await cl.get(ep.path, { service: ep.service, query: { ...OPEN_QUERY, ...(status ? { status } : {}) } });
170
+ const items = (data && (data.items || data)) || [];
171
+ return Array.isArray(items) ? items : [];
172
+ };
173
+ const shortId = (id) => String(id).slice(-6);
174
+
175
+ async function pickApproval(orgId, given, cl = client) {
176
+ const want = String(given).trim().toUpperCase();
177
+ const items = await listOpen(orgId, undefined, cl);
178
+ const hits = items.filter((it) => {
179
+ const id = String(it.id).toUpperCase();
180
+ return id === want || id.endsWith(want) || String(it.aee_correlation_id || '').toUpperCase() === want;
181
+ });
182
+ const exact = hits.filter((it) => String(it.id).toUpperCase() === want);
183
+ if (exact.length === 1) return exact[0];
184
+ if (hits.length === 1) return hits[0];
185
+ if (hits.length > 1) {
186
+ throw new QuoxUserError(`"${given}" is ambiguous: it matches ${hits.length} open approvals (${hits.map((h) => shortId(h.id)).join(', ')}).`, {
187
+ next: 'use more of the id, or the full id from `quox approvals list`.', exitCode: 2, code: 'ambiguous_id',
188
+ });
189
+ }
190
+ throw new QuoxUserError(`No open approval matches "${given}" in this org.`, {
191
+ next: '`quox approvals list` shows what is waiting; `quox inbox get <id>` finds an answered one.', exitCode: 5, code: 'not_found',
192
+ });
193
+ }
194
+
195
+ function summaryLine(it) {
196
+ const d = describeInboxItem(it);
197
+ const what = [d.tool, d.target && d.target !== d.tool ? d.target : null].filter(Boolean).join(' on ') || d.title || it.title || it.id;
198
+ const who = d.by || approvalSourceLabel(it);
199
+ return `[${shortId(it.id)}] ${what} (asked by ${who})`;
200
+ }
201
+
202
+ async function decide(verb, given, options) {
203
+ auth.requireAuth();
204
+ const orgId = resolveOrgId(auth, program);
205
+ const client = await clientFor(orgId); // another org only when --org names one
206
+ const it = await pickApproval(orgId, given, client);
207
+ const approve = verb === 'approve';
208
+ console.log(`${approve ? 'Approve' : 'Deny'}: ${summaryLine(it)}`);
209
+ if (approve) {
210
+ if (needsPin(it)) {
211
+ throw new QuoxUserError('High risk: a yes needs your step-up PIN, which a plain command cannot take.', {
212
+ next: 'answer it in `quox` with /approvals, or deny it here with `quox approvals deny`.', exitCode: 4, code: 'step_up_required',
213
+ });
214
+ }
215
+ if (isDestructive(it)) {
216
+ const { name } = destructiveObject(it);
217
+ if (!process.stdin.isTTY) {
218
+ throw new QuoxUserError(`This deletes "${name}": its name must be typed at a terminal. --yes does not cover it.`, {
219
+ next: 'run this command in an interactive terminal.', exitCode: 2, code: 'no_terminal',
220
+ });
221
+ }
222
+ const typed = await askTyped(`This is destructive. Type "${name}" to approve`);
223
+ if (String(typed ?? '').trim() !== name) {
224
+ throw new QuoxUserError(`What you typed did not match "${name}". Nothing was approved.`, { exitCode: 1, code: 'confirm_mismatch' });
225
+ }
226
+ } else if (!options.yes) {
227
+ const ok = await confirmInteractive('Approve this?', { flag: '--yes', purpose: 'approve' });
228
+ if (!ok) { console.log('Not approved. The request stays open.'); return; }
229
+ }
230
+ }
231
+ const ep = resolveEndpoint('inbox.respond', { orgId, itemId: it.id });
232
+ try {
233
+ const body = { action: approve ? 'approve' : 'reject', ...(options.note ? { comment: options.note } : {}) };
234
+ // DS-010 / #1335: only an approve can hit a step-up sink; ask for the PIN (hidden, TTY only) and retry once.
235
+ if (approve) await withStepUp((headers) => client.post(ep.path, { service: ep.service, headers, body }), { client, orgId, io: ctx.stepUpIO, what: 'This approval' });
236
+ else await client.post(ep.path, { service: ep.service, body });
237
+ } catch (err) {
238
+ announceFailure('approvals', `${approve ? 'approve' : 'deny'} ${shortId(it.id)}`, err);
239
+ if (err && (err.status === 403 || err.status === 401) && !(err instanceof QuoxUserError)) {
240
+ const msg = err.body?.message || err.body?.error || err.message;
241
+ const denied = new QuoxUserError(String(msg), { exitCode: 4, code: err.body?.error || 'forbidden', status: err.status, body: err.body });
242
+ Object.defineProperty(denied, 'quoxAnnounced', { value: true });
243
+ throw denied;
244
+ }
245
+ throw err;
246
+ }
247
+ announce('approvals', `${approve ? 'approved' : 'denied'} ${shortId(it.id)}`, { result: summaryLine(it).replace(/^\[\w+\]\s*/, '').replace(/\s+\(asked by .*$/, '') });
248
+ printSuccess(`${approve ? 'Approved' : 'Denied'} ${shortId(it.id)}.`);
249
+ }
250
+
251
+ approvals
252
+ .command('list')
253
+ .description('List approvals waiting for a human (same call as `quox inbox list`, approvals only)')
254
+ .option('--status <status>', 'open (default), all, or a status list')
255
+ .option('--limit <n>', 'max results', '50')
256
+ .action(async (options) => {
257
+ auth.requireAuth();
258
+ const orgId = resolveOrgId(auth, program);
259
+ const cl = await clientFor(orgId);
260
+ const ep = resolveEndpoint('inbox.list', { orgId });
261
+ const data = await cl.get(ep.path, { service: ep.service, query: { ...OPEN_QUERY, limit: options.limit, ...(options.status ? { status: options.status } : {}) } });
262
+ const items = ((data && (data.items || data)) || []).map((it) => ({ ...it, short: shortId(it.id), what: summaryLine(it).replace(/^\[\w+\]\s*/, '') }));
263
+ formatOutput({
264
+ columns: ['short', 'what', 'status', 'created_at', 'id'],
265
+ headers: ['ID', 'What', 'Status', 'Created', 'Full id'],
266
+ rows: items,
267
+ }, { format: program.opts().output, emptyMessage: 'No approvals are waiting.' });
268
+ // Waiting in the user's OTHER orgs: a count and the exact command, table output only (json stays pure data).
269
+ if (program.opts().output === 'table' || !program.opts().output) {
270
+ for (const l of crossOrgLines(await otherOrgsWaiting({ client: baseClient, currentOrgId: orgId }))) console.log(l);
271
+ }
272
+ });
273
+
274
+ approvals
275
+ .command('approve <id>')
276
+ .description('Approve one waiting request by id (full id or the short tail on its card). A high-risk yes needs a PIN, so use `quox`.')
277
+ .option('--note <text>', 'a note recorded with the decision')
278
+ .option('-y, --yes', 'skip the y/N question (not a destructive delete, which needs its name typed)')
279
+ .addHelpText('after', '\nExamples:\n quox approvals list\n quox approvals approve D05HQY\n quox approvals approve D05HQY --yes --note "checked the host"\n')
280
+ .action(announceOnFail('approvals', (id) => `approve ${id}`, (id, options) => decide('approve', id, options)));
281
+
282
+ approvals
283
+ .command('deny <id>')
284
+ .description('Deny one waiting request by id (full id or the short tail on its card)')
285
+ .option('--note <text>', 'why, recorded with the decision')
286
+ .addHelpText('after', '\nExamples:\n quox approvals deny FJ340S --note "wrong host"\n')
287
+ .action(announceOnFail('approvals', (id) => `deny ${id}`, (id, options) => decide('deny', id, options)));
288
+
289
+ approvals
290
+ .command('withdraw <id>')
291
+ .description('Take back an approval request YOU raised (full id). It is marked withdrawn, never denied; the held call cannot run; only the identity that raised it may do this')
292
+ .option('--note <text>', 'why, recorded with the withdrawal')
293
+ .addHelpText('after', '\nExamples:\n quox approvals withdraw 01KXYZ... --note "raised by mistake"\n')
294
+ .action(announceOnFail('approvals', (id) => `withdraw ${id}`, async (given, options) => {
295
+ auth.requireAuth();
296
+ const orgId = resolveOrgId(auth, program);
297
+ // The full item id or approval id goes straight to the server, which checks you are the requester. A short tail is
298
+ // looked up among the open approvals you can see; one you cannot see is not yours to withdraw by tail.
299
+ let id = String(given).trim();
300
+ if (id.length < 20) { const it = await pickApproval(orgId, id); id = it.id; }
301
+ const ep = resolveEndpoint('inbox.withdraw', { orgId, itemId: id });
302
+ try {
303
+ await client.post(ep.path, { service: ep.service, body: { ...(options.note ? { reason: options.note } : {}) } });
304
+ } catch (err) {
305
+ if (err && (err.status === 403 || err.status === 404 || err.status === 400) && !(err instanceof QuoxUserError)) {
306
+ const msg = err.body?.message || err.body?.error || err.message;
307
+ throw new QuoxUserError(String(msg), { exitCode: err.status === 403 ? 4 : 5, code: err.body?.error || 'withdraw_refused', status: err.status, body: err.body });
308
+ }
309
+ throw err;
310
+ }
311
+ announce('approvals', `withdrew ${shortId(id)}`, { result: 'withdrawn, nothing ran' });
312
+ printSuccess(`Withdrew ${shortId(id)}. It is marked withdrawn and cannot run.`);
313
+ }));
124
314
  }
@@ -130,7 +130,7 @@ export function registerAskCommands(program, { client, auth }) {
130
130
  const d = (await client.get(ep.path, { service: ep.service })).decision;
131
131
  if (program.opts().output === 'json') { console.log(JSON.stringify(d, null, 2)); return; }
132
132
  console.log(`${d.state}${d.answer?.option ? ` ${d.answer.option}` : ''}${d.answer?.note ? ` (${d.answer.note})` : ''}`);
133
- if (d.state === 'expired' || d.state === 'denied') process.exitCode = EXIT_NO_ANSWER;
133
+ if (d.state === 'expired' || d.state === 'denied' || d.state === 'withdrawn') process.exitCode = EXIT_NO_ANSWER;
134
134
  });
135
135
 
136
136
  ask
@@ -7,7 +7,19 @@ import { formatDetail, printSuccess, printInfo } from '../core/output.js';
7
7
  import { resolveEndpoint } from '../api/endpoints.js';
8
8
  import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from '../core/residentAuth.js';
9
9
  import { readStoredKit, resolveMyAttach, sessionKey, myAttachState } from './agentAttach.js';
10
+ import { loginPrefillEmail, profileHoldingEmail, isNonDefaultProfile, sameIdentityWarning } from '../core/loginGuard.js';
10
11
  import { resolveActing } from '../core/actingAs.js';
12
+ import { QuoxUserError } from '../core/errors.js';
13
+
14
+ const PASSWORD_POLICY_HINT = 'Password policy: at least 12 characters, with an upper-case letter, a lower-case letter, a digit and a symbol.';
15
+
16
+ /** whoami line for "-p/--profile given": say which agent is acting when the profile is an agent identity. */
17
+ export function profileActingLine({ actorType, profile, user }) {
18
+ if (actorType === 'agent') {
19
+ return ` Acting as agent ${user || 'unknown'} via profile ${profile || 'unknown'} (-p/--profile given)`;
20
+ }
21
+ return ' Acting as yourself (-p/--profile given)';
22
+ }
11
23
 
12
24
  export function registerAuthCommands(program, ctx) {
13
25
  const { config, auth, client } = ctx;
@@ -122,6 +134,7 @@ export function registerAuthCommands(program, ctx) {
122
134
  type: 'input',
123
135
  name: 'email',
124
136
  message: 'Email:',
137
+ default: loginPrefillEmail(config) || undefined,
125
138
  validate: v => v.includes('@') || 'Please enter a valid email',
126
139
  }]);
127
140
  email = ans.email;
@@ -138,6 +151,21 @@ export function registerAuthCommands(program, ctx) {
138
151
  }
139
152
  }
140
153
 
154
+ // Stop agent-only profiles quietly becoming another profile's (the owner's) identity.
155
+ if (isNonDefaultProfile(config)) {
156
+ const holder = profileHoldingEmail(config, email, config.getActiveProfile?.()?.url || null);
157
+ if (holder) {
158
+ const msg = sameIdentityWarning(email, holder, config.getActiveProfileName());
159
+ if (!process.stdin.isTTY) {
160
+ console.error(`Error: ${msg}\nRefusing in a non-interactive shell. Use the profile's own account, or run interactively and answer y.`);
161
+ process.exit(1);
162
+ }
163
+ const { default: inquirer } = await import('inquirer');
164
+ const ans = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: msg.replace(/ y\/N$/, ''), default: false }]);
165
+ if (!ans.ok) { printInfo('Cancelled. Nothing was sent.'); return; }
166
+ }
167
+ }
168
+
141
169
  const baseUrl = config.getServiceUrl('auth');
142
170
  const ora = (await import('ora')).default;
143
171
  const spinner = ora('Authenticating...').start();
@@ -196,6 +224,7 @@ export function registerAuthCommands(program, ctx) {
196
224
  program
197
225
  .command('whoami')
198
226
  .description('Show current user and session info')
227
+ .addHelpText('after', '\nAdd --json for machine output (user, email, org, role, mfa, session, profile, actor_type, host_id).')
199
228
  .action(async () => {
200
229
  auth.requireAuth();
201
230
  const data = await client.get(resolveEndpoint('auth.me.list').path, { service: resolveEndpoint('auth.me.list').service });
@@ -309,7 +338,7 @@ export function registerAuthCommands(program, ctx) {
309
338
  } else if (acting.source === 'self') {
310
339
  printInfo(' Acting as yourself (--as self)');
311
340
  } else if (acting.source === 'profile') {
312
- printInfo(' Acting as yourself (-p/--profile given)');
341
+ printInfo(profileActingLine({ actorType, profile: info.profile, user: info.user }));
313
342
  } else if (myAttachState().state === 'ambiguous') {
314
343
  printInfo(' Acting as yourself: this session has more than one attach (ambiguous), so no agent default applies. Pick one with --as <agent>.');
315
344
  }
@@ -337,18 +366,42 @@ export function registerAuthCommands(program, ctx) {
337
366
  .command('signup')
338
367
  .description('Create a new user account')
339
368
  .requiredOption('--email <email>', 'email address')
340
- .requiredOption('--password <password>', 'password')
369
+ .option('--password <password>', 'password (prefer --password-stdin: argv is visible in process lists)')
370
+ .option('--password-stdin', 'read the password from stdin (one line), no secret in argv or shell history')
341
371
  .requiredOption('--name <name>', 'display name')
342
372
  .action(async (options) => {
343
- const data = await client.post(resolveEndpoint('auth.signup').path, {
344
- service: resolveEndpoint('auth.signup').service,
345
- body: {
346
- email: options.email,
347
- password: options.password,
348
- display_name: options.name,
349
- },
350
- noAuth: true,
351
- });
373
+ let password = options.password;
374
+ if (!password && options.passwordStdin) {
375
+ const chunks = [];
376
+ for await (const chunk of process.stdin) chunks.push(chunk);
377
+ password = Buffer.concat(chunks).toString('utf8').split('\n')[0].trim();
378
+ if (!password) throw new QuoxUserError('--password-stdin given but stdin was empty', { next: 'Pipe the password in, for example: printf %s "$PW" | quox signup --password-stdin ...' });
379
+ } else if (password) {
380
+ console.error('Warning: argv is visible to other users (process list) and lands in shell history, so a password given with --password is exposed. Prefer --password-stdin.');
381
+ }
382
+ if (!password) {
383
+ throw new QuoxUserError('A password is required. Use --password-stdin (reads one line from stdin) or --password <password>.');
384
+ }
385
+ const ep = resolveEndpoint('auth.signup');
386
+ try {
387
+ await client.post(ep.path, {
388
+ service: ep.service,
389
+ body: { email: options.email, password, display_name: options.name },
390
+ noAuth: true,
391
+ });
392
+ } catch (err) {
393
+ if (err?.status === 400 && err?.code === 'validation_error') {
394
+ const reason = err.body?.message || err.message;
395
+ const list = Array.isArray(err.body?.errors) ? err.body.errors.filter((e) => typeof e === 'string' && e !== reason) : [];
396
+ const detail = [reason, ...list].join('; ');
397
+ const aboutPassword = /password/i.test(detail);
398
+ throw new QuoxUserError(`Signup was rejected: ${detail}`, {
399
+ next: aboutPassword ? PASSWORD_POLICY_HINT : null,
400
+ code: 'validation_error', status: 400, body: err.body,
401
+ });
402
+ }
403
+ throw err;
404
+ }
352
405
  printSuccess(`Account created for ${options.email}. Check your email for verification.`);
353
406
  });
354
407
 
@@ -37,12 +37,19 @@ export function registerBackupCommands(program, ctx) {
37
37
  auth.requireAuth();
38
38
  const orgId = resolveOrgId(auth, program);
39
39
  const data = await client.get(resolveEndpoint('backups.list', { orgId }).path, { service: resolveEndpoint('backups.list', { orgId }).service, query: { limit: options.limit } });
40
+ const items = data.items || data.backups || data;
41
+ // Name leads (ids are for scripting: --json, -q). A failed row says why, clipped.
42
+ const clip = (t, n = 60) => { const x = String(t).replace(/\s+/g, ' ').trim(); return x.length > n ? `${x.slice(0, n - 1)}…` : x; };
43
+ const rows = (Array.isArray(items) ? items : []).map((b) => ({
44
+ ...b,
45
+ status: b.status === 'failed' && b.error_message ? `failed: ${clip(b.error_message)}` : b.status,
46
+ }));
40
47
  formatOutput({
41
48
  // fleet #392: backups has name/backup_type/archive_size_bytes.
42
- columns: ['id', 'name', 'backup_type', 'archive_size_bytes', 'status', 'created_at'],
43
- headers: ['ID', 'Name', 'Type', 'Size (bytes)', 'Status', 'Created'],
44
- rows: data.items || data.backups || data,
45
- }, { format: program.opts().output });
49
+ columns: ['name', 'backup_type', 'archive_size_bytes', 'status', 'created_at', 'id'],
50
+ headers: ['Name', 'Type', 'Size (bytes)', 'Status', 'Created', 'ID'],
51
+ rows,
52
+ }, { format: program.opts().output, raw: Array.isArray(items) ? items : undefined, quietColumn: 'id' });
46
53
  });
47
54
 
48
55
  backup
@@ -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
+ }