quoxcli 0.3.1 → 0.4.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/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +20 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/index.js +12 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +173 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/v1.js +34 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +46 -7
- package/lib/commands/approvals.js +5 -0
- package/lib/commands/ask.js +3 -1
- package/lib/commands/blog.js +110 -0
- package/lib/commands/chat.js +127 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +3 -32
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/loop.js +291 -0
- package/lib/commands/objective.js +48 -1
- package/lib/commands/org.js +21 -4
- package/lib/commands/provider.js +58 -0
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +18 -1
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +113 -0
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +20 -4
- package/lib/commands/vm.js +2 -2
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/client.js +10 -2
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +1 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +1 -3
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +36 -7
- package/lib/core/output.js +7 -7
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/api.js +192 -6
- package/lib/quoxon/app.js +655 -89
- package/lib/quoxon/approvalFacts.js +8 -6
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +63 -4
- package/lib/quoxon/commandShort.js +15 -3
- package/lib/quoxon/commands.js +75 -10
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/events.js +24 -3
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +12 -3
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/orgSwitch.js +27 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +6 -3
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +130 -33
- package/lib/quoxon/roster.js +26 -12
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +76 -1
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/palette.js +1 -1
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +25 -1
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/suggestCommand.js +33 -2
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/whatsnew.jsonl +45 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/package.json +2 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* quox engagement create <name> --environment-tier <tier> [--risk-ceiling <tier>]
|
|
5
5
|
* quox engagement list
|
|
6
6
|
* quox engagement show <id>
|
|
7
|
+
* quox engagement verify <id>
|
|
7
8
|
* quox engagement add-target <id> <type> <value> [--verification-method <method>]
|
|
8
9
|
* quox engagement verify-target <id> <targetId>
|
|
9
10
|
*
|
|
@@ -20,6 +21,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
20
21
|
import { formatOutput, formatDetail, printSuccess, printInfo } from '../core/output.js';
|
|
21
22
|
import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
|
|
22
23
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
24
|
+
import { gatherEvidence, evidenceChanged } from '../core/findingReconcile.js';
|
|
23
25
|
|
|
24
26
|
export function registerEngagementCommands(program, ctx) {
|
|
25
27
|
const { client, auth } = ctx;
|
|
@@ -80,6 +82,31 @@ export function registerEngagementCommands(program, ctx) {
|
|
|
80
82
|
}, { format: program.opts().output, emptyMessage: 'No targets added yet.' });
|
|
81
83
|
});
|
|
82
84
|
|
|
85
|
+
engagement
|
|
86
|
+
.command('verify <id>')
|
|
87
|
+
.description('Verify the WARD witness for an engagement (recompute receipt + Ed25519 tip signature)')
|
|
88
|
+
.action(async (id) => {
|
|
89
|
+
auth.requireAuth();
|
|
90
|
+
const ep = resolveEndpoint('engagement.verify', { id });
|
|
91
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
92
|
+
if (program.opts().output === 'json') {
|
|
93
|
+
console.log(JSON.stringify(data, null, 2));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const ok = data.pass === true;
|
|
97
|
+
console.log(ok ? '✓ WITNESS VERIFIED' : '✗ NOT VERIFIED');
|
|
98
|
+
console.log(` engagement ${data.engagement_id || id}`);
|
|
99
|
+
console.log(` witnessed ${data.witnessed ? 'yes' : 'no'}`);
|
|
100
|
+
console.log(` chain ${data.chainId || '-'}`);
|
|
101
|
+
if (data.seq != null) console.log(` entry #${data.seq}`);
|
|
102
|
+
if (data.chainHash) console.log(` chain hash ${String(data.chainHash).slice(0, 40)}`);
|
|
103
|
+
console.log(` chain recompute ${data.chainHashRecomputed ? 'ok' : 'FAILED'}`);
|
|
104
|
+
const sig = data.signature || {};
|
|
105
|
+
console.log(` tip signature ${sig.status}${sig.keyId ? ` (key ${String(sig.keyId).slice(0, 16)})` : ''}`);
|
|
106
|
+
if (data.signedTip && data.signedTip.tipId) console.log(` signed tip ${data.signedTip.tipId} (seq ${data.signedTip.tipSeq})`);
|
|
107
|
+
if (!ok && data.reason) console.log(` reason ${data.reason}`);
|
|
108
|
+
});
|
|
109
|
+
|
|
83
110
|
engagement
|
|
84
111
|
.command('set-gripe-target <id>')
|
|
85
112
|
.description('Set which repo a finding filed from this engagement goes to, and which org GitHub connector token files it. Lets a customer-site engagement file into the customer repo with the customer org token. Pass --clear (or --repo "") to fall back to the platform default (quoxai/quox) and the engagement own org.')
|
|
@@ -575,6 +602,69 @@ export function registerEngagementCommands(program, ctx) {
|
|
|
575
602
|
printSuccess(`Schedule "${scheduleId}" deleted.`);
|
|
576
603
|
});
|
|
577
604
|
|
|
605
|
+
// SITREP-FINDINGS: reconcile open findings against their issue and fix. Evidence is gathered
|
|
606
|
+
// here (git + gh on this host); the collector decides and keeps the audit trail. Resolves only
|
|
607
|
+
// on a closed issue AND a fix in the running build; a critical is never auto-resolved.
|
|
608
|
+
engagement
|
|
609
|
+
.command('reconcile [findingId]')
|
|
610
|
+
.description('Reconcile open findings against their linked issue and fix commit (landed on main, deployed in the running build). Resolves only when the issue is closed AND the fix is deployed; a critical never auto-resolves. Every change is audited')
|
|
611
|
+
.option('--link <ref...>', 'link evidence: <findingId>=<owner/repo#N | #N | commit-sha> (repeatable)')
|
|
612
|
+
.option('--repo-dir <dir>', 'git checkout to read origin/main from (default: ~/quox-dashboard)')
|
|
613
|
+
.option('--build-dir <dir>', 'the tree the running build was built from (default: ~/.quox-build/quox-dashboard)')
|
|
614
|
+
.option('--issue-repo <owner/repo>', 'repo a bare #N refers to', 'quoxai/quox')
|
|
615
|
+
.option('--no-fetch', 'do not fetch origin/main first')
|
|
616
|
+
.option('--dry-run', 'show the evidence without recording anything')
|
|
617
|
+
.action(async (findingId, options) => {
|
|
618
|
+
auth.requireAuth();
|
|
619
|
+
const { spawnSync } = await import('node:child_process');
|
|
620
|
+
const run = (cmd, args) => {
|
|
621
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 30000 });
|
|
622
|
+
return { ok: r.status === 0, out: String(r.stdout || '') };
|
|
623
|
+
};
|
|
624
|
+
const home = (await import('node:os')).homedir();
|
|
625
|
+
options.repoDir = options.repoDir || `${home}/quox-dashboard`;
|
|
626
|
+
options.buildDir = options.buildDir || `${home}/.quox-build/quox-dashboard`;
|
|
627
|
+
const links = {};
|
|
628
|
+
for (const l of options.link || []) {
|
|
629
|
+
const m = /^(FND_[0-9A-Z]+)=(.+)$/.exec(String(l));
|
|
630
|
+
if (!m) throw new Error(`--link must look like FND_...=<issue or sha>, got "${l}"`);
|
|
631
|
+
const v = m[2].trim();
|
|
632
|
+
links[m[1]] = { ...(links[m[1]] || {}), ...(/^[0-9a-f]{7,40}$/i.test(v) ? { commit: v } : { issue: v }) };
|
|
633
|
+
}
|
|
634
|
+
if (options.fetch !== false) run('git', ['-C', options.repoDir, 'fetch', '--quiet', 'origin', 'main']);
|
|
635
|
+
const ep = resolveEndpoint('engagement.findings.open', {});
|
|
636
|
+
const all = (await client.get(ep.path, { service: ep.service })).findings || [];
|
|
637
|
+
const findings = findingId ? all.filter((f) => f.id === findingId) : all;
|
|
638
|
+
if (findingId && !findings.length) throw new Error(`Finding "${findingId}" is not open in this org`);
|
|
639
|
+
const rows = [];
|
|
640
|
+
for (const f of findings) {
|
|
641
|
+
const ev = gatherEvidence(f, links[f.id] || {}, { repoDir: options.repoDir, buildDir: options.buildDir, issueRepo: options.issueRepo, run });
|
|
642
|
+
const row = { id: f.id, severity: f.severity, status: f.status, issue: ev.issue_ref ? `${ev.issue_ref}${ev.issue_state ? ` (${ev.issue_state})` : ''}` : '',
|
|
643
|
+
fix: ev.fix_commit ? `${ev.fix_commit.slice(0, 9)} ${ev.fix_state}` : '', outcome: '' };
|
|
644
|
+
if (!Object.keys(ev).length) { row.outcome = 'no evidence'; rows.push(row); continue; }
|
|
645
|
+
if (!evidenceChanged(f, ev)) { row.outcome = 'unchanged'; rows.push(row); continue; }
|
|
646
|
+
if (options.dryRun) { row.outcome = 'would record'; rows.push(row); continue; }
|
|
647
|
+
const rep = resolveEndpoint('engagement.findings.reconcile', { findingId: f.id });
|
|
648
|
+
const data = await client.post(rep.path, { service: rep.service, body: ev });
|
|
649
|
+
row.outcome = `${data.outcome}: ${data.reason}`;
|
|
650
|
+
row.status = data.finding?.status || row.status;
|
|
651
|
+
rows.push(row);
|
|
652
|
+
}
|
|
653
|
+
formatOutput({ columns: ['id', 'severity', 'status', 'issue', 'fix', 'outcome'], headers: ['Finding', 'Severity', 'Status', 'Issue', 'Fix', 'Outcome'], rows },
|
|
654
|
+
{ format: program.opts().output, emptyMessage: 'No open findings.' });
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
engagement
|
|
658
|
+
.command('reconciliations <findingId>')
|
|
659
|
+
.description('Show the reconciliation audit trail of one finding (what evidence was recorded, when, by whom, and the outcome)')
|
|
660
|
+
.action(async (findingId) => {
|
|
661
|
+
auth.requireAuth();
|
|
662
|
+
const ep = resolveEndpoint('engagement.findings.reconciliations', { findingId });
|
|
663
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
664
|
+
formatOutput({ columns: ['created_at', 'outcome', 'reason', 'actor', 'evidence'], headers: ['When', 'Outcome', 'Reason', 'Actor', 'Evidence'], rows: data.reconciliations || [] },
|
|
665
|
+
{ format: program.opts().output, emptyMessage: 'No reconciliations recorded.' });
|
|
666
|
+
});
|
|
667
|
+
|
|
578
668
|
engagement
|
|
579
669
|
.command('sync-issues <id>')
|
|
580
670
|
.description('Reconcile this engagement\'s filed findings against their GitHub issue state. A closed issue auto-resolves its finding. Honest no-op if GitHub is not configured')
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence commands (DEVFLEET EV-1): `quox evidence status` shows size, rows, time span, growth, archive and
|
|
3
|
+
* backup for the AEE, WARD and VOLT stores. Read-only; the collector route is instance admin only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { formatOutput } from '../core/output.js';
|
|
8
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { exportConversationBundle, newestConversationId } from '../evidence/bundleExport.js';
|
|
11
|
+
import { verifyEvidenceBundle } from '../evidence/bundleVerify.js';
|
|
12
|
+
|
|
13
|
+
const NA = 'n/a';
|
|
14
|
+
|
|
15
|
+
// verify_state from auth, said once: 'verified', 'never verified', 'verify failed', 'verify n/a'.
|
|
16
|
+
function verifyLabel(v) {
|
|
17
|
+
if (v === null || v === undefined || v === '') return 'verify n/a';
|
|
18
|
+
if (v === 'verified') return 'verified';
|
|
19
|
+
if (v === 'failed') return 'verify failed';
|
|
20
|
+
if (v === 'never_verified') return 'never verified';
|
|
21
|
+
return `verify ${String(v).replace(/_/g, ' ')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function bytes(n) {
|
|
25
|
+
if (n === null || n === undefined) return NA;
|
|
26
|
+
if (n < 1024) return `${n} B`;
|
|
27
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
28
|
+
let v = n; let i = -1;
|
|
29
|
+
do { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1);
|
|
30
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const val = (v) => (v === null || v === undefined ? NA : String(v));
|
|
34
|
+
const day = (iso) => (iso ? String(iso).slice(0, 10) : NA);
|
|
35
|
+
|
|
36
|
+
function archiveCell(a) {
|
|
37
|
+
if (a === null || a === undefined) return NA;
|
|
38
|
+
if (typeof a === 'string') return a;
|
|
39
|
+
return `${a.anchors} anchors${a.newest_archived_at ? `, newest ${day(a.newest_archived_at)}` : ''}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function diskCell(d) {
|
|
43
|
+
if (!d) return NA;
|
|
44
|
+
return `${bytes(d.free_bytes)} free of ${bytes(d.total_bytes)} (${d.mount})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerEvidenceCommands(program, ctx) {
|
|
48
|
+
const { client, auth } = ctx;
|
|
49
|
+
|
|
50
|
+
const evidence = program
|
|
51
|
+
.command('evidence')
|
|
52
|
+
.description('Evidence stores: AEE, WARD, VOLT');
|
|
53
|
+
|
|
54
|
+
evidence
|
|
55
|
+
.command('status')
|
|
56
|
+
.description('Evidence stores (AEE, WARD, VOLT): size, rows, time span, growth, archive and backup, read-only')
|
|
57
|
+
.action(async () => {
|
|
58
|
+
auth.requireAuth();
|
|
59
|
+
const ep = resolveEndpoint('evidence.status');
|
|
60
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
61
|
+
if (!data || !Array.isArray(data.stores)) {
|
|
62
|
+
throw new Error(`Unexpected evidence status response${data && data.error ? `: ${data.error}` : ''}`);
|
|
63
|
+
}
|
|
64
|
+
const format = program.opts().output;
|
|
65
|
+
if (format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
66
|
+
|
|
67
|
+
const rows = data.stores.map((s) => ({
|
|
68
|
+
store: s.name,
|
|
69
|
+
size: bytes(s.bytes),
|
|
70
|
+
wal: bytes(s.wal_bytes),
|
|
71
|
+
rows: val(s.rows),
|
|
72
|
+
oldest: day(s.oldest),
|
|
73
|
+
newest: day(s.newest),
|
|
74
|
+
last7d: s.per_day && s.per_day.last_7d ? `${s.per_day.last_7d.per_day}/day` : NA,
|
|
75
|
+
archive: archiveCell(s.archive),
|
|
76
|
+
disk: diskCell(s.disk),
|
|
77
|
+
}));
|
|
78
|
+
formatOutput({
|
|
79
|
+
columns: ['store', 'size', 'wal', 'rows', 'oldest', 'newest', 'last7d', 'archive', 'disk'],
|
|
80
|
+
headers: ['Store', 'Size', 'WAL', 'Rows', 'Oldest', 'Newest', 'Last 7d', 'Archive', 'Disk'],
|
|
81
|
+
rows,
|
|
82
|
+
}, { format, full: true });
|
|
83
|
+
|
|
84
|
+
const b = data.backup || {};
|
|
85
|
+
if (b.last) {
|
|
86
|
+
const l = b.last;
|
|
87
|
+
const when = l.ts ? `${String(l.ts).slice(0, 10)} ${String(l.ts).slice(11, 16)} UTC` : NA;
|
|
88
|
+
const parts = [
|
|
89
|
+
b.age_hours === null || b.age_hours === undefined ? 'age n/a' : `${b.age_hours} h ago`,
|
|
90
|
+
l.count === null || l.count === undefined ? 'count n/a' : `${l.count} backups`,
|
|
91
|
+
(b.scope && b.scope.ts) || 'all orgs',
|
|
92
|
+
];
|
|
93
|
+
const line = `Last backup ${when} (${parts.join(', ')})${b.stale ? ' STALE' : ''}`;
|
|
94
|
+
console.log(b.stale ? chalk.yellow(line) : line);
|
|
95
|
+
// The newest backup can belong to any org; health and verify state are the caller's org only.
|
|
96
|
+
console.log(`Backup health (${(b.scope && b.scope.health) || 'your org'}): ${val(l.status)}, ${verifyLabel(l.verified)}`);
|
|
97
|
+
if (l.last_failed) console.log(` last failure: ${val(l.last_failed.error)} (${l.last_failed.at ? String(l.last_failed.at).slice(0, 16).replace('T', ' ') + ' UTC' : NA})`);
|
|
98
|
+
if (b.verify_error) console.log(` ${b.verify_error}`);
|
|
99
|
+
} else {
|
|
100
|
+
console.log(`Backup: ${NA}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const errors = [];
|
|
104
|
+
for (const s of data.stores) for (const e of s.errors || []) errors.push(`${s.name}: ${e}`);
|
|
105
|
+
if (b.error) errors.push(`backup: ${b.error}`);
|
|
106
|
+
if (errors.length) {
|
|
107
|
+
console.log(chalk.yellow('Errors'));
|
|
108
|
+
for (const e of errors) console.log(` ${e}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
evidence
|
|
112
|
+
.command('bundle')
|
|
113
|
+
.description('Export a signed, offline-verifiable evidence bundle (zip) of every AEE envelope, WARD receipt and VOLT run for one conversation')
|
|
114
|
+
.argument('[conversation]', 'conversation id (the corr); default: your newest chat conversation')
|
|
115
|
+
.option('--out <file.zip>', 'where to write it (default ~/quox-evidence/<conversation>-<timestamp>.zip)')
|
|
116
|
+
.option('--json', 'print the result as JSON')
|
|
117
|
+
.action(async (conversation, options) => {
|
|
118
|
+
auth.requireAuth();
|
|
119
|
+
const corr = conversation || await newestConversationId(client);
|
|
120
|
+
if (!corr) throw new Error('No conversation to export: pass a conversation id (`quox conversation list`).');
|
|
121
|
+
const r = await exportConversationBundle({ client, corr, out: options.out });
|
|
122
|
+
const failed = r.verify.checks.filter((c) => !c.ok);
|
|
123
|
+
if (options.json || program.opts().output === 'json') {
|
|
124
|
+
console.log(JSON.stringify({ path: r.path, conversation: r.corr, bytes: r.bytes, envelopes: r.envelopes, receipts: r.receipts, verified: r.verify.ok, failed_checks: failed }, null, 2));
|
|
125
|
+
} else {
|
|
126
|
+
console.log(`${r.verify.ok ? chalk.green('Evidence bundle written') : chalk.red('Evidence bundle written but FAILED verification')}: ${r.path}`);
|
|
127
|
+
console.log(` conversation ${r.corr} | ${r.envelopes} envelopes | ${r.receipts} WARD receipts | ${r.bytes} bytes`);
|
|
128
|
+
console.log(` verify offline: quox evidence verify ${r.path}`);
|
|
129
|
+
for (const c of failed) console.log(chalk.red(` FAILED ${c.name}: ${c.detail || ''}`));
|
|
130
|
+
}
|
|
131
|
+
if (!r.verify.ok) process.exitCode = 1;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
evidence
|
|
135
|
+
.command('verify')
|
|
136
|
+
.description('Verify an evidence bundle zip offline: file hashes, envelope list hash, counts and the Ed25519 signature (no network)')
|
|
137
|
+
.argument('<file.zip>', 'a bundle from `quox evidence bundle`')
|
|
138
|
+
.option('--json', 'print the result as JSON')
|
|
139
|
+
.action(async (file, options) => {
|
|
140
|
+
let bytes;
|
|
141
|
+
try { bytes = fs.readFileSync(file); } catch (e) { throw new Error(`Cannot read ${file}: ${e.message}`); }
|
|
142
|
+
const v = await verifyEvidenceBundle(bytes);
|
|
143
|
+
if (options.json || program.opts().output === 'json') {
|
|
144
|
+
console.log(JSON.stringify({ file, verified: v.ok, checks: v.checks, counts: v.manifest?.counts || null, conversation: v.manifest?.corr || null, key_id: v.signature?.key_id || null }, null, 2));
|
|
145
|
+
} else {
|
|
146
|
+
for (const c of v.checks) console.log(` ${c.ok ? chalk.green('PASS') : chalk.red('FAIL')} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
|
|
147
|
+
const m = v.manifest;
|
|
148
|
+
console.log(v.ok
|
|
149
|
+
? chalk.green(`VERIFIED: ${m.counts.envelopes} envelopes, ${m.counts.ward_receipts} WARD receipts, conversation ${m.corr}, signed by ${v.signature.key_id}`)
|
|
150
|
+
: chalk.red('NOT VERIFIED: this bundle was altered or is not a Quox evidence bundle.'));
|
|
151
|
+
}
|
|
152
|
+
if (!v.ok) process.exitCode = 1;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quox ext add|remove|list <pkg>
|
|
3
|
+
*
|
|
4
|
+
* The listing behind quoxon's extension host (cli/lib/quoxon/extensions.js, contract EXTENSIONS.md). It edits
|
|
5
|
+
* ~/.quox/extensions.json only; it does not install npm packages (npm i -g <pkg> does that). Nothing loads unless
|
|
6
|
+
* it is both installed and listed.
|
|
7
|
+
*/
|
|
8
|
+
import { readExtList, extAdd, extRemove, extListPath } from '../quoxon/extensions.js';
|
|
9
|
+
|
|
10
|
+
export function registerExtCommands(program) {
|
|
11
|
+
const ext = program.command('ext').description('Optional quoxon extensions: list, add or remove one (npm i -g <pkg> installs it)');
|
|
12
|
+
const out = (r) => { console.log(r.text); if (!r.ok) process.exitCode = 1; };
|
|
13
|
+
ext.command('list').description('Extensions listed in ~/.quox/extensions.json')
|
|
14
|
+
.option('--json', 'print as JSON')
|
|
15
|
+
.action((opts) => {
|
|
16
|
+
const list = readExtList();
|
|
17
|
+
if (opts.json || program.opts().output === 'json') { console.log(JSON.stringify({ file: extListPath(), extensions: list }, null, 2)); return; }
|
|
18
|
+
if (!list.length) { console.log('No extensions listed. quox ext add <pkg>'); return; }
|
|
19
|
+
for (const x of list) console.log(`${x.name} ${x.enabled ? 'on' : 'off'}`);
|
|
20
|
+
});
|
|
21
|
+
ext.command('add').description('List an extension so quoxon loads it once installed').argument('<pkg>').action((pkg) => out(extAdd(pkg)));
|
|
22
|
+
ext.command('remove').description('Stop listing an extension').argument('<pkg>').action((pkg) => out(extRemove(pkg)));
|
|
23
|
+
}
|
package/lib/commands/fleet.js
CHANGED
|
@@ -595,6 +595,31 @@ export function registerFleetCommands(program, ctx) {
|
|
|
595
595
|
}, { format: program.opts().output, emptyMessage: data && data.bastion === false ? 'No agents enrolled yet.' : 'No hosts in the Bastion registry.' });
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
+
fleet
|
|
599
|
+
.command('ping [host]')
|
|
600
|
+
.description('Refresh Bastion host state by pinging every host (or one); org admin, writes a quox.fleet.ping evidence envelope')
|
|
601
|
+
.action(async (host) => {
|
|
602
|
+
auth.requireAuth();
|
|
603
|
+
const ep = resolveEndpoint('v1.fleet.ping');
|
|
604
|
+
const data = await client.post(ep.path, { service: ep.service, body: host ? { host } : {}, timeout: 60000 });
|
|
605
|
+
const results = data.results || [];
|
|
606
|
+
const summary = data.summary || { requested: results.length, reachable: results.filter(r => r.reachable).length, unreachable: results.filter(r => !r.reachable).length };
|
|
607
|
+
if (program.opts().output === 'json') {
|
|
608
|
+
formatDetail({ success: data.success !== false, results, summary }, { format: 'json' });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
formatOutput({
|
|
612
|
+
columns: ['host_id', 'reachable', 'latency'],
|
|
613
|
+
headers: ['Host', 'Reachable', 'Latency'],
|
|
614
|
+
rows: results.map(r => ({
|
|
615
|
+
host_id: r.host_id,
|
|
616
|
+
reachable: r.reachable ? 'yes' : (r.error ? `no (${r.error})` : 'no'),
|
|
617
|
+
latency: typeof r.latency_ms === 'number' ? `${r.latency_ms}ms` : '-',
|
|
618
|
+
})),
|
|
619
|
+
}, { format: program.opts().output, emptyMessage: 'No hosts answered the ping.' });
|
|
620
|
+
printInfo(`${summary.reachable} of ${summary.requested} reachable, ${summary.unreachable} unreachable`);
|
|
621
|
+
});
|
|
622
|
+
|
|
598
623
|
fleet
|
|
599
624
|
.command('gpu')
|
|
600
625
|
.description('List the durable GPU fleet')
|
package/lib/commands/hat.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* quox hat list
|
|
5
5
|
* quox hat show <id|key> [--rev <n>]
|
|
6
6
|
* quox hat reset <id|key> --yes (audited reset-to-template, built-ins)
|
|
7
|
+
* quox hat upgrade <id|key> [--dry-run|--yes] (3-way merge of an org-edited built-in onto the current template)
|
|
7
8
|
* quox hat create <key> --name "<Name>" [--from <hat>] [--agents a,b] [--tools x,y] [--mode m] [--offer o]
|
|
8
9
|
* quox hat edit <hat> [--name N] [--add-agents|--remove-agents|--agents] [--add-tools|--remove-tools|--tools] [--mode m]
|
|
9
10
|
* quox hat delete <hat> --yes (custom hats only)
|
|
@@ -42,6 +43,12 @@ export function templateLabel(hat) {
|
|
|
42
43
|
const latestAuthor = Array.isArray(hat.revisions) && hat.revisions[0] ? hat.revisions[0].created_by : null;
|
|
43
44
|
const customised = typeof latestAuthor === 'string' && !latestAuthor.startsWith('template:');
|
|
44
45
|
if (latest && cur && cur < latest) return `v${cur} (template v${latest} available)`;
|
|
46
|
+
// Merge-upgrade: the last applied 3-way merge (org edits carried onto the new template).
|
|
47
|
+
const u = hat.last_upgrade;
|
|
48
|
+
if (u && u.to_template_version === cur) {
|
|
49
|
+
const k = Number(u.org_changes_kept) || 0;
|
|
50
|
+
return `v${cur}, upgraded from v${u.from_template_version}, ${k} org change${k === 1 ? '' : 's'} kept`;
|
|
51
|
+
}
|
|
45
52
|
return customised ? `v${cur || '?'} (edited)` : `v${cur || '?'}`;
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -89,6 +96,30 @@ export async function findUnknownAgents(client, orgId, ids) {
|
|
|
89
96
|
|
|
90
97
|
function isBuiltin(h) { return !!h?.template_key; }
|
|
91
98
|
|
|
99
|
+
const fmtItem = (e) => (e.item !== undefined ? `${e.path}: ${e.item}` : e.path);
|
|
100
|
+
|
|
101
|
+
/** Human preview of a merge upgrade (exported for reuse by other surfaces). */
|
|
102
|
+
export function renderUpgradePreview(key, p) {
|
|
103
|
+
const out = [];
|
|
104
|
+
out.push(chalk.bold(`Hat ${key}: template v${p.from_template_version} -> v${p.to_template_version}`) + chalk.dim(` (from rev ${p.from_revision})`));
|
|
105
|
+
out.push(`Tools added: ${(p.tools_added || []).join(', ') || 'none'}`);
|
|
106
|
+
out.push(`Tools removed: ${(p.tools_removed || []).join(', ') || 'none'}`);
|
|
107
|
+
const changed = (p.template_changed || []).map(c => c.path);
|
|
108
|
+
if (changed.length) out.push(`Template changed: ${changed.join(', ')}`);
|
|
109
|
+
out.push(chalk.bold(`Org changes kept (${(p.org_changes || []).length}):`));
|
|
110
|
+
for (const c of p.org_changes || []) out.push(` ${c.change.padEnd(7)} ${fmtItem(c)}`);
|
|
111
|
+
if (!(p.org_changes || []).length) out.push(' none');
|
|
112
|
+
out.push(chalk.bold(`Conflicts, org choice kept (${(p.conflicts || []).length}):`));
|
|
113
|
+
for (const c of p.conflicts || []) out.push(` ${c.path}${c.reason ? ` (${c.reason})` : ''}`);
|
|
114
|
+
if (!(p.conflicts || []).length) out.push(' none');
|
|
115
|
+
out.push(chalk.bold(`Held, would widen authorization (${(p.held || []).length}):`));
|
|
116
|
+
for (const c of p.held || []) out.push(` ${c.path} ${(c.fields || []).join(', ')}`);
|
|
117
|
+
if (!(p.held || []).length) out.push(' none');
|
|
118
|
+
out.push(`Valid: ${p.valid ? 'yes' : `NO: ${(p.errors || []).join('; ')}`}`);
|
|
119
|
+
out.push(`Digest: ${p.from_digest || '-'} -> ${p.digest}`);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
92
123
|
export function registerHatCommands(program, ctx) {
|
|
93
124
|
const { client, auth } = ctx;
|
|
94
125
|
|
|
@@ -234,6 +265,51 @@ export function registerHatCommands(program, ctx) {
|
|
|
234
265
|
if (program.opts().output === 'json') { console.log(JSON.stringify({ hat: h }, null, 2)); return; }
|
|
235
266
|
console.log(` Ⓠ QUOX │ hat ${h.key} reset to template │ rev ${h.current_revision} · ${templateLabel(h)} ✔`);
|
|
236
267
|
});
|
|
268
|
+
// ── hat upgrade ─────────────────────────────────────────────────────────────
|
|
269
|
+
hat
|
|
270
|
+
.command('upgrade <hat>')
|
|
271
|
+
.description('Carry an org-edited built-in hat onto the current template (3-way merge: org changes kept, conflicts keep the org choice, widening authz changes held); previews unless --yes')
|
|
272
|
+
.option('--dry-run', 'preview the merge only (writes nothing); the default without --yes')
|
|
273
|
+
.option('--yes', 'apply the previewed merge as a new system-authored revision (audited)')
|
|
274
|
+
.action(async (ref, options) => {
|
|
275
|
+
auth.requireAuth();
|
|
276
|
+
if (options.dryRun && options.yes) throw new Error('Pass either --dry-run (preview) or --yes (apply), not both.');
|
|
277
|
+
const orgId = resolveOrgId(auth, program);
|
|
278
|
+
const list = await fetchList(client, orgId);
|
|
279
|
+
const found = findHat(list?.hats, ref);
|
|
280
|
+
if (!found) {
|
|
281
|
+
const keys = (list?.hats || []).map(h => h.key).join(', ') || 'none';
|
|
282
|
+
throw new Error(`No hat '${ref}' in this org. Available: ${keys}`);
|
|
283
|
+
}
|
|
284
|
+
const pep = resolveEndpoint('hat.upgrade.preview', { orgId, hatId: found.id });
|
|
285
|
+
let data;
|
|
286
|
+
try {
|
|
287
|
+
data = await client.get(pep.path, { service: pep.service });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
const code = err?.response?.data?.error || err?.data?.error || err?.body?.error;
|
|
290
|
+
if (code === 'up_to_date') { console.log(` Ⓠ QUOX │ hat ${found.key} upgrade │ already on template v${found.template_version} ✔`); return; }
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
const p = data?.preview;
|
|
294
|
+
if (!p) throw new Error(`Upgrade preview for '${ref}' returned nothing`);
|
|
295
|
+
const json = program.opts().output === 'json';
|
|
296
|
+
|
|
297
|
+
if (!options.yes) {
|
|
298
|
+
if (json) { console.log(JSON.stringify({ preview: p, applied: false }, null, 2)); return; }
|
|
299
|
+
console.log(renderUpgradePreview(found.key, p).join('\n'));
|
|
300
|
+
console.log('');
|
|
301
|
+
console.log(` dry run: nothing written. Apply with: quox hat upgrade ${found.key} --yes`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!p.valid) throw new Error(`The merged hat does not validate: ${(p.errors || []).join('; ')}`);
|
|
305
|
+
const aep = resolveEndpoint('hat.upgrade', { orgId, hatId: found.id });
|
|
306
|
+
const res = await client.post(aep.path, { service: aep.service, body: { expected_revision: p.from_revision } });
|
|
307
|
+
const h = res?.hat;
|
|
308
|
+
if (!h) throw new Error(`Upgrade of '${ref}' returned no hat`);
|
|
309
|
+
if (json) { console.log(JSON.stringify({ hat: h, preview: res.preview || p, applied: true }, null, 2)); return; }
|
|
310
|
+
console.log(` Ⓠ QUOX │ hat ${h.key} upgraded │ rev ${h.current_revision} · ${templateLabel(h)} ✔`);
|
|
311
|
+
});
|
|
312
|
+
|
|
237
313
|
// ── hat create (custom) ───────────────────────────────────────────────────────
|
|
238
314
|
hat
|
|
239
315
|
.command('create <key>')
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quox image generate "<prompt>" [--out file] [--provider gemini|openai] [--aspect-ratio 16:9] [--count n]
|
|
3
|
+
*
|
|
4
|
+
* CLI verb first for image generation. It runs the SAME governed tool the chat uses (gemini_image_generate via
|
|
5
|
+
* POST /api/v1/tools/execute), so the provider fallback, the receipt/witnessing and the plain all-fail message are
|
|
6
|
+
* the collector's, not re-implemented here. Providers tried: Gemini, then OpenAI, using only what the org already
|
|
7
|
+
* connected (`quox provider list`). Nothing here adds a provider or a key.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { printSuccess } from '../core/output.js';
|
|
12
|
+
import { executeGovernedTool } from './tool.js';
|
|
13
|
+
|
|
14
|
+
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
|
15
|
+
|
|
16
|
+
export function registerImageCommands(program, ctx) {
|
|
17
|
+
const { client, auth } = ctx;
|
|
18
|
+
const image = program.command('image').description('Generate images with the providers your org has connected');
|
|
19
|
+
|
|
20
|
+
image
|
|
21
|
+
.command('generate')
|
|
22
|
+
.description('Generate an image from a prompt (tries Gemini, then OpenAI; says which made it)')
|
|
23
|
+
.argument('<prompt>', 'what to draw')
|
|
24
|
+
.option('--out <file>', 'where to save the image (default ./quox-image-<time>.png)')
|
|
25
|
+
.option('--provider <name>', 'use only this provider: gemini or openai (no fall-through)')
|
|
26
|
+
.option('--aspect-ratio <ratio>', 'e.g. 16:9 for a hero, 1:1 for social, 9:16 for stories')
|
|
27
|
+
.option('--count <n>', 'how many images (1-4)', '1')
|
|
28
|
+
.action(async (prompt, options) => {
|
|
29
|
+
const input = { prompt };
|
|
30
|
+
if (options.provider) input.provider = options.provider;
|
|
31
|
+
if (options.aspectRatio) input.aspect_ratio = options.aspectRatio;
|
|
32
|
+
const n = Number(options.count);
|
|
33
|
+
if (n > 1) input.number_of_images = Math.min(n, 4);
|
|
34
|
+
|
|
35
|
+
const data = await executeGovernedTool({ client, auth, program }, 'gemini_image_generate', input, { reason: 'quox image generate' });
|
|
36
|
+
const r = (data && data.result && typeof data.result === 'object') ? data.result : (data || {});
|
|
37
|
+
const failed = (data && data.success === false) || r.success === false;
|
|
38
|
+
|
|
39
|
+
if (program.opts().output === 'json') {
|
|
40
|
+
// Never dump base64 into a terminal or a pipe: report where the files went.
|
|
41
|
+
const out = failed ? r : { ...r, images: undefined };
|
|
42
|
+
if (!failed) out.files = saveImages(r.images, options.out);
|
|
43
|
+
console.log(JSON.stringify({ ...out, ...(failed && !r.error && data.error ? { error: data.error } : {}) }, null, 2));
|
|
44
|
+
if (failed) process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (failed) {
|
|
48
|
+
console.error(r.error || data.error || 'Image generation failed.');
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const files = saveImages(r.images, options.out);
|
|
53
|
+
printSuccess(`Made by ${r.provider_used || 'gemini'}${r.model_used ? ` (${r.model_used})` : ''}: ${files.join(', ')}`);
|
|
54
|
+
if (r.note) console.log(r.note);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function saveImages(images, out) {
|
|
59
|
+
const list = Array.isArray(images) ? images : [];
|
|
60
|
+
const stamp = Date.now();
|
|
61
|
+
return list.map((img, i) => {
|
|
62
|
+
const ext = EXT[img.mime_type] || 'png';
|
|
63
|
+
let file = out || `quox-image-${stamp}.${ext}`;
|
|
64
|
+
if (list.length > 1) { const p = path.parse(file); file = path.join(p.dir, `${p.name}-${i + 1}${p.ext || `.${ext}`}`); }
|
|
65
|
+
fs.writeFileSync(file, Buffer.from(img.base64, 'base64'));
|
|
66
|
+
return file;
|
|
67
|
+
});
|
|
68
|
+
}
|