quoxcli 0.5.0 → 0.6.1

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 (130) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/governance.js +7 -0
  5. package/lib/api/endpoints/inboxq.js +39 -0
  6. package/lib/api/endpoints/index.js +4 -0
  7. package/lib/api/endpoints/lens.js +34 -0
  8. package/lib/api/endpoints/lucid.js +18 -0
  9. package/lib/api/endpoints/mcp.js +35 -0
  10. package/lib/api/endpoints/mon.js +6 -7
  11. package/lib/api/endpoints/notesq.js +2 -0
  12. package/lib/api/endpoints/quoxplan.js +51 -0
  13. package/lib/api/endpoints/v1.js +57 -0
  14. package/lib/commands/actions.js +51 -13
  15. package/lib/commands/admin.js +86 -0
  16. package/lib/commands/approvals.js +5 -1
  17. package/lib/commands/calendar.js +27 -0
  18. package/lib/commands/chat.js +1 -1
  19. package/lib/commands/contacts.js +2 -0
  20. package/lib/commands/fleet.js +164 -0
  21. package/lib/commands/fleetRecipe.js +174 -0
  22. package/lib/commands/lens.js +268 -0
  23. package/lib/commands/lucid.js +6 -0
  24. package/lib/commands/lucidGoals.js +423 -0
  25. package/lib/commands/mail.js +287 -7
  26. package/lib/commands/mcp.js +190 -0
  27. package/lib/commands/mon.js +14 -0
  28. package/lib/commands/monitoring.js +73 -1
  29. package/lib/commands/notesSeal.js +380 -22
  30. package/lib/commands/offload.js +51 -10
  31. package/lib/commands/plan.js +507 -54
  32. package/lib/commands/research.js +78 -0
  33. package/lib/commands/settings.js +7 -1
  34. package/lib/commands/volt.js +6 -1
  35. package/lib/commands/wazuh.js +74 -9
  36. package/lib/commands/wazuhIntake.js +55 -6
  37. package/lib/core/commandRegistrars.js +2 -0
  38. package/lib/core/config.js +14 -1
  39. package/lib/core/offload.js +83 -0
  40. package/lib/core/output.js +29 -4
  41. package/lib/core/selfUpdate.js +2 -0
  42. package/lib/core/version.js +15 -2
  43. package/lib/dragon/select.js +2 -2
  44. package/lib/quoxon/api.js +4 -0
  45. package/lib/quoxon/app.js +258 -49
  46. package/lib/quoxon/approvalsView.js +3 -1
  47. package/lib/quoxon/ask.js +4 -2
  48. package/lib/quoxon/bang.js +7 -1
  49. package/lib/quoxon/commandShort.js +1 -1
  50. package/lib/quoxon/commands.js +5 -5
  51. package/lib/quoxon/delegationDragon.js +49 -0
  52. package/lib/quoxon/dnd.js +47 -0
  53. package/lib/quoxon/dragonCompanion.js +24 -12
  54. package/lib/quoxon/events.js +12 -1
  55. package/lib/quoxon/extensions.js +2 -0
  56. package/lib/quoxon/foreignText.js +49 -0
  57. package/lib/quoxon/inboxView.js +5 -5
  58. package/lib/quoxon/launcher.js +2 -1
  59. package/lib/quoxon/loopCard.js +2 -1
  60. package/lib/quoxon/main.js +8 -2
  61. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  62. package/lib/quoxon/onboarding/view.js +1 -1
  63. package/lib/quoxon/render.js +5 -2
  64. package/lib/quoxon/resend.js +14 -0
  65. package/lib/quoxon/rows.js +3 -1
  66. package/lib/quoxon/secretShape.js +2 -0
  67. package/lib/quoxon/slash/broadcast.js +30 -6
  68. package/lib/quoxon/slash/broadcastRun.js +12 -6
  69. package/lib/quoxon/slash/cliLeaves.json +54 -0
  70. package/lib/quoxon/slash/dashboard.js +2 -0
  71. package/lib/quoxon/slash/listView.js +2 -1
  72. package/lib/quoxon/slash/palette.js +41 -0
  73. package/lib/quoxon/slash/paletteController.js +6 -2
  74. package/lib/quoxon/slash/restart.js +2 -0
  75. package/lib/quoxon/slotChat.js +7 -3
  76. package/lib/quoxon/slotRun.js +2 -0
  77. package/lib/quoxon/webPty.js +15 -0
  78. package/lib/quoxon/whatsnew.jsonl +7 -0
  79. package/lib/render/planBrief.js +614 -0
  80. package/lib/toolOutputShape.js +6 -3
  81. package/package.json +1 -1
  82. package/vendor/dragonlab/SOURCE +3 -3
  83. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  84. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  85. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  86. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  87. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  88. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  89. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  90. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  91. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  92. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  93. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  94. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  95. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  96. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  97. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  98. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  99. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  100. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  101. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  102. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  103. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  104. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  105. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  106. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  107. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  108. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  109. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  110. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  111. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  112. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  113. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  114. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  115. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  116. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  117. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  118. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  119. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  120. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  121. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  122. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  123. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  124. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  125. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  126. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  127. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  128. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  129. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  130. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
@@ -11,6 +11,9 @@ import { readFileSync, statSync } from 'fs';
11
11
  import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
12
12
  import { parseJSON, resolveOrgId } from './helpers.js';
13
13
  import { resolveEndpoint } from '../api/endpoints.js';
14
+ import { announce, announceOnFail } from '../core/announce.js';
15
+ import { confirmInteractive } from '../core/confirm.js';
16
+ import { QuoxUserError } from '../core/errors.js';
14
17
 
15
18
  // --- minimal CSV parsing (quoted fields + escaped quotes, no deps) ----------
16
19
  function splitCsvLine(line) {
@@ -46,7 +49,77 @@ function parseCsvFile(path) {
46
49
  }
47
50
 
48
51
  const VALID_ZONES = ['focus', 'fyi', 'auto', 'screened'];
49
- const VALID_KINDS = ['file', 'block', 'draft'];
52
+ // The server accepts file|block|draft|rezone|folder_create|rule (api.js PROPOSAL_KINDS).
53
+ // 'rule' is left out on purpose: it has no executor, so approving one only records the
54
+ // decision and nothing happens to the mailbox (api.js hResolveProposal). Offering it
55
+ // would let a user file an approval that can never do what it says.
56
+ const VALID_KINDS = ['file', 'block', 'draft', 'rezone', 'folder_create'];
57
+ const PROPOSE_PAYLOADS = `Payload per --kind (a JSON object):
58
+ block {"scope":"sender"|"domain","target":"<address or domain>"}
59
+ scope sender: target is the sender address (fromAddr is accepted too);
60
+ scope domain: target is the domain, e.g. "spam.test" (domain is accepted too).
61
+ Approving it runs the same block as \`quox mail block\`: stored mail is
62
+ screened and moved to Junk. \`quox mail block <addr|@domain> --propose\`
63
+ builds this payload for you.
64
+ file {"messageIds":["<id>",...],"folder":"<folder name, id or IMAP path>"}
65
+ rezone {"messageIds":["<id>",...],"toZone":"focus"|"fyi"|"auto"|"screened"}
66
+ folder_create {"name":"<folder name>"}
67
+ draft {"messageId":"<id to reply to>","body":"<text>"} (or "to" instead of messageId)
68
+ Kind "rule" is not offered: the server has no executor for it yet, so approving one would change nothing.`;
69
+
70
+ /**
71
+ * Parse a block/unblock target: "@domain" is a domain, "a@b" a sender address.
72
+ * --scope domain on an address blocks the address's domain.
73
+ * -> { scope, target, fromAddr?, domain? } (all lower-cased).
74
+ */
75
+ function parseBlockTarget(raw, scopeOpt) {
76
+ const t = String(raw || '').trim().toLowerCase();
77
+ if (scopeOpt && !['sender', 'domain'].includes(scopeOpt)) {
78
+ throw new QuoxUserError(`invalid --scope "${scopeOpt}" (expected sender or domain)`, { exitCode: 2 });
79
+ }
80
+ if (t.startsWith('@')) {
81
+ const domain = t.slice(1);
82
+ if (!domain || domain.includes('@')) throw new QuoxUserError(`"${raw}" is not a domain. Use @example.com.`, { exitCode: 2 });
83
+ if (scopeOpt === 'sender') throw new QuoxUserError(`--scope sender needs a sender address, not ${raw}. Drop --scope (an @domain is a domain block) or give the address.`, { exitCode: 2 });
84
+ return { scope: 'domain', target: domain, domain };
85
+ }
86
+ const at = t.indexOf('@');
87
+ if (at > 0 && at < t.length - 1 && t.indexOf('@', at + 1) === -1) {
88
+ if (scopeOpt === 'domain') {
89
+ const domain = t.slice(at + 1);
90
+ return { scope: 'domain', target: domain, fromAddr: t, domain };
91
+ }
92
+ return { scope: 'sender', target: t, fromAddr: t };
93
+ }
94
+ throw new QuoxUserError(`"${raw}" is not a sender address or @domain. Give an address (tanya@example.com) or a domain (@example.com). To find an address by name: quox mail list --account <id>`, { exitCode: 2 });
95
+ }
96
+
97
+ /** The block proposal payload the server executor reads (api.js executeBlockProposal). */
98
+ function blockProposalPayload(t) {
99
+ return t.scope === 'domain'
100
+ ? { scope: 'domain', target: t.target, domain: t.domain }
101
+ : { scope: 'sender', target: t.target, fromAddr: t.fromAddr };
102
+ }
103
+
104
+ /** Validate a block payload before it reaches the server (the server also checks). */
105
+ function checkBlockPayload(payload) {
106
+ const p = payload && typeof payload === 'object' ? payload : {};
107
+ if (!['sender', 'domain'].includes(p.scope)) {
108
+ throw new QuoxUserError('block payload needs "scope": "sender" or "domain". Example: {"scope":"sender","target":"tanya@example.com"}', { exitCode: 2 });
109
+ }
110
+ const target = p.target || (p.scope === 'domain' ? (p.domain || (p.fromAddr ? String(p.fromAddr).split('@')[1] : '')) : p.fromAddr);
111
+ if (!target || !String(target).trim()) {
112
+ throw new QuoxUserError(`block payload needs "target" (the ${p.scope === 'domain' ? 'domain' : 'sender address'}). Example: {"scope":"${p.scope}","target":"${p.scope === 'domain' ? 'example.com' : 'tanya@example.com'}"}`, { exitCode: 2 });
113
+ }
114
+ }
115
+
116
+ const JUNK_NAME = /\b(spam|junk|bulk)\b/i;
117
+
118
+ /** --yes (local or root -y) or an interactive y/N. Throws with no terminal. */
119
+ async function confirmWrite(program, options, message) {
120
+ if (options.yes || (program.opts && program.opts().yes)) return true;
121
+ return confirmInteractive(message, { flag: '--yes', purpose: 'confirm' });
122
+ }
50
123
  const VALID_DECISIONS = ['approve', 'deny'];
51
124
  // quox#1518: past tense comes from a map, never verb + 'd' ("denyd").
52
125
  const DECISION_PAST_TENSE = { approve: 'approved', deny: 'denied', reject: 'rejected' };
@@ -112,9 +185,11 @@ export function registerMailCommands(program, ctx) {
112
185
  .command('connect')
113
186
  .description('Connect a mailbox. Reuse a stored credential (--credential), or create one inline from IMAP settings (--imap-host with --password-env, or --provider gmail|icloud|outlook to pre-fill host/port). Creating a credential needs a human/admin profile, e.g. quox -p moo mail connect ...')
114
187
  .option('--credential <id>', 'reuse a stored email_account credential ID')
115
- .requiredOption('--email <address>', 'mailbox address (also the IMAP username unless --username is given)')
188
+ .option('--email <address>', 'mailbox address (also the IMAP username unless --username is given); required except with --provider fixture')
116
189
  .option('--username <user>', 'IMAP username, if different from --email')
117
- .option('--provider <name>', 'named email provider (gmail, icloud, outlook): pre-fills IMAP/SMTP host and port')
190
+ .option('--provider <name>', 'named email provider (gmail, icloud, outlook): pre-fills IMAP/SMTP host and port; fixture = a seeded scratch-org mailbox (needs --seed)')
191
+ .option('--seed <name>', 'with --provider fixture: the fixture seed to load (e.g. scratch-office-day); scratch orgs on a fixture-enabled deployment only, the server decides')
192
+ .option('--run <id>', 'with --provider fixture: proof run id substituted into the seed ({{run}})')
118
193
  .option('--imap-host <host>', 'IMAP host (creating a new credential; not required with --provider)')
119
194
  .option('--imap-port <n>', 'IMAP port', '993')
120
195
  .option('--smtp-host <host>', 'SMTP host (defaults to --imap-host)')
@@ -142,6 +217,23 @@ export function registerMailCommands(program, ctx) {
142
217
  );
143
218
  }
144
219
 
220
+ // AQ-9H LADON-P1B: a seeded fixture mailbox for scratch-org proof runs. No credential and no env
221
+ // pre-check here: the collector's fixture gate (deployment env + platform-admin scratch mark) decides,
222
+ // and its 403 names the condition that failed.
223
+ if (options.provider === 'fixture') {
224
+ if (!options.seed) throw new Error('--seed <name> is required with --provider fixture (e.g. --seed scratch-office-day).');
225
+ const fbody = { provider: 'fixture', seed: options.seed };
226
+ if (options.run) fbody.run = options.run;
227
+ if (options.name) fbody.displayName = options.name;
228
+ const fep = resolveEndpoint('inboxq.accounts.create');
229
+ const fdata = await client.post(fep.path, { service: fep.service, body: fbody });
230
+ if (isJsonOutput(program)) return printJson(fdata);
231
+ const facc = fdata.account || fdata;
232
+ printSuccess(`Connected fixture mailbox ${facc.emailAddress || ''} (${facc.id}) from seed ${options.seed} in org ${sessionOrg || '(active)'}`);
233
+ return;
234
+ }
235
+ if (!options.email) throw new Error("required option '--email <address>' not specified");
236
+
145
237
  let credentialId = options.credential;
146
238
 
147
239
  // Inline credential creation: turns the ugly `vault create --credentials-stdin`
@@ -579,20 +671,208 @@ export function registerMailCommands(program, ctx) {
579
671
  printSuccess(`Watch ${watchId} is now ${active ? 'active' : 'inactive'}.`);
580
672
  });
581
673
 
674
+ // ── senders: block / unblock / blocked / junk / trust ──────────────────
675
+ // EMAIL-Q CLI audit (2026-11-09): the SAME routes the dashboard calls
676
+ // (src/components/inboxq/lib/api.js). Blocking is the human-authoritative
677
+ // instant path (POST /senders/block), exactly as the "Block sender" button;
678
+ // --propose files a governed block proposal instead.
679
+
680
+ mail
681
+ .command('block')
682
+ .description('Block a sender address or @domain on one account (stored mail screened and moved to Junk). --dry-run counts matches first; --propose files an approval instead')
683
+ .addHelpText('after', `
684
+ Same action as the dashboard "Block sender" button (POST /senders/block). Future mail from it is
685
+ screened while Quox syncs the account; no rule is set on the mail server itself.
686
+ Examples:
687
+ quox mail block tanya@example.com --account <id> --dry-run
688
+ quox mail block tanya@example.com --account <id> --yes
689
+ quox mail block @example.com --account <id> --propose
690
+ Proposal payload (--propose): {"scope":"sender"|"domain","target":"<address or domain>"}
691
+ `)
692
+ .argument('<target>', 'sender address (tanya@example.com) or @domain (@example.com)')
693
+ .requiredOption('--account <id>', 'account ID (quox mail accounts)')
694
+ .option('--scope <scope>', 'sender or domain (default: sender for an address, domain for @domain)')
695
+ .option('--dry-run', 'count the stored messages that match and what would move; change nothing')
696
+ .option('--propose', 'file a block proposal for approval instead of blocking now (payload {scope, target, fromAddr|domain})')
697
+ .option('--yes', 'confirm the block (refused without it when there is no terminal)')
698
+ .action(announceOnFail('mail', (target) => `block ${target}`, async (rawTarget, options) => {
699
+ auth.requireAuth();
700
+ if (options.dryRun && options.propose) {
701
+ throw new QuoxUserError('Use --dry-run or --propose, not both. Run the dry run first, then --propose.', { exitCode: 2 });
702
+ }
703
+ const t = parseBlockTarget(rawTarget, options.scope);
704
+ const accountId = options.account;
705
+
706
+ if (options.dryRun) {
707
+ const ep = resolveEndpoint('inboxq.messages.search');
708
+ const query = { accountId, limit: 200 };
709
+ if (t.scope === 'domain') query.fromDomain = t.domain; else query.from = t.fromAddr;
710
+ const data = await client.get(ep.path, { service: ep.service, query });
711
+ const msgs = Array.isArray(data?.messages) ? data.messages : [];
712
+ // The block executor matches the sender EXACTLY (domain: address ends with @domain);
713
+ // the search is a substring match, so count only what the block would really touch.
714
+ const hit = (m) => {
715
+ const f = String(m.fromAddr || '').trim().toLowerCase();
716
+ return t.scope === 'domain' ? f.endsWith(`@${t.domain}`) : f === t.fromAddr;
717
+ };
718
+ const matched = msgs.filter(hit);
719
+ const alreadyInJunk = matched.filter((m) => JUNK_NAME.test(String(m.folder || ''))).length;
720
+ const capped = Number(data?.total || 0) > msgs.length;
721
+ const result = { dryRun: true, accountId, scope: t.scope, target: t.target, matched: matched.length, alreadyInJunk, wouldMove: matched.length - alreadyInJunk, capped };
722
+ if (isJsonOutput(program)) return printJson(result);
723
+ const atLeast = capped ? 'at least ' : '';
724
+ console.log(`Dry run: blocking ${t.target} (${t.scope}) on ${accountId} would screen ${atLeast}${matched.length} stored message(s) and move ${atLeast}${result.wouldMove} to Junk (${alreadyInJunk} already there).`);
725
+ if (capped) console.log('The search returns at most 200 messages, so the real count can be higher.');
726
+ console.log('Nothing changed. Run it without --dry-run to block, or with --propose to file an approval.');
727
+ announce('mail', `block ${t.target} dry run`, { result: `${atLeast}${matched.length} match, nothing changed`, status: 'ok' });
728
+ return;
729
+ }
730
+
731
+ if (options.propose) {
732
+ const ep = resolveEndpoint('inboxq.proposals.create');
733
+ const body = { accountId, kind: 'block', payload: blockProposalPayload(t), createdVia: 'cli' };
734
+ const data = await client.post(ep.path, { service: ep.service, body });
735
+ if (isJsonOutput(program)) return printJson(data);
736
+ const proposal = data.proposal || data;
737
+ printSuccess(`Proposed blocking ${t.target} (${t.scope}) on ${accountId}: proposal ${proposal.id}. Nothing changes until it is approved.`);
738
+ console.log(`Approve: quox mail resolve ${proposal.id} --decision approve`);
739
+ announce('mail', `propose block ${t.target}`, { result: `proposal ${proposal.id}`, status: 'ok' });
740
+ return;
741
+ }
742
+
743
+ const go = await confirmWrite(program, options, `Block ${t.target} (${t.scope}) on ${accountId}? Its stored mail is screened and moved to Junk.`);
744
+ if (!go) {
745
+ console.log('Not blocked. Nothing changed.');
746
+ announce('mail', `block ${t.target}`, { result: 'cancelled, nothing changed', status: 'warn' });
747
+ return;
748
+ }
749
+ const ep = resolveEndpoint('inboxq.senders.block');
750
+ const body = t.scope === 'domain'
751
+ ? { ...(t.fromAddr ? { fromAddr: t.fromAddr } : {}), accountId, scope: 'domain', domain: t.domain }
752
+ : { fromAddr: t.fromAddr, accountId, scope: 'sender' };
753
+ const data = await client.post(ep.path, { service: ep.service, body });
754
+ if (isJsonOutput(program)) return printJson(data);
755
+ const screened = data?.screened ?? 0;
756
+ const moved = data?.junkMoved ?? 0;
757
+ printSuccess(`Blocked ${data?.target || t.target} (${data?.scope || t.scope}) on ${accountId}: ${screened} stored message(s) screened, ${moved} moved to ${data?.junkFolder || 'Junk'}.`);
758
+ if (data?.junkFailed) {
759
+ console.error(`The move to Junk failed for ${Math.max(0, (data.junkMatched ?? 0) - moved)} message(s); they are screened in Quox but still in their folder on the mail server. Retry: quox mail sync ${accountId}, then block again.`);
760
+ }
761
+ if (!data?.providerRule || data.providerRule === 'none') {
762
+ console.log('Future mail from it is screened while Quox syncs this account; no rule was set on the mail server itself.');
763
+ }
764
+ announce('mail', `block ${t.target}`, { result: `${screened} screened, ${moved} to Junk`, status: data?.junkFailed ? 'warn' : 'ok' });
765
+ }));
766
+
767
+ mail
768
+ .command('unblock')
769
+ .description('Lift a sender or domain block on one account (future mail is no longer screened for it). Mail already in Junk stays there; use quox mail trust <address> to move a sender\'s mail back. Example: quox mail unblock @example.com --account <id> --yes')
770
+ .argument('<target>', 'sender address or @domain')
771
+ .requiredOption('--account <id>', 'account ID')
772
+ .option('--scope <scope>', 'sender or domain (default: sender for an address, domain for @domain)')
773
+ .option('--yes', 'confirm (refused without it when there is no terminal)')
774
+ .action(announceOnFail('mail', (target) => `unblock ${target}`, async (rawTarget, options) => {
775
+ auth.requireAuth();
776
+ const t = parseBlockTarget(rawTarget, options.scope);
777
+ const go = await confirmWrite(program, options, `Unblock ${t.target} (${t.scope}) on ${options.account}?`);
778
+ if (!go) {
779
+ console.log('Not unblocked. Nothing changed.');
780
+ announce('mail', `unblock ${t.target}`, { result: 'cancelled, nothing changed', status: 'warn' });
781
+ return;
782
+ }
783
+ const ep = resolveEndpoint('inboxq.senders.unblock');
784
+ const data = await client.post(ep.path, { service: ep.service, body: { target: t.target, type: t.scope, accountId: options.account } });
785
+ if (isJsonOutput(program)) return printJson(data);
786
+ printSuccess(`Unblocked ${t.target} (${t.scope}) on ${options.account}.`);
787
+ announce('mail', `unblock ${t.target}`, { result: t.scope, status: 'ok' });
788
+ }));
789
+
790
+ mail
791
+ .command('blocked')
792
+ .description('List the blocked senders and domains on one account')
793
+ .requiredOption('--account <id>', 'account ID')
794
+ .action(async (options) => {
795
+ auth.requireAuth();
796
+ const ep = resolveEndpoint('inboxq.senders.blocked');
797
+ const data = await client.get(ep.path, { service: ep.service, query: { accountId: options.account } });
798
+ if (isJsonOutput(program)) return printJson(data);
799
+ const rows = data?.blocked || [];
800
+ if (!rows.length) { console.log(`Nothing is blocked on ${options.account}.`); return; }
801
+ formatOutput({
802
+ columns: ['target', 'type', 'blockedAt'],
803
+ headers: ['Blocked', 'Type', 'Since'],
804
+ rows,
805
+ }, { format: program.opts().output });
806
+ });
807
+
808
+ mail
809
+ .command('junk')
810
+ .description('Confirm messages are junk: each sender is blocked and the messages move to the account\'s Junk folder on the mail server. Example: quox mail junk <messageId> <messageId> --account <id> --yes')
811
+ .argument('<messageIds...>', 'message IDs (quox mail list)')
812
+ .requiredOption('--account <id>', 'account ID')
813
+ .option('--yes', 'confirm (refused without it when there is no terminal)')
814
+ .action(announceOnFail('mail', (ids) => `junk ${ids.length} message(s)`, async (messageIds, options) => {
815
+ auth.requireAuth();
816
+ const go = await confirmWrite(program, options, `Mark ${messageIds.length} message(s) as junk on ${options.account}? Their senders are blocked and the messages move to Junk.`);
817
+ if (!go) {
818
+ console.log('Nothing marked as junk.');
819
+ announce('mail', `junk ${messageIds.length} message(s)`, { result: 'cancelled, nothing changed', status: 'warn' });
820
+ return;
821
+ }
822
+ const ep = resolveEndpoint('inboxq.messages.confirmJunk');
823
+ const data = await client.post(ep.path, { service: ep.service, body: { messageIds, accountId: options.account } });
824
+ if (isJsonOutput(program)) {
825
+ if ((data?.failed || []).length) process.exitCode = 1;
826
+ return printJson(data);
827
+ }
828
+ const failed = data?.failed || [];
829
+ printSuccess(`Moved ${data?.moved ?? 0} message(s) to Junk and blocked ${data?.blocked ?? 0} sender(s) on ${options.account}.`);
830
+ if (failed.length) printFailure(`${failed.length} message(s) were not moved: ${failed.join(', ')} (not found on this account, or the mail server refused the move).`);
831
+ announce('mail', `junk ${messageIds.length} message(s)`, { result: `${data?.moved ?? 0} to Junk${failed.length ? `, ${failed.length} failed` : ''}`, status: failed.length ? 'fail' : 'ok' });
832
+ }));
833
+
834
+ mail
835
+ .command('trust')
836
+ .description('Trust a sender (not junk): lifts their block, restores their stored mail to its zone, and moves it out of Junk back to INBOX. Example: quox mail trust tanya@example.com --account <id> --yes')
837
+ .argument('<address>', 'sender address')
838
+ .requiredOption('--account <id>', 'account ID')
839
+ .option('--yes', 'confirm (refused without it when there is no terminal)')
840
+ .action(announceOnFail('mail', (a) => `trust ${a}`, async (rawAddress, options) => {
841
+ auth.requireAuth();
842
+ const t = parseBlockTarget(rawAddress);
843
+ if (t.scope !== 'sender') throw new QuoxUserError('trust takes one sender address, not a domain.', { exitCode: 2 });
844
+ const go = await confirmWrite(program, options, `Trust ${t.fromAddr} on ${options.account}? Their mail moves out of Junk back to INBOX.`);
845
+ if (!go) {
846
+ console.log('Not trusted. Nothing changed.');
847
+ announce('mail', `trust ${t.fromAddr}`, { result: 'cancelled, nothing changed', status: 'warn' });
848
+ return;
849
+ }
850
+ const ep = resolveEndpoint('inboxq.senders.trust');
851
+ const data = await client.post(ep.path, { service: ep.service, body: { fromAddr: t.fromAddr, accountId: options.account } });
852
+ if (isJsonOutput(program)) return printJson(data);
853
+ const back = data?.movedBack;
854
+ printSuccess(`Trusted ${t.fromAddr} on ${options.account}: ${data?.restored ?? 0} stored message(s) restored${back !== undefined ? `, ${back} moved back to INBOX` : ''}.`);
855
+ if (data?.movedBackFailed) console.error('Some of their mail could not be moved out of Junk on the mail server; it is restored in Quox only.');
856
+ announce('mail', `trust ${t.fromAddr}`, { result: `${data?.restored ?? 0} restored`, status: data?.movedBackFailed ? 'warn' : 'ok' });
857
+ }));
858
+
582
859
  // ── proposals ────────────────────────────────────────────────────────
583
860
 
584
861
  mail
585
862
  .command('propose')
586
- .description('Create a proposal (file, block or draft) for an account')
863
+ .description('Propose a change for approval (file, block, draft, rezone, folder_create). Block payload: {"scope":"sender"|"domain","target":"<address or domain>"}')
587
864
  .requiredOption('--account <id>', 'account ID')
588
- .requiredOption('--kind <kind>', 'proposal kind: file, block, draft')
589
- .requiredOption('--payload <json>', 'proposal payload as a JSON object')
865
+ .requiredOption('--kind <kind>', `proposal kind: ${VALID_KINDS.join(', ')} (not rule: no executor yet)`)
866
+ .requiredOption('--payload <json>', 'proposal payload as a JSON object (shape per kind below)')
867
+ .addHelpText('after', `\nNothing changes until a human approves it: quox mail resolve <id> --decision approve\n\n${PROPOSE_PAYLOADS}\n`)
590
868
  .action(async (options) => {
591
869
  auth.requireAuth();
592
870
  if (!VALID_KINDS.includes(options.kind)) {
593
- throw new Error(`invalid --kind "${options.kind}" (expected one of ${VALID_KINDS.join(', ')})`);
871
+ const why = options.kind === 'rule' ? ' Kind "rule" has no executor on the server yet, so approving it would change nothing.' : '';
872
+ throw new Error(`invalid --kind "${options.kind}" (expected one of ${VALID_KINDS.join(', ')}).${why}`);
594
873
  }
595
874
  const payload = parseJSON(options.payload, '--payload');
875
+ if (options.kind === 'block') checkBlockPayload(payload);
596
876
  const body = { accountId: options.account, kind: options.kind, payload };
597
877
  const ep = resolveEndpoint('inboxq.proposals.create');
598
878
  const data = await client.post(ep.path, { service: ep.service, body });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * QuoxMCP setup from the CLI (VULCAN): the CLI rung under Settings > MCP credentials.
3
+ *
4
+ * quox mcp token create --label <text> [--days <1-90>] mint a personal MCP token (raw token shown ONCE)
5
+ * quox mcp token list your MCP tokens (never the token or its hash)
6
+ * quox mcp token revoke <id> revoke one of your tokens
7
+ * quox mcp bundle [--out <file>] personalised Claude Desktop .mcpb (EMBEDS a fresh token)
8
+ *
9
+ * Tokens: auth /api/auth/tokens/mcp (services/auth/routes/mcp-tokens.js). Bundle: collector /api/v1/mcp/bundle
10
+ * (services/collector/routes/mcp-bundle.js), which takes the raw token in the body and bakes it into the
11
+ * bundle manifest as QUOX_SERVICE_KEY, so the bundle file is a credential: written 0600, never printed.
12
+ */
13
+
14
+ import { existsSync, writeFileSync, chmodSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import { formatOutput, printSuccess, printInfo, printWarning } from '../core/output.js';
17
+ import { resolveEndpoint } from '../api/endpoints.js';
18
+ import { QuoxError } from '../core/errors.js';
19
+
20
+ const MAX_DAYS = 90;
21
+ const OUTPUT_FORMATS = ['table', 'json', 'csv', 'quiet'];
22
+ const DEFAULT_BUNDLE = 'quoxmcp.mcpb';
23
+
24
+ function parseDays(raw) {
25
+ if (raw === undefined) return undefined;
26
+ const s = String(raw).trim();
27
+ const n = Number(s);
28
+ if (!/^\d+$/.test(s) || n < 1 || n > MAX_DAYS) {
29
+ throw new QuoxError(`--days must be a whole number from 1 to ${MAX_DAYS} (got "${raw}")`, 1);
30
+ }
31
+ return n;
32
+ }
33
+
34
+ function parseLabel(raw) {
35
+ const label = String(raw ?? '').trim();
36
+ if (!label) throw new QuoxError('--label must not be empty', 1);
37
+ if (label.length > 128) throw new QuoxError('--label must be 128 characters or fewer', 1);
38
+ return label;
39
+ }
40
+
41
+ const isJson = (program) => program.opts().output === 'json';
42
+
43
+ export function registerMcpCommands(program, ctx) {
44
+ const { client, auth, config } = ctx;
45
+
46
+ async function mint(label, days) {
47
+ const ep = resolveEndpoint('mcp.tokens.create');
48
+ return client.post(ep.path, {
49
+ service: ep.service,
50
+ sensitive: true,
51
+ body: { label, ...(days !== undefined ? { expires_in_days: days } : {}) },
52
+ });
53
+ }
54
+
55
+ const mcp = program
56
+ .command('mcp')
57
+ .description('QuoxMCP setup: personal MCP tokens and the Claude Desktop bundle');
58
+
59
+ const token = mcp
60
+ .command('token')
61
+ .description('Personal MCP tokens (qmcp_...) for connecting an MCP client to Quox');
62
+
63
+ token
64
+ .command('create')
65
+ .description(`Mint a personal MCP token for your active org; the raw token is shown ONCE (expires in ${MAX_DAYS} days unless --days is lower)`)
66
+ .requiredOption('--label <text>', 'what this token is for, e.g. "work laptop" (max 128 chars)')
67
+ .option('--days <n>', `days until it expires, 1 to ${MAX_DAYS} (default ${MAX_DAYS})`)
68
+ .action(async (opts) => {
69
+ auth.requireAuth();
70
+ const label = parseLabel(opts.label);
71
+ const days = parseDays(opts.days);
72
+ const data = await mint(label, days);
73
+ if (isJson(program)) {
74
+ console.log(JSON.stringify(data, null, 2));
75
+ return;
76
+ }
77
+ printSuccess(`MCP token ${data.id} created (label "${data.label ?? label}").`);
78
+ console.log('Token (shown once, store it now; it cannot be shown again):');
79
+ console.log(data.token);
80
+ console.log(`Expires: ${data.expires_at || 'no expiry'}`);
81
+ printInfo(`Revoke it any time with: quox mcp token revoke ${data.id}`);
82
+ });
83
+
84
+ token
85
+ .command('list')
86
+ .description('List your MCP tokens (id, label, created, last used, expiry, revoked); never shows a token value')
87
+ .action(async () => {
88
+ auth.requireAuth();
89
+ const ep = resolveEndpoint('mcp.tokens.list');
90
+ const data = await client.get(ep.path, { service: ep.service });
91
+ const tokens = Array.isArray(data?.tokens) ? data.tokens : [];
92
+ formatOutput({
93
+ columns: ['id', 'label', 'created_at', 'last_used_at', 'expires_at', 'revoked_at'],
94
+ headers: ['ID', 'Label', 'Created', 'Last used', 'Expires', 'Revoked'],
95
+ rows: tokens.map((t) => ({
96
+ id: t.id,
97
+ label: t.label,
98
+ created_at: t.created_at || '',
99
+ last_used_at: t.last_used_at || 'never',
100
+ expires_at: t.expires_at || 'no expiry',
101
+ revoked_at: t.revoked_at || 'no',
102
+ })),
103
+ }, { format: program.opts().output, raw: tokens, emptyMessage: 'No MCP tokens. Create one with: quox mcp token create --label <text>' });
104
+ });
105
+
106
+ token
107
+ .command('revoke')
108
+ .description('Revoke one of your MCP tokens (takes effect at the collector straight away)')
109
+ .argument('<id>', 'token id from `quox mcp token list`')
110
+ .action(async (id) => {
111
+ auth.requireAuth();
112
+ const ep = resolveEndpoint('mcp.tokens.revoke', { id });
113
+ try {
114
+ await client.delete(ep.path, { service: ep.service });
115
+ } catch (err) {
116
+ if (err?.status === 404) throw new QuoxError(`MCP token ${id} not found or already revoked.`, 1);
117
+ throw err;
118
+ }
119
+ printSuccess(`MCP token ${id} revoked.`);
120
+ });
121
+
122
+ mcp
123
+ .command('bundle')
124
+ .description('Download a personalised QuoxMCP bundle (.mcpb) for Claude Desktop. It mints a NEW MCP token and embeds a live MCP token in the file: treat the file as a secret (written 0600, never printed)')
125
+ .option('--out <path>', `where to save it (-o <path> also works); default ./${DEFAULT_BUNDLE}`)
126
+ .option('--label <text>', 'label for the token minted into the bundle', 'Claude Desktop')
127
+ .option('--days <n>', `days until the embedded token expires, 1 to ${MAX_DAYS} (default ${MAX_DAYS})`)
128
+ .option('--collector-url <url>', 'collector URL the bundle will call (default: this profile\'s collector URL)')
129
+ .option('--force', 'overwrite an existing file')
130
+ .addHelpText('after', '\nThe bundle embeds a live MCP token. Anyone holding the file can act as you over MCP until the token\nexpires or you revoke it (quox mcp token revoke <id>). Do not commit or share it.\n')
131
+ .action(async (opts) => {
132
+ auth.requireAuth();
133
+ const label = parseLabel(opts.label);
134
+ const days = parseDays(opts.days);
135
+ const collectorUrl = opts.collectorUrl || config?.getServiceUrl?.('collector');
136
+ try {
137
+ if (!collectorUrl) throw new Error('missing');
138
+ new URL(collectorUrl);
139
+ } catch {
140
+ throw new QuoxError(`--collector-url must be a valid URL (got "${collectorUrl ?? ''}")`, 1);
141
+ }
142
+ const g = program.opts().output;
143
+ const outArg = opts.out || (g && !OUTPUT_FORMATS.includes(g) ? g : undefined) || DEFAULT_BUNDLE;
144
+ const target = resolve(ctx.cwd || process.cwd(), outArg);
145
+ if (existsSync(target) && !opts.force) throw new QuoxError(`${target} already exists. Use --force to overwrite.`, 1);
146
+
147
+ const minted = await mint(label, days);
148
+ const revokeMinted = async () => {
149
+ try {
150
+ const rep = resolveEndpoint('mcp.tokens.revoke', { id: minted.id });
151
+ await client.delete(rep.path, { service: rep.service });
152
+ printWarning(`Revoked the token minted for this bundle (${minted.id}).`);
153
+ } catch {
154
+ printWarning(`Could not revoke the token minted for this bundle; revoke it with: quox mcp token revoke ${minted.id}`);
155
+ }
156
+ };
157
+
158
+ const bep = resolveEndpoint('mcp.bundle');
159
+ let buf;
160
+ try {
161
+ const res = await client.post(bep.path, {
162
+ service: bep.service,
163
+ raw: true,
164
+ sensitive: true,
165
+ timeout: 120000,
166
+ body: { token: minted.token, collector_url: collectorUrl, agent_id: 'quox' },
167
+ });
168
+ if (!res.ok) {
169
+ let detail = '';
170
+ try { const b = await res.json(); detail = b?.message || b?.error || ''; } catch { /* non-JSON body */ }
171
+ throw new QuoxError(`Bundle generation failed: HTTP ${res.status}${detail ? ` (${detail})` : ''}.`, 1);
172
+ }
173
+ buf = Buffer.from(await res.arrayBuffer());
174
+ } catch (err) {
175
+ await revokeMinted();
176
+ throw err;
177
+ }
178
+
179
+ writeFileSync(target, buf, { mode: 0o600 });
180
+ chmodSync(target, 0o600); // mode only applies on create; --force over an existing file must tighten it too
181
+ if (isJson(program)) {
182
+ console.log(JSON.stringify({ path: target, bytes: buf.length, token_id: minted.id, expires_at: minted.expires_at ?? null, collector_url: collectorUrl }, null, 2));
183
+ return;
184
+ }
185
+ printSuccess(`Saved ${target} (${buf.length} bytes, mode 0600).`);
186
+ console.log(`It embeds MCP token ${minted.id} (expires ${minted.expires_at || 'never'}) and calls ${collectorUrl}.`);
187
+ console.log('Open the file with Claude Desktop to install QuoxMCP. Treat it as a secret.');
188
+ printInfo(`Revoke it any time with: quox mcp token revoke ${minted.id}`);
189
+ });
190
+ }
@@ -78,6 +78,17 @@ function reportDockerReadFailures(data) {
78
78
  return false;
79
79
  }
80
80
 
81
+ /**
82
+ * AGH-chain reads answer 202 { status: 'computing' } while the collector builds them in the
83
+ * background; an empty body then is not data. Says so and returns true (caller stops).
84
+ */
85
+ function reportComputing(program, data, message) {
86
+ if (!data || data.status !== 'computing') return false;
87
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return true; }
88
+ printWarning(message);
89
+ return true;
90
+ }
91
+
81
92
  export function registerMonCommands(program, ctx) {
82
93
  const { client, auth } = ctx;
83
94
 
@@ -372,6 +383,7 @@ export function registerMonCommands(program, ctx) {
372
383
  auth.requireAuth();
373
384
  const ep = resolveEndpoint('mon.agh-chain.leaderboard.list');
374
385
  const data = await client.get(ep.path, { service: ep.service, query: { window: options.window } });
386
+ if (reportComputing(program, data, 'The leaderboard is computing, try again shortly.')) return;
375
387
  formatOutput({
376
388
  columns: ['agent_id', 'claims_made', 'claims_verified', 'claims_stripped', 'unfulfilled_promises', 'honesty_rate'],
377
389
  headers: ['Agent', 'Claims Made', 'Verified', 'Stripped', 'Unfulfilled', 'Honesty Rate'],
@@ -388,6 +400,7 @@ export function registerMonCommands(program, ctx) {
388
400
  auth.requireAuth();
389
401
  const ep = resolveEndpoint('mon.agh-chain.leaderboard.get', { agentId });
390
402
  const data = await client.get(ep.path, { service: ep.service, query: { window: options.window } });
403
+ if (reportComputing(program, data, 'These honesty metrics are computing, try again shortly.')) return;
391
404
  formatDetail(data, { format: program.opts().output, title: `Honesty metrics: ${agentId}` });
392
405
  });
393
406
 
@@ -401,6 +414,7 @@ export function registerMonCommands(program, ctx) {
401
414
  auth.requireAuth();
402
415
  const ep = resolveEndpoint('mon.agh-chain.leaderboard.events.list', { agentId });
403
416
  const data = await client.get(ep.path, { service: ep.service, query: { window: options.window, guard: options.guard } });
417
+ if (reportComputing(program, data, 'These drill-through events are computing, try again shortly.')) return;
404
418
  formatOutput({
405
419
  columns: ['event_id', 'run_id', 'ts', 'payload_preview'],
406
420
  headers: ['Event', 'Run', 'Time', 'Preview'],