quoxcli 0.3.1 → 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 (306) 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/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -8,6 +8,7 @@ import { resolveOrgId } from './helpers.js';
8
8
  import { resolveEndpoint } from '../api/endpoints.js';
9
9
  import { QuoxApiError } from '../core/errors.js';
10
10
  import { loadBundledAssistantsRoster } from '../core/agentAlias.js';
11
+ import { AGENT_FAMILY_IDS } from '../core/agentFamilies.js';
11
12
  import { registerAgentAttachCommands } from './agentAttach.js';
12
13
 
13
14
  // quox#1169: the prompt, domains and parent a user gives their own agent. Auth reads
@@ -64,6 +65,7 @@ export function registerAgentCommands(program, ctx) {
64
65
  .option('--level <level>', 'filter by level (0-4)')
65
66
  .option('--source <source>', 'registry | org | all', 'all')
66
67
  .option('--plugin <id>', 'only agents that require this plugin')
68
+ .option('--family <id>', `only agents in this family (${AGENT_FAMILY_IDS.join(', ')})`)
67
69
  .option('--search <text>', 'substring match on id, name or description')
68
70
  .action(async (options) => {
69
71
  auth.requireAuth();
@@ -71,6 +73,9 @@ export function registerAgentCommands(program, ctx) {
71
73
  if (!['registry', 'org', 'all'].includes(source)) {
72
74
  throw new Error(`--source must be registry, org or all (got "${options.source}")`);
73
75
  }
76
+ if (options.family !== undefined && !AGENT_FAMILY_IDS.includes(options.family)) {
77
+ throw new Error(`unknown family "${options.family}". Valid families: ${AGENT_FAMILY_IDS.join(', ')}`);
78
+ }
74
79
  const notes = [];
75
80
 
76
81
  // Registered specialists (server roster; bundled roster if the server lacks the route).
@@ -84,7 +89,7 @@ export function registerAgentCommands(program, ctx) {
84
89
  } catch (err) {
85
90
  notes.push(`note: server registry unavailable (${err.message}); showing the bundled roster, which may be out of date.`);
86
91
  registry = loadBundledAssistantsRoster().map(r => ({
87
- agent_id: r.id, name: r.name || r.id, level: null, requires_plugin: null, tool_count: null, source: 'registry',
92
+ agent_id: r.id, name: r.name || r.id, level: null, requires_plugin: null, family: r.family || null, tool_count: null, source: 'registry',
88
93
  }));
89
94
  }
90
95
  }
@@ -113,7 +118,7 @@ export function registerAgentCommands(program, ctx) {
113
118
  const key = o.agent_id || o.id;
114
119
  const reg = merged.get(key);
115
120
  merged.set(key, reg
116
- ? { ...reg, ...o, requires_plugin: reg.requires_plugin, description: reg.description ?? o.description, tool_count: o.tool_count ?? reg.tool_count, source: 'org+registry' }
121
+ ? { ...reg, ...o, requires_plugin: reg.requires_plugin, family: reg.family ?? null, description: reg.description ?? o.description, tool_count: o.tool_count ?? reg.tool_count, source: 'org+registry' }
117
122
  : o);
118
123
  }
119
124
 
@@ -121,6 +126,7 @@ export function registerAgentCommands(program, ctx) {
121
126
  if (options.level !== undefined) all = all.filter(r => String(r.level) === String(options.level));
122
127
  if (options.status) all = all.filter(r => r.status === options.status);
123
128
  if (options.plugin) all = all.filter(r => r.requires_plugin === options.plugin);
129
+ if (options.family) all = all.filter(r => r.family === options.family);
124
130
  if (options.search) {
125
131
  const q = options.search.toLowerCase();
126
132
  all = all.filter(r => [r.agent_id, r.name, r.description].some(v => String(v ?? '').toLowerCase().includes(q)));
@@ -130,10 +136,11 @@ export function registerAgentCommands(program, ctx) {
130
136
  for (const n of notes) console.error(n);
131
137
  const dash = (v) => (v === null || v === undefined || v === '' ? '-' : v);
132
138
  formatOutput({
133
- columns: ['agent_id', 'name', 'source', 'level', 'state', 'tool_count'],
134
- headers: ['Agent ID', 'Name', 'Source', 'Level', 'Status/Plugin', 'Tools'],
139
+ columns: ['agent_id', 'name', 'family', 'source', 'level', 'state', 'tool_count'],
140
+ headers: ['Agent ID', 'Name', 'Family', 'Source', 'Level', 'Status/Plugin', 'Tools'],
135
141
  rows: all.map(r => ({
136
142
  ...r,
143
+ family: dash(r.family),
137
144
  level: dash(r.level),
138
145
  state: r.status ? r.status : (r.requires_plugin ? `plugin:${r.requires_plugin}` : '-'),
139
146
  tool_count: dash(r.tool_count),
@@ -435,6 +435,38 @@ export function execResultFailed(data) {
435
435
  return negative(data.success) || negative(data.ok);
436
436
  }
437
437
 
438
+ /**
439
+ * A governed tool that was held for a human returns { success:false, pending:true, approvalId }. That is not a
440
+ * failure: the approval is filed and waiting. Returns the approval id, or null when the result is not pending.
441
+ */
442
+ export function execResultPending(data) {
443
+ if (!data || typeof data !== 'object') return null;
444
+ const src = data.pending === true ? data : (data.result && typeof data.result === 'object' && data.result.pending === true ? data.result : null);
445
+ if (!src) return null;
446
+ return src.approvalId || src.approval_id || (src._approval && src._approval.id) || 'unknown';
447
+ }
448
+
449
+ /**
450
+ * A governed tool held by the policy layer answers HTTP 403 with {pending:true, approvalId}: the approval IS filed.
451
+ * Returns that body when an API error carries one (so the caller renders awaiting approval), else null.
452
+ */
453
+ export function pendingBodyOfError(err) {
454
+ const body = err && err.body && typeof err.body === 'object' ? err.body : null;
455
+ return body && execResultPending(body) ? body : null;
456
+ }
457
+
458
+ /**
459
+ * Is this 401/403 really about the attach token? 401 is an unauthenticated bearer. A 403 counts only when the server
460
+ * says so (attach/lease/token in its code or message); any other 403 is the server's own refusal and is shown verbatim.
461
+ */
462
+ export function isAttachTokenError(err) {
463
+ const status = err && err.status;
464
+ if (status === 401) return true;
465
+ if (status !== 403) return false;
466
+ const text = [err && err.code, err && err.body && err.body.error, err && err.body && err.body.error_code, err && err.message].filter(Boolean).join(' ');
467
+ return /attach|lease|token/i.test(text);
468
+ }
469
+
438
470
  /** The first human-readable error string a failed tool result carries, or a default. */
439
471
  function execResultError(data) {
440
472
  return (data && (data.error || data.error_code || data.reason || data.message)) || 'tool reported failure';
@@ -1290,6 +1322,21 @@ export function registerAgentAttachCommands(agent, ctx, program) {
1290
1322
  if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1291
1323
  });
1292
1324
 
1325
+ agent
1326
+ .command('attaches')
1327
+ .description('List every live agent attach in the org (agent, splinter, session, lease, heartbeat, expiry)')
1328
+ .action(async () => {
1329
+ auth.requireAuth();
1330
+ const ep = resolveEndpoint('agent-attach.list');
1331
+ const data = await client.get(ep.path, { service: ep.service });
1332
+ const rows = Array.isArray(data?.attaches) ? data.attaches : [];
1333
+ if (program.opts().output === 'json') return formatDetail(data, { format: 'json' });
1334
+ if (!rows.length) return printInfo('No live agent attaches in this org.');
1335
+ for (const a of rows) {
1336
+ printInfo(`${a.agent_id} ${a.splinter || 'S1'} · ${a.session_label || '(no label)'} · lease ${a.lease_id} · heartbeat ${fmtAge(a.last_heartbeat_at)} · expires ${a.expires_at || '?'}`);
1337
+ }
1338
+ });
1339
+
1293
1340
  agent
1294
1341
  .command('attach-heartbeat')
1295
1342
  .description("Heartbeat this session's attach lease, rotating the stored token if one is issued")
@@ -1343,13 +1390,17 @@ export function registerAgentAttachCommands(agent, ctx, program) {
1343
1390
  });
1344
1391
  } catch (err) {
1345
1392
  const status = err instanceof QuoxApiError ? err.status : 'network error';
1346
- if (err instanceof QuoxApiError && (err.status === 401 || err.status === 403)) {
1393
+ const held = err instanceof QuoxApiError ? pendingBodyOfError(err) : null;
1394
+ if (held) {
1395
+ data = held; // the policy layer filed an approval: that is a result, not an error
1396
+ } else if (err instanceof QuoxApiError && isAttachTokenError(err)) {
1347
1397
  throw new Error(
1348
1398
  `POST ${ep.path} failed (${status}): the attach token for "${agentId}" is no longer valid `
1349
1399
  + `(expired or revoked lease). Re-attach with \`quox agent attach ${agentId}\`.`
1350
1400
  );
1401
+ } else {
1402
+ throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1351
1403
  }
1352
- throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1353
1404
  }
1354
1405
 
1355
1406
  // finding (autonomous loop): the glyph must reflect the TOOL result, not
@@ -1358,14 +1409,17 @@ export function registerAgentAttachCommands(agent, ctx, program) {
1358
1409
  // agent read failures as passes ([error-body-is-not-data] at the CLI
1359
1410
  // surface). Set a non-zero exit code too so a scripted / feedback-loop
1360
1411
  // caller can't mistake it for a pass.
1361
- const toolOk = !execResultFailed(data);
1412
+ const pendingId = execResultPending(data);
1413
+ const toolOk = pendingId ? true : !execResultFailed(data);
1362
1414
  // Skip the announce line under --json/-o json: a scripted caller parses
1363
1415
  // stdout as a single JSON document, and the glyph line would break that.
1364
1416
  if (program.opts().output !== 'json') {
1365
- printEvent(line('quox', `exec ${options.tool}`, {
1366
- result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
1367
- status: toolOk ? 'ok' : 'fail',
1368
- }));
1417
+ printEvent(line('quox', `exec ${options.tool}`, pendingId
1418
+ ? { result: `${agentId} · awaiting approval ${pendingId}`, status: 'busy' }
1419
+ : {
1420
+ result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
1421
+ status: toolOk ? 'ok' : 'fail',
1422
+ }));
1369
1423
  }
1370
1424
  formatDetail(data, { format: program.opts().output, title: `Tool result: ${options.tool}` });
1371
1425
  if (!toolOk) process.exitCode = 1;
@@ -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')
@@ -34,6 +48,15 @@ export function registerApprovalsCommands(program, ctx) {
34
48
  }
35
49
  console.log(`Approval ${data.approval_id} tool: ${data.tool} status: ${data.status}`
36
50
  + (data.executed_at ? ` executed: ${data.executed_at}` : ''));
51
+ if (data.decided_by) {
52
+ const who = data.decided_by.name || data.decided_by.id;
53
+ const via = data.decided_via && data.decided_via.channel ? ` via ${data.decided_via.channel}` : '';
54
+ console.log(`decided by ${who} (${data.decided_by.actor_type || 'user'})${via} at ${data.decided_at || 'unknown time'}`);
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
+ }
37
60
  if (data.result === null || data.result === undefined) {
38
61
  if (data.status === 'pending') console.log('Still pending: a human has not decided yet.');
39
62
  else if (data.status === 'resuming') console.log('Approved, executing. Re-run shortly.');
@@ -48,6 +71,26 @@ export function registerApprovalsCommands(program, ctx) {
48
71
  else console.log(JSON.stringify(r, null, 2));
49
72
  });
50
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
+
51
94
  // Fleet standing grants: an org admin pre-approves ONE host + an anchored command
52
95
  // shape + a VMID range until an expiry, for ssh_exec. Every use still lands in
53
96
  // approvals history with an AEE envelope; anything outside the grant files HITL.
@@ -56,7 +99,7 @@ export function registerApprovalsCommands(program, ctx) {
56
99
  .description('Standing grants: org-admin pre-approved fleet command lanes (ssh_exec)');
57
100
 
58
101
  grants
59
- .command('list')
102
+ .command('list', { isDefault: true })
60
103
  .description('List active standing grants (--all includes revoked and expired)')
61
104
  .option('--all', 'include revoked and expired grants')
62
105
  .action(async (options) => {
@@ -69,7 +112,7 @@ export function registerApprovalsCommands(program, ctx) {
69
112
  for (const g of rows) {
70
113
  const state = g.revoked_at ? `revoked ${g.revoked_at}` : `expires ${g.expires_at}`;
71
114
  const range = g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : '';
72
- 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}]`);
73
116
  console.log(` reason: ${g.reason} (by ${g.created_by})`);
74
117
  }
75
118
  });
@@ -102,6 +145,7 @@ export function registerApprovalsCommands(program, ctx) {
102
145
  const data = await client.post(ep.path, { service: ep.service, body });
103
146
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
104
147
  const g = data.grant || {};
148
+ announce('approvals', `grant created ${g.tool || 'ssh_exec'}@${g.host || options.host}`, { result: g.grant_id });
105
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}`);
106
150
  console.log(`Revoke any time: quox approvals grants revoke ${g.grant_id}`);
107
151
  });
@@ -114,6 +158,157 @@ export function registerApprovalsCommands(program, ctx) {
114
158
  const ep = resolveEndpoint('v1.approvals.grants.revoke', { id: grantId });
115
159
  const data = await client.post(ep.path, { service: ep.service, body: {} });
116
160
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
161
+ announce('approvals', `grant revoked ${grantId}`);
117
162
  console.log(`Revoked ${data.grant?.grant_id || grantId} at ${data.grant?.revoked_at || 'now'}.`);
118
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
+ }));
119
314
  }
@@ -96,6 +96,7 @@ export function registerAskCommands(program, { client, auth }) {
96
96
  .option('--paths <files...>', 'every test file the commit edits (absolute, worktree or repo-relative: all are normalised)')
97
97
  .option('--justify <text>', 'why it is safe, in plain words (required)')
98
98
  .option('--why <text>', 'detail: the findings, before and after lines')
99
+ .option('--recommend <y|n>', 'your advice for the card: y or n (shown as the recommendation)')
99
100
  .option('--session <name>', 'which session is asking (default: $QUOX_SESSION)')
100
101
  .option('--slot <name>', 'your slot')
101
102
  .option('--expires <dur>', 'how long it stays open')
@@ -105,6 +106,7 @@ export function registerAskCommands(program, { client, auth }) {
105
106
  // commander lets the parent `ask` swallow flags it shares with this verb (--justify, --why, --session...), so merge both
106
107
  const options = { ...ask.opts(), ...Object.fromEntries(Object.entries(subOptions).filter(([, v]) => v !== undefined)) };
107
108
  if (!String(options.justify || '').trim()) { err('say why it is safe: --justify "<plain words>"'); process.exitCode = 2; return; }
109
+ if (!options.recommend) err('tip: add --recommend y|n so the card shows your advice');
108
110
  const raw = [].concat(options.paths || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
109
111
  let root;
110
112
  try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); } catch { root = process.cwd(); }
@@ -114,7 +116,7 @@ export function registerAskCommands(program, { client, auth }) {
114
116
  // 2026-10-19: the server truncates long titles, and the attestation names only paths found in title+body,
115
117
  // so a 7-file ask covered 3. Every path also goes in the body.
116
118
  const files = `Files (${paths.length}):\n${paths.map((p) => `- ${p}`).join('\n')}`;
117
- await fileAsk({ ...options, title: `Approve test edits: ${paths.join(' ')}`, why: options.why ? `${files}\n\n${options.why}` : files, yn: true, highRisk: true, justify: options.justify });
119
+ await fileAsk({ ...options, title: `Approve test edits: ${paths.join(' ')}`, why: options.why ? `${files}\n\n${options.why}` : files, yn: true, highRisk: true, justify: options.justify, recommend: options.recommend });
118
120
  });
119
121
 
120
122
  ask
@@ -128,7 +130,7 @@ export function registerAskCommands(program, { client, auth }) {
128
130
  const d = (await client.get(ep.path, { service: ep.service })).decision;
129
131
  if (program.opts().output === 'json') { console.log(JSON.stringify(d, null, 2)); return; }
130
132
  console.log(`${d.state}${d.answer?.option ? ` ${d.answer.option}` : ''}${d.answer?.note ? ` (${d.answer.note})` : ''}`);
131
- 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;
132
134
  });
133
135
 
134
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