quoxcli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -29,6 +29,19 @@ export const tools = {
29
29
  path: () => `/api/tools/install-to-scope`,
30
30
  tags: ['live-write'],
31
31
  },
32
+ // CLI-COV 2026-11-05: read and undo what `tool install` wrote (tool_installations, not the org's tool definitions)
33
+ 'tools.installed': {
34
+ method: 'GET',
35
+ service: 'auth',
36
+ path: () => `/api/tools/installed`,
37
+ tags: ['live-safe'],
38
+ },
39
+ 'tools.uninstall-from-scope': {
40
+ method: 'DELETE',
41
+ service: 'auth',
42
+ path: ({ toolId }) => `/api/tools/install-to-scope/${encodeURIComponent(toolId)}`,
43
+ tags: ['live-write'],
44
+ },
32
45
  'tools.list': {
33
46
  method: 'GET',
34
47
  service: 'auth',
@@ -365,6 +365,19 @@ export const v1 = {
365
365
  path: ({ id }) => `/api/v1/approvals/grants/${encodeURIComponent(id)}/revoke`,
366
366
  tags: ['live-write'],
367
367
  },
368
+ // ApprovalsQ call-me policy (2026-11-03): human org admin only; flips the stale-approval phone watcher.
369
+ 'v1.approvals.call-me.get': {
370
+ method: 'GET',
371
+ service: 'collector',
372
+ path: () => `/api/v1/approvalsq/call-me`,
373
+ tags: ['live-safe'],
374
+ },
375
+ 'v1.approvals.call-me.put': {
376
+ method: 'PUT',
377
+ service: 'collector',
378
+ path: () => `/api/v1/approvalsq/call-me`,
379
+ tags: ['live-write'],
380
+ },
368
381
  'v1.tools.list': {
369
382
  method: 'GET',
370
383
  service: 'collector',
@@ -41,6 +41,39 @@ async function listItems(ctx, { status = 'all', limit = 100, scope } = {}) {
41
41
  throw new QuoxError(`More than ${MAX_PAGES * pageSize} items; narrow with --status or --scope.`);
42
42
  }
43
43
 
44
+ // Short ids, as `quox approvals` shows them: the last 6 characters of the ULID. --json keeps the full id.
45
+ const SHORT_LEN = 6;
46
+ export const shortActionId = (id) => String(id ?? '').slice(-SHORT_LEN);
47
+
48
+ // The width to fit: the terminal when there is one, else COLUMNS (the Quox TUI sets it for ! and /<verb> runs).
49
+ function outputWidth() {
50
+ if (process.stdout.isTTY && process.stdout.columns) return process.stdout.columns;
51
+ const env = Number(process.env.COLUMNS);
52
+ return env > 0 ? env : 0;
53
+ }
54
+
55
+ /** An exact id, or a unique id suffix (the short id the list shows). -> item | null; ambiguous throws. */
56
+ function findByIdOrSuffix(items, given) {
57
+ const want = String(given || '').trim().toUpperCase();
58
+ if (!want) return null;
59
+ const exact = items.find((i) => String(i.id).toUpperCase() === want);
60
+ if (exact) return exact;
61
+ const hits = items.filter((i) => String(i.id).toUpperCase().endsWith(want));
62
+ if (hits.length > 1) {
63
+ throw new QuoxError(`"${given}" matches ${hits.length} action items (${hits.map((h) => h.id).join(', ')}); use more of the id.`, 2);
64
+ }
65
+ return hits[0] || null;
66
+ }
67
+
68
+ // A short id (base32, shorter than a ULID) is resolved against the list; anything else goes to the server as given.
69
+ const looksShort = (id) => /^[0-9A-Z]{4,25}$/i.test(String(id || '').trim());
70
+ async function resolveActionId(ctx, given) {
71
+ if (!looksShort(given)) return given;
72
+ const item = findByIdOrSuffix(await listItems(ctx, { status: 'all', limit: 100 }), given);
73
+ if (!item) throw new QuoxError(`No action item matches ${given}.`, 5);
74
+ return item.id;
75
+ }
76
+
44
77
  export function registerActionsCommands(program, ctx) {
45
78
  const actions = program
46
79
  .command('actions')
@@ -54,20 +87,24 @@ export function registerActionsCommands(program, ctx) {
54
87
  .option('--scope <scopeId>', 'only items in this team/department/division scope')
55
88
  .action(async (options) => {
56
89
  const items = await listItems({ ...ctx, program }, options);
90
+ // Fit the terminal (owner 2026-11-07): short id, title cut with an ellipsis, no all-empty Due column.
91
+ const hasDue = items.some((i) => i.dueDate);
92
+ const columns = ['id', 'status', 'priority', 'title', ...(hasDue ? ['dueDate'] : [])];
93
+ const headers = ['ID', 'Status', 'Priority', 'Title', ...(hasDue ? ['Due'] : [])];
57
94
  formatOutput({
58
- columns: ['id', 'status', 'priority', 'title', 'dueDate'],
59
- headers: ['ID', 'Status', 'Priority', 'Title', 'Due'],
60
- rows: items.map((i) => ({ ...i, dueDate: i.dueDate || '-' })),
61
- }, { raw: items });
95
+ columns,
96
+ headers,
97
+ rows: items.map((i) => ({ ...i, fullId: i.id, id: shortActionId(i.id), dueDate: i.dueDate ? String(i.dueDate).slice(0, 10) : '' })),
98
+ }, { raw: items, quietColumn: 'fullId', fit: { width: outputWidth(), flex: 'title' } });
62
99
  });
63
100
 
64
101
  actions
65
102
  .command('get')
66
103
  .description('Show one action item by id')
67
- .argument('<id>', 'action item id (from `quox actions list`)')
104
+ .argument('<id>', 'action item id, or the short id `quox actions list` shows')
68
105
  .action(async (id) => {
69
106
  const items = await listItems({ ...ctx, program }, { status: 'all', limit: 100 });
70
- const item = items.find((i) => i.id === id);
107
+ const item = findByIdOrSuffix(items, id);
71
108
  if (!item) throw new QuoxError(`No action item ${id} among the latest 100 items.`);
72
109
  formatDetail(item);
73
110
  });
@@ -90,10 +127,10 @@ export function registerActionsCommands(program, ctx) {
90
127
  actions
91
128
  .command('done')
92
129
  .description('Mark an Action Tracker item done')
93
- .argument('<id>', 'action item id (from `quox actions list`)')
130
+ .argument('<id>', 'action item id, or the short id `quox actions list` shows')
94
131
  .action(async (id) => {
95
132
  ctx.auth.requireAuth();
96
- const item = await completeActionItem(ctx.client, id);
133
+ const item = await completeActionItem(ctx.client, await resolveActionId({ ...ctx, program }, id));
97
134
  if (program.opts().output === 'json') return formatDetail(item);
98
135
  printSuccess(`Marked done ${item.id}: ${item.title}`);
99
136
  return undefined;
@@ -102,11 +139,11 @@ export function registerActionsCommands(program, ctx) {
102
139
  actions
103
140
  .command('move')
104
141
  .description('Move an Action Tracker item to todo, doing or done')
105
- .argument('<id>', 'action item id (from `quox actions list`)')
142
+ .argument('<id>', 'action item id, or the short id `quox actions list` shows')
106
143
  .argument('<status>', STATUS_COLUMNS.join(', '))
107
144
  .action(async (id, status) => {
108
145
  ctx.auth.requireAuth();
109
- const item = await moveActionItem(ctx.client, id, status);
146
+ const item = await moveActionItem(ctx.client, await resolveActionId({ ...ctx, program }, id), status);
110
147
  if (program.opts().output === 'json') return formatDetail(item);
111
148
  printSuccess(`Moved ${item.id} to ${item.status}: ${item.title}`);
112
149
  return undefined;
@@ -115,15 +152,16 @@ export function registerActionsCommands(program, ctx) {
115
152
  actions
116
153
  .command('delete')
117
154
  .description('Delete one of your Action Tracker items (asks first; --yes skips, required without a terminal)')
118
- .argument('<id>', 'action item id (from `quox actions list`)')
155
+ .argument('<id>', 'action item id, or the short id `quox actions list` shows')
119
156
  .option('-y, --yes', 'do not ask')
120
157
  .action(async (id, options) => {
121
158
  ctx.auth.requireAuth();
122
- if (!(await confirmDestructive(program, { force: options.yes === true }, `Delete action item ${id}?`))) {
159
+ const fullId = await resolveActionId({ ...ctx, program }, id);
160
+ if (!(await confirmDestructive(program, { force: options.yes === true }, `Delete action item ${fullId}?`))) {
123
161
  console.log('Not deleted.');
124
162
  return undefined;
125
163
  }
126
- const r = await deleteActionItem(ctx.client, id);
164
+ const r = await deleteActionItem(ctx.client, fullId);
127
165
  if (program.opts().output === 'json') return formatDetail(r);
128
166
  printSuccess(`Deleted ${r.id}${r.title ? `: ${r.title}` : ''}`);
129
167
  return undefined;
@@ -4,6 +4,9 @@
4
4
 
5
5
  import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
6
6
  import { resolveEndpoint } from '../api/endpoints.js';
7
+ import { withStepUp } from '../core/stepUpPrompt.js';
8
+ import { announce, announceFailure } from '../core/announce.js';
9
+ import { resolveOrgId } from './helpers.js';
7
10
 
8
11
  export function registerAdminCommands(program, ctx) {
9
12
  const { client, auth } = ctx;
@@ -154,6 +157,38 @@ export function registerAdminCommands(program, ctx) {
154
157
  console.log(data.token);
155
158
  });
156
159
 
160
+ // ADMIN-VERIFY-EMAIL: accounts with no real mailbox (test/service accounts) can never click the
161
+ // emailed link, so they never see org invites. Instance-admin, human, step-up PIN (hidden prompt).
162
+ const users = admin
163
+ .command('users')
164
+ .description('Instance user administration');
165
+
166
+ users
167
+ .command('verify-email')
168
+ .description('Mark a user email verified (instance admin; asks for your step-up PIN; audited)')
169
+ .argument('<email>', 'the email address to mark verified')
170
+ .requiredOption('--reason <text>', 'why this address is verified without a mailbox (recorded in the audit log)')
171
+ .option('--user-id <id>', 'refuse unless the email belongs to this user id')
172
+ .action(async (email, opts) => {
173
+ auth.requireAuth();
174
+ const reason = String(opts.reason || '').trim();
175
+ if (!reason) throw new Error('--reason must not be empty');
176
+ const orgId = resolveOrgId(auth, program);
177
+ const ep = resolveEndpoint('admin.users.verify_email');
178
+ let data;
179
+ try {
180
+ data = await withStepUp(
181
+ (headers) => client.post(ep.path, { service: ep.service, headers, body: { email, reason, ...(opts.userId ? { user_id: opts.userId } : {}) } }),
182
+ { client, orgId, scope: 'override', io: ctx.stepUpIO, what: 'Verifying an email address' },
183
+ );
184
+ } catch (err) {
185
+ announceFailure('quox', `admin verify-email ${email}`, err);
186
+ throw err;
187
+ }
188
+ announce('quox', `admin verify-email ${email}`, { result: `verified · user ${data.user_id}` });
189
+ printSuccess(`Email ${data.email} is now verified. Reason recorded in the audit log.`);
190
+ });
191
+
157
192
  // Email / SMTP (QCPRO-SMTP): instance email onboarding parity
158
193
  const email = admin
159
194
  .command('email')
@@ -1322,6 +1322,21 @@ export function registerAgentAttachCommands(agent, ctx, program) {
1322
1322
  if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1323
1323
  });
1324
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
+
1325
1340
  agent
1326
1341
  .command('attach-heartbeat')
1327
1342
  .description("Heartbeat this session's attach lease, rotating the stored token if one is issued")
@@ -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