quoxcli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/lib/api/endpoints/governance.js +7 -0
  3. package/lib/api/endpoints/inboxq.js +39 -0
  4. package/lib/api/endpoints/index.js +2 -0
  5. package/lib/api/endpoints/lucid.js +17 -0
  6. package/lib/api/endpoints/mcp.js +35 -0
  7. package/lib/api/endpoints/notesq.js +2 -0
  8. package/lib/api/endpoints/quoxplan.js +51 -0
  9. package/lib/commands/actions.js +51 -13
  10. package/lib/commands/contacts.js +2 -0
  11. package/lib/commands/lucid.js +6 -0
  12. package/lib/commands/lucidGoals.js +389 -0
  13. package/lib/commands/mail.js +266 -5
  14. package/lib/commands/mcp.js +190 -0
  15. package/lib/commands/mon.js +14 -0
  16. package/lib/commands/monitoring.js +68 -0
  17. package/lib/commands/notesSeal.js +380 -22
  18. package/lib/commands/offload.js +51 -10
  19. package/lib/commands/plan.js +471 -53
  20. package/lib/commands/volt.js +6 -1
  21. package/lib/commands/wazuh.js +65 -9
  22. package/lib/commands/wazuhIntake.js +46 -5
  23. package/lib/core/commandRegistrars.js +1 -0
  24. package/lib/core/config.js +14 -1
  25. package/lib/core/offload.js +83 -0
  26. package/lib/core/output.js +29 -4
  27. package/lib/core/selfUpdate.js +2 -0
  28. package/lib/core/version.js +1 -1
  29. package/lib/quoxon/api.js +4 -0
  30. package/lib/quoxon/app.js +257 -48
  31. package/lib/quoxon/approvalsView.js +3 -1
  32. package/lib/quoxon/ask.js +4 -2
  33. package/lib/quoxon/bang.js +7 -1
  34. package/lib/quoxon/commandShort.js +1 -1
  35. package/lib/quoxon/commands.js +4 -4
  36. package/lib/quoxon/delegationDragon.js +49 -0
  37. package/lib/quoxon/dnd.js +47 -0
  38. package/lib/quoxon/events.js +12 -1
  39. package/lib/quoxon/extensions.js +2 -0
  40. package/lib/quoxon/foreignText.js +49 -0
  41. package/lib/quoxon/inboxView.js +5 -5
  42. package/lib/quoxon/launcher.js +2 -1
  43. package/lib/quoxon/loopCard.js +2 -1
  44. package/lib/quoxon/main.js +8 -2
  45. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  46. package/lib/quoxon/onboarding/view.js +1 -1
  47. package/lib/quoxon/render.js +5 -2
  48. package/lib/quoxon/resend.js +14 -0
  49. package/lib/quoxon/rows.js +3 -1
  50. package/lib/quoxon/secretShape.js +2 -0
  51. package/lib/quoxon/slash/broadcast.js +30 -6
  52. package/lib/quoxon/slash/broadcastRun.js +12 -6
  53. package/lib/quoxon/slash/cliLeaves.json +35 -0
  54. package/lib/quoxon/slash/dashboard.js +2 -0
  55. package/lib/quoxon/slash/listView.js +2 -1
  56. package/lib/quoxon/slash/palette.js +41 -0
  57. package/lib/quoxon/slash/paletteController.js +6 -2
  58. package/lib/quoxon/slash/restart.js +2 -0
  59. package/lib/quoxon/slotChat.js +7 -3
  60. package/lib/quoxon/slotRun.js +2 -0
  61. package/lib/quoxon/webPty.js +15 -0
  62. package/lib/quoxon/whatsnew.jsonl +6 -0
  63. package/lib/render/planBrief.js +614 -0
  64. package/lib/toolOutputShape.js +6 -3
  65. package/package.json +1 -1
@@ -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' };
@@ -579,20 +652,208 @@ export function registerMailCommands(program, ctx) {
579
652
  printSuccess(`Watch ${watchId} is now ${active ? 'active' : 'inactive'}.`);
580
653
  });
581
654
 
655
+ // ── senders: block / unblock / blocked / junk / trust ──────────────────
656
+ // EMAIL-Q CLI audit (2026-11-09): the SAME routes the dashboard calls
657
+ // (src/components/inboxq/lib/api.js). Blocking is the human-authoritative
658
+ // instant path (POST /senders/block), exactly as the "Block sender" button;
659
+ // --propose files a governed block proposal instead.
660
+
661
+ mail
662
+ .command('block')
663
+ .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')
664
+ .addHelpText('after', `
665
+ Same action as the dashboard "Block sender" button (POST /senders/block). Future mail from it is
666
+ screened while Quox syncs the account; no rule is set on the mail server itself.
667
+ Examples:
668
+ quox mail block tanya@example.com --account <id> --dry-run
669
+ quox mail block tanya@example.com --account <id> --yes
670
+ quox mail block @example.com --account <id> --propose
671
+ Proposal payload (--propose): {"scope":"sender"|"domain","target":"<address or domain>"}
672
+ `)
673
+ .argument('<target>', 'sender address (tanya@example.com) or @domain (@example.com)')
674
+ .requiredOption('--account <id>', 'account ID (quox mail accounts)')
675
+ .option('--scope <scope>', 'sender or domain (default: sender for an address, domain for @domain)')
676
+ .option('--dry-run', 'count the stored messages that match and what would move; change nothing')
677
+ .option('--propose', 'file a block proposal for approval instead of blocking now (payload {scope, target, fromAddr|domain})')
678
+ .option('--yes', 'confirm the block (refused without it when there is no terminal)')
679
+ .action(announceOnFail('mail', (target) => `block ${target}`, async (rawTarget, options) => {
680
+ auth.requireAuth();
681
+ if (options.dryRun && options.propose) {
682
+ throw new QuoxUserError('Use --dry-run or --propose, not both. Run the dry run first, then --propose.', { exitCode: 2 });
683
+ }
684
+ const t = parseBlockTarget(rawTarget, options.scope);
685
+ const accountId = options.account;
686
+
687
+ if (options.dryRun) {
688
+ const ep = resolveEndpoint('inboxq.messages.search');
689
+ const query = { accountId, limit: 200 };
690
+ if (t.scope === 'domain') query.fromDomain = t.domain; else query.from = t.fromAddr;
691
+ const data = await client.get(ep.path, { service: ep.service, query });
692
+ const msgs = Array.isArray(data?.messages) ? data.messages : [];
693
+ // The block executor matches the sender EXACTLY (domain: address ends with @domain);
694
+ // the search is a substring match, so count only what the block would really touch.
695
+ const hit = (m) => {
696
+ const f = String(m.fromAddr || '').trim().toLowerCase();
697
+ return t.scope === 'domain' ? f.endsWith(`@${t.domain}`) : f === t.fromAddr;
698
+ };
699
+ const matched = msgs.filter(hit);
700
+ const alreadyInJunk = matched.filter((m) => JUNK_NAME.test(String(m.folder || ''))).length;
701
+ const capped = Number(data?.total || 0) > msgs.length;
702
+ const result = { dryRun: true, accountId, scope: t.scope, target: t.target, matched: matched.length, alreadyInJunk, wouldMove: matched.length - alreadyInJunk, capped };
703
+ if (isJsonOutput(program)) return printJson(result);
704
+ const atLeast = capped ? 'at least ' : '';
705
+ 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).`);
706
+ if (capped) console.log('The search returns at most 200 messages, so the real count can be higher.');
707
+ console.log('Nothing changed. Run it without --dry-run to block, or with --propose to file an approval.');
708
+ announce('mail', `block ${t.target} dry run`, { result: `${atLeast}${matched.length} match, nothing changed`, status: 'ok' });
709
+ return;
710
+ }
711
+
712
+ if (options.propose) {
713
+ const ep = resolveEndpoint('inboxq.proposals.create');
714
+ const body = { accountId, kind: 'block', payload: blockProposalPayload(t), createdVia: 'cli' };
715
+ const data = await client.post(ep.path, { service: ep.service, body });
716
+ if (isJsonOutput(program)) return printJson(data);
717
+ const proposal = data.proposal || data;
718
+ printSuccess(`Proposed blocking ${t.target} (${t.scope}) on ${accountId}: proposal ${proposal.id}. Nothing changes until it is approved.`);
719
+ console.log(`Approve: quox mail resolve ${proposal.id} --decision approve`);
720
+ announce('mail', `propose block ${t.target}`, { result: `proposal ${proposal.id}`, status: 'ok' });
721
+ return;
722
+ }
723
+
724
+ const go = await confirmWrite(program, options, `Block ${t.target} (${t.scope}) on ${accountId}? Its stored mail is screened and moved to Junk.`);
725
+ if (!go) {
726
+ console.log('Not blocked. Nothing changed.');
727
+ announce('mail', `block ${t.target}`, { result: 'cancelled, nothing changed', status: 'warn' });
728
+ return;
729
+ }
730
+ const ep = resolveEndpoint('inboxq.senders.block');
731
+ const body = t.scope === 'domain'
732
+ ? { ...(t.fromAddr ? { fromAddr: t.fromAddr } : {}), accountId, scope: 'domain', domain: t.domain }
733
+ : { fromAddr: t.fromAddr, accountId, scope: 'sender' };
734
+ const data = await client.post(ep.path, { service: ep.service, body });
735
+ if (isJsonOutput(program)) return printJson(data);
736
+ const screened = data?.screened ?? 0;
737
+ const moved = data?.junkMoved ?? 0;
738
+ printSuccess(`Blocked ${data?.target || t.target} (${data?.scope || t.scope}) on ${accountId}: ${screened} stored message(s) screened, ${moved} moved to ${data?.junkFolder || 'Junk'}.`);
739
+ if (data?.junkFailed) {
740
+ 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.`);
741
+ }
742
+ if (!data?.providerRule || data.providerRule === 'none') {
743
+ console.log('Future mail from it is screened while Quox syncs this account; no rule was set on the mail server itself.');
744
+ }
745
+ announce('mail', `block ${t.target}`, { result: `${screened} screened, ${moved} to Junk`, status: data?.junkFailed ? 'warn' : 'ok' });
746
+ }));
747
+
748
+ mail
749
+ .command('unblock')
750
+ .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')
751
+ .argument('<target>', 'sender address or @domain')
752
+ .requiredOption('--account <id>', 'account ID')
753
+ .option('--scope <scope>', 'sender or domain (default: sender for an address, domain for @domain)')
754
+ .option('--yes', 'confirm (refused without it when there is no terminal)')
755
+ .action(announceOnFail('mail', (target) => `unblock ${target}`, async (rawTarget, options) => {
756
+ auth.requireAuth();
757
+ const t = parseBlockTarget(rawTarget, options.scope);
758
+ const go = await confirmWrite(program, options, `Unblock ${t.target} (${t.scope}) on ${options.account}?`);
759
+ if (!go) {
760
+ console.log('Not unblocked. Nothing changed.');
761
+ announce('mail', `unblock ${t.target}`, { result: 'cancelled, nothing changed', status: 'warn' });
762
+ return;
763
+ }
764
+ const ep = resolveEndpoint('inboxq.senders.unblock');
765
+ const data = await client.post(ep.path, { service: ep.service, body: { target: t.target, type: t.scope, accountId: options.account } });
766
+ if (isJsonOutput(program)) return printJson(data);
767
+ printSuccess(`Unblocked ${t.target} (${t.scope}) on ${options.account}.`);
768
+ announce('mail', `unblock ${t.target}`, { result: t.scope, status: 'ok' });
769
+ }));
770
+
771
+ mail
772
+ .command('blocked')
773
+ .description('List the blocked senders and domains on one account')
774
+ .requiredOption('--account <id>', 'account ID')
775
+ .action(async (options) => {
776
+ auth.requireAuth();
777
+ const ep = resolveEndpoint('inboxq.senders.blocked');
778
+ const data = await client.get(ep.path, { service: ep.service, query: { accountId: options.account } });
779
+ if (isJsonOutput(program)) return printJson(data);
780
+ const rows = data?.blocked || [];
781
+ if (!rows.length) { console.log(`Nothing is blocked on ${options.account}.`); return; }
782
+ formatOutput({
783
+ columns: ['target', 'type', 'blockedAt'],
784
+ headers: ['Blocked', 'Type', 'Since'],
785
+ rows,
786
+ }, { format: program.opts().output });
787
+ });
788
+
789
+ mail
790
+ .command('junk')
791
+ .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')
792
+ .argument('<messageIds...>', 'message IDs (quox mail list)')
793
+ .requiredOption('--account <id>', 'account ID')
794
+ .option('--yes', 'confirm (refused without it when there is no terminal)')
795
+ .action(announceOnFail('mail', (ids) => `junk ${ids.length} message(s)`, async (messageIds, options) => {
796
+ auth.requireAuth();
797
+ 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.`);
798
+ if (!go) {
799
+ console.log('Nothing marked as junk.');
800
+ announce('mail', `junk ${messageIds.length} message(s)`, { result: 'cancelled, nothing changed', status: 'warn' });
801
+ return;
802
+ }
803
+ const ep = resolveEndpoint('inboxq.messages.confirmJunk');
804
+ const data = await client.post(ep.path, { service: ep.service, body: { messageIds, accountId: options.account } });
805
+ if (isJsonOutput(program)) {
806
+ if ((data?.failed || []).length) process.exitCode = 1;
807
+ return printJson(data);
808
+ }
809
+ const failed = data?.failed || [];
810
+ printSuccess(`Moved ${data?.moved ?? 0} message(s) to Junk and blocked ${data?.blocked ?? 0} sender(s) on ${options.account}.`);
811
+ 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).`);
812
+ announce('mail', `junk ${messageIds.length} message(s)`, { result: `${data?.moved ?? 0} to Junk${failed.length ? `, ${failed.length} failed` : ''}`, status: failed.length ? 'fail' : 'ok' });
813
+ }));
814
+
815
+ mail
816
+ .command('trust')
817
+ .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')
818
+ .argument('<address>', 'sender address')
819
+ .requiredOption('--account <id>', 'account ID')
820
+ .option('--yes', 'confirm (refused without it when there is no terminal)')
821
+ .action(announceOnFail('mail', (a) => `trust ${a}`, async (rawAddress, options) => {
822
+ auth.requireAuth();
823
+ const t = parseBlockTarget(rawAddress);
824
+ if (t.scope !== 'sender') throw new QuoxUserError('trust takes one sender address, not a domain.', { exitCode: 2 });
825
+ const go = await confirmWrite(program, options, `Trust ${t.fromAddr} on ${options.account}? Their mail moves out of Junk back to INBOX.`);
826
+ if (!go) {
827
+ console.log('Not trusted. Nothing changed.');
828
+ announce('mail', `trust ${t.fromAddr}`, { result: 'cancelled, nothing changed', status: 'warn' });
829
+ return;
830
+ }
831
+ const ep = resolveEndpoint('inboxq.senders.trust');
832
+ const data = await client.post(ep.path, { service: ep.service, body: { fromAddr: t.fromAddr, accountId: options.account } });
833
+ if (isJsonOutput(program)) return printJson(data);
834
+ const back = data?.movedBack;
835
+ printSuccess(`Trusted ${t.fromAddr} on ${options.account}: ${data?.restored ?? 0} stored message(s) restored${back !== undefined ? `, ${back} moved back to INBOX` : ''}.`);
836
+ 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.');
837
+ announce('mail', `trust ${t.fromAddr}`, { result: `${data?.restored ?? 0} restored`, status: data?.movedBackFailed ? 'warn' : 'ok' });
838
+ }));
839
+
582
840
  // ── proposals ────────────────────────────────────────────────────────
583
841
 
584
842
  mail
585
843
  .command('propose')
586
- .description('Create a proposal (file, block or draft) for an account')
844
+ .description('Propose a change for approval (file, block, draft, rezone, folder_create). Block payload: {"scope":"sender"|"domain","target":"<address or domain>"}')
587
845
  .requiredOption('--account <id>', 'account ID')
588
- .requiredOption('--kind <kind>', 'proposal kind: file, block, draft')
589
- .requiredOption('--payload <json>', 'proposal payload as a JSON object')
846
+ .requiredOption('--kind <kind>', `proposal kind: ${VALID_KINDS.join(', ')} (not rule: no executor yet)`)
847
+ .requiredOption('--payload <json>', 'proposal payload as a JSON object (shape per kind below)')
848
+ .addHelpText('after', `\nNothing changes until a human approves it: quox mail resolve <id> --decision approve\n\n${PROPOSE_PAYLOADS}\n`)
590
849
  .action(async (options) => {
591
850
  auth.requireAuth();
592
851
  if (!VALID_KINDS.includes(options.kind)) {
593
- throw new Error(`invalid --kind "${options.kind}" (expected one of ${VALID_KINDS.join(', ')})`);
852
+ const why = options.kind === 'rule' ? ' Kind "rule" has no executor on the server yet, so approving it would change nothing.' : '';
853
+ throw new Error(`invalid --kind "${options.kind}" (expected one of ${VALID_KINDS.join(', ')}).${why}`);
594
854
  }
595
855
  const payload = parseJSON(options.payload, '--payload');
856
+ if (options.kind === 'block') checkBlockPayload(payload);
596
857
  const body = { accountId: options.account, kind: options.kind, payload };
597
858
  const ep = resolveEndpoint('inboxq.proposals.create');
598
859
  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'],
@@ -10,10 +10,46 @@
10
10
  * GET /api/v1/health/aggregate — same endpoint `quox status` uses
11
11
  * GET /governance/incidents — list
12
12
  * GET /governance/incidents/:id — detail + linked AEE envelopes
13
+ * POST /governance/incidents/:id/transition — claim (self only) / move status, witnessed
14
+ * (quox.governance.incident.updated; lib/governanceIncidentLifecycle.js)
15
+ *
16
+ * `incidents list --json` prints a bare array of incidents, the CLI-wide
17
+ * formatOutput convention for list verbs; proof cases read it by index
18
+ * (docs/matrix/suites/monitoring.json mon-incidents-open, mon-incident-detail),
19
+ * so the shape stays as is. `get --json` prints the incident object.
13
20
  */
14
21
 
15
22
  import { formatOutput, formatDetail } from '../core/output.js';
16
23
  import { resolveEndpoint } from '../api/endpoints.js';
24
+ import { QuoxError } from '../core/errors.js';
25
+
26
+ // WAZ P11 slice 1b: statuses a person can move an incident to from the CLI. The collector
27
+ // validates against INCIDENT_STATUSES and enforces the resolution rule too; this only saves a call.
28
+ export const MOVE_STATUSES = ['investigating', 'resolved', 'closed', 'false_positive'];
29
+ const NEEDS_RESOLUTION = new Set(['resolved', 'closed', 'false_positive']);
30
+ const RESOLUTION_MAX = 500;
31
+
32
+ function printTransition(verb, id, data) {
33
+ const inc = data.incident || {};
34
+ const t = data.transition || {};
35
+ const w = data.witnessed || {};
36
+ const who = inc.assigned_to ? ` · assigned to ${inc.assigned_to}` : '';
37
+ const move = t.from && t.to ? `${t.from} -> ${t.to}` : (inc.status || '-');
38
+ const wit = w.stored ? `witnessed${w.id ? ` (${w.id})` : ''}` : `NOT witnessed (${w.reason || 'no envelope stored'})`;
39
+ console.log(`${w.stored ? '✔' : '⚠'} incidents ${verb} │ ${inc.id || id} · ${move}${who} │ ${wit}`);
40
+ if (inc.resolution && NEEDS_RESOLUTION.has(inc.status)) console.log(` resolution: ${inc.resolution}`);
41
+ }
42
+
43
+ // Module level (not a closure) so cli/scripts/export-leaf-methods.mjs sees the POST as evidence for both leaves.
44
+ async function postTransition(client, program, verb, id, body) {
45
+ const ep = resolveEndpoint('governance.incidents.transition', { id });
46
+ const data = await client.post(ep.path, { service: ep.service, body });
47
+ if (!data || data.error || !data.incident) {
48
+ throw new QuoxError(`quox monitoring incidents ${verb}: ${ep.method} ${ep.path} returned an error body: ${(data && (data.error || data.message)) || 'response had no incident'}`, 1);
49
+ }
50
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
51
+ printTransition(verb, id, data);
52
+ }
17
53
 
18
54
  export function registerMonitoringCommands(program, ctx) {
19
55
  const { client, auth } = ctx;
@@ -83,4 +119,36 @@ export function registerMonitoringCommands(program, ctx) {
83
119
  const data = await client.get(resolveEndpoint('governance.incidents.get', { id }).path, { service: resolveEndpoint('governance.incidents.get', { id }).service });
84
120
  formatDetail(data.incident || data, { format: program.opts().output, title: `Incident: ${id}` });
85
121
  });
122
+
123
+ incidents
124
+ .command('claim')
125
+ .description('Claim a governance incident: assigns it to you and moves open to investigating (witnessed)')
126
+ .argument('<id>', 'incident ID from `quox monitoring incidents list`')
127
+ .action(async (id) => {
128
+ auth.requireAuth();
129
+ await postTransition(client, program, 'claim', id, { assigned_to: 'me' });
130
+ });
131
+
132
+ incidents
133
+ .command('status')
134
+ .description(`Move a governance incident to ${MOVE_STATUSES.join('|')}; resolved, closed and false_positive need --resolution (witnessed)`)
135
+ .argument('<id>', 'incident ID from `quox monitoring incidents list`')
136
+ .argument('<status>', MOVE_STATUSES.join('|'))
137
+ .option('--resolution <text>', `what was done or why it is closing (required for resolved, closed, false_positive; max ${RESOLUTION_MAX} chars)`)
138
+ .action(async (id, status, options) => {
139
+ if (!MOVE_STATUSES.includes(status)) {
140
+ throw new QuoxError(`quox monitoring incidents status: status must be one of: ${MOVE_STATUSES.join(', ')} (got "${status}").`, 1);
141
+ }
142
+ const resolution = typeof options.resolution === 'string' ? options.resolution.trim() : '';
143
+ if (NEEDS_RESOLUTION.has(status) && !resolution) {
144
+ throw new QuoxError(`quox monitoring incidents status: moving to ${status} needs --resolution "<what was done>".`, 1);
145
+ }
146
+ if (resolution.length > RESOLUTION_MAX) {
147
+ throw new QuoxError(`quox monitoring incidents status: --resolution is at most ${RESOLUTION_MAX} characters (got ${resolution.length}).`, 1);
148
+ }
149
+ auth.requireAuth();
150
+ const body = { status };
151
+ if (resolution) body.resolution = resolution;
152
+ await postTransition(client, program, 'status', id, body);
153
+ });
86
154
  }