quoxcli 0.3.1 → 0.4.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 (188) hide show
  1. package/lib/api/endpoints/actions.js +15 -0
  2. package/lib/api/endpoints/admin.js +20 -0
  3. package/lib/api/endpoints/blog.js +16 -0
  4. package/lib/api/endpoints/chat.js +7 -0
  5. package/lib/api/endpoints/contactsq.js +4 -0
  6. package/lib/api/endpoints/devServerRegistry.js +25 -0
  7. package/lib/api/endpoints/engagement.js +26 -0
  8. package/lib/api/endpoints/evidence.js +20 -0
  9. package/lib/api/endpoints/hat.js +14 -0
  10. package/lib/api/endpoints/index.js +12 -0
  11. package/lib/api/endpoints/loops.js +26 -0
  12. package/lib/api/endpoints/mon.js +173 -0
  13. package/lib/api/endpoints/objectives.js +6 -0
  14. package/lib/api/endpoints/quoxseo.js +6 -0
  15. package/lib/api/endpoints/server.js +21 -0
  16. package/lib/api/endpoints/setup.js +6 -0
  17. package/lib/api/endpoints/v1.js +34 -0
  18. package/lib/commands/actions.js +81 -11
  19. package/lib/commands/agent.js +11 -4
  20. package/lib/commands/agentAttach.js +46 -7
  21. package/lib/commands/approvals.js +5 -0
  22. package/lib/commands/ask.js +3 -1
  23. package/lib/commands/blog.js +110 -0
  24. package/lib/commands/chat.js +127 -28
  25. package/lib/commands/chatCompanion.js +27 -0
  26. package/lib/commands/chatState.js +45 -0
  27. package/lib/commands/claude.js +3 -32
  28. package/lib/commands/contacts.js +85 -4
  29. package/lib/commands/contactsSendAt.js +21 -0
  30. package/lib/commands/engagement.js +90 -0
  31. package/lib/commands/evidence.js +154 -0
  32. package/lib/commands/ext.js +23 -0
  33. package/lib/commands/fleet.js +25 -0
  34. package/lib/commands/hat.js +76 -0
  35. package/lib/commands/image.js +68 -0
  36. package/lib/commands/kuma.js +1050 -0
  37. package/lib/commands/kumaSla.js +174 -0
  38. package/lib/commands/licence.js +39 -1
  39. package/lib/commands/loop.js +291 -0
  40. package/lib/commands/objective.js +48 -1
  41. package/lib/commands/org.js +21 -4
  42. package/lib/commands/provider.js +58 -0
  43. package/lib/commands/quoxseo.js +31 -0
  44. package/lib/commands/research.js +79 -0
  45. package/lib/commands/rows.js +9 -0
  46. package/lib/commands/run.js +18 -1
  47. package/lib/commands/scope.js +2 -2
  48. package/lib/commands/server.js +113 -0
  49. package/lib/commands/shortcut.js +41 -0
  50. package/lib/commands/skills.js +20 -4
  51. package/lib/commands/vm.js +2 -2
  52. package/lib/commands/youtube.js +172 -0
  53. package/lib/core/actionItems.js +115 -0
  54. package/lib/core/agentAlias.js +2 -0
  55. package/lib/core/agentFamilies.js +7 -0
  56. package/lib/core/client.js +10 -2
  57. package/lib/core/commandRegistrars.js +9 -0
  58. package/lib/core/config.js +1 -1
  59. package/lib/core/findingReconcile.js +90 -0
  60. package/lib/core/global-flags.js +1 -1
  61. package/lib/core/governedSession.js +1 -3
  62. package/lib/core/liveSessionsReport.js +1 -1
  63. package/lib/core/loopGlyph.js +97 -0
  64. package/lib/core/offload.js +36 -7
  65. package/lib/core/output.js +7 -7
  66. package/lib/core/sessionHeartbeat.js +12 -8
  67. package/lib/core/shortcut.js +83 -0
  68. package/lib/core/version.js +1 -1
  69. package/lib/core/vm.js +5 -0
  70. package/lib/dragon/companionView.js +34 -0
  71. package/lib/dragon/frames.js +118 -0
  72. package/lib/dragon/jobs.js +123 -0
  73. package/lib/dragon/renderWorker.js +15 -0
  74. package/lib/dragon/select.js +72 -0
  75. package/lib/evidence/bundleExport.js +47 -0
  76. package/lib/evidence/bundleVerify.js +77 -0
  77. package/lib/quoxon/EXTENSIONS.md +23 -0
  78. package/lib/quoxon/api.js +192 -6
  79. package/lib/quoxon/app.js +655 -89
  80. package/lib/quoxon/approvalFacts.js +8 -6
  81. package/lib/quoxon/approvalSentence.js +44 -0
  82. package/lib/quoxon/approvalsView.js +63 -4
  83. package/lib/quoxon/commandShort.js +15 -3
  84. package/lib/quoxon/commands.js +75 -10
  85. package/lib/quoxon/countdownLine.js +10 -1
  86. package/lib/quoxon/deck.js +30 -0
  87. package/lib/quoxon/demo.autoplay.js +1 -1
  88. package/lib/quoxon/demo.js +3 -1
  89. package/lib/quoxon/dividerAocl.js +4 -3
  90. package/lib/quoxon/dividerRule.js +3 -3
  91. package/lib/quoxon/dragonCompanion.js +255 -0
  92. package/lib/quoxon/dragonModes.js +1 -0
  93. package/lib/quoxon/dragonMoments.js +82 -0
  94. package/lib/quoxon/dragonReads.js +68 -0
  95. package/lib/quoxon/dragonView.js +2 -2
  96. package/lib/quoxon/events.js +24 -3
  97. package/lib/quoxon/extensions.js +276 -0
  98. package/lib/quoxon/inboxView.js +79 -0
  99. package/lib/quoxon/installCli.js +79 -0
  100. package/lib/quoxon/keys.js +2 -2
  101. package/lib/quoxon/loopCard.js +456 -0
  102. package/lib/quoxon/loopCards.js +180 -0
  103. package/lib/quoxon/loopStream.js +87 -0
  104. package/lib/quoxon/main.js +12 -3
  105. package/lib/quoxon/mouse.js +12 -1
  106. package/lib/quoxon/objectiveOffer.js +82 -0
  107. package/lib/quoxon/objects/index.js +22 -4
  108. package/lib/quoxon/objects/model.js +33 -0
  109. package/lib/quoxon/objects/proof.js +12 -4
  110. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  111. package/lib/quoxon/onboarding/view.js +1 -1
  112. package/lib/quoxon/orgSwitch.js +27 -1
  113. package/lib/quoxon/planSteps.js +51 -0
  114. package/lib/quoxon/print.js +6 -3
  115. package/lib/quoxon/qdragonFly.js +7 -6
  116. package/lib/quoxon/qmotion.js +17 -0
  117. package/lib/quoxon/render.js +130 -33
  118. package/lib/quoxon/roster.js +26 -12
  119. package/lib/quoxon/rows.js +244 -1
  120. package/lib/quoxon/serverChip.js +42 -0
  121. package/lib/quoxon/session.js +10 -0
  122. package/lib/quoxon/slash/cliCommand.js +42 -1
  123. package/lib/quoxon/slash/cliLeaves.json +76 -1
  124. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  125. package/lib/quoxon/slash/palette.js +1 -1
  126. package/lib/quoxon/slash/qpeCmd.js +35 -0
  127. package/lib/quoxon/slash/restart.js +25 -1
  128. package/lib/quoxon/slash/resume.js +18 -1
  129. package/lib/quoxon/slash/todoCmd.js +122 -0
  130. package/lib/quoxon/suggestCommand.js +33 -2
  131. package/lib/quoxon/toolCards.js +30 -0
  132. package/lib/quoxon/whatsnew.jsonl +45 -0
  133. package/lib/quoxon/windowOrg.js +11 -1
  134. package/lib/server/devServers.js +137 -0
  135. package/package.json +2 -1
  136. package/vendor/dragonlab/SOURCE +4 -0
  137. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  138. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  139. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  140. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  141. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  142. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  143. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  144. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  145. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  146. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  147. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  148. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  149. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  150. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  151. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  152. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  153. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  154. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  155. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  156. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  157. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  158. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  159. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  160. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  161. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  162. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  163. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  164. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  165. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  166. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  167. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  168. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  169. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  170. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  171. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  172. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  173. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  174. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  175. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  176. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  177. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  178. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  179. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  180. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  181. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  182. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  183. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  184. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  185. package/vendor/dragonlab/lib/power-effects.js +294 -0
  186. package/vendor/dragonlab/lib/power-registry.js +74 -0
  187. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  188. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -7,6 +7,7 @@ import { formatOutput, formatDetail, printSuccess, printWarning } from '../core/
7
7
  import { otherProfileUsers, listProcs } from '../core/profileUsers.js';
8
8
  import { resolveOrgId } from './helpers.js';
9
9
  import { resolveEndpoint } from '../api/endpoints.js';
10
+ import { numberedOrgLines, pickOrgByInput, sharedSwitchHint } from '../quoxon/orgSwitch.js';
10
11
 
11
12
  export function registerOrgCommands(program, ctx) {
12
13
  const { client, auth, config } = ctx;
@@ -90,9 +91,26 @@ export function registerOrgCommands(program, ctx) {
90
91
  org
91
92
  .command('switch')
92
93
  .description('Switch active organization context')
93
- .argument('<orgId>', 'organization ID or slug')
94
- .action(async (orgId) => {
94
+ .argument('[orgId]', 'organization ID, slug, or the number from the list (omit to pick from a numbered list)')
95
+ .action(async (orgIdArg) => {
95
96
  auth.requireAuth();
97
+ let orgId = orgIdArg;
98
+ if (!orgId || /^\d+$/.test(orgId)) {
99
+ const l = await client.get(resolveEndpoint('orgs.list').path, { service: resolveEndpoint('orgs.list').service });
100
+ const orgs = Array.isArray(l) ? l : (l?.organizations || l?.orgs || l?.data || []);
101
+ if (!orgs.length) throw new QuoxError('No organisations returned for this login.', 1);
102
+ if (!orgId) {
103
+ const curId = (() => { try { return auth.getOrgId(); } catch { return null; } })();
104
+ for (const line of numberedOrgLines(orgs, curId)) console.log(line);
105
+ if (!process.stdin.isTTY) throw new QuoxError('Pass a number, slug or ID: quox org switch <number>.', 1);
106
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout });
107
+ try { orgId = await rl.question('Switch to which? (number, slug, or Enter to cancel) '); } finally { rl.close(); }
108
+ if (!orgId.trim()) { console.log('Not switched.'); return; }
109
+ }
110
+ const pick = pickOrgByInput(orgId, orgs);
111
+ if (pick.org) orgId = pick.org.id;
112
+ else if (/^\d+$/.test(orgId)) throw new QuoxError(`No org numbered ${orgId}. Run \`quox org switch\` to see the list.`, 1);
113
+ }
96
114
  // The server matches memberships by org ID only. Accept a slug (or exact name) by
97
115
  // resolving it against the caller's org list first; a 26-char ULID passes through.
98
116
  let targetId = orgId;
@@ -126,8 +144,7 @@ export function registerOrgCommands(program, ctx) {
126
144
  const name = config.getActiveProfileName?.();
127
145
  const others = name ? otherProfileUsers({ profile: name, defaultProfile: config._data?.activeProfile, procs: listProcs(), selfPids: [process.pid, process.ppid] }) : [];
128
146
  if (others.length) {
129
- printWarning(`${others.length} other quox session${others.length === 1 ? '' : 's'} use profile "${name}" (pid ${others.map(p => p.pid).join(', ')}). `
130
- + 'They follow this org on their next token refresh or restart. To move one window only, use /org switch inside it.');
147
+ printWarning(sharedSwitchHint({ name, pids: others.map(p => p.pid) }));
131
148
  }
132
149
  } catch { /* best effort: never fail the switch over the warning */ }
133
150
  });
@@ -17,6 +17,9 @@ import { createApi, isInteractiveHuman } from '../quoxon/api.js';
17
17
  import { PROVIDERS, providerFor, looksLikeKey, connectionLines, connectFlow, connectOllamaFlow, connectCompatFlow } from '../quoxon/connect.js';
18
18
  import { needsConsent, consentCovers, connectConsentNote, providerLabel } from '../quoxon/consent.js';
19
19
  import { readStdin } from '../quoxon/print.js';
20
+ import { INSTALL_TOOLS, toolFor, planLine, offerQuestion, runInstall, isAgentCaller } from '../quoxon/installCli.js';
21
+ import { YN_OPTIONS, waitForDecision } from '../core/askDecision.js';
22
+ import { detectCallerSource } from '../core/callerSource.js';
20
23
 
21
24
  const KINDS = Object.keys(PROVIDERS);
22
25
  const SAFE_ROW = ['id', 'integration_type', 'label', 'is_default', 'test_status', 'last_tested_at', 'created_at'];
@@ -25,6 +28,8 @@ const CONTAINER_NOTE = 'Quox runs in containers: 127.0.0.1 or localhost means th
25
28
  function defaultIO() {
26
29
  return {
27
30
  isTTY: () => isInteractiveHuman(),
31
+ isAgent: () => isAgentCaller(process.env),
32
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28
33
  readStdin: () => readStdin(),
29
34
  promptSecret: async (message) => {
30
35
  const { default: inquirer } = await import('inquirer');
@@ -129,6 +134,59 @@ export function registerProviderCommands(program, ctx) {
129
134
  await consentAfter(p.type);
130
135
  });
131
136
 
137
+ // ── provider install (LAZY-AI-CLI) ────────────────────────────────────────
138
+ // The user's own server downloads the vendor's npm package (never a binary Quox ships). A person decides: y/N on
139
+ // a terminal, a high-risk approval card when an agent runs it (an agent's --yes is not a person's yes).
140
+ provider
141
+ .command('install')
142
+ .description('Install Claude Code or Codex on this Quox server from npm (the vendor\'s own package), so Claude sign-in and Codex work. Instance admin only')
143
+ .argument('<tool>', `what to install: ${Object.keys(INSTALL_TOOLS).join(', ')}`)
144
+ .option('--yes', 'install without the y/N question (a script; an agent still gets an approval card)')
145
+ .action(async (toolArg, opts) => {
146
+ auth.requireAuth();
147
+ const tool = toolFor(toolArg);
148
+ if (!tool) throw new Error(`No installable CLI called "${toolArg}". Use one of: ${Object.keys(INSTALL_TOOLS).join(', ')}`);
149
+ const t = INSTALL_TOOLS[tool];
150
+ const a = api();
151
+ const st = await a.providerCliStatus();
152
+ const cur = st?.ok ? st.tools.find((x) => x.id === tool) : null;
153
+ if (cur?.installed) { printInfo(`✔ ${t.name} is already installed on this server (${cur.version || 'version unknown'}${cur.location ? `, ${cur.location}` : ''}). Nothing to do.`); return; }
154
+ const plan = await a.providerCliPlan(tool);
155
+ if (!plan?.ok) {
156
+ if (plan?.status === 404 || plan?.status === 405) throw new Error(`This Quox server is too old to install ${t.name} for you. Update it first: quox server update`);
157
+ throw new Error(plan?.status === 403 ? (plan.error || 'Only an instance admin can install software on this server.') : `Could not plan the install: ${plan?.error || 'no answer'}`);
158
+ }
159
+ printInfo(planLine(plan));
160
+ let approvalId = null;
161
+ if (io.isAgent()) {
162
+ const orgId = resolveOrgId(auth, program);
163
+ const src = (() => { try { return detectCallerSource(process.env); } catch { return {}; } })();
164
+ const session = process.env.QUOX_SESSION || src?.label || 'agent';
165
+ const ep = resolveEndpoint('inbox.decision.create', { orgId });
166
+ const filed = await client.post(ep.path, { service: ep.service, body: {
167
+ session, title: `Install ${t.name} on this server?`, why: planLine(plan), options: YN_OPTIONS, recommend: 'y', high_risk: true,
168
+ justification: `${session} asked to install ${t.vendor}'s ${plan.package} ${plan.version} from npm on this Quox server, so ${t.name} works. It runs as the Quox service user, never root, and every install is audited.`,
169
+ agent: process.env.QUOX_AGENT_ID || undefined,
170
+ } });
171
+ const id = filed?.decision?.id;
172
+ if (!id) throw new Error('Could not file the approval card. Nothing was installed.');
173
+ printInfo(`An agent is running this, so a person decides: approval card ${id} is in /approvals (needs the step-up PIN).`);
174
+ const r = await waitForDecision({
175
+ poll: (w) => { const g = resolveEndpoint('inbox.decision.get', { orgId, id }); return client.get(g.path, { service: g.service, query: { wait: w }, timeout: (w + 15) * 1000, retries: 0 }); },
176
+ log: (m) => process.stderr.write(`${m}\n`),
177
+ });
178
+ if (r.decision?.state !== 'answered' || r.decision?.answer?.option !== 'y') { printInfo(`Not installed: the card was not approved. Nothing was downloaded.`); process.exitCode = 2; return; }
179
+ approvalId = id;
180
+ } else if (!opts.yes) {
181
+ if (!io.isTTY()) throw new Error(`Run again with --yes to install ${t.name}, or run it in a terminal to be asked. Nothing was installed.`);
182
+ if (!(await io.confirm(offerQuestion(tool, plan)))) { printInfo('Not installed. Nothing was downloaded.'); return; }
183
+ }
184
+ printInfo(`◐ installing ${t.name} ${plan.version} on the server (this takes a minute or two)`);
185
+ const r = await runInstall(a, tool, { approvalId, onProgress: printInfo, sleep: io.sleep || undefined });
186
+ if (!r.ok) throw new Error(r.lines.join(' '));
187
+ for (const l of r.lines) printInfo(l);
188
+ });
189
+
132
190
  // ── provider test ──────────────────────────────────────────────────────────
133
191
  provider
134
192
  .command('test')
@@ -231,6 +231,37 @@ export function registerQuoxseoCommands(program, ctx) {
231
231
  } catch (err) { annotate(err); }
232
232
  });
233
233
 
234
+ s.command('queries')
235
+ .description('Ranked Search Console query rows: query, page, impressions, clicks, CTR, position')
236
+ .option('--sort <s>', 'impressions | clicks | position-opportunity (high impressions at position 5-20)', 'impressions')
237
+ .option('--limit <n>', 'max rows (default 25, max 100)', '25')
238
+ .option('--filter <text>', 'only queries containing this text, e.g. "conversation"')
239
+ .option('--gaps', 'topic-gap view: queries with impressions that no crawled page targets')
240
+ .action(async (opts) => {
241
+ try {
242
+ const qs = new URLSearchParams();
243
+ qs.set('sort', opts.sort);
244
+ qs.set('limit', opts.limit);
245
+ if (opts.filter) qs.set('filter', opts.filter);
246
+ if (opts.gaps) qs.set('gaps', '1');
247
+ const data = await get(client, 'quoxseo.queries.rows', { query: `?${qs}` });
248
+ if (jsonOut(data)) return;
249
+ if (!data.crawl_id) { console.log('No Search Console query data yet; the ingest with Search Console access has not run.'); return; }
250
+ const p = data.period;
251
+ const when = p && p.start ? `${p.start}..${p.end}` : 'period unknown';
252
+ const rows = data.rows || [];
253
+ if (data.view === 'topic-gaps') {
254
+ console.log(`topic gaps (impressions, no page targets the query) · ${when} · ${rows.length} of ${data.total_gaps}`);
255
+ } else {
256
+ console.log(`queries by ${data.sort}${opts.filter ? ` matching "${opts.filter}"` : ''} · ${when} · ${rows.length} of ${data.matched}`);
257
+ }
258
+ if (!rows.length) { console.log('No rows.'); return; }
259
+ for (const r of rows) {
260
+ console.log(`${r.query} | ${r.page} impr ${r.impressions} clk ${r.clicks} ctr ${(r.ctr * 100).toFixed(1)}% pos ${r.position ?? '-'}`);
261
+ }
262
+ } catch (err) { annotate(err); }
263
+ });
264
+
234
265
  s.command('coverage')
235
266
  .description("Google index coverage: what's indexed, and where Google disagrees with the page")
236
267
  .option('--filter <f>', 'indexed | not-indexed')
@@ -0,0 +1,79 @@
1
+ /**
2
+ * quox research domains list|add <domain>|remove <domain>
3
+ *
4
+ * The curated trusted research list: hosts the research agent may web_read after a web_search has run.
5
+ * Stored as the org setting RESEARCH_TRUSTED_DOMAINS through the SAME governed settings path as
6
+ * `quox settings set` (PATCH /api/v1/platform/settings/flags/:key, human org admin, witnessed there).
7
+ * Unset or empty means the built-in default list, which add/remove seed from so a first edit never drops it.
8
+ */
9
+
10
+ import { resolveEndpoint } from '../api/endpoints.js';
11
+
12
+ const KEY = 'RESEARCH_TRUSTED_DOMAINS';
13
+
14
+ // Mirror of services/collector/lib/researchTrustedDefaults.js DEFAULT_TRUSTED_SETTING (a test keeps them identical).
15
+ export const DEFAULT_DOMAINS = (`nist.gov,cisa.gov,cve.org,=cve.mitre.org,=cveawg.mitre.org,=www.kernel.org,=developers.cloudflare.com,=attack.mitre.org,=www.python.org,=docs.python.org,=peps.python.org,=go.dev,=tip.golang.org,` +
16
+ `=nginx.org/en/,=nginx.org/license,=www.postgresql.org/docs/,=www.postgresql.org/support/security/,=www.postgresql.org/about/news/,` +
17
+ `openssl-library.org,openssl.org,openssh.com,=kubernetes.io,msrc.microsoft.com,=www.rfc-editor.org,=datatracker.ietf.org/doc/,` +
18
+ `security-tracker.debian.org,ubuntu.com/security,github.com/advisories/ghsa-`).split(',');
19
+
20
+ const tokens = (v) => String(v || '').split(/[\s,]+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
21
+
22
+ function cleanDomain(raw) {
23
+ const eq = String(raw || '').trim().startsWith('=') ? '=' : '';
24
+ const t = String(raw || '').trim().replace(/^=/, '').toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
25
+ const host = t.split('/')[0].replace(/\.+$/, '');
26
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/.test(host)) return null; // IP literals and junk refused
27
+ return eq + (t.includes('/') ? host + t.slice(t.indexOf('/')) : host);
28
+ }
29
+
30
+ async function effective(client) {
31
+ const ep = resolveEndpoint('v1.settings.list');
32
+ const data = await client.get(ep.path, { service: ep.service });
33
+ const f = (data.flags || []).find((x) => x.key === KEY);
34
+ const list = tokens(f && f.value);
35
+ const isOrg = !!(f && f.source === 'setting' && list.length);
36
+ return { list: isOrg ? list : DEFAULT_DOMAINS.slice(), source: isOrg ? 'org setting' : 'built-in default' };
37
+ }
38
+
39
+ async function write(client, list) {
40
+ const ep = resolveEndpoint('v1.settings.set', { key: KEY });
41
+ return client.patch(ep.path, { service: ep.service, body: { value: list.join(',') } });
42
+ }
43
+
44
+ export function registerResearchCommands(program, ctx) {
45
+ const { client } = ctx;
46
+ const r = program.command('research').description('Governed web research settings');
47
+ const d = r.command('domains').description('Trusted research domains: hosts agents may read after a web search (org setting)');
48
+
49
+ d.command('list', { isDefault: true })
50
+ .description('Show the effective trusted research list and where it comes from')
51
+ .action(async () => {
52
+ const { list, source } = await effective(client);
53
+ process.stdout.write(`source: ${source}\n`);
54
+ for (const e of list) process.stdout.write(` ${e}\n`);
55
+ });
56
+
57
+ d.command('add <domain>')
58
+ .description('Add a domain (or host/path-prefix; a leading = means exact host only) to the org list (human org admin)')
59
+ .action(async (domain) => {
60
+ const dom = cleanDomain(domain);
61
+ if (!dom) { process.stderr.write(`Not added: "${domain}" is not a domain name (IP literals are never trusted)\n`); process.exitCode = 1; return; }
62
+ const { list } = await effective(client);
63
+ if (list.includes(dom)) { process.stdout.write(`${dom} is already on the list\n`); return; }
64
+ await write(client, [...list, dom]);
65
+ process.stdout.write(`added ${dom} (${list.length + 1} entries, org setting)\n`);
66
+ });
67
+
68
+ d.command('remove <domain>')
69
+ .description('Remove a domain from the org list (human org admin)')
70
+ .action(async (domain) => {
71
+ const dom = cleanDomain(domain);
72
+ const { list } = await effective(client);
73
+ if (!dom || !list.includes(dom)) { process.stderr.write(`Not removed: ${domain} is not on the list\n`); process.exitCode = 1; return; }
74
+ const next = list.filter((e) => e !== dom);
75
+ if (!next.length) { process.stderr.write('Not removed: that would empty the list, which means the built-in default. Add another entry first.\n'); process.exitCode = 1; return; }
76
+ await write(client, next);
77
+ process.stdout.write(`removed ${dom} (${next.length} entries, org setting)\n`);
78
+ });
79
+ }
@@ -38,9 +38,16 @@ export const CONTRACT = {
38
38
  trend: 'optional. up, down, flat, or a number (the change: 3 shows ▲3, -2 shows ▼2).',
39
39
  link: 'optional. An http(s) URL to look at for more; shown by quox rows test and /row test.'
40
40
  },
41
+ // TUI-QPE 2026-10-19: additive v1.1 fields, kept apart so the v1 field list stays exactly as published.
42
+ fields_v1_1: {
43
+ cells: 'optional. [{parts: [{text, tone, bold, keep, group}], keep}]: one row in several colours. A narrow window drops the lowest keep first (a whole part, its group, or a cell; no keep = never dropped), never half a word.',
44
+ grid: 'optional. A short name; rows with the same grid line their cells up in columns (quox-qpe-row uses "qpe").',
45
+ min_cols: 'optional. A whole number; the row hides when the drawn width (the terminal width less one) is under it.'
46
+ },
41
47
  example: { label: 'Approvals', value: 3, text: 'waiting for you', tone: 'warn', trend: 1, link: 'https://quox.example/approvals' },
42
48
  rules: [
43
49
  'Plain text output counts as {"text": "<the text>"}.',
50
+ 'One source can draw several lines: {"text": "<short line for older clients>", "grid": "...", "lines": [<status row>, ...]} (up to 4 lines in all above the status bar).',
44
51
  'Terminal escapes and control characters are removed before anything is drawn.',
45
52
  'A row definition never carries a secret: use a quox or plugin row (your session is used) or a local command that reads its own secret.',
46
53
  'Command rows run on the machine that holds them only. They are never synced, shared or imported without a person saying yes to the exact command.'
@@ -347,6 +354,8 @@ export function registerRowsCommands(program, ctx) {
347
354
  if (json()) return say(JSON.stringify(CONTRACT, null, 2));
348
355
  say(tint('heading').bold('Status-row contract v1'));
349
356
  for (const [k, v] of Object.entries(CONTRACT.fields)) say(` ${tint('id').bold(k.padEnd(6))} ${v}`);
357
+ say(tint('label')('v1.1 (colours and narrow windows):'));
358
+ for (const [k, v] of Object.entries(CONTRACT.fields_v1_1)) say(` ${tint('id').bold(k.padEnd(8))} ${v}`);
350
359
  say(tint('label')('Example:'));
351
360
  say(' ' + JSON.stringify(CONTRACT.example));
352
361
  say(tint('label')('Draws as:'));
@@ -90,7 +90,24 @@ export function registerRunCommands(program, ctx) {
90
90
  }
91
91
  }
92
92
 
93
- formatDetail({ ...data, source }, { format: program.opts().output });
93
+ // RUNS: surface WHICH step failed and the retry hint at the top level, so a failed
94
+ // run no longer reads as a bare "fetch failed" with no step.
95
+ const detail = { ...data, source };
96
+ const d = data.error && typeof data.error.details === 'object' && data.error.details ? data.error.details : null;
97
+ if (data.status === 'failed' && d && d.nodeId) {
98
+ detail.failedStep = d.nodeName && d.nodeName !== d.nodeId ? `${d.nodeName} (${d.nodeId})` : String(d.nodeId);
99
+ }
100
+ if (data.status === 'failed' && data.error && data.error.message) {
101
+ // formatDetail truncates long nested values; show the full code + network detail here.
102
+ detail.failure = `${data.error.code || 'ERROR'}: ${data.error.message}`;
103
+ }
104
+ if (data.metadata && data.metadata.retryable) {
105
+ detail.retry = data.metadata.retryExecutionId
106
+ ? `auto-retried as ${data.metadata.retryExecutionId}`
107
+ : `retryable: ${data.metadata.retryHint || `quox run create --workflow ${data.workflowId}`}`;
108
+ }
109
+
110
+ formatDetail(detail, { format: program.opts().output, full: true });
94
111
  });
95
112
 
96
113
  run
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Scopes are read-only projections of the org/division/department/team
7
7
  * hierarchy — there is no backend write route for scopes. To create,
8
- * update, or delete a scope, use `quox org`, `quox division`,
8
+ * update, or delete a scope, use `quox org`, `quox org divisions`,
9
9
  * `quox department`, or `quox team` instead.
10
10
  */
11
11
 
@@ -18,7 +18,7 @@ export function registerScopeCommands(program, ctx) {
18
18
 
19
19
  const scope = program
20
20
  .command('scope')
21
- .description('View scopes (read-only — use `quox org`/`quox division`/`quox team` to manage)');
21
+ .description('View scopes (read-only — use `quox org`/`quox org divisions`/`quox department`/`quox team` to manage)');
22
22
 
23
23
  scope
24
24
  .command('list')
@@ -24,6 +24,7 @@ import { planUninstall, runUninstall } from '../server/uninstall.js';
24
24
  import { serverChecks } from '../server/doctor.js';
25
25
  import { writeDiagBundle } from '../server/diag.js';
26
26
  import { latestSnapshot, listSnapshots } from '../server/snapshot.js';
27
+ import { loadRegistry, saveServer, buildRows, planGo, hereIdentity, registryPath } from '../server/devServers.js';
27
28
 
28
29
  const icon = (s) => (s === 'pass' ? chalk.green('✔') : s === 'warn' ? chalk.yellow('⚠') : s === 'skip' ? chalk.dim('·') : chalk.red('✖'));
29
30
 
@@ -288,5 +289,117 @@ export function registerServerCommands(program, ctx = {}) {
288
289
  console.log(`Private-network targets: ${r.enabled ? 'on' : 'off'} ${chalk.dim('(quox server config lan-targets on|off)')}`);
289
290
  });
290
291
 
292
+ // DEVFLEET CX-2: `quox server roles` — the server roles registry (role -> url). A role (brain today) can be pointed
293
+ // off-box so the service runs on another host without an env edit; resolveServiceUrl reads the registry before env.
294
+ // list is readable by any instance admin; set/unset mutate instance-wide state so are instance-OWNER (superadmin) only.
295
+ const roles = server.command('roles').description('Point a server role (e.g. brain) at a URL, so a service can run off-box without an env change');
296
+ roles.command('list')
297
+ .description('Show each role, its URL, and whether it comes from the registry or the environment (read-only)')
298
+ .action(async () => {
299
+ const { client, auth } = ctx;
300
+ auth?.requireAuth?.();
301
+ const ep = resolveEndpoint('server.roles.list');
302
+ const r = await client.get(ep.path, { service: ep.service });
303
+ if (json()) { console.log(JSON.stringify(r, null, 2)); return; }
304
+ if (r.corrupt) console.log(chalk.yellow(` ⚠ the roles store is unreadable (${r.error}); showing environment values only
305
+ `));
306
+ for (const row of (r.roles || [])) {
307
+ const where = row.source === 'registry' ? chalk.green('registry') : (row.source === 'env' ? chalk.dim('environment') : chalk.dim('unset'));
308
+ console.log(` ${row.role.padEnd(10)} ${row.url || chalk.dim('(none)')} ${chalk.dim(`[${where}]`)}`);
309
+ }
310
+ if (!(r.roles || []).length) console.log(chalk.dim(' no roles'));
311
+ });
312
+ roles.command('set')
313
+ .description('Point a role at a URL (instance owner only). Use --test to probe it for health and version first')
314
+ .argument('<role>', 'the role, e.g. brain')
315
+ .argument('<url>', 'the URL the role should resolve to, e.g. http://10.1.2.3:3106')
316
+ .option('--test', 'probe <url> for health and version before storing it; abort if it is unreachable')
317
+ .action(async (role, url, o) => {
318
+ const { client, auth } = ctx;
319
+ auth?.requireAuth?.();
320
+ if (o.test) {
321
+ const tp = resolveEndpoint('server.roles.test', { role });
322
+ const t = await client.post(tp.path, { service: tp.service, body: { url } });
323
+ if (!t.success) { const e = new Error(`--test failed: ${t.error || `status ${t.status}`}; not storing`); e.exitCode = 1; throw e; }
324
+ if (!json()) console.log(chalk.dim(` probe ok: status ${t.status}${t.version ? `, version ${t.version}` : ''}`));
325
+ }
326
+ const ep = resolveEndpoint('server.roles.set', { role });
327
+ const r = await client.put(ep.path, { service: ep.service, body: { url } });
328
+ if (json()) { console.log(JSON.stringify(r, null, 2)); return; }
329
+ console.log(chalk.green(` ✔ role ${r.role} -> ${r.url}`));
330
+ });
331
+ roles.command('unset')
332
+ .description('Remove a role from the registry so it falls back to the environment value (instance owner only)')
333
+ .argument('<role>', 'the role to remove')
334
+ .action(async (role) => {
335
+ const { client, auth } = ctx;
336
+ auth?.requireAuth?.();
337
+ const ep = resolveEndpoint('server.roles.unset', { role });
338
+ const r = await client.delete(ep.path, { service: ep.service });
339
+ if (json()) { console.log(JSON.stringify(r, null, 2)); return; }
340
+ console.log(chalk.green(r.removed ? ` ✔ role ${role} removed; falling back to the environment value` : ` role ${role} was not set`));
341
+ });
342
+
343
+ // DEVFLEET: the registered dev servers. Bastion supplies address + state, the org's dev-server registry supplies purpose/project/workdir.
344
+ const regPath = () => deps.registryPath || registryPath(env);
345
+ async function devRows() {
346
+ const { client, auth } = ctx;
347
+ auth?.requireAuth?.();
348
+ const ep = resolveEndpoint('v1.fleet.hosts.list');
349
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 5000 });
350
+ const hosts = d?.hosts || d;
351
+ if (!Array.isArray(hosts)) throw new Error('could not read the Bastion host registry (quox fleet hosts)');
352
+ { const reg = await loadRegistry({ client, path: regPath() }); return buildRows(hosts, { servers: reg.servers, defaults: reg.defaults }, deps.here || hereIdentity()); }
353
+ }
354
+ server.command('list')
355
+ .description('The registered dev servers: purpose, project, reachable, and which one this is (read-only)')
356
+ .action(async () => {
357
+ const rows = await devRows();
358
+ if (json()) { console.log(JSON.stringify(rows.map(({ ip, user, jump, ...r }) => r), null, 2)); return; }
359
+ if (!rows.length) { console.log(chalk.dim(` no dev servers registered. Add one: quox server mark <host> --purpose "main dev"`)); return; }
360
+ const showDeploy = rows.some(r => r.lastDeploy);
361
+ const cell = (v) => (v == null || v === '' ? '-' : String(v));
362
+ const w = (k) => Math.max(k.length, ...rows.map(r => cell(r[k]).length));
363
+ const cols = ['name', 'purpose', 'project', 'repo'];
364
+ const widths = Object.fromEntries(cols.map(k => [k, w(k)]));
365
+ console.log(chalk.bold(` ${cols.map(k => k.toUpperCase().padEnd(widths[k])).join(' ')} REACHABLE${showDeploy ? ' LAST DEPLOY' : ''}`));
366
+ for (const r of rows) {
367
+ const line = `${cols.map(k => cell(r[k]).padEnd(widths[k])).join(' ')} ${(r.reachable ? 'yes' : 'no').padEnd(9)}${showDeploy ? ` ${cell(r.lastDeploy)}` : ''}`;
368
+ console.log(` ${line}${r.current ? chalk.cyan(' <- this server') : ''}`);
369
+ }
370
+ });
371
+ server.command('mark')
372
+ .description('Record what a dev server is for (org registry on the Quox server, additive): purpose, project, repo, workdir, ci')
373
+ .argument('<name>', 'the Bastion host id, e.g. web01')
374
+ .option('--purpose <text>', 'what this server is for, e.g. "main dev"')
375
+ .option('--project <text>').option('--repo <text>').option('--workdir <path>', 'where go starts quox on that server')
376
+ .option('--ci <text>', 'CI or auto-deploy note or URL').option('--user <user>', 'ssh user (default control)').option('--jump <host>', 'ssh ProxyJump alias (default: the registry defaults.jump, else a direct ssh)')
377
+ .option('--last-deploy <text>', 'last deploy note (shown only if any server has one)')
378
+ .action(async (name, o) => {
379
+ ctx.auth?.requireAuth?.();
380
+ const e = await saveServer({ client: ctx.client, name, fields: { ...o, lastDeploy: o.lastDeploy }, path: regPath() });
381
+ if (json()) { console.log(JSON.stringify({ name, ...e }, null, 2)); return; }
382
+ console.log(chalk.green(` ✔ ${name} recorded${e.purpose ? ` as ${e.purpose}` : ''}`));
383
+ });
384
+ server.command('go')
385
+ .description('Open a Quox window on a registered dev server (ssh via your own config and the Bastion jump host, then quox in its workdir)')
386
+ .argument('<name>', 'a server from `quox server list`')
387
+ .option('--dry-run', 'print what would run and run nothing')
388
+ .action(async (name, o) => {
389
+ const rows = await devRows();
390
+ const plan = planGo(rows.find(r => r.name === name));
391
+ if (plan.error) {
392
+ const known = rows.map(r => r.name).join(', ') || 'none registered';
393
+ const e = new Error(plan.error === 'unknown server' ? `unknown server "${name}". Registered: ${known}` : plan.error);
394
+ e.exitCode = 1; throw e;
395
+ }
396
+ console.log(chalk.dim(` going to ${name}: ${plan.text}`));
397
+ if (dry(o)) return;
398
+ const spawnImpl = deps.spawnSync || (await import('node:child_process')).spawnSync;
399
+ const r = spawnImpl(plan.cmd, plan.args, { stdio: 'inherit', cwd: plan.cwd });
400
+ if (r?.error) { const e = new Error(`could not start ${plan.cmd}: ${r.error.message}`); e.exitCode = 1; throw e; }
401
+ if (r?.status) process.exitCode = r.status;
402
+ });
403
+
291
404
  return server;
292
405
  }
@@ -0,0 +1,41 @@
1
+ // `quox shortcut add|remove`: a short command (default `q`) for quox. Local only; never shadows an existing command.
2
+ import chalk from 'chalk';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { addShortcut, removeShortcut, DEFAULT_NAME } from '../core/shortcut.js';
5
+ import { askText } from '../quoxon/ask.js';
6
+
7
+ async function askYes(line) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ try { return /^y(es)?$/i.test((await rl.question(`${line} `)).trim()); } finally { rl.close(); }
10
+ }
11
+
12
+ export function registerShortcutCommand(program, ctx = {}) {
13
+ const d = ctx.shortcutDeps || {};
14
+ const out = d.log || ((l) => console.log(l));
15
+ const sc = program.command('shortcut')
16
+ .description('A short command for quox, like q (add / remove); refuses if the name already runs something else, never uses sudo');
17
+ sc.command('add')
18
+ .description('Make a "q" command that runs quox: a symlink in a directory you own that is on PATH. Refuses (and says what it found) if q already resolves to something else on PATH; never shadows an existing command, no sudo, no shell rc edits. Safe to repeat.')
19
+ .option('--name <name>', 'the short command name', DEFAULT_NAME)
20
+ .option('-y, --yes', 'create without asking (needed in a non-interactive shell)')
21
+ .action(async (o) => {
22
+ const name = o.name || DEFAULT_NAME;
23
+ const yes = o.yes || program.opts().yes;
24
+ if (!yes) {
25
+ const tty = d.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
26
+ if (!tty) { out('Not created: this needs a yes. Re-run with quox shortcut add --yes in a non-interactive shell.'); process.exitCode = 1; return; }
27
+ if (!(await (d.ask || askYes)(askText(`Make \`${name}\` run quox?`, { keys: 'y/N' })))) { out('Not created. Nothing was changed.'); return; }
28
+ }
29
+ const r = (d.add || addShortcut)({ name, ...(d.opts || {}) });
30
+ out(r.ok ? chalk.green(`✔ ${r.message}`) : chalk.red(`✖ ${r.message}`));
31
+ if (!r.ok) process.exitCode = 1;
32
+ });
33
+ sc.command('remove')
34
+ .description('Remove the short command, only if it is a link to this quox (anything else named q is left alone)')
35
+ .option('--name <name>', 'the short command name', DEFAULT_NAME)
36
+ .action(async (o) => {
37
+ const r = (d.remove || removeShortcut)({ name: o.name || DEFAULT_NAME, ...(d.opts || {}) });
38
+ out(r.ok ? r.message : chalk.red(`✖ ${r.message}`));
39
+ if (!r.ok) process.exitCode = 1;
40
+ });
41
+ }
@@ -585,11 +585,27 @@ export function registerSkillsCommands(program, ctx) {
585
585
  sealed
586
586
  .command('show')
587
587
  .description('Show a sealed skill\'s manifest (name, description, input contract, content hash). Manifest only.')
588
- .argument('<sealedId>', 'sealed skill ID')
589
- .action(async (sealedId) => {
588
+ .argument('<sealedId>', 'sealed skill ID or name')
589
+ .action(async (idOrName) => {
590
590
  auth.requireAuth();
591
- const ep = resolveEndpoint('skills.sealed.get', { sealedId });
592
- const data = await client.get(ep.path, { service: ep.service });
591
+ let sealedId = idOrName;
592
+ let data;
593
+ try {
594
+ const ep = resolveEndpoint('skills.sealed.get', { sealedId });
595
+ data = await client.get(ep.path, { service: ep.service });
596
+ } catch (err) {
597
+ // Coverage wave (ev_mvg4iwfp_d0b2e3): chat passed the skill's name. A 404 that matches exactly one sealed
598
+ // skill's name resolves to its id; anything else keeps the original error.
599
+ if (err.status !== 404) throw err;
600
+ const lep = resolveEndpoint('skills.sealed.list');
601
+ const list = await client.get(lep.path, { service: lep.service });
602
+ const rows = (list && (list.sealed ?? list)) || [];
603
+ const hits = (Array.isArray(rows) ? rows : []).filter((r) => r && r.name === idOrName);
604
+ if (hits.length !== 1) throw err;
605
+ sealedId = hits[0].sealedId;
606
+ const ep = resolveEndpoint('skills.sealed.get', { sealedId });
607
+ data = await client.get(ep.path, { service: ep.service });
608
+ }
593
609
  printInfo('Manifest only. The sealed content is never returned by the API.');
594
610
  formatDetail(data, { format: program.opts().output, title: `Sealed skill: ${sealedId}` });
595
611
  });
@@ -19,7 +19,7 @@ import { resolveOrgId } from './helpers.js';
19
19
  import { formatOutput, printInfo, printWarning } from '../core/output.js';
20
20
  import {
21
21
  VM_ACTIONS, VmPendingError, pendingHint, makeExec, makePoolRest,
22
- listVms, vmInfo, vmDoctor, doctorLines, vmAction, vmSnapshot, vmRehearse, waitRehearsal, poolStatus, poolLease, poolDone, poolConfig, poolHostKeyLines,
22
+ listVms, vmListRows, vmInfo, vmDoctor, doctorLines, vmAction, vmSnapshot, vmRehearse, waitRehearsal, poolStatus, poolLease, poolDone, poolConfig, poolHostKeyLines,
23
23
  readPubkey, textOf, payloadOf,
24
24
  } from '../core/vm.js';
25
25
 
@@ -92,7 +92,7 @@ export function registerVmCommands(program, ctx) {
92
92
  const vms = await listVms(exec, { node: options.node, type: options.type });
93
93
  if (json()) { console.log(JSON.stringify(vms.map((v) => v.raw), null, 2)); return; }
94
94
  formatOutput(
95
- { columns: ['vmid', 'name', 'node', 'type', 'status'], rows: vms.map(({ raw, ...r }) => r) },
95
+ { columns: ['vmid', 'name', 'node', 'type', 'status', 'tags'], rows: vmListRows(vms) },
96
96
  { emptyMessage: 'No VMs found.' },
97
97
  );
98
98
  }));