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.
- package/CHANGELOG.md +65 -1
- package/lib/api/endpoints/admin.js +21 -0
- package/lib/api/endpoints/calendarq.js +8 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lens.js +34 -0
- package/lib/api/endpoints/lucid.js +18 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +6 -7
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/v1.js +57 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +86 -0
- package/lib/commands/approvals.js +5 -1
- package/lib/commands/calendar.js +27 -0
- package/lib/commands/chat.js +1 -1
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/fleet.js +164 -0
- package/lib/commands/fleetRecipe.js +174 -0
- package/lib/commands/lens.js +268 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +423 -0
- package/lib/commands/mail.js +287 -7
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +73 -1
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +507 -54
- package/lib/commands/research.js +78 -0
- package/lib/commands/settings.js +7 -1
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +74 -9
- package/lib/commands/wazuhIntake.js +55 -6
- package/lib/core/commandRegistrars.js +2 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +15 -2
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +258 -49
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +5 -5
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/dragonCompanion.js +24 -12
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +54 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +7 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
- package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
- package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
- package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
- package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
- package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
- package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
- package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
- package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
- package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
- package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
- package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
- package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
- package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
- package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
- package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
- package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
- package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
- package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
- package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
- package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
- package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
- package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
- package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
- package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
- package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
- package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
- package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
package/lib/commands/mail.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
.
|
|
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('
|
|
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>',
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/commands/mon.js
CHANGED
|
@@ -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'],
|