quoxcli 0.0.0-stage → 0.2.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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Config management for QuoxCLI
3
+ *
4
+ * Stores profiles and auth tokens in ~/.config/quox/config.json
5
+ * File permissions: 0600 (owner read/write only)
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, renameSync, openSync, closeSync, statSync, unlinkSync, copyFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+
12
+ function getConfigDir() { return join(homedir(), '.config', 'quox'); }
13
+ function getConfigFile() { return join(getConfigDir(), 'config.json'); }
14
+
15
+ // quox#713: cross-process lock around read-merge-write of config.json.
16
+ const LOCK_STALE_MS = 10000;
17
+ const LOCK_WAIT_MS = 15000;
18
+ function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
19
+ function debugLog(msg) { if (process.env.QUOX_DEBUG) process.stderr.write(`[quox config] ${msg}\n`); }
20
+ function acquireLock(lockFile) {
21
+ const deadline = Date.now() + LOCK_WAIT_MS;
22
+ for (;;) {
23
+ try {
24
+ const fd = openSync(lockFile, 'wx', 0o600);
25
+ closeSync(fd);
26
+ return true;
27
+ } catch (e) {
28
+ if (e.code !== 'EEXIST') return false;
29
+ try {
30
+ if (Date.now() - statSync(lockFile).mtimeMs > LOCK_STALE_MS) {
31
+ debugLog('breaking stale config lock');
32
+ try { unlinkSync(lockFile); } catch { /* another breaker won */ }
33
+ continue;
34
+ }
35
+ } catch { continue; /* lock vanished, retry */ }
36
+ if (Date.now() > deadline) return false;
37
+ sleepSync(15);
38
+ }
39
+ }
40
+ }
41
+ function releaseLock(lockFile) { try { unlinkSync(lockFile); } catch { /* already gone */ } }
42
+ const J = (v) => JSON.stringify(v === undefined ? null : v);
43
+
44
+ const DEFAULT_CONFIG = {
45
+ version: 1,
46
+ activeProfile: 'local',
47
+ profiles: {
48
+ local: {
49
+ url: 'http://localhost:3000',
50
+ auth: null,
51
+ },
52
+ },
53
+ };
54
+
55
+ // Service port map matching docker-compose.yml
56
+ export const SERVICE_PORTS = {
57
+ auth: 3101,
58
+ files: 3102,
59
+ memory: 3103,
60
+ orchestrator: 3100,
61
+ tasks: 3104,
62
+ collector: 9848,
63
+ // WP2.2: quoxflow exposes 127.0.0.1:${QUOXFLOW_PORT:-3200}:3200 in
64
+ // docker-compose.yml. This entry was previously absent, which silently
65
+ // broke getServiceUrl('quoxflow') — step 3 below only rewrites the port
66
+ // when a SERVICE_PORTS entry exists, so without it the call fell through
67
+ // to the unmodified base URL (wrong port). doctor.js has referenced
68
+ // `service: 'quoxflow', port: 3200` since CLI-3b without this entry ever
69
+ // backing it — that health check was likely silently probing the wrong
70
+ // port. See cli/lib/api/endpoints/triggers.js header for the full
71
+ // quoxflow-access decision this fixes.
72
+ quoxflow: 3200,
73
+ quoxbrain: 3106,
74
+ };
75
+
76
+ // Hosts where a SERVICE_PORTS rewrite necessarily lands on a plain node HTTP
77
+ // server rather than on nginx. URL.hostname keeps the brackets for IPv6, so
78
+ // '[::1]' is the form to match. See getServiceUrl step 3 for the rule.
79
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
80
+
81
+ // #572/#623: RFC1918 private-LAN hosts (+ *.local). On a single-box Quox stack
82
+ // the compose services bind 127.0.0.1 and ONLY the edge (:3000) terminates TLS
83
+ // and answers on the LAN address — so a service-port derived from a private-LAN
84
+ // base (e.g. https://quox.example.com:3000) must point at the reachable loopback
85
+ // bind, not https://<lan-ip>:PORT (which returns 000/refused, made `quox doctor`
86
+ // cry CRITICAL and broke run/objective/memory/volt). PUBLIC/remote hosts are
87
+ // deliberately NOT matched: they may run a real per-port TLS-terminating
88
+ // deployment, and an explicit per-service override always wins regardless.
89
+ function isPrivateHost(hostname) {
90
+ if (/\.local$/i.test(hostname)) return true;
91
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
92
+ if (!m) return false;
93
+ const a = Number(m[1]); const b = Number(m[2]);
94
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
95
+ }
96
+
97
+ // Pure port-swap derivation, extracted so it is unit-testable without a
98
+ // full config/profile fixture. Given a base URL (normally the ACTIVE
99
+ // profile's own `url`, never a global default), swap in the target
100
+ // service's port and return the origin. See getServiceUrl step 3 for the
101
+ // loopback scheme-downgrade rule this also carries.
102
+ //
103
+ // DEVSUITE-GRIPE-P7 / Bug A: this used to be computed from getBaseUrl(),
104
+ // which honours the global QUOX_URL env override BEFORE the active
105
+ // profile's own url. That meant `-p <profile>` picked which profile's
106
+ // token/services were used, but a QUOX_URL left set in the shell silently
107
+ // overrode which HOST every derived service URL (auth, memory, etc.)
108
+ // pointed at — `quox -p vulcan-proof signup ...` derived the auth base
109
+ // from whatever QUOX_URL said, not from vulcan-proof's own url. Deriving
110
+ // directly from the profile url here means -p is authoritative for service
111
+ // URL derivation the same way it already is for auth/services overrides.
112
+ export function deriveServiceUrl(baseUrl, port) {
113
+ try {
114
+ const url = new URL(baseUrl);
115
+ url.port = String(port);
116
+ if (LOOPBACK_HOSTS.has(url.hostname)) {
117
+ // Loopback base: force http (compose services are plain HTTP); keep the
118
+ // hostname as-is (localhost stays localhost — already reachable).
119
+ url.protocol = 'http:';
120
+ } else if (isPrivateHost(url.hostname)) {
121
+ // Private-LAN base: the service binds loopback, so point at the reachable
122
+ // bind (#572/#623). Public/remote hosts fall through unchanged.
123
+ url.protocol = 'http:';
124
+ url.hostname = '127.0.0.1';
125
+ }
126
+ return url.origin;
127
+ } catch {
128
+ return baseUrl;
129
+ }
130
+ }
131
+
132
+ // -p/--profile must be resolved BEFORE commander parses argv: bin/quox.js
133
+ // builds the shared ctx (loadConfig) at module load, when program.opts() is
134
+ // still empty. Reading the flag there always yielded undefined, so -p
135
+ // silently fell back to activeProfile — a login aimed at a dedicated profile
136
+ // clobbered another profile's session on 2026-08-15. This pre-scan is the
137
+ // only correct source for the flag at ctx-build time.
138
+ // Commands whose remaining argv belongs to another program (`quox claude -p
139
+ // "prompt"` is claude's --print, not quox's --profile).
140
+ const WRAPPED_PROGRAM_COMMANDS = new Set(['claude', 'codex', 'gemini', 'wrap']);
141
+
142
+ export function resolveProfileFlagFromArgv(argv) {
143
+ for (let i = 0; i < argv.length; i++) {
144
+ const a = argv[i];
145
+ if (WRAPPED_PROGRAM_COMMANDS.has(a)) return undefined;
146
+ if (a === '-p' || a === '--profile') return argv[i + 1];
147
+ if (typeof a === 'string' && a.startsWith('--profile=')) return a.slice('--profile='.length);
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ // fleet #479: -p/--profile and QUOX_PROFILE are documented as a one-shot
153
+ // override, but loadConfig used to write the override straight onto
154
+ // config.activeProfile. Any later save() (e.g. a login) then persisted it,
155
+ // silently changing the user's default profile for every command after.
156
+ // An override is a lens, not a switch: it must never survive to disk unless
157
+ // the caller explicitly asks for that with setActiveProfile(). The fix
158
+ // keeps the persisted default in _data.activeProfile untouched and tracks
159
+ // the "profile this invocation is using" separately on the wrapper as
160
+ // _effectiveProfile. Every reader that means "current" reads that; only
161
+ // setActiveProfile() is allowed to move the persisted default.
162
+ // #736 (2026-10-09): a config file that cannot be parsed must never be
163
+ // replaced by defaults in silence. The disk filled, the file became
164
+ // unreadable for a moment, and the next save wrote a near-empty config over
165
+ // every profile. Recover from the last good copy, or keep the bad file aside.
166
+ function moveConfigAside(reason) {
167
+ try {
168
+ const aside = `${getConfigFile()}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`;
169
+ renameSync(getConfigFile(), aside);
170
+ process.stderr.write(`Warning: ${getConfigFile()} was unreadable (${reason}); kept it as ${aside}.\n`);
171
+ } catch { /* nothing to move */ }
172
+ }
173
+ function recoverConfig(reason) {
174
+ const bak = `${getConfigFile()}.bak`;
175
+ try {
176
+ const parsed = JSON.parse(readFileSync(bak, 'utf-8'));
177
+ if (parsed && typeof parsed === 'object' && parsed.profiles) {
178
+ moveConfigAside(reason);
179
+ process.stderr.write(`Recovered quox config from ${bak}.\n`);
180
+ return parsed;
181
+ }
182
+ } catch { /* no usable backup */ }
183
+ moveConfigAside(reason);
184
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
185
+ }
186
+
187
+ export function loadConfig(profileOverride) {
188
+ let config;
189
+
190
+ if (existsSync(getConfigFile())) {
191
+ try {
192
+ config = JSON.parse(readFileSync(getConfigFile(), 'utf-8'));
193
+ if (!config || typeof config !== 'object') throw new Error('not an object');
194
+ } catch (err) {
195
+ config = recoverConfig(err.message);
196
+ }
197
+ } else {
198
+ config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
199
+ }
200
+
201
+ // Ensure structure
202
+ if (!config.profiles) config.profiles = {};
203
+ if (!config.activeProfile) config.activeProfile = 'local';
204
+
205
+ // Profile override from CLI flag or env. This is session-scoped state
206
+ // only: it must never be assigned onto config.activeProfile (the
207
+ // persisted default), see comment block above.
208
+ const effectiveProfile = profileOverride || process.env.QUOX_PROFILE || config.activeProfile;
209
+ // A NAMED override that doesn't exist yet is only legitimate for commands
210
+ // that create it (login, config profile add) — warn instead of silently
211
+ // proceeding on an empty profile, so a typo'd -p never masquerades as a
212
+ // logged-out state.
213
+ if (profileOverride && config.profiles && !config.profiles[profileOverride]) {
214
+ process.stderr.write(`Note: profile "${profileOverride}" does not exist yet; it will be created on login.\n`);
215
+ // Vivify in memory so login's setAuth() has a slot to write (persisted
216
+ // only when a mutation saves); read-only commands just see a logged-out
217
+ // profile, which is the accurate answer. A later save() (e.g. login)
218
+ // persists this profile entry, but never the activeProfile default.
219
+ config.profiles[profileOverride] = {
220
+ url: (config.profiles[config.activeProfile] || {}).url || DEFAULT_CONFIG.profiles.local.url,
221
+ auth: null,
222
+ };
223
+ }
224
+
225
+ return {
226
+ _data: config,
227
+ // The profile this invocation is using (persisted default, or the -p /
228
+ // QUOX_PROFILE override for this process only). Never written to _data.
229
+ _effectiveProfile: effectiveProfile,
230
+
231
+ getActiveProfile() {
232
+ const name = this._effectiveProfile;
233
+ return this._data.profiles[name] || null;
234
+ },
235
+
236
+ getActiveProfileName() {
237
+ return this._effectiveProfile;
238
+ },
239
+
240
+ // The ONLY deliberate persistent switch: moves the on-disk default AND
241
+ // the effective name, then saves. Everything else in this file is a
242
+ // session-scoped lens onto whichever name is current.
243
+ setActiveProfile(name) {
244
+ if (!this._data.profiles[name]) {
245
+ throw new Error(`Profile "${name}" does not exist.`);
246
+ }
247
+ this._data.activeProfile = name;
248
+ this._effectiveProfile = name;
249
+ this.save();
250
+ },
251
+
252
+ addProfile(name, opts = {}) {
253
+ if (this._data.profiles[name]) {
254
+ throw new Error(`Profile "${name}" already exists.`);
255
+ }
256
+ this._data.profiles[name] = {
257
+ url: opts.url || 'http://localhost:3000',
258
+ auth: null,
259
+ };
260
+ this.save();
261
+ },
262
+
263
+ removeProfile(name) {
264
+ if (name === this._effectiveProfile) {
265
+ throw new Error('Cannot remove the active profile. Switch first.');
266
+ }
267
+ delete this._data.profiles[name];
268
+ // quox#527: save() merges profiles it finds on disk but does not know
269
+ // about (so a parallel session's new profile survives our write). An
270
+ // EXPLICIT removal must not be resurrected by that merge.
271
+ this._removedProfiles.add(name);
272
+ this.save();
273
+ },
274
+
275
+ listProfiles() {
276
+ return Object.entries(this._data.profiles).map(([name, profile]) => ({
277
+ name,
278
+ url: profile.url,
279
+ active: name === this._effectiveProfile,
280
+ loggedIn: !!profile.auth?.access_token,
281
+ email: profile.auth?.email || null,
282
+ orgId: profile.auth?.org_id || null,
283
+ }));
284
+ },
285
+
286
+ // Resident profiles (auth.kind === 'resident') are the durable,
287
+ // passwordless credential for unattended automation: no refresh token and
288
+ // no server-side session row to revoke, they re-authenticate via a signed
289
+ // Ed25519 challenge on every 401 (see residentAuth.js). When a password
290
+ // session dead-ends (revoked/expired) the CLI used to loop agents through
291
+ // `quox login` — a human action an unattended agent cannot take — so this
292
+ // lets the dead-end paths point at the resident profile instead.
293
+ // Added devtooling-fix 2026-09-10 after the owner was re-logging-in for an
294
+ // agent repeatedly while a working resident profile sat unused.
295
+ listResidentProfiles() {
296
+ return Object.entries(this._data.profiles)
297
+ .filter(([, p]) => p?.auth?.kind === 'resident')
298
+ .map(([name, p]) => ({
299
+ name,
300
+ orgId: p.auth?.org_id || null,
301
+ email: p.auth?.email || null,
302
+ }));
303
+ },
304
+
305
+ getAuth() {
306
+ // Env var override
307
+ if (process.env.QUOX_TOKEN) {
308
+ return { access_token: process.env.QUOX_TOKEN };
309
+ }
310
+ return this.getActiveProfile()?.auth || null;
311
+ },
312
+
313
+ // quox#455 gap 2: a resident bootstrap carries per-service reach-back
314
+ // URLs discovered at enable time (the instance edge, the collector's
315
+ // real scheme+port). Persist them onto the ACTIVE profile so every
316
+ // later command resolves services from what the host actually reaches,
317
+ // instead of deriving them by port/scheme guesswork.
318
+ setProfileConnection({ url, services } = {}) {
319
+ const profile = this.getActiveProfile();
320
+ if (!profile) throw new Error('No active profile.');
321
+ if (url) profile.url = url;
322
+ if (services && typeof services === 'object') {
323
+ profile.services = { ...(profile.services || {}), ...services };
324
+ }
325
+ this.save();
326
+ },
327
+
328
+ setAuth(authData, opts = {}) {
329
+ const profile = this.getActiveProfile();
330
+ if (!profile) throw new Error('No active profile.');
331
+ profile.auth = authData;
332
+ if (opts.refresh) this._refreshedAuth.add(this._effectiveProfile);
333
+ else this._refreshedAuth.delete(this._effectiveProfile);
334
+ this.save();
335
+ },
336
+
337
+ // opts.refresh: the clear is a reaction to a refresh rejection from this
338
+ // process's (possibly stale) session; it must not wipe a newer login.
339
+ clearAuth(opts = {}) {
340
+ const profile = this.getActiveProfile();
341
+ if (profile) {
342
+ profile.auth = null;
343
+ this._clearedAuth.add(this._effectiveProfile);
344
+ if (opts.refresh) this._refreshedAuth.add(this._effectiveProfile);
345
+ else this._refreshedAuth.delete(this._effectiveProfile);
346
+ }
347
+ this.save();
348
+ },
349
+
350
+ getBaseUrl() {
351
+ // Env var override
352
+ if (process.env.QUOX_URL) return process.env.QUOX_URL;
353
+ return this.getActiveProfile()?.url || 'http://localhost:3000';
354
+ },
355
+
356
+ getServiceUrl(service) {
357
+ // 1. Per-service env var override (e.g. QUOX_AUTH_URL, QUOX_BASTION_URL)
358
+ const envKey = `QUOX_${service.toUpperCase()}_URL`;
359
+ if (process.env[envKey]) return process.env[envKey];
360
+
361
+ // 1.5. Per-profile service overrides (quox#455: stamped by resident
362
+ // login from the enable-time bootstrap; what the host truly reaches)
363
+ const profileServices = this.getActiveProfile()?.services;
364
+ if (profileServices && profileServices[service]) return profileServices[service];
365
+
366
+ // 2. Shared services config (for external services: bastion, quoxagent, etc.)
367
+ const services = this._data.services;
368
+ if (services && services[service]) return services[service];
369
+
370
+ // 3. Known service port map (for docker-compose services).
371
+ //
372
+ // DEVSUITE-GRIPE-P7 / Bug A: derive from the ACTIVE PROFILE's own url,
373
+ // never getBaseUrl() — getBaseUrl() honours the global QUOX_URL env
374
+ // override ahead of the profile, which let a stale QUOX_URL silently
375
+ // defeat an explicit -p/--profile selection for every service-port
376
+ // derivation (auth, memory, etc.) while the profile still correctly
377
+ // held its own token/services. -p must be authoritative here the same
378
+ // way it already is for the overrides above. A default-config fallback
379
+ // (no active profile at all) still falls back to the documented
380
+ // default base, matching getBaseUrl()'s own no-profile fallback.
381
+ const profileUrl = this.getActiveProfile()?.url
382
+ || (process.env.QUOX_URL) // no active profile: nothing more specific to prefer
383
+ || 'http://localhost:3000';
384
+ const port = SERVICE_PORTS[service];
385
+ if (!port) return this.getBaseUrl();
386
+
387
+ // Replace the port in the base URL.
388
+ //
389
+ // BRAIN2-AUDIT 2026-09-12: the port rewrite used to keep the base URL's
390
+ // scheme, so a profile pointed at https://localhost:3000 (nginx, the
391
+ // ONLY TLS terminator in docker-compose.yml) derived
392
+ // https://localhost:3106 for quoxbrain — every docker-compose service
393
+ // port is a plain node HTTP server, so the TLS handshake never
394
+ // completed and the call died as "fetch failed". `quox brain status`
395
+ // had plausibly never worked against this deployment shape (the
396
+ // product's own usage ledger recorded zero CLI brain calls).
397
+ // On a LOOPBACK host, a port-map rewrite therefore forces http:. This
398
+ // is deliberately narrow: it applies only to this derivation step, so
399
+ // env-var overrides (step 1), per-profile service overrides (step 1.5)
400
+ // and the shared services config (step 2) still say exactly what they
401
+ // say, and a remote/non-loopback host keeps its scheme (a real
402
+ // deployment may terminate TLS per port).
403
+ return deriveServiceUrl(profileUrl, port);
404
+ },
405
+
406
+ get(key) {
407
+ // quox#570: url is a PROFILE field. Every reader (getBaseUrl, service
408
+ // derivation) prefers the active profile's url, so get must read the
409
+ // same place set writes or a set/get round-trip lies.
410
+ if (key === 'url') {
411
+ const profile = this.getActiveProfile();
412
+ if (profile) return profile.url;
413
+ }
414
+ return this._data[key];
415
+ },
416
+
417
+ set(key, value) {
418
+ const PROTECTED = new Set(['version', 'profiles', 'activeProfile']);
419
+ if (PROTECTED.has(key)) {
420
+ throw new Error(`Cannot set protected key "${key}". Use profile commands instead.`);
421
+ }
422
+ // quox#570: `config set url` used to write a top-level `url` key that
423
+ // no reader consults, so repointing a profile silently kept the old
424
+ // host. Route it to the effective profile (which honours -p) when one
425
+ // exists; top-level stays the fallback for a profile-less config.
426
+ if (key === 'url') {
427
+ const profile = this.getActiveProfile();
428
+ if (profile) {
429
+ profile.url = value;
430
+ this.save();
431
+ return;
432
+ }
433
+ }
434
+ this._data[key] = value;
435
+ this.save();
436
+ },
437
+
438
+ // quox#527: parallel sessions share this file. Two protections:
439
+ // (1) merge-on-save: profiles present on disk that THIS process has
440
+ // never seen are kept (a sibling session's fresh login/profile is
441
+ // not wiped by our unrelated write); explicit removals recorded in
442
+ // _removedProfiles win over the merge so deletion stays deletion.
443
+ // (2) atomic write: temp file + rename, so a concurrent reader never
444
+ // sees a torn/partial JSON file.
445
+ // The narrower last-writer-wins on a SHARED profile's fields remains —
446
+ // that needs a lock, not a merge — but "my profile vanished mid-session"
447
+ // (the filed symptom) is closed by (1).
448
+ _removedProfiles: new Set(),
449
+ // (3) 2026-10-05: a profile this process LOADED but never changed is not
450
+ // ours to write. Background processes refreshing `resident` were
451
+ // writing their stale copy of `moo` over a fresh `quox -p moo login`
452
+ // seconds later. Snapshot each profile at load; an unchanged one
453
+ // takes the on-disk version on save.
454
+ _loadedProfiles: Object.fromEntries(Object.entries(config.profiles || {}).map(([n, v]) => [n, JSON.stringify(v)])),
455
+
456
+ // quox#713: save() is PROFILE-SCOPED and FIELD-SCOPED. Under config.json.lock
457
+ // it re-reads the file and applies only the fields this process changed
458
+ // relative to what it loaded (snapshot diff). Rules:
459
+ // - an unchanged profile/field is never written;
460
+ // - auth becomes null only via an explicit clearAuth() on that profile;
461
+ // - a REFRESH (setAuth(a, {refresh:true})) never overwrites an on-disk
462
+ // auth that differs from the one this process loaded (a newer login or
463
+ // sibling refresh happened meanwhile): disk wins.
464
+ _clearedAuth: new Set(),
465
+ _refreshedAuth: new Set(),
466
+ _loadedTop: Object.fromEntries(Object.entries(config).filter(([k]) => k !== 'profiles').map(([k, v]) => [k, J(v)])),
467
+
468
+ save() {
469
+ if (!existsSync(getConfigDir())) {
470
+ mkdirSync(getConfigDir(), { recursive: true });
471
+ }
472
+ const lockFile = `${getConfigFile()}.lock`;
473
+ const locked = acquireLock(lockFile);
474
+ if (!locked) debugLog('could not acquire config lock, writing unlocked');
475
+ try {
476
+ let disk = null;
477
+ try {
478
+ disk = JSON.parse(readFileSync(getConfigFile(), 'utf-8'));
479
+ if (!disk || typeof disk !== 'object') disk = null;
480
+ } catch { /* no file yet, or unreadable JSON */ }
481
+ // #736: an existing file we cannot parse is kept aside, never overwritten.
482
+ if (!disk && existsSync(getConfigFile())) moveConfigAside('unreadable at save');
483
+ const out = disk || JSON.parse(JSON.stringify(this._data));
484
+ if (!out.profiles) out.profiles = {};
485
+ if (disk) {
486
+ // top-level keys (activeProfile, services, ...): only ones we changed
487
+ for (const [k, v] of Object.entries(this._data)) {
488
+ if (k === 'profiles') continue;
489
+ if (this._loadedTop[k] !== J(v)) out[k] = v;
490
+ }
491
+ for (const name of Object.keys(this._data.profiles)) {
492
+ if (this._removedProfiles.has(name)) continue;
493
+ const mine = this._data.profiles[name];
494
+ const loaded = this._loadedProfiles[name] === undefined ? undefined : JSON.parse(this._loadedProfiles[name]);
495
+ const wasCleared = this._clearedAuth.has(name);
496
+ if (loaded !== undefined && J(mine) === J(loaded) && !wasCleared) continue; // untouched
497
+ const target = out.profiles[name];
498
+ if (target === undefined) { out.profiles[name] = JSON.parse(JSON.stringify(mine)); continue; }
499
+ for (const key of new Set([...Object.keys(mine), ...Object.keys(loaded || {})])) {
500
+ if (loaded && J(mine[key]) === J(loaded[key]) && !(key === 'auth' && wasCleared)) continue;
501
+ if (key === 'auth') {
502
+ if (mine.auth == null && !wasCleared) { debugLog(`refusing implicit null auth for ${name}`); continue; }
503
+ if (this._refreshedAuth.has(name) && target.auth != null && J(target.auth) !== J(loaded?.auth)) {
504
+ debugLog(`keeping newer on-disk auth for ${name} over refresh from older session`);
505
+ continue;
506
+ }
507
+ }
508
+ if (mine[key] === undefined) delete target[key]; else target[key] = mine[key];
509
+ }
510
+ }
511
+ for (const name of this._removedProfiles) delete out.profiles[name];
512
+ }
513
+ // adopt merged result in memory (in place, callers hold profile refs)
514
+ for (const [name, prof] of Object.entries(out.profiles)) {
515
+ const cur = this._data.profiles[name];
516
+ if (cur && cur !== prof) {
517
+ for (const k of Object.keys(cur)) delete cur[k];
518
+ Object.assign(cur, prof);
519
+ } else if (!cur) {
520
+ this._data.profiles[name] = prof;
521
+ }
522
+ }
523
+ for (const [k, v] of Object.entries(out)) if (k !== 'profiles') this._data[k] = v;
524
+ const tmpFile = `${getConfigFile()}.tmp-${process.pid}`;
525
+ try {
526
+ writeFileSync(tmpFile, JSON.stringify(out, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
527
+ } catch (err) {
528
+ // ENOSPC and friends: never leave a stray tmp, never touch config.json.
529
+ try { unlinkSync(tmpFile); } catch { /* not created */ }
530
+ throw err;
531
+ }
532
+ try {
533
+ chmodSync(tmpFile, 0o600);
534
+ } catch {
535
+ // May fail on Windows, acceptable
536
+ }
537
+ // #736: keep the last GOOD config as .bak before replacing it.
538
+ if (disk) {
539
+ try { copyFileSync(getConfigFile(), `${getConfigFile()}.bak`); chmodSync(`${getConfigFile()}.bak`, 0o600); } catch { /* best effort */ }
540
+ }
541
+ renameSync(tmpFile, getConfigFile());
542
+ this._loadedProfiles = Object.fromEntries(Object.entries(this._data.profiles || {}).map(([n, v]) => [n, J(v)]));
543
+ this._loadedTop = Object.fromEntries(Object.entries(this._data).filter(([k]) => k !== 'profiles').map(([k, v]) => [k, J(v)]));
544
+ this._clearedAuth.clear();
545
+ this._refreshedAuth.clear();
546
+ } finally {
547
+ if (locked) releaseLock(lockFile);
548
+ }
549
+ },
550
+ };
551
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared non-TTY-safe destructive-action confirmation.
3
+ *
4
+ * fleet #398/#399: several commands (`prompt delete`, `tag delete`, etc.)
5
+ * either lacked a --force/-y escape hatch entirely, or called inquirer
6
+ * unconditionally when neither was set. On a non-interactive shell (no
7
+ * stdin TTY — cron, CI, a piped script) inquirer throws
8
+ * `Error: User force closed the prompt with 13 null` AND leaks
9
+ * `Warning: Detected unsettled top-level await at cli/bin/quox.js:221`,
10
+ * verified live on .131. Checking `process.stdin.isTTY` BEFORE importing
11
+ * inquirer avoids both — fail fast with a clear message instead.
12
+ */
13
+
14
+ /**
15
+ * @param {import('commander').Command} program - root commander program (for global --yes)
16
+ * @param {{ force?: boolean }} options - local command options (for --force)
17
+ * @param {string} message - confirmation prompt text
18
+ * @returns {Promise<boolean>}
19
+ */
20
+ export async function confirmDestructive(program, options, message) {
21
+ if (options.force === true || program.opts().yes === true) {
22
+ return true;
23
+ }
24
+
25
+ if (!process.stdin.isTTY) {
26
+ throw new Error(`${message} — this needs confirmation; re-run with --force (or -y) in a non-interactive shell`);
27
+ }
28
+
29
+ const { default: inquirer } = await import('inquirer');
30
+ const ans = await inquirer.prompt([{
31
+ type: 'confirm',
32
+ name: 'confirm',
33
+ message,
34
+ default: false,
35
+ }]);
36
+ return ans.confirm;
37
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Deep Links — quox:// URI scheme
3
+ *
4
+ * Every Quox entity has a canonical URI:
5
+ * quox://fleet/docker01
6
+ * quox://workflow/WF_abc123/run/RUN_xyz
7
+ * quox://agent/sentinel
8
+ * quox://aee/ENV_789
9
+ * quox://memory/MEM_123
10
+ *
11
+ * URIs resolve to both a dashboard URL (browser) and a CLI command.
12
+ *
13
+ * CLI-3f (Stream CLI Phase 3)
14
+ */
15
+
16
+ const ROUTE_MAP = {
17
+ fleet: { dashboard: '/noc?host=', cli: 'bastion host show' },
18
+ agent: { dashboard: '/agents?id=', cli: 'quox agent get' },
19
+ workflow: { dashboard: '/workflows?id=', cli: 'quox workflow get' },
20
+ aee: { dashboard: '/activity?id=', cli: 'quox aee get' },
21
+ memory: { dashboard: '/memory?id=', cli: 'quox memory get' },
22
+ tool: { dashboard: '/tools?id=', cli: 'quox tool get' },
23
+ vault: { dashboard: '/settings/vault?id=', cli: 'quox vault get' },
24
+ session: { dashboard: '/fleet?session=', cli: 'quoxagent agent info' },
25
+ conversation: { dashboard: '/assistants?conv=', cli: 'quox conversation get' },
26
+ };
27
+
28
+ /**
29
+ * Build a quox:// URI for an entity.
30
+ * @param {string} type - Entity type (fleet, workflow, agent, aee, etc.)
31
+ * @param {string} id - Entity ID
32
+ * @param {string} [subpath] - Optional sub-path (e.g., "run/RUN_xyz")
33
+ * @returns {string}
34
+ */
35
+ export function buildQuoxUri(type, id, subpath) {
36
+ let uri = `quox://${type}/${id}`;
37
+ if (subpath) uri += `/${subpath}`;
38
+ return uri;
39
+ }
40
+
41
+ /**
42
+ * Resolve a quox:// URI to a dashboard URL and CLI command.
43
+ * @param {string} uri - The quox:// URI
44
+ * @param {string} baseUrl - Dashboard base URL (e.g., http://localhost:3000)
45
+ * @returns {{_ref: string, type: string, id: string, dashboardUrl: string, cliCommand: string}|null}
46
+ */
47
+ export function resolveQuoxUri(uri, baseUrl) {
48
+ if (!uri || !uri.startsWith('quox://')) return null;
49
+
50
+ const path = uri.slice('quox://'.length);
51
+ const parts = path.split('/');
52
+ const type = parts[0];
53
+ const id = parts[1];
54
+
55
+ if (!type || !id) return null;
56
+
57
+ const route = ROUTE_MAP[type];
58
+ if (!route) {
59
+ // Unknown type — best effort
60
+ return {
61
+ _ref: uri,
62
+ type,
63
+ id,
64
+ dashboardUrl: `${baseUrl}/${type}?id=${id}`,
65
+ cliCommand: `quox ${type} get ${id}`,
66
+ };
67
+ }
68
+
69
+ return {
70
+ _ref: uri,
71
+ type,
72
+ id,
73
+ dashboardUrl: `${baseUrl}${route.dashboard}${id}`,
74
+ cliCommand: `${route.cli} ${id}`,
75
+ };
76
+ }