quoxcli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -8,9 +8,23 @@
|
|
|
8
8
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
9
9
|
|
|
10
10
|
import { attachWalkthrough } from './walkthrough.js';
|
|
11
|
+
import { resolveOrgId } from './helpers.js';
|
|
12
|
+
import { confirmInteractive, askTyped } from '../core/confirm.js';
|
|
13
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
14
|
+
import { announce, announceFailure, announceOnFail } from '../core/announce.js';
|
|
15
|
+
import { withStepUp } from '../core/stepUpPrompt.js';
|
|
16
|
+
import { formatOutput, printSuccess } from '../core/output.js';
|
|
17
|
+
import { needsPin } from '../quoxon/approvalsView.js';
|
|
18
|
+
import { describeInboxItem, approvalSourceLabel } from '../quoxon/events.js';
|
|
19
|
+
import { runCallMe, statusLines } from '../core/callMe.js';
|
|
20
|
+
import { isDestructive, destructiveObject } from '../quoxon/destructive.js';
|
|
21
|
+
import { otherOrgsWaiting, crossOrgLines, clientForTarget } from '../core/crossOrgApprovals.js';
|
|
11
22
|
|
|
12
23
|
export function registerApprovalsCommands(program, ctx) {
|
|
13
|
-
const { client, auth } = ctx;
|
|
24
|
+
const { client: baseClient, auth, clientForOrg } = ctx;
|
|
25
|
+
// The client for the org a verb targets: --org another org (a verb in CROSS_ORG, see core/org-override.js) uses that org's own token.
|
|
26
|
+
const clientFor = (orgId) => clientForTarget({ client: baseClient, clientForOrg, sessionOrgId: auth.getOrgId(), orgId });
|
|
27
|
+
const client = baseClient; // `result`, grants and the rest stay on the session org
|
|
14
28
|
|
|
15
29
|
const approvals = program
|
|
16
30
|
.command('approvals')
|
|
@@ -39,6 +53,10 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
39
53
|
const via = data.decided_via && data.decided_via.channel ? ` via ${data.decided_via.channel}` : '';
|
|
40
54
|
console.log(`decided by ${who} (${data.decided_by.actor_type || 'user'})${via} at ${data.decided_at || 'unknown time'}`);
|
|
41
55
|
}
|
|
56
|
+
if (data.human_decision) {
|
|
57
|
+
const hd = { yes: 'yes (a person approved it with a verified step-up PIN)', no: 'no (not human-verified: denied, withdrawn, expired, approved by an agent, or approved without a verified PIN)', pending: 'pending' }[data.human_decision] || data.human_decision;
|
|
58
|
+
console.log(`human decision: ${hd}`);
|
|
59
|
+
}
|
|
42
60
|
if (data.result === null || data.result === undefined) {
|
|
43
61
|
if (data.status === 'pending') console.log('Still pending: a human has not decided yet.');
|
|
44
62
|
else if (data.status === 'resuming') console.log('Approved, executing. Re-run shortly.');
|
|
@@ -53,6 +71,26 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
53
71
|
else console.log(JSON.stringify(r, null, 2));
|
|
54
72
|
});
|
|
55
73
|
|
|
74
|
+
// Call-me: the switch for the ApprovalsQ watcher that phones a human when a high-risk
|
|
75
|
+
// approval sits unanswered. It never approves anything; the call only asks the human.
|
|
76
|
+
approvals
|
|
77
|
+
.command('call-me [action]')
|
|
78
|
+
.description('Phone me when a high-risk approval sits unanswered: status (default), on or off (human org admin only)')
|
|
79
|
+
.option('--after <minutes>', 'ring after this many minutes unanswered (1-1440)')
|
|
80
|
+
.option('--quiet <HH:MM-HH:MM>', 'quiet hours, no calls in this window (e.g. 22:00-08:00)')
|
|
81
|
+
.action(announceOnFail('quox', 'approvals call-me', async (action, options) => {
|
|
82
|
+
auth.requireAuth();
|
|
83
|
+
const act = action || 'status';
|
|
84
|
+
if (act === 'status' && (options.after || options.quiet)) {
|
|
85
|
+
throw new QuoxUserError('--after and --quiet change the policy: use `quox approvals call-me on --after 10` (or off).', { code: 'bad_args' });
|
|
86
|
+
}
|
|
87
|
+
const { data, changed } = await runCallMe(client, { action: act, after: options.after, quiet: options.quiet });
|
|
88
|
+
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
89
|
+
const p = (data && data.policy) || {};
|
|
90
|
+
announce('quox', 'approvals call-me', { result: `${changed ? 'set ' : ''}${p.enabled ? `ON after ${p.age_threshold_minutes} min` : 'OFF'}` });
|
|
91
|
+
for (const l of statusLines(data)) console.log(l);
|
|
92
|
+
}));
|
|
93
|
+
|
|
56
94
|
// Fleet standing grants: an org admin pre-approves ONE host + an anchored command
|
|
57
95
|
// shape + a VMID range until an expiry, for ssh_exec. Every use still lands in
|
|
58
96
|
// approvals history with an AEE envelope; anything outside the grant files HITL.
|
|
@@ -61,7 +99,7 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
61
99
|
.description('Standing grants: org-admin pre-approved fleet command lanes (ssh_exec)');
|
|
62
100
|
|
|
63
101
|
grants
|
|
64
|
-
.command('list')
|
|
102
|
+
.command('list', { isDefault: true })
|
|
65
103
|
.description('List active standing grants (--all includes revoked and expired)')
|
|
66
104
|
.option('--all', 'include revoked and expired grants')
|
|
67
105
|
.action(async (options) => {
|
|
@@ -74,7 +112,7 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
74
112
|
for (const g of rows) {
|
|
75
113
|
const state = g.revoked_at ? `revoked ${g.revoked_at}` : `expires ${g.expires_at}`;
|
|
76
114
|
const range = g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : '';
|
|
77
|
-
console.log(`${g.
|
|
115
|
+
console.log(`${g.tool} on ${g.host}${range} ${g.preset || `${(g.patterns || []).length} patterns`} uses ${g.use_count} ${state} [${g.grant_id}]`);
|
|
78
116
|
console.log(` reason: ${g.reason} (by ${g.created_by})`);
|
|
79
117
|
}
|
|
80
118
|
});
|
|
@@ -107,6 +145,7 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
107
145
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
108
146
|
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
109
147
|
const g = data.grant || {};
|
|
148
|
+
announce('approvals', `grant created ${g.tool || 'ssh_exec'}@${g.host || options.host}`, { result: g.grant_id });
|
|
110
149
|
console.log(`Created ${g.grant_id}: ${g.tool}@${g.host}${g.vmid_min != null ? ` vmids ${g.vmid_min}-${g.vmid_max}` : ''}, expires ${g.expires_at}`);
|
|
111
150
|
console.log(`Revoke any time: quox approvals grants revoke ${g.grant_id}`);
|
|
112
151
|
});
|
|
@@ -119,6 +158,157 @@ export function registerApprovalsCommands(program, ctx) {
|
|
|
119
158
|
const ep = resolveEndpoint('v1.approvals.grants.revoke', { id: grantId });
|
|
120
159
|
const data = await client.post(ep.path, { service: ep.service, body: {} });
|
|
121
160
|
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
161
|
+
announce('approvals', `grant revoked ${grantId}`);
|
|
122
162
|
console.log(`Revoked ${data.grant?.grant_id || grantId} at ${data.grant?.revoked_at || 'now'}.`);
|
|
123
163
|
});
|
|
164
|
+
|
|
165
|
+
// ---- list | approve | deny: the verb people expect, over the SAME server path as `quox inbox respond`.
|
|
166
|
+
const OPEN_QUERY = { status: 'open', kind: 'approval', limit: 200 };
|
|
167
|
+
const listOpen = async (orgId, status, cl = client) => {
|
|
168
|
+
const ep = resolveEndpoint('inbox.list', { orgId });
|
|
169
|
+
const data = await cl.get(ep.path, { service: ep.service, query: { ...OPEN_QUERY, ...(status ? { status } : {}) } });
|
|
170
|
+
const items = (data && (data.items || data)) || [];
|
|
171
|
+
return Array.isArray(items) ? items : [];
|
|
172
|
+
};
|
|
173
|
+
const shortId = (id) => String(id).slice(-6);
|
|
174
|
+
|
|
175
|
+
async function pickApproval(orgId, given, cl = client) {
|
|
176
|
+
const want = String(given).trim().toUpperCase();
|
|
177
|
+
const items = await listOpen(orgId, undefined, cl);
|
|
178
|
+
const hits = items.filter((it) => {
|
|
179
|
+
const id = String(it.id).toUpperCase();
|
|
180
|
+
return id === want || id.endsWith(want) || String(it.aee_correlation_id || '').toUpperCase() === want;
|
|
181
|
+
});
|
|
182
|
+
const exact = hits.filter((it) => String(it.id).toUpperCase() === want);
|
|
183
|
+
if (exact.length === 1) return exact[0];
|
|
184
|
+
if (hits.length === 1) return hits[0];
|
|
185
|
+
if (hits.length > 1) {
|
|
186
|
+
throw new QuoxUserError(`"${given}" is ambiguous: it matches ${hits.length} open approvals (${hits.map((h) => shortId(h.id)).join(', ')}).`, {
|
|
187
|
+
next: 'use more of the id, or the full id from `quox approvals list`.', exitCode: 2, code: 'ambiguous_id',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
throw new QuoxUserError(`No open approval matches "${given}" in this org.`, {
|
|
191
|
+
next: '`quox approvals list` shows what is waiting; `quox inbox get <id>` finds an answered one.', exitCode: 5, code: 'not_found',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function summaryLine(it) {
|
|
196
|
+
const d = describeInboxItem(it);
|
|
197
|
+
const what = [d.tool, d.target && d.target !== d.tool ? d.target : null].filter(Boolean).join(' on ') || d.title || it.title || it.id;
|
|
198
|
+
const who = d.by || approvalSourceLabel(it);
|
|
199
|
+
return `[${shortId(it.id)}] ${what} (asked by ${who})`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function decide(verb, given, options) {
|
|
203
|
+
auth.requireAuth();
|
|
204
|
+
const orgId = resolveOrgId(auth, program);
|
|
205
|
+
const client = await clientFor(orgId); // another org only when --org names one
|
|
206
|
+
const it = await pickApproval(orgId, given, client);
|
|
207
|
+
const approve = verb === 'approve';
|
|
208
|
+
console.log(`${approve ? 'Approve' : 'Deny'}: ${summaryLine(it)}`);
|
|
209
|
+
if (approve) {
|
|
210
|
+
if (needsPin(it)) {
|
|
211
|
+
throw new QuoxUserError('High risk: a yes needs your step-up PIN, which a plain command cannot take.', {
|
|
212
|
+
next: 'answer it in `quox` with /approvals, or deny it here with `quox approvals deny`.', exitCode: 4, code: 'step_up_required',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (isDestructive(it)) {
|
|
216
|
+
const { name } = destructiveObject(it);
|
|
217
|
+
if (!process.stdin.isTTY) {
|
|
218
|
+
throw new QuoxUserError(`This deletes "${name}": its name must be typed at a terminal. --yes does not cover it.`, {
|
|
219
|
+
next: 'run this command in an interactive terminal.', exitCode: 2, code: 'no_terminal',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const typed = await askTyped(`This is destructive. Type "${name}" to approve`);
|
|
223
|
+
if (String(typed ?? '').trim() !== name) {
|
|
224
|
+
throw new QuoxUserError(`What you typed did not match "${name}". Nothing was approved.`, { exitCode: 1, code: 'confirm_mismatch' });
|
|
225
|
+
}
|
|
226
|
+
} else if (!options.yes) {
|
|
227
|
+
const ok = await confirmInteractive('Approve this?', { flag: '--yes', purpose: 'approve' });
|
|
228
|
+
if (!ok) { console.log('Not approved. The request stays open.'); return; }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const ep = resolveEndpoint('inbox.respond', { orgId, itemId: it.id });
|
|
232
|
+
try {
|
|
233
|
+
const body = { action: approve ? 'approve' : 'reject', ...(options.note ? { comment: options.note } : {}) };
|
|
234
|
+
// DS-010 / #1335: only an approve can hit a step-up sink; ask for the PIN (hidden, TTY only) and retry once.
|
|
235
|
+
if (approve) await withStepUp((headers) => client.post(ep.path, { service: ep.service, headers, body }), { client, orgId, io: ctx.stepUpIO, what: 'This approval' });
|
|
236
|
+
else await client.post(ep.path, { service: ep.service, body });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
announceFailure('approvals', `${approve ? 'approve' : 'deny'} ${shortId(it.id)}`, err);
|
|
239
|
+
if (err && (err.status === 403 || err.status === 401) && !(err instanceof QuoxUserError)) {
|
|
240
|
+
const msg = err.body?.message || err.body?.error || err.message;
|
|
241
|
+
const denied = new QuoxUserError(String(msg), { exitCode: 4, code: err.body?.error || 'forbidden', status: err.status, body: err.body });
|
|
242
|
+
Object.defineProperty(denied, 'quoxAnnounced', { value: true });
|
|
243
|
+
throw denied;
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
announce('approvals', `${approve ? 'approved' : 'denied'} ${shortId(it.id)}`, { result: summaryLine(it).replace(/^\[\w+\]\s*/, '').replace(/\s+\(asked by .*$/, '') });
|
|
248
|
+
printSuccess(`${approve ? 'Approved' : 'Denied'} ${shortId(it.id)}.`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
approvals
|
|
252
|
+
.command('list')
|
|
253
|
+
.description('List approvals waiting for a human (same call as `quox inbox list`, approvals only)')
|
|
254
|
+
.option('--status <status>', 'open (default), all, or a status list')
|
|
255
|
+
.option('--limit <n>', 'max results', '50')
|
|
256
|
+
.action(async (options) => {
|
|
257
|
+
auth.requireAuth();
|
|
258
|
+
const orgId = resolveOrgId(auth, program);
|
|
259
|
+
const cl = await clientFor(orgId);
|
|
260
|
+
const ep = resolveEndpoint('inbox.list', { orgId });
|
|
261
|
+
const data = await cl.get(ep.path, { service: ep.service, query: { ...OPEN_QUERY, limit: options.limit, ...(options.status ? { status: options.status } : {}) } });
|
|
262
|
+
const items = ((data && (data.items || data)) || []).map((it) => ({ ...it, short: shortId(it.id), what: summaryLine(it).replace(/^\[\w+\]\s*/, '') }));
|
|
263
|
+
formatOutput({
|
|
264
|
+
columns: ['short', 'what', 'status', 'created_at', 'id'],
|
|
265
|
+
headers: ['ID', 'What', 'Status', 'Created', 'Full id'],
|
|
266
|
+
rows: items,
|
|
267
|
+
}, { format: program.opts().output, emptyMessage: 'No approvals are waiting.' });
|
|
268
|
+
// Waiting in the user's OTHER orgs: a count and the exact command, table output only (json stays pure data).
|
|
269
|
+
if (program.opts().output === 'table' || !program.opts().output) {
|
|
270
|
+
for (const l of crossOrgLines(await otherOrgsWaiting({ client: baseClient, currentOrgId: orgId }))) console.log(l);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
approvals
|
|
275
|
+
.command('approve <id>')
|
|
276
|
+
.description('Approve one waiting request by id (full id or the short tail on its card). A high-risk yes needs a PIN, so use `quox`.')
|
|
277
|
+
.option('--note <text>', 'a note recorded with the decision')
|
|
278
|
+
.option('-y, --yes', 'skip the y/N question (not a destructive delete, which needs its name typed)')
|
|
279
|
+
.addHelpText('after', '\nExamples:\n quox approvals list\n quox approvals approve D05HQY\n quox approvals approve D05HQY --yes --note "checked the host"\n')
|
|
280
|
+
.action(announceOnFail('approvals', (id) => `approve ${id}`, (id, options) => decide('approve', id, options)));
|
|
281
|
+
|
|
282
|
+
approvals
|
|
283
|
+
.command('deny <id>')
|
|
284
|
+
.description('Deny one waiting request by id (full id or the short tail on its card)')
|
|
285
|
+
.option('--note <text>', 'why, recorded with the decision')
|
|
286
|
+
.addHelpText('after', '\nExamples:\n quox approvals deny FJ340S --note "wrong host"\n')
|
|
287
|
+
.action(announceOnFail('approvals', (id) => `deny ${id}`, (id, options) => decide('deny', id, options)));
|
|
288
|
+
|
|
289
|
+
approvals
|
|
290
|
+
.command('withdraw <id>')
|
|
291
|
+
.description('Take back an approval request YOU raised (full id). It is marked withdrawn, never denied; the held call cannot run; only the identity that raised it may do this')
|
|
292
|
+
.option('--note <text>', 'why, recorded with the withdrawal')
|
|
293
|
+
.addHelpText('after', '\nExamples:\n quox approvals withdraw 01KXYZ... --note "raised by mistake"\n')
|
|
294
|
+
.action(announceOnFail('approvals', (id) => `withdraw ${id}`, async (given, options) => {
|
|
295
|
+
auth.requireAuth();
|
|
296
|
+
const orgId = resolveOrgId(auth, program);
|
|
297
|
+
// The full item id or approval id goes straight to the server, which checks you are the requester. A short tail is
|
|
298
|
+
// looked up among the open approvals you can see; one you cannot see is not yours to withdraw by tail.
|
|
299
|
+
let id = String(given).trim();
|
|
300
|
+
if (id.length < 20) { const it = await pickApproval(orgId, id); id = it.id; }
|
|
301
|
+
const ep = resolveEndpoint('inbox.withdraw', { orgId, itemId: id });
|
|
302
|
+
try {
|
|
303
|
+
await client.post(ep.path, { service: ep.service, body: { ...(options.note ? { reason: options.note } : {}) } });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err && (err.status === 403 || err.status === 404 || err.status === 400) && !(err instanceof QuoxUserError)) {
|
|
306
|
+
const msg = err.body?.message || err.body?.error || err.message;
|
|
307
|
+
throw new QuoxUserError(String(msg), { exitCode: err.status === 403 ? 4 : 5, code: err.body?.error || 'withdraw_refused', status: err.status, body: err.body });
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
announce('approvals', `withdrew ${shortId(id)}`, { result: 'withdrawn, nothing ran' });
|
|
312
|
+
printSuccess(`Withdrew ${shortId(id)}. It is marked withdrawn and cannot run.`);
|
|
313
|
+
}));
|
|
124
314
|
}
|
package/lib/commands/ask.js
CHANGED
|
@@ -130,7 +130,7 @@ export function registerAskCommands(program, { client, auth }) {
|
|
|
130
130
|
const d = (await client.get(ep.path, { service: ep.service })).decision;
|
|
131
131
|
if (program.opts().output === 'json') { console.log(JSON.stringify(d, null, 2)); return; }
|
|
132
132
|
console.log(`${d.state}${d.answer?.option ? ` ${d.answer.option}` : ''}${d.answer?.note ? ` (${d.answer.note})` : ''}`);
|
|
133
|
-
if (d.state === 'expired' || d.state === 'denied') process.exitCode = EXIT_NO_ANSWER;
|
|
133
|
+
if (d.state === 'expired' || d.state === 'denied' || d.state === 'withdrawn') process.exitCode = EXIT_NO_ANSWER;
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
ask
|
package/lib/commands/auth.js
CHANGED
|
@@ -7,7 +7,19 @@ import { formatDetail, printSuccess, printInfo } from '../core/output.js';
|
|
|
7
7
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
8
8
|
import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from '../core/residentAuth.js';
|
|
9
9
|
import { readStoredKit, resolveMyAttach, sessionKey, myAttachState } from './agentAttach.js';
|
|
10
|
+
import { loginPrefillEmail, profileHoldingEmail, isNonDefaultProfile, sameIdentityWarning } from '../core/loginGuard.js';
|
|
10
11
|
import { resolveActing } from '../core/actingAs.js';
|
|
12
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
13
|
+
|
|
14
|
+
const PASSWORD_POLICY_HINT = 'Password policy: at least 12 characters, with an upper-case letter, a lower-case letter, a digit and a symbol.';
|
|
15
|
+
|
|
16
|
+
/** whoami line for "-p/--profile given": say which agent is acting when the profile is an agent identity. */
|
|
17
|
+
export function profileActingLine({ actorType, profile, user }) {
|
|
18
|
+
if (actorType === 'agent') {
|
|
19
|
+
return ` Acting as agent ${user || 'unknown'} via profile ${profile || 'unknown'} (-p/--profile given)`;
|
|
20
|
+
}
|
|
21
|
+
return ' Acting as yourself (-p/--profile given)';
|
|
22
|
+
}
|
|
11
23
|
|
|
12
24
|
export function registerAuthCommands(program, ctx) {
|
|
13
25
|
const { config, auth, client } = ctx;
|
|
@@ -122,6 +134,7 @@ export function registerAuthCommands(program, ctx) {
|
|
|
122
134
|
type: 'input',
|
|
123
135
|
name: 'email',
|
|
124
136
|
message: 'Email:',
|
|
137
|
+
default: loginPrefillEmail(config) || undefined,
|
|
125
138
|
validate: v => v.includes('@') || 'Please enter a valid email',
|
|
126
139
|
}]);
|
|
127
140
|
email = ans.email;
|
|
@@ -138,6 +151,21 @@ export function registerAuthCommands(program, ctx) {
|
|
|
138
151
|
}
|
|
139
152
|
}
|
|
140
153
|
|
|
154
|
+
// Stop agent-only profiles quietly becoming another profile's (the owner's) identity.
|
|
155
|
+
if (isNonDefaultProfile(config)) {
|
|
156
|
+
const holder = profileHoldingEmail(config, email, config.getActiveProfile?.()?.url || null);
|
|
157
|
+
if (holder) {
|
|
158
|
+
const msg = sameIdentityWarning(email, holder, config.getActiveProfileName());
|
|
159
|
+
if (!process.stdin.isTTY) {
|
|
160
|
+
console.error(`Error: ${msg}\nRefusing in a non-interactive shell. Use the profile's own account, or run interactively and answer y.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const { default: inquirer } = await import('inquirer');
|
|
164
|
+
const ans = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: msg.replace(/ y\/N$/, ''), default: false }]);
|
|
165
|
+
if (!ans.ok) { printInfo('Cancelled. Nothing was sent.'); return; }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
const baseUrl = config.getServiceUrl('auth');
|
|
142
170
|
const ora = (await import('ora')).default;
|
|
143
171
|
const spinner = ora('Authenticating...').start();
|
|
@@ -196,6 +224,7 @@ export function registerAuthCommands(program, ctx) {
|
|
|
196
224
|
program
|
|
197
225
|
.command('whoami')
|
|
198
226
|
.description('Show current user and session info')
|
|
227
|
+
.addHelpText('after', '\nAdd --json for machine output (user, email, org, role, mfa, session, profile, actor_type, host_id).')
|
|
199
228
|
.action(async () => {
|
|
200
229
|
auth.requireAuth();
|
|
201
230
|
const data = await client.get(resolveEndpoint('auth.me.list').path, { service: resolveEndpoint('auth.me.list').service });
|
|
@@ -309,7 +338,7 @@ export function registerAuthCommands(program, ctx) {
|
|
|
309
338
|
} else if (acting.source === 'self') {
|
|
310
339
|
printInfo(' Acting as yourself (--as self)');
|
|
311
340
|
} else if (acting.source === 'profile') {
|
|
312
|
-
printInfo(
|
|
341
|
+
printInfo(profileActingLine({ actorType, profile: info.profile, user: info.user }));
|
|
313
342
|
} else if (myAttachState().state === 'ambiguous') {
|
|
314
343
|
printInfo(' Acting as yourself: this session has more than one attach (ambiguous), so no agent default applies. Pick one with --as <agent>.');
|
|
315
344
|
}
|
|
@@ -337,18 +366,42 @@ export function registerAuthCommands(program, ctx) {
|
|
|
337
366
|
.command('signup')
|
|
338
367
|
.description('Create a new user account')
|
|
339
368
|
.requiredOption('--email <email>', 'email address')
|
|
340
|
-
.
|
|
369
|
+
.option('--password <password>', 'password (prefer --password-stdin: argv is visible in process lists)')
|
|
370
|
+
.option('--password-stdin', 'read the password from stdin (one line), no secret in argv or shell history')
|
|
341
371
|
.requiredOption('--name <name>', 'display name')
|
|
342
372
|
.action(async (options) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
373
|
+
let password = options.password;
|
|
374
|
+
if (!password && options.passwordStdin) {
|
|
375
|
+
const chunks = [];
|
|
376
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
377
|
+
password = Buffer.concat(chunks).toString('utf8').split('\n')[0].trim();
|
|
378
|
+
if (!password) throw new QuoxUserError('--password-stdin given but stdin was empty', { next: 'Pipe the password in, for example: printf %s "$PW" | quox signup --password-stdin ...' });
|
|
379
|
+
} else if (password) {
|
|
380
|
+
console.error('Warning: argv is visible to other users (process list) and lands in shell history, so a password given with --password is exposed. Prefer --password-stdin.');
|
|
381
|
+
}
|
|
382
|
+
if (!password) {
|
|
383
|
+
throw new QuoxUserError('A password is required. Use --password-stdin (reads one line from stdin) or --password <password>.');
|
|
384
|
+
}
|
|
385
|
+
const ep = resolveEndpoint('auth.signup');
|
|
386
|
+
try {
|
|
387
|
+
await client.post(ep.path, {
|
|
388
|
+
service: ep.service,
|
|
389
|
+
body: { email: options.email, password, display_name: options.name },
|
|
390
|
+
noAuth: true,
|
|
391
|
+
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (err?.status === 400 && err?.code === 'validation_error') {
|
|
394
|
+
const reason = err.body?.message || err.message;
|
|
395
|
+
const list = Array.isArray(err.body?.errors) ? err.body.errors.filter((e) => typeof e === 'string' && e !== reason) : [];
|
|
396
|
+
const detail = [reason, ...list].join('; ');
|
|
397
|
+
const aboutPassword = /password/i.test(detail);
|
|
398
|
+
throw new QuoxUserError(`Signup was rejected: ${detail}`, {
|
|
399
|
+
next: aboutPassword ? PASSWORD_POLICY_HINT : null,
|
|
400
|
+
code: 'validation_error', status: 400, body: err.body,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
352
405
|
printSuccess(`Account created for ${options.email}. Check your email for verification.`);
|
|
353
406
|
});
|
|
354
407
|
|
package/lib/commands/backup.js
CHANGED
|
@@ -37,12 +37,19 @@ export function registerBackupCommands(program, ctx) {
|
|
|
37
37
|
auth.requireAuth();
|
|
38
38
|
const orgId = resolveOrgId(auth, program);
|
|
39
39
|
const data = await client.get(resolveEndpoint('backups.list', { orgId }).path, { service: resolveEndpoint('backups.list', { orgId }).service, query: { limit: options.limit } });
|
|
40
|
+
const items = data.items || data.backups || data;
|
|
41
|
+
// Name leads (ids are for scripting: --json, -q). A failed row says why, clipped.
|
|
42
|
+
const clip = (t, n = 60) => { const x = String(t).replace(/\s+/g, ' ').trim(); return x.length > n ? `${x.slice(0, n - 1)}…` : x; };
|
|
43
|
+
const rows = (Array.isArray(items) ? items : []).map((b) => ({
|
|
44
|
+
...b,
|
|
45
|
+
status: b.status === 'failed' && b.error_message ? `failed: ${clip(b.error_message)}` : b.status,
|
|
46
|
+
}));
|
|
40
47
|
formatOutput({
|
|
41
48
|
// fleet #392: backups has name/backup_type/archive_size_bytes.
|
|
42
|
-
columns: ['
|
|
43
|
-
headers: ['
|
|
44
|
-
rows
|
|
45
|
-
}, { format: program.opts().output });
|
|
49
|
+
columns: ['name', 'backup_type', 'archive_size_bytes', 'status', 'created_at', 'id'],
|
|
50
|
+
headers: ['Name', 'Type', 'Size (bytes)', 'Status', 'Created', 'ID'],
|
|
51
|
+
rows,
|
|
52
|
+
}, { format: program.opts().output, raw: Array.isArray(items) ? items : undefined, quietColumn: 'id' });
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
backup
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
11
|
-
import { capabilityLines, findCapability, offerFor } from '../quoxon/capabilities.js';
|
|
11
|
+
import { capabilityLines, findCapability, offerFor, needsSetupNames } from '../quoxon/capabilities.js';
|
|
12
12
|
|
|
13
13
|
export function registerCapabilitiesCommands(program, ctx) {
|
|
14
14
|
const { client, auth } = ctx;
|
|
15
15
|
program
|
|
16
16
|
.command('capabilities [name]')
|
|
17
17
|
.description('What this Quox can do for your org: ready, needs setup, not available, planned')
|
|
18
|
+
.addHelpText('after', '\nAdd --json for the full list (id, name, availability, reason, setup, permissions_ok).')
|
|
18
19
|
.action(async (name) => {
|
|
19
20
|
auth.requireAuth();
|
|
20
21
|
const ep = resolveEndpoint('v1.capabilities');
|
|
21
22
|
const data = await client.get(ep.path, { service: ep.service });
|
|
22
|
-
if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
23
|
+
if (program.opts().output === 'json') { console.log(JSON.stringify({ needs_setup: needsSetupNames(data?.capabilities), ...data }, null, 2)); return; }
|
|
23
24
|
const res = { ok: Array.isArray(data?.capabilities), capabilities: data?.capabilities, error: 'unexpected response' };
|
|
24
25
|
if (name) {
|
|
25
26
|
const c = findCapability(name, res);
|
package/lib/commands/chat.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { formatDetail } from '../core/output.js';
|
|
7
|
+
import { eventLine, announceMuted } from '../core/announce.js';
|
|
7
8
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
8
9
|
import { existsSync } from 'node:fs';
|
|
9
10
|
import { lastConversation, saveLastConversation } from './chatState.js';
|
|
@@ -70,6 +71,14 @@ function printTrace(ev) {
|
|
|
70
71
|
if (f) process.stderr.write((TONE_PAINT[f.tone] || chalk.cyan)(f.text) + '\n');
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** A turn's quox_event frame as its QuoxGlyph line on stderr (stdout stays the answer); silent under --json/--quiet. */
|
|
75
|
+
export function printQuoxEvent(ev) {
|
|
76
|
+
if (announceMuted()) return null;
|
|
77
|
+
const q = eventLine(ev);
|
|
78
|
+
if (q) process.stderr.write(`${q}\n`);
|
|
79
|
+
return q;
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
function generateQueryId() {
|
|
74
83
|
return `CLI_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
75
84
|
}
|
|
@@ -81,6 +90,7 @@ function generateQueryId() {
|
|
|
81
90
|
export async function consumeStream(client, { message, agent, sessionId, context }, { onAgentLabel, trace = false, companionPath, companionDisplay } = {}) {
|
|
82
91
|
const queryId = generateQueryId();
|
|
83
92
|
const body = buildStreamQuery({ queryId, message, agent, sessionId, context });
|
|
93
|
+
body.turn_id = queryId; // an explicit turn id selects the v1 event stream: tool and quox_event frames only flow with it
|
|
84
94
|
|
|
85
95
|
let text = '';
|
|
86
96
|
let resultSessionId = sessionId;
|
|
@@ -145,6 +155,10 @@ export async function consumeStream(client, { message, agent, sessionId, context
|
|
|
145
155
|
write('\n');
|
|
146
156
|
break;
|
|
147
157
|
|
|
158
|
+
case 'quox_event':
|
|
159
|
+
printQuoxEvent(event);
|
|
160
|
+
break;
|
|
161
|
+
|
|
148
162
|
case 'error':
|
|
149
163
|
throw new Error(event.error || 'Stream error');
|
|
150
164
|
|
|
@@ -361,6 +375,7 @@ export function registerChatCommands(program, ctx) {
|
|
|
361
375
|
});
|
|
362
376
|
spinner.stop();
|
|
363
377
|
noteConversation(convId);
|
|
378
|
+
for (const qe of Array.isArray(data.quox_events) ? data.quox_events : []) printQuoxEvent(qe); // the server's per-turn Quox effects, when it returns them
|
|
364
379
|
|
|
365
380
|
if (program.opts().output === 'json') {
|
|
366
381
|
console.log(JSON.stringify({ ...data, conversation_id: convId || null }, null, 2));
|
package/lib/commands/claude.js
CHANGED
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
*/
|
|
30
30
|
export function parseClaudeArgs(raw) {
|
|
31
31
|
let noTmux = false;
|
|
32
|
+
let force = false;
|
|
32
33
|
let as;
|
|
33
34
|
let slot;
|
|
34
35
|
const args = [];
|
|
@@ -38,6 +39,7 @@ export function parseClaudeArgs(raw) {
|
|
|
38
39
|
if (past) { args.push(a); continue; }
|
|
39
40
|
if (a === '--') { past = true; args.push(a); continue; }
|
|
40
41
|
if (a === '--no-tmux') noTmux = true;
|
|
42
|
+
else if (a === '--force') force = true;
|
|
41
43
|
else if ((a === '--as' || a === '--slot') && raw[i + 1] && !raw[i + 1].startsWith('-')) {
|
|
42
44
|
if (a === '--as') as = raw[i + 1]; else slot = raw[i + 1];
|
|
43
45
|
i++;
|
|
@@ -72,7 +74,7 @@ export function parseClaudeArgs(raw) {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
if (name !== undefined && !validateName(name)) name = undefined;
|
|
75
|
-
return { args, name, resume, noTmux, as, slot };
|
|
77
|
+
return { args, name, resume, noTmux, ...(force ? { force } : {}), as, slot };
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/**
|
|
@@ -151,6 +153,7 @@ quox's own flags (consumed here, not passed to claude):
|
|
|
151
153
|
--as <agent> start already attached to a Quox agent
|
|
152
154
|
--slot <name> start already holding a dev slot
|
|
153
155
|
--no-tmux run claude directly, without tmux
|
|
156
|
+
--force with --resume: start a second copy even if that conversation is already running
|
|
154
157
|
-n, --name <n> name the session (also names the tmux session; claude's own flag)
|
|
155
158
|
--resume <n> resume the named session (claude's own flag)
|
|
156
159
|
-h, --help show this help
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance commands: scheduled compliance report jobs.
|
|
3
|
+
*
|
|
4
|
+
* `compliance jobs list` calls GET /compliance/jobs (services/collector/server.js),
|
|
5
|
+
* the same route the compliance_list_jobs agent tool uses. Jobs can carry an
|
|
6
|
+
* optional name (set via the compliance_schedule_job tool / POST body `name`).
|
|
7
|
+
*
|
|
8
|
+
* `compliance reports list|show` read GET /compliance/reports and /compliance/reports/:id, the
|
|
9
|
+
* routes compliance_get_reports reads (org-scoped by the caller's session). The ORG column is
|
|
10
|
+
* always shown: a row with no org (org_id null) is printed as `none`, never hidden.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatOutput, formatDetail } from '../core/output.js';
|
|
14
|
+
import { resolveOrgId } from './helpers.js';
|
|
15
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
16
|
+
|
|
17
|
+
export function registerComplianceCommands(program, ctx) {
|
|
18
|
+
const { client, auth } = ctx;
|
|
19
|
+
|
|
20
|
+
const compliance = program
|
|
21
|
+
.command('compliance')
|
|
22
|
+
.description('Compliance: generated reports and scheduled report jobs');
|
|
23
|
+
|
|
24
|
+
const jobs = compliance
|
|
25
|
+
.command('jobs')
|
|
26
|
+
.description('Scheduled compliance report jobs');
|
|
27
|
+
|
|
28
|
+
jobs
|
|
29
|
+
.command('list')
|
|
30
|
+
.description('List scheduled compliance report jobs (with NAME)')
|
|
31
|
+
.option('--status <status>', 'filter by status (active|paused|error)')
|
|
32
|
+
.action(async (options) => {
|
|
33
|
+
auth.requireAuth();
|
|
34
|
+
const orgId = resolveOrgId(auth, program);
|
|
35
|
+
const ep = resolveEndpoint('compliance.jobs.list');
|
|
36
|
+
const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
|
|
37
|
+
let rows = data.jobs || [];
|
|
38
|
+
if (options.status) rows = rows.filter((j) => j.status === options.status);
|
|
39
|
+
formatOutput({
|
|
40
|
+
columns: ['id', 'name', 'format', 'schedule_interval', 'status', 'next_run'],
|
|
41
|
+
headers: ['ID', 'NAME', 'Format', 'Interval', 'Status', 'Next run'],
|
|
42
|
+
rows: rows.map((j) => ({ ...j, name: j.name || '-' })),
|
|
43
|
+
}, { format: program.opts().output, emptyMessage: 'No compliance jobs found.', raw: rows });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const reports = compliance
|
|
47
|
+
.command('reports')
|
|
48
|
+
.description('Generated compliance reports (read only)');
|
|
49
|
+
|
|
50
|
+
const scoreOf = (r) => (r.summary && typeof r.summary === 'object' && r.summary.overallScore != null ? r.summary.overallScore : '-');
|
|
51
|
+
|
|
52
|
+
reports
|
|
53
|
+
.command('list')
|
|
54
|
+
.description('List generated compliance reports: framework, period, score, sampled evidence, ORG')
|
|
55
|
+
.option('--format <format>', 'only this framework (soc2, hipaa, gdpr, iso27001, euAiAct, iso42001, nistAiRmf)')
|
|
56
|
+
.option('--limit <n>', 'max rows', '20')
|
|
57
|
+
.action(async (options) => {
|
|
58
|
+
auth.requireAuth();
|
|
59
|
+
const orgId = resolveOrgId(auth, program);
|
|
60
|
+
const ep = resolveEndpoint('compliance.reports.list');
|
|
61
|
+
const query = { org_id: orgId, limit: String(options.limit || 20) };
|
|
62
|
+
if (options.format) query.format = options.format;
|
|
63
|
+
const data = await client.get(ep.path, { service: ep.service, query });
|
|
64
|
+
let rows = data.reports || [];
|
|
65
|
+
// the route does not filter by format, so the flag is applied here too
|
|
66
|
+
if (options.format) rows = rows.filter((r) => r.format === options.format);
|
|
67
|
+
formatOutput({
|
|
68
|
+
columns: ['id', 'format', 'generated_at', 'period_days', 'score', 'status', 'sample', 'org'],
|
|
69
|
+
headers: ['ID', 'Format', 'Generated', 'Days', 'Score', 'Status', 'Evidence sample', 'ORG'],
|
|
70
|
+
rows: rows.map((r) => ({
|
|
71
|
+
...r,
|
|
72
|
+
period_days: r.period_days ?? '-',
|
|
73
|
+
score: scoreOf(r),
|
|
74
|
+
status: (r.summary && r.summary.overallStatus) || '-',
|
|
75
|
+
sample: (r.summary && Array.isArray(r.summary.evidenceSample) && r.summary.evidenceSample.length) ? r.summary.evidenceSample.join(' ') : '-',
|
|
76
|
+
org: r.org_id || 'none',
|
|
77
|
+
})),
|
|
78
|
+
}, { format: program.opts().output, emptyMessage: 'No compliance reports found.', raw: rows });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
reports
|
|
82
|
+
.command('show <id>')
|
|
83
|
+
.description('Show one generated report: framework, period, score, per-control status, sampled evidence')
|
|
84
|
+
.action(async (id) => {
|
|
85
|
+
auth.requireAuth();
|
|
86
|
+
const orgId = resolveOrgId(auth, program);
|
|
87
|
+
const ep = resolveEndpoint('compliance.reports.get', { id });
|
|
88
|
+
const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
|
|
89
|
+
const r = data.report || data;
|
|
90
|
+
const fmt = program.opts().output;
|
|
91
|
+
if (fmt && fmt !== 'table') { formatDetail(r, { format: fmt }); return; }
|
|
92
|
+
const s = (r.summary && typeof r.summary === 'object') ? r.summary : {};
|
|
93
|
+
const body = (r.report_data && typeof r.report_data === 'object') ? r.report_data : {};
|
|
94
|
+
const lines = [
|
|
95
|
+
`Report ${r.id}`,
|
|
96
|
+
`Framework ${s.framework || body.framework || r.format}`,
|
|
97
|
+
`Org ${r.org_id || 'none'}`,
|
|
98
|
+
`Period ${r.period_start || '-'} .. ${r.period_end || '-'} (${r.period_days ?? '-'} days)`,
|
|
99
|
+
`Score ${s.overallScore ?? body.overallScore ?? '-'} status ${s.overallStatus || body.overallStatus || '-'}`,
|
|
100
|
+
`Evidence ${Array.isArray(s.evidenceSample) && s.evidenceSample.length ? s.evidenceSample.join(' ') : '-'}`,
|
|
101
|
+
];
|
|
102
|
+
const sections = body.sections && typeof body.sections === 'object' ? body.sections : {};
|
|
103
|
+
for (const [key, sec] of Object.entries(sections)) {
|
|
104
|
+
lines.push('', `${key} ${sec && sec.name ? sec.name : ''}`.trimEnd());
|
|
105
|
+
for (const c of (sec && Array.isArray(sec.controls) ? sec.controls : [])) lines.push(` ${String(c.id || '-').padEnd(10)} ${String(c.status || '-').padEnd(16)} ${c.name || ''}`);
|
|
106
|
+
}
|
|
107
|
+
for (const l of lines) console.log(l);
|
|
108
|
+
});
|
|
109
|
+
}
|