quoxcli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * quox notes seal init|status|new|open|edit|print: ENQRYPT sealed notes (ENQRYPT stream E4, enqrypt ADR-0001/0002/0003).
3
+ *
4
+ * Sealing and opening happen HERE, on the client, with the owner's ENQRYPT identity (passphrase-protected key
5
+ * file, default ~/.quox/enqrypt). NotesQ only ever receives the sealed envelope: the server, the collector and
6
+ * agents cannot read a sealed note. Titles, collections and tags are NOT sealed (they stay searchable).
7
+ * ENQRYPT is an adapter dependency, loaded on demand; core Quox does not need it.
8
+ */
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { join, resolve } from 'path';
12
+ import { randomUUID } from 'crypto';
13
+ import { pathToFileURL } from 'url';
14
+ import { QuoxError } from '../core/errors.js';
15
+ import { resolveEndpoint } from '../api/endpoints.js';
16
+
17
+ export const SEAL_PURPOSE = 'notesq.note';
18
+ export const SEAL_MARK = 'enqrypt-sealed-note';
19
+ const FENCE = 'enqrypt-envelope';
20
+ const FP_RE = /^[0-9a-f]{64}$/;
21
+ const B64U_RE = /^[A-Za-z0-9_-]+$/;
22
+
23
+ export const keystoreDir = (opt) => resolve(opt || join(homedir(), '.quox', 'enqrypt'));
24
+
25
+ /** Markdown NotesQ stores for a sealed note: a heading, a plain explanation, and the envelope. Nothing secret. */
26
+ export function sealedNoteMarkdown({ title, envelope, fingerprint }) {
27
+ const b64 = Buffer.from(envelope).toString('base64url');
28
+ const wrapped = b64.match(/.{1,76}/g).join('\n');
29
+ return `# ${title}\n\n🔐 Sealed with ENQRYPT (HPKE, RFC 9180). The server stores only this envelope and cannot read it.\n`
30
+ + `Open it on a device holding the key with fingerprint \`${fingerprint.slice(0, 16)}…\`: \`quox notes seal open <id>\`\n\n`
31
+ + '```' + FENCE + '\n' + wrapped + '\n```\n';
32
+ }
33
+
34
+ export function extractEnvelope(markdown) {
35
+ const blocks = [...String(markdown || '').matchAll(new RegExp('```' + FENCE + '\\n([\\s\\S]*?)\\n```', 'g'))];
36
+ if (blocks.length !== 1) throw new QuoxError(blocks.length ? 'This note has more than one ENQRYPT envelope; refusing to guess.' : 'This note holds no ENQRYPT envelope.', 1);
37
+ const b64 = blocks[0][1].replace(/\n/g, '');
38
+ if (!B64U_RE.test(b64)) throw new QuoxError('The ENQRYPT envelope in this note is damaged.', 1);
39
+ return new Uint8Array(Buffer.from(b64, 'base64url'));
40
+ }
41
+
42
+ export function sealProperties({ fingerprint, objectId, version, collection, tags }) {
43
+ const p = { enqrypt: SEAL_MARK, enqrypt_fp: fingerprint, enqrypt_object: objectId, enqrypt_version: version };
44
+ if (collection) p.collection = collection;
45
+ if (tags) p.tags = tags;
46
+ return p;
47
+ }
48
+
49
+ export function readSealProperties(props) {
50
+ const p = props || {};
51
+ const version = Number(p.enqrypt_version);
52
+ if (p.enqrypt !== SEAL_MARK) throw new QuoxError('This note is not an ENQRYPT sealed note.', 1);
53
+ if (!FP_RE.test(String(p.enqrypt_fp)) || !/^[0-9a-f-]{36}$/.test(String(p.enqrypt_object)) || !Number.isSafeInteger(version) || version < 1) {
54
+ throw new QuoxError('This sealed note has damaged ENQRYPT properties.', 1);
55
+ }
56
+ return { fingerprint: p.enqrypt_fp, objectId: p.enqrypt_object, version };
57
+ }
58
+
59
+ // Rollback floor: the highest version this client has opened per sealed object. Older envelopes are refused.
60
+ const floorFile = (dir) => join(dir, 'notes-floor.json');
61
+ export function readFloor(dir, objectId) {
62
+ try { return Number(JSON.parse(readFileSync(floorFile(dir), 'utf8'))[objectId]) || 0; } catch { return 0; }
63
+ }
64
+ export function bumpFloor(dir, objectId, version) {
65
+ let all = {};
66
+ try { all = JSON.parse(readFileSync(floorFile(dir), 'utf8')); } catch { /* first use */ }
67
+ if (!(Number(all[objectId]) >= version)) all[objectId] = version;
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ writeFileSync(floorFile(dir), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
70
+ }
71
+
72
+ /** A passphrase file must be private to its owner; anything looser is refused rather than trusted. */
73
+ export function readPassphraseFile(path) {
74
+ const st = statSync(path);
75
+ if ((st.mode & 0o077) !== 0) throw new QuoxError(`Refusing ${path}: passphrase files must be chmod 600.`, 2);
76
+ return readFileSync(path, 'utf8').replace(/\r?\n$/, '');
77
+ }
78
+
79
+ async function defaultLoadEnqrypt() {
80
+ const base = process.env.QUOX_ENQRYPT_PATH;
81
+ const at = (sub) => (base ? pathToFileURL(join(base, 'src', `${sub}.mjs`)).href : `@quoxai/enqrypt-script/${sub}`);
82
+ try {
83
+ const [seal, keystore, armor] = await Promise.all([import(at('seal')), import(at('keystore')), import(at('armor'))]);
84
+ return { ...seal, ...keystore, ...armor };
85
+ } catch {
86
+ throw new QuoxError('ENQRYPT is not available on this machine. It is not published yet: set QUOX_ENQRYPT_PATH to a checkout of quoxai/enqrypt (never `npm install enqrypt`, that name is an unrelated malicious package).', 1);
87
+ }
88
+ }
89
+
90
+ async function askPassphrase(message, confirm = false) {
91
+ if (!process.stdin.isTTY) throw new QuoxError('No terminal to ask for the passphrase: pass --passphrase-file <chmod-600 file>.', 2);
92
+ const { default: inquirer } = await import('inquirer');
93
+ const { p } = await inquirer.prompt([{ type: 'password', name: 'p', message, mask: '*' }]);
94
+ if (confirm) {
95
+ const { q } = await inquirer.prompt([{ type: 'password', name: 'q', message: 'Repeat the passphrase', mask: '*' }]);
96
+ if (p !== q) throw new QuoxError('The passphrases did not match. Nothing was created.', 2);
97
+ }
98
+ return p;
99
+ }
100
+
101
+ export function registerNotesSeal(notes, ctx) {
102
+ const { program, client, auth, call, getNote, putNote, readContent, isJson, printJson, announce } = ctx;
103
+ const loadEnqrypt = ctx.loadEnqrypt || defaultLoadEnqrypt;
104
+ const passphrase = (options, msg, confirm) => (options.passphraseFile ? readPassphraseFile(options.passphraseFile) : askPassphrase(msg, confirm));
105
+ const mapErr = (e) => {
106
+ if (e && (e.name === 'EnqryptError' || e.name === 'KeystoreError')) return new QuoxError(`ENQRYPT refused: ${e.code} (${e.message}). Nothing was shown.`, 1);
107
+ return e;
108
+ };
109
+ const unlock = async (E, options) => {
110
+ try { return await E.unlockKeystore({ dir: keystoreDir(options.keystore), passphrase: await passphrase(options, 'ENQRYPT passphrase') }); }
111
+ catch (e) { throw mapErr(e); }
112
+ };
113
+
114
+ const seal = notes.command('seal').description('ENQRYPT sealed notes: encrypted on this device, the server stores only the envelope (prototype, security review pending)');
115
+ const common = (cmd) => cmd
116
+ .option('--keystore <dir>', 'ENQRYPT keystore directory (default ~/.quox/enqrypt)')
117
+ .option('--passphrase-file <path>', 'read the passphrase from a chmod-600 file instead of asking');
118
+
119
+ common(seal.command('init').description('Create your ENQRYPT key (passphrase-protected) and show the one-time recovery key'))
120
+ .action(async (options) => {
121
+ const E = await loadEnqrypt();
122
+ const dir = keystoreDir(options.keystore);
123
+ if (existsSync(join(dir, 'identity.age'))) throw new QuoxError(`An ENQRYPT key already exists in ${dir}. Nothing was changed.`, 1);
124
+ console.log('If you lose BOTH this passphrase and the recovery key shown next, your sealed notes cannot be recovered by anyone.');
125
+ let out;
126
+ try { out = await E.createKeystore({ dir, passphrase: await passphrase(options, 'New ENQRYPT passphrase (10+ characters)', true) }); }
127
+ catch (e) { throw mapErr(e); }
128
+ if (isJson(program)) return printJson({ dir, fingerprint: out.fingerprint, recovery_key: out.recoveryKey });
129
+ console.log(`\nKey created in ${dir}\nFingerprint: ${out.fingerprint}\n`);
130
+ console.log('RECOVERY KEY (shown once; store it offline, it opens every sealed note):');
131
+ console.log(` ${out.recoveryKey}\n`);
132
+ announce('seal init', `key ${out.fingerprint.slice(0, 16)}…`);
133
+ });
134
+
135
+ common(seal.command('status').description('Show whether this device has an ENQRYPT key, and its fingerprint'))
136
+ .action(async (options) => {
137
+ const dir = keystoreDir(options.keystore);
138
+ let E;
139
+ try { E = await loadEnqrypt(); } catch (e) {
140
+ // status must report, not fail, when the seal backend is absent (the other verbs still refuse).
141
+ const reason = e && e.message ? e.message : 'ENQRYPT could not be loaded.';
142
+ if (isJson(program)) return printJson({ dir, backend: 'unavailable', fingerprint: null, reason });
143
+ return console.log(`ENQRYPT backend: not available on this machine, so no key can be read from ${dir}.\n${reason}`);
144
+ }
145
+ try {
146
+ const pub = await E.readPublicIdentity({ dir });
147
+ if (isJson(program)) return printJson({ dir, fingerprint: pub.fingerprint });
148
+ console.log(`ENQRYPT key in ${dir}\nFingerprint: ${pub.fingerprint}`);
149
+ } catch (e) {
150
+ if (e && e.code === 'MISSING') return console.log(`No ENQRYPT key in ${dir}. Create one: quox notes seal init`);
151
+ throw mapErr(e);
152
+ }
153
+ });
154
+
155
+ common(seal.command('new').description('Create a sealed note from --file or --stdin. The title is NOT sealed.'))
156
+ .option('--scope <scope>', 'user | org (default user)', 'user')
157
+ .option('--title <t>', 'visible title (not sealed)', 'Sealed note')
158
+ .option('--collection <c>', 'collection (not sealed)')
159
+ .option('--tag <t>', 'tag (repeatable, not sealed)', (v, prev) => (prev || []).concat(v))
160
+ .option('--file <path>', 'read the secret text from a file')
161
+ .option('--stdin', 'read the secret text from stdin')
162
+ .action(async (options) => {
163
+ auth.requireAuth();
164
+ if (!['user', 'org'].includes(options.scope)) throw new QuoxError('--scope must be user or org.', 2);
165
+ const text = await readContent(options);
166
+ if (text === null) throw new QuoxError('seal new needs --file <path> or --stdin (the secret is never taken from a flag).', 2);
167
+ const E = await loadEnqrypt();
168
+ const me = await unlock(E, options);
169
+ const fingerprint = await E.fingerprint(me.publicKey);
170
+ const objectId = randomUUID();
171
+ let envelope;
172
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: 1, plaintext: new TextEncoder().encode(text) }); }
173
+ catch (e) { throw mapErr(e); }
174
+ const ep = resolveEndpoint('notesq.notes.create');
175
+ const body = { scope: options.scope, markdown: sealedNoteMarkdown({ title: options.title, envelope, fingerprint }), properties: sealProperties({ fingerprint, objectId, version: 1, collection: options.collection, tags: options.tag }) };
176
+ const data = await call(() => client.post(ep.path, { service: ep.service, body }));
177
+ bumpFloor(keystoreDir(options.keystore), objectId, 1);
178
+ if (isJson(program)) return printJson({ id: data.note?.id, object: objectId, version: 1, fingerprint });
179
+ console.log(`Sealed note ${data.note?.id} created (${text.length} characters sealed; the title "${options.title}" is visible).`);
180
+ announce('seal new', `sealed note ${data.note?.id}`);
181
+ });
182
+
183
+ common(seal.command('print').description('Print a sealed note as a plain ASCII armour block (ciphertext only, safe to print or file; no passphrase needed)'))
184
+ .argument('<id>', 'note id')
185
+ .action(async (id) => {
186
+ auth.requireAuth();
187
+ const n = await getNote(id);
188
+ const props = readSealProperties(n.properties);
189
+ const envelope = extractEnvelope(n.markdown ?? n.body);
190
+ const E = await loadEnqrypt();
191
+ const armor = await E.toAsciiArmor(envelope, { Version: '1', Key: props.fingerprint.slice(0, 16), Object: props.objectId, Note: String(id).slice(0, 64) });
192
+ if (isJson(program)) return printJson({ id, armor });
193
+ process.stdout.write(armor);
194
+ });
195
+
196
+ common(seal.command('open').description('Open a sealed note on this device and print it (by note id, or offline from a printed armour block)'))
197
+ .argument('[id]', 'note id (omit with --armor-file)')
198
+ .option('--armor-file <path>', 'open a printed/saved ASCII armour block instead of fetching the note ("-" = stdin)')
199
+ .action(async (id, options) => {
200
+ let envelope, objectId, expectFp = null, label = id;
201
+ const E = await loadEnqrypt();
202
+ if (options.armorFile) {
203
+ if (id) throw new QuoxError('Give a note id or --armor-file, not both.', 2);
204
+ const text = options.armorFile === '-' ? await readContent({ stdin: true }) : readFileSync(options.armorFile, 'utf8');
205
+ let parsed;
206
+ try { parsed = await E.fromAsciiArmor(text); } catch (e) { throw new QuoxError(`That armour block was refused: ${e.message}. Nothing was opened.`, 1); }
207
+ envelope = parsed.envelope;
208
+ objectId = parsed.headers.Object;
209
+ if (!/^[0-9a-f-]{36}$/.test(String(objectId))) throw new QuoxError('That armour block has no valid Object header; it cannot be bound to a note.', 1);
210
+ label = parsed.headers.Note || 'armour';
211
+ } else {
212
+ if (!id) throw new QuoxError('seal open needs a note id or --armor-file <path>.', 2);
213
+ auth.requireAuth();
214
+ const n = await getNote(id);
215
+ const props = readSealProperties(n.properties);
216
+ envelope = extractEnvelope(n.markdown ?? n.body);
217
+ objectId = props.objectId;
218
+ expectFp = props.fingerprint;
219
+ }
220
+ const me = await unlock(E, options);
221
+ const fingerprint = await E.fingerprint(me.publicKey);
222
+ if (expectFp && fingerprint !== expectFp) throw new QuoxError(`This note was sealed to key ${expectFp.slice(0, 16)}…, not this device's key ${fingerprint.slice(0, 16)}….`, 1);
223
+ const dir = keystoreDir(options.keystore);
224
+ let plain;
225
+ try { plain = await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, minVersion: readFloor(dir, objectId) }); }
226
+ catch (e) { throw mapErr(e); }
227
+ const v = E.parseEnvelope(envelope).seq;
228
+ bumpFloor(dir, objectId, v);
229
+ const text = new TextDecoder().decode(plain);
230
+ if (isJson(program)) return printJson({ id: label, version: v, text });
231
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
232
+ });
233
+
234
+ common(seal.command('edit').description('Replace a sealed note\'s secret text (--file or --stdin); writes a new sealed version'))
235
+ .argument('<id>', 'note id')
236
+ .option('--file <path>', 'read the new secret text from a file')
237
+ .option('--stdin', 'read the new secret text from stdin')
238
+ .action(async (id, options) => {
239
+ auth.requireAuth();
240
+ const text = await readContent(options);
241
+ if (text === null) throw new QuoxError('seal edit needs --file <path> or --stdin.', 2);
242
+ const cur = await getNote(id);
243
+ const props = readSealProperties(cur.properties);
244
+ const E = await loadEnqrypt();
245
+ const me = await unlock(E, options);
246
+ const fingerprint = await E.fingerprint(me.publicKey);
247
+ if (fingerprint !== props.fingerprint) throw new QuoxError('This note was sealed to a different key; refusing to re-seal it.', 1);
248
+ const version = props.version + 1;
249
+ let envelope;
250
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, seq: version, plaintext: new TextEncoder().encode(text) }); }
251
+ catch (e) { throw mapErr(e); }
252
+ const title = String(cur.title || 'Sealed note');
253
+ const data = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties: { ...cur.properties, enqrypt_version: version }, base_hash: cur.content_hash });
254
+ bumpFloor(keystoreDir(options.keystore), props.objectId, version);
255
+ if (isJson(program)) return printJson({ id, version, revision: data.revision?.rev });
256
+ console.log(`Sealed note ${id} updated to sealed version ${version}. Earlier revisions hold earlier envelopes, never plaintext.`);
257
+ announce('seal edit', `sealed note ${id} v${version}`);
258
+ });
259
+ }
@@ -12,6 +12,7 @@
12
12
  * live loop gate: budget, risk, interval).
13
13
  */
14
14
 
15
+ import { withStepUp } from '../core/stepUpPrompt.js';
15
16
  import { formatOutput, formatDetail, printSuccess, printWarning } from '../core/output.js';
16
17
  import { parseJSON } from './helpers.js';
17
18
  import { resolveEndpoint } from '../api/endpoints.js';
@@ -181,7 +182,8 @@ export function registerObjectiveCommands(program, ctx) {
181
182
  .argument('<id>', 'objective ID')
182
183
  .action(async (id) => {
183
184
  auth.requireAuth();
184
- const data = await client.post(resolveEndpoint('objectives.approve-plan', { id }).path, { service: resolveEndpoint('objectives.approve-plan', { id }).service });
185
+ const planEp = resolveEndpoint('objectives.approve-plan', { id });
186
+ const data = await withStepUp((headers) => client.post(planEp.path, { service: planEp.service, headers }), { client, orgId: auth.getOrgId?.(program.opts().org) || null, io: ctx.stepUpIO, what: 'Approving this plan' });
185
187
  printSuccess(`Objective ${id}: ${data.status}${data.message ? ` — ${data.message}` : ''}`);
186
188
  });
187
189
 
@@ -231,7 +233,8 @@ export function registerObjectiveCommands(program, ctx) {
231
233
  const body = {};
232
234
  if (options.notes) body.notes = options.notes;
233
235
  const params = { id: objectiveId, checkpointId };
234
- const data = await client.post(resolveEndpoint('objectives.checkpoint-approve', params).path, { service: resolveEndpoint('objectives.checkpoint-approve', params).service, body });
236
+ const cpEp = resolveEndpoint('objectives.checkpoint-approve', params);
237
+ const data = await withStepUp((headers) => client.post(cpEp.path, { service: cpEp.service, headers, body }), { client, orgId: auth.getOrgId?.(program.opts().org) || null, io: ctx.stepUpIO, what: 'Approving this checkpoint' });
235
238
  // LOOPQ-HALT: "approved" is not "running". When the server says the loop did not start, say so in words.
236
239
  if (data.started === false) {
237
240
  printWarning(`Checkpoint ${checkpointId}: ${data.status}, but the objective did NOT start (objective ${data.objective_status || 'pending'}).`);
@@ -9,10 +9,9 @@
9
9
  * (same as `quox vm pool lease`) with a dedicated key at ~/.config/quox/offload_ed25519.
10
10
  */
11
11
 
12
- import path from 'node:path';
13
12
  import { printInfo } from '../core/output.js';
14
13
  import { makePoolRest } from '../core/vm.js';
15
- import { OffloadError, realDeps, runOffload, releaseWorker, workerStatus, gitToplevel, WORKER_NAME_RE } from '../core/offload.js';
14
+ import { OffloadError, realDeps, runOffload, releaseWorker, workerStatus, resolveSyncDir, WORKER_NAME_RE } from '../core/offload.js';
16
15
 
17
16
  export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
18
17
  const { client, auth } = ctx;
@@ -45,8 +44,8 @@ export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
45
44
  .action(async (command, options) => {
46
45
  auth.requireAuth();
47
46
  const cwd = process.cwd();
48
- const dir = path.resolve(options.dir || gitToplevel(cwd));
49
47
  try {
48
+ const dir = resolveSyncDir(cwd, options.dir);
50
49
  const code = await runOffload(mk(options), {
51
50
  dir, cwd, command, fresh: !!options.fresh, docker: !!options.docker,
52
51
  cores: options.cores, memoryGb: options.memoryGb, diskGb: options.diskGb,
@@ -207,6 +207,22 @@ function renderStreamList(payload, { lens = 'tier' } = {}) {
207
207
  return lines;
208
208
  }
209
209
 
210
+ /** Honest count of what was printed, plus a one-line glyph legend (the list itself prints glyphs only). */
211
+ function renderListFooter(payload, lens) {
212
+ const streams = (payload && payload.streams) || [];
213
+ if (streams.length === 0) return [];
214
+ const tiers = lens === 'tier' ? streams : null;
215
+ const top = tiers ? tiers.flatMap((g) => g.streams || []) : streams;
216
+ const subs = top.reduce((n, s) => n + (s.substreams || []).length, 0);
217
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
218
+ const count = `${plural(top.length, 'stream')}${subs ? ` + ${plural(subs, 'substream')}` : ''}${tiers ? ` in ${plural(tiers.length, 'tier')}` : ''}`;
219
+ return [
220
+ '',
221
+ chalk.dim(` ${count}`),
222
+ chalk.dim(' ◇ not started ⬗ in progress ◈ decision needed ◆ blocked (red) or complete (dim) └ substream'),
223
+ ];
224
+ }
225
+
210
226
  // ── render: stream detail ───────────────────────────────────────────────
211
227
 
212
228
  function renderStreamDetail(detail) {
@@ -601,6 +617,7 @@ export function registerPlanCommands(program, ctx) {
601
617
  if (isJsonOutput(program)) return printJson(data);
602
618
  console.log(`\n ${roadmapId} (lens: ${options.lens})`);
603
619
  console.log(renderStreamList(data, { lens: options.lens }).join('\n'));
620
+ console.log(renderListFooter(data, options.lens).join('\n'));
604
621
  });
605
622
 
606
623
  // ── focus ────────────────────────────────────────────────────────────
@@ -58,17 +58,19 @@ export function registerProviderCommands(program, ctx) {
58
58
  // ── provider list ──────────────────────────────────────────────────────────
59
59
  provider
60
60
  .command('list')
61
- .description('Show which AI providers this org can use, and how each is connected')
62
- .action(async () => {
61
+ .description('Show which AI providers this org can use, and how each is connected (--all adds the other integrations in the vault)')
62
+ .option('--all', 'also list the other integrations in the vault (Slack, GitHub, bastion...), not just AI providers')
63
+ .action(async (opts = {}) => {
63
64
  auth.requireAuth();
64
65
  const a = api();
65
66
  const [vault, caps] = await Promise.all([a.connections(), a.capabilities ? a.capabilities() : null]);
66
67
  if (program.opts().output === 'json') {
67
- const rows = vault?.ok ? vault.rows.map((r) => Object.fromEntries(SAFE_ROW.filter((k) => r[k] !== undefined).map((k) => [k, r[k]]))) : null;
68
+ const known = new Set(Object.values(PROVIDERS).map((p) => p.type));
69
+ const rows = vault?.ok ? vault.rows.filter((r) => opts.all || known.has(r.integration_type)).map((r) => Object.fromEntries(SAFE_ROW.filter((k) => r[k] !== undefined).map((k) => [k, r[k]]))) : null;
68
70
  formatDetail({ connections: rows, ...(vault?.ok ? {} : { error: vault?.error || 'vault not readable' }), capabilities: caps?.ok ? caps.capabilities : null }, { format: 'json' });
69
71
  return;
70
72
  }
71
- say(connectionLines(vault, caps));
73
+ say(connectionLines(vault, caps, { includeOthers: Boolean(opts.all) }));
72
74
  });
73
75
 
74
76
  // ── provider connect ───────────────────────────────────────────────────────
@@ -202,16 +204,35 @@ export function registerProviderCommands(program, ctx) {
202
204
  const rows = vault.rows.filter((r) => types.has(r.integration_type) && (r.is_default === 1 || r.is_default === true));
203
205
  if (!rows.length) throw new Error(p ? `No ${p.id} connection is stored for this org. Connect one: quox provider connect ${p.id}` : 'No provider connection is stored for this org. Connect one: quox provider connect <kind>');
204
206
  const orgId = resolveOrgId(auth, program);
207
+ const asJson = program.opts().output === 'json';
208
+ const results = [];
205
209
  let failed = 0;
206
210
  for (const row of rows) {
207
- const name = (Object.values(PROVIDERS).find((x) => x.type === row.integration_type) || {}).name || row.integration_type;
211
+ const meta = Object.values(PROVIDERS).find((x) => x.type === row.integration_type) || {};
212
+ const name = meta.name || row.integration_type;
208
213
  const ep = resolveEndpoint('vault.test', { orgId, id: row.id });
209
- let res;
214
+ let res; let reason = null;
210
215
  try { res = await client.post(ep.path, { service: ep.service, retries: 0 }); }
211
- catch (e) { res = { success: false, message: e?.message || 'failed' }; }
212
- if (res?.success === false) { failed++; printInfo(`✖ ${name} ${row.label || row.id} failed: ${res?.message || 'no reason given'}`); }
216
+ catch (e) {
217
+ res = { success: false, message: e?.message || 'failed' };
218
+ reason = e?.body?.reason || (String(e?.message || '').match(/\(reason: ([^)]+)\)/) || [])[1] || null;
219
+ }
220
+ const ok = res?.success !== false;
221
+ if (!ok) failed++;
222
+ results.push({
223
+ id: row.id, kind: meta.id || row.integration_type, label: row.label || row.id, ok,
224
+ ...(ok ? (res?.message ? { message: res.message } : {}) : { error: res?.message || 'no reason given', reason }),
225
+ });
226
+ if (asJson) continue;
227
+ if (!ok) printInfo(`✖ ${name} ${row.label || row.id} failed: ${res?.message || 'no reason given'}`);
213
228
  else printInfo(`✔ ${name} ${row.label || row.id} ok${res?.message ? `: ${res.message}` : ''}`);
214
229
  }
230
+ if (asJson) formatDetail({
231
+ results,
232
+ summary: { total: results.length, ok: results.length - failed, failed },
233
+ // a top-level error string is what the collector's quox_cli wrapper quotes to the model on a non-zero exit
234
+ ...(failed ? { error: results.filter((r) => !r.ok).map((r) => `${r.kind}: ${r.error}`).join('; ') } : {}),
235
+ }, { format: 'json' });
215
236
  if (failed) process.exitCode = 1;
216
237
  });
217
238
  }
@@ -49,6 +49,25 @@ function annotate(err) {
49
49
  throw err;
50
50
  }
51
51
 
52
+ /**
53
+ * Inspectors whose server-side run() calls assertSelector (qlarity/src/inspectors.js).
54
+ * The registry lives in another repo and `inspectors list` does not expose the
55
+ * flag, so this mirrors it; storage, dom_summary, layout_health and
56
+ * responsive_sweep default or skip the selector and are deliberately absent.
57
+ */
58
+ export const SELECTOR_INSPECTORS = ['computed_style', 'pseudo_style', 'text_content', 'attribute', 'count', 'bounding_box', 'expect'];
59
+
60
+ /** Keep scheme+host+port only (lowercased); anything unparseable goes through for the server to explain. */
61
+ export function normaliseOrigin(input) {
62
+ try {
63
+ const u = new URL(String(input).trim());
64
+ if (!/^https?:$/.test(u.protocol)) return input;
65
+ return u.origin;
66
+ } catch {
67
+ return input;
68
+ }
69
+ }
70
+
52
71
  // quox#907: a browser login is slower than a read; well above the client's 30s default.
53
72
  const LOGIN_TIMEOUT_MS = 120000;
54
73
 
@@ -308,14 +327,21 @@ export function registerQlarityCommands(program, ctx) {
308
327
  al.command('add <origin>')
309
328
  .description('Declare an origin this org may inspect, e.g. https://staging.example.com')
310
329
  .option('--note <text>', 'why it is allowed, shown alongside it later')
311
- .action(async (origin, opts) => {
330
+ .action(async (rawOrigin, opts) => {
331
+ const origin = normaliseOrigin(rawOrigin);
312
332
  try {
333
+ if (origin !== rawOrigin) process.stdout.write(`allowlisting ${origin} (scheme+host+port of ${rawOrigin})\n`);
313
334
  const d = await call(client, 'qlarity.orgAllowlist.add', {}, { origin, note: opts.note });
314
335
  process.stdout.write(`\u{1F50D} QLARITY: added ${d.entry.origin}\n`);
315
336
  process.stdout.write(` ${d.origins.length} origin(s) now declared for this org\n`);
316
337
  } catch (err) {
317
338
  // A refusal here is usually a wildcard or a typo, and the server sends
318
339
  // the reason. Surface it instead of a bare 400.
340
+ if (err?.status === 403) {
341
+ process.stderr.write(`Not added: adding an origin is an org-admin action by design (an agent cannot self-authorize egress).\nRetry as an org admin: quox -p <admin-profile> qlarity origins add ${origin}\n`);
342
+ process.exitCode = 4;
343
+ return;
344
+ }
319
345
  if (err?.status === 400 && err?.message) {
320
346
  process.stderr.write(`Not added: ${err.message}\n`);
321
347
  process.exitCode = 1;
@@ -395,6 +421,11 @@ export function registerQlarityCommands(program, ctx) {
395
421
  .option('--session <id>', 'reuse an existing session (e.g. one left open by `quox qlarity login`) instead of opening a fresh one')
396
422
  .option('--json', 'raw JSON output')
397
423
  .action(async (url, options) => {
424
+ if (SELECTOR_INSPECTORS.includes(options.inspector) && !options.selector) {
425
+ process.stderr.write(`${options.inspector} needs --selector. Try: quox qlarity read ${url} --inspector ${options.inspector} --selector 'body'\n`);
426
+ process.exitCode = 2;
427
+ return;
428
+ }
398
429
  let sessionId = null;
399
430
  let reused = false;
400
431
  try {
@@ -39,7 +39,8 @@ export function registerRunCommands(program, ctx) {
39
39
  let items = data.data || data.items || (Array.isArray(data) ? data : []);
40
40
  // an older engine that ignores the filter must still never show another workflow's runs
41
41
  if (options.workflow) items = items.filter((it) => (it.workflowId ?? it.workflow_id) === options.workflow);
42
- rows = items.map((item) => ({ ...item, source: 'engine' }));
42
+ // quoxflow rows are camelCase (workflowName, startedAt, completedAt); the table reads snake_case columns.
43
+ rows = items.map((item) => ({ ...item, workflow_name: item.workflow_name ?? item.workflowName, started_at: item.started_at ?? item.startedAt, finished_at: item.finished_at ?? item.completedAt ?? item.finishedAt, source: 'engine' }));
43
44
  source = 'engine';
44
45
  } catch (err) {
45
46
  printWarning(`Engine (quoxflow) execution list failed, falling back to the auth-service store: ${err.message}`);
@@ -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 { runInstallFlow } from '../server/installFlow.js';
27
28
  import { loadRegistry, saveServer, buildRows, planGo, hereIdentity, registryPath } from '../server/devServers.js';
28
29
 
29
30
  const icon = (s) => (s === 'pass' ? chalk.green('✔') : s === 'warn' ? chalk.yellow('⚠') : s === 'skip' ? chalk.dim('·') : chalk.red('✖'));
@@ -70,6 +71,42 @@ export function registerServerCommands(program, ctx = {}) {
70
71
  const server = program.command('server').description('Your self-hosted Quox install: status, update, rollback, uninstall, doctor, diag');
71
72
  const withDir = (c) => c.option('--dir <path>', 'the install directory (default: the one Getting started recorded)');
72
73
 
74
+ server.command('install')
75
+ .description('Install Quox on this machine: checks, download, start, owner account, one-time dashboard link (the same installer Getting started runs)')
76
+ .option('--dir <path>', 'where to install (default: ~/quox-install)')
77
+ .option('--dry-run', 'check this machine, print the plan, change nothing')
78
+ .option('-y, --yes', 'skip the confirmation (required without a terminal)')
79
+ .option('--json', 'one JSON object per line: stage events, then a result')
80
+ .option('--email <address>', 'owner account email (asked when there is a terminal)')
81
+ .option('--password-stdin', 'read the owner password from stdin, one line (never an argument)')
82
+ .option('--print-login-link', 'print the live one-time sign-in link (default: only on an interactive terminal, never in --json)')
83
+ .action(async (o) => {
84
+ const asJson = Boolean(o.json) || json();
85
+ const human = (e) => {
86
+ if (e.type === 'log') { console.log(chalk.dim(` ${e.line}`)); return; }
87
+ if (e.type === 'stage') {
88
+ if (e.status === 'running') console.log(` ${chalk.cyan('◐')} ${e.stage}`);
89
+ else if (e.status === 'ok') console.log(` ${chalk.green('✔')} ${e.stage}${e.detail ? chalk.dim(` ${e.detail}`) : ''}`);
90
+ else if (e.status === 'skip') console.log(` ${chalk.dim('·')} ${e.stage} skipped${e.detail ? `: ${e.detail}` : ''}${e.next ? `. ${e.next}` : ''}`);
91
+ else if (e.status === 'fail') { console.log(chalk.red(` ✖ ${e.stage} failed: ${e.error}`)); console.log(` Next: ${e.next}`); }
92
+ } else if (e.type === 'plan') {
93
+ console.log(chalk.bold(`\n Plan: install Quox into ${e.dir || '~/quox-install'}`));
94
+ for (const c of e.checks) console.log(` ${c.status === 'fail' ? chalk.red('✖') : c.status === 'warn' ? chalk.yellow('⚠') : chalk.green('✔')} ${c.id.padEnd(8)} ${c.body}`);
95
+ console.log(' It will:'); e.steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
96
+ console.log(chalk.dim('\n Dry run: nothing was changed. Run quox server install to install.\n'));
97
+ } else if (e.type === 'result' && e.ok && !e.dryRun) {
98
+ console.log(chalk.green('\n ✔ Your Quox is running.'));
99
+ if (e.link) console.log(` Dashboard sign-in link (single use, 10 minutes): ${e.link}`);
100
+ else console.log(` A one-time sign-in link was issued but not printed (stdout is not a terminal). Get one with: ${e.linkHow}`);
101
+ console.log(chalk.dim(' Your browser will warn about the certificate once: it is the self-signed one this install made.\n'));
102
+ }
103
+ };
104
+ const emit = (e) => (asJson ? console.log(JSON.stringify(e)) : human(e));
105
+ const r = await runInstallFlow({ dir: o.dir, dryRun: dry(o), yes: yes(o), email: o.email, passwordStdin: Boolean(o.passwordStdin), printLink: Boolean(o.printLoginLink) || (!asJson && Boolean(process.stdout.isTTY)) }, deps.installFlowDeps || {}, emit);
106
+ if (!r.ok && !r.dryRun) process.exitCode = r.declined ? 1 : 2;
107
+ if (!r.ok && r.dryRun === undefined && r.failedStage && dry(o)) process.exitCode = 2;
108
+ });
109
+
73
110
  withDir(server.command('status').description('Version, health per service, disk, ports and the last update (read-only)'))
74
111
  .action(async (o) => {
75
112
  const i = await install(o);