quoxcli 0.6.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 +18 -3
- package/lib/api/endpoints/admin.js +21 -0
- package/lib/api/endpoints/calendarq.js +8 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/lens.js +34 -0
- package/lib/api/endpoints/lucid.js +2 -1
- package/lib/api/endpoints/mon.js +6 -7
- package/lib/api/endpoints/v1.js +57 -0
- 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/fleet.js +164 -0
- package/lib/commands/fleetRecipe.js +174 -0
- package/lib/commands/lens.js +268 -0
- package/lib/commands/lucidGoals.js +36 -2
- package/lib/commands/mail.js +21 -2
- package/lib/commands/monitoring.js +5 -1
- package/lib/commands/plan.js +36 -1
- package/lib/commands/research.js +78 -0
- package/lib/commands/settings.js +7 -1
- package/lib/commands/wazuh.js +9 -0
- package/lib/commands/wazuhIntake.js +11 -3
- package/lib/core/commandRegistrars.js +1 -0
- package/lib/core/version.js +15 -2
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/app.js +1 -1
- package/lib/quoxon/commands.js +1 -1
- package/lib/quoxon/dragonCompanion.js +24 -12
- package/lib/quoxon/main.js +1 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/slash/cliLeaves.json +19 -0
- package/lib/quoxon/whatsnew.jsonl +1 -0
- 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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox fleet recipe ...` (BASTION-JANUS P3): the org's governed fleet recipe library.
|
|
3
|
+
*
|
|
4
|
+
* A recipe is a saved list of exact shell lines plus a risk tier, stored server-side per org.
|
|
5
|
+
* `run` never runs anything itself: the collector renders the recipe and hands it to the same
|
|
6
|
+
* ssh_exec gate as `quox fleet exec`, so a change raises one approval card (up to 25 hosts) and
|
|
7
|
+
* an allowlisted read runs without one. Output of a held run: `quox approvals result <id>`.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
import { formatOutput, formatDetail, printInfo } from '../core/output.js';
|
|
12
|
+
import { printEvent, line as glyphLine } from '../core/quoxglyph.js';
|
|
13
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
14
|
+
|
|
15
|
+
/** Parse a recipe file (YAML or JSON) into a plain object. */
|
|
16
|
+
export function parseRecipeFile(text, filename = '') {
|
|
17
|
+
let doc;
|
|
18
|
+
if (/\.json$/i.test(filename)) doc = JSON.parse(text);
|
|
19
|
+
else doc = yaml.load(text, { schema: yaml.JSON_SCHEMA });
|
|
20
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
21
|
+
throw new Error(`${filename || 'recipe file'} must hold one recipe object (name, risk, commands, ...)`);
|
|
22
|
+
}
|
|
23
|
+
return doc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `--param k=v` (repeatable) into { k: v }. */
|
|
27
|
+
export function parseParamFlags(list = []) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const p of list) {
|
|
30
|
+
const i = String(p).indexOf('=');
|
|
31
|
+
if (i < 1) throw new Error(`--param ${JSON.stringify(p)} must be k=v`);
|
|
32
|
+
const k = p.slice(0, i);
|
|
33
|
+
if (Object.prototype.hasOwnProperty.call(out, k)) throw new Error(`--param ${k} given twice`);
|
|
34
|
+
out[k] = p.slice(i + 1);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `--host a,b --host c` into ['a', 'b', 'c']. */
|
|
40
|
+
export function parseHostList(list = []) {
|
|
41
|
+
const hosts = [].concat(list || []).flatMap((h) => String(h).split(',')).map((h) => h.trim()).filter(Boolean);
|
|
42
|
+
if (!hosts.length) throw new Error('Give at least one --host <h>[,h...] (up to 25)');
|
|
43
|
+
return hosts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const collect = (v, prev) => (prev || []).concat([v]);
|
|
47
|
+
|
|
48
|
+
/** Org for automation callers, same precedence as `fleet exec`: --org > QUOX_ORG_ID > the profile's org. */
|
|
49
|
+
function orgFor(auth, program) {
|
|
50
|
+
const orgId = auth.getOrgId(program.opts().org || process.env.QUOX_ORG_ID);
|
|
51
|
+
if (!orgId && process.env.QUOX_SERVICE_KEY) {
|
|
52
|
+
throw new Error('QUOX_SERVICE_KEY is set but no org could be resolved. Set QUOX_ORG_ID=<org> or pass --org <id>.');
|
|
53
|
+
}
|
|
54
|
+
return orgId || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerFleetRecipeCommands(fleet, { client, auth, program }) {
|
|
58
|
+
const isJson = () => (program.opts().output === 'json');
|
|
59
|
+
const recipe = fleet
|
|
60
|
+
.command('recipe')
|
|
61
|
+
.description('Governed fleet recipe library: saved command sets, run through the ssh_exec approval gate');
|
|
62
|
+
|
|
63
|
+
recipe
|
|
64
|
+
.command('list')
|
|
65
|
+
.description('List the built-in and org fleet recipes')
|
|
66
|
+
.action(async () => {
|
|
67
|
+
auth.requireAuth();
|
|
68
|
+
const ep = resolveEndpoint('v1.fleet.recipes.list');
|
|
69
|
+
const org = orgFor(auth, program);
|
|
70
|
+
const data = await client.get(ep.path, { service: ep.service, ...(org ? { query: { org_id: org } } : {}) });
|
|
71
|
+
if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
72
|
+
formatOutput({
|
|
73
|
+
columns: ['name', 'risk', 'source', 'detached', 'commands', 'title'],
|
|
74
|
+
headers: ['Name', 'Risk', 'Source', 'Detached', 'Lines', 'Title'],
|
|
75
|
+
rows: (data.recipes || []).map((r) => ({
|
|
76
|
+
name: r.name, risk: r.risk, source: r.builtin ? 'built-in' : 'org',
|
|
77
|
+
detached: r.detached ? 'yes' : 'no', commands: r.command_count, title: r.title,
|
|
78
|
+
})),
|
|
79
|
+
}, { format: program.opts().output, emptyMessage: 'No fleet recipes.' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
recipe
|
|
83
|
+
.command('show <name>')
|
|
84
|
+
.description('Show one fleet recipe, every command line included')
|
|
85
|
+
.action(async (name) => {
|
|
86
|
+
auth.requireAuth();
|
|
87
|
+
const ep = resolveEndpoint('v1.fleet.recipes.get', { name });
|
|
88
|
+
const org = orgFor(auth, program);
|
|
89
|
+
const data = await client.get(ep.path, { service: ep.service, ...(org ? { query: { org_id: org } } : {}) });
|
|
90
|
+
if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
91
|
+
const r = data.recipe || {};
|
|
92
|
+
formatDetail({
|
|
93
|
+
name: r.name, title: r.title, risk: r.risk, source: r.builtin ? 'built-in (read-only)' : 'org',
|
|
94
|
+
detached: r.detached ? 'yes (systemd-run unit, log in the journal)' : 'no',
|
|
95
|
+
params: (r.params || []).map((p) => `${p.name} (${p.kind})`).join(', ') || 'none',
|
|
96
|
+
description: r.description || '',
|
|
97
|
+
}, { format: program.opts().output });
|
|
98
|
+
console.log('\nCommands:');
|
|
99
|
+
for (const c of r.commands || []) console.log(` ${c}`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
recipe
|
|
103
|
+
.command('add')
|
|
104
|
+
.description('Add an org fleet recipe from a YAML or JSON file (human org admin or operator)')
|
|
105
|
+
.requiredOption('--file <path>', 'recipe file: name, title, description, risk (read|change|destructive), commands[], detached, params[]')
|
|
106
|
+
.action(async (options) => {
|
|
107
|
+
auth.requireAuth();
|
|
108
|
+
const body = parseRecipeFile(readFileSync(options.file, 'utf8'), options.file);
|
|
109
|
+
const ep = resolveEndpoint('v1.fleet.recipes.create');
|
|
110
|
+
const data = await client.post(ep.path, { service: ep.service, body });
|
|
111
|
+
if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
112
|
+
const r = data.recipe || body;
|
|
113
|
+
printEvent(glyphLine('bastion', 'recipe added', { result: `${r.name} (${r.risk})`, status: 'ok' }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
recipe
|
|
117
|
+
.command('remove <name>')
|
|
118
|
+
.description('Remove an org fleet recipe (human org admin or operator; built-ins cannot be removed)')
|
|
119
|
+
.action(async (name) => {
|
|
120
|
+
auth.requireAuth();
|
|
121
|
+
const ep = resolveEndpoint('v1.fleet.recipes.delete', { name });
|
|
122
|
+
const data = await client.delete(ep.path, { service: ep.service });
|
|
123
|
+
if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
124
|
+
printEvent(glyphLine('bastion', 'recipe removed', { result: data.removed || name, status: 'ok' }));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
recipe
|
|
128
|
+
.command('run <name>')
|
|
129
|
+
.description('Run a fleet recipe on 1-25 hosts through the governed ssh_exec gate (a change waits on one approval card)')
|
|
130
|
+
.requiredOption('--host <h[,h...]>', 'target host(s); repeat or comma-separate, up to 25', collect)
|
|
131
|
+
.option('--param <k=v>', 'recipe parameter (repeatable)', collect)
|
|
132
|
+
.option('--reason <text>', 'why you are running this; shown on the approval card')
|
|
133
|
+
.option('--session-label <name>', 'name this session on approval cards (default: $QUOX_SESSION)')
|
|
134
|
+
.action(async (name, options) => {
|
|
135
|
+
auth.requireAuth();
|
|
136
|
+
const body = { recipe: name, hosts: parseHostList(options.host), params: parseParamFlags(options.param || []) };
|
|
137
|
+
if (options.reason) body.reason = String(options.reason);
|
|
138
|
+
if (options.sessionLabel) body.session_label = String(options.sessionLabel);
|
|
139
|
+
const org = orgFor(auth, program);
|
|
140
|
+
if (org) body.org_id = org;
|
|
141
|
+
const ep = resolveEndpoint('v1.fleet.recipes.run');
|
|
142
|
+
const data = await client.post(ep.path, { service: ep.service, body, timeout: 180000 });
|
|
143
|
+
const run = (data && data.recipe_run) || {};
|
|
144
|
+
const heldId = data && data.pending && (data.approvalId || (data._approval && data._approval.id));
|
|
145
|
+
const failed = data && data.success === false && !data.pending;
|
|
146
|
+
if (isJson()) {
|
|
147
|
+
console.log(JSON.stringify(data, null, 2));
|
|
148
|
+
} else {
|
|
149
|
+
printEvent(glyphLine('bastion', `recipe ${name}`, {
|
|
150
|
+
result: heldId ? `held ${heldId}` : (failed ? (data.error || 'failed') : `${run.mode || 'ran'} on ${body.hosts.length} host(s)`),
|
|
151
|
+
status: heldId ? 'busy' : (failed ? 'fail' : 'ok'),
|
|
152
|
+
}));
|
|
153
|
+
if (Array.isArray(data.steps)) {
|
|
154
|
+
for (const s of data.steps) {
|
|
155
|
+
console.log(`$ ${s.command}`);
|
|
156
|
+
const r = s.result !== undefined ? s.result : s;
|
|
157
|
+
console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
|
|
158
|
+
}
|
|
159
|
+
} else if (data.result !== undefined) {
|
|
160
|
+
console.log(typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2));
|
|
161
|
+
} else if (failed) {
|
|
162
|
+
console.error(data.error || JSON.stringify(data));
|
|
163
|
+
}
|
|
164
|
+
if (heldId) printInfo(`waiting on approval ${heldId}; after approval run: quox approvals result ${heldId}`);
|
|
165
|
+
if (run.detached && run.detached.follow_up) {
|
|
166
|
+
printInfo(`detached as unit ${run.detached.unit}; read its log (allowlisted, no card): `
|
|
167
|
+
+ `quox fleet exec --tool ssh_exec --host <h> --input '${JSON.stringify({ command: run.detached.follow_up })}'`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (failed) process.exitCode = 1;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return recipe;
|
|
174
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox lens`: QuoxLens, apply a declared-stance Lens to a target text.
|
|
3
|
+
* Collector routes under /api/v1/lens (services/collector/routes/quoxlens.js), behind
|
|
4
|
+
* requireServiceOrUserAuth + requirePluginEntitlement('quoxlens').
|
|
5
|
+
*
|
|
6
|
+
* quox lens list every Lens, its stance, measured lift, unproven flag
|
|
7
|
+
* quox lens show <id> one Lens in full (blind spots, finding classes, body)
|
|
8
|
+
* quox lens apply <lensId> --file <path|-> apply one Lens to text from a file or stdin (-)
|
|
9
|
+
* [--target-label <s>] a human label for the target (artifact title)
|
|
10
|
+
* quox lens panel --file <path|-> the Critic panel: several Lenses, repeated runs, run consensus
|
|
11
|
+
* [--lenses a,b] [--runs N] [--min-agreement K] [--depth quick|standard|thorough]
|
|
12
|
+
* [--target-label <s>] [--allow-unproven]
|
|
13
|
+
*
|
|
14
|
+
* All honour --json / -o json. An error body is not data: a non-2xx (400 unknown lens,
|
|
15
|
+
* 402 LICENSE_REQUIRED, 404, 409, 422) is reported with its status and exits 1.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
20
|
+
|
|
21
|
+
const isJson = (program) => program.opts().output === 'json';
|
|
22
|
+
const printJson = (d) => console.log(JSON.stringify(d, null, 2));
|
|
23
|
+
|
|
24
|
+
async function readTarget(file, ctx) {
|
|
25
|
+
if (file === '-') {
|
|
26
|
+
const input = ctx.stdin || process.stdin;
|
|
27
|
+
let buf = '';
|
|
28
|
+
for await (const chunk of input) buf += chunk;
|
|
29
|
+
return buf;
|
|
30
|
+
}
|
|
31
|
+
return readFileSync(file, 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Stable per text, so finding fingerprints (lens + artifact.id + class + location) stay
|
|
35
|
+
// stable when the same target is re-checked.
|
|
36
|
+
function artifactIdFor(text) {
|
|
37
|
+
return `cli-${createHash('sha256').update(text).digest('hex').slice(0, 16)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function describeError(err) {
|
|
41
|
+
const body = err.body || {};
|
|
42
|
+
const parts = [`HTTP ${err.status || err.statusCode}`];
|
|
43
|
+
if (body.error && !String(err.message || '').includes(body.error)) parts.push(body.error);
|
|
44
|
+
if (err.message) parts.push(err.message);
|
|
45
|
+
const detail = body.detail || err.details;
|
|
46
|
+
if (detail && typeof detail === 'string' && !String(err.message || '').includes(detail)) parts.push(`(${detail})`);
|
|
47
|
+
if (body.plugin) parts.push(`[plugin: ${body.plugin}]`);
|
|
48
|
+
return parts.join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const PANEL_DEPTHS = ['quick', 'standard', 'thorough'];
|
|
52
|
+
// One lens call can take up to 5 minutes server-side and a panel runs lenses x runs calls in sequence.
|
|
53
|
+
const PANEL_CALL_WAIT_MS = 5.5 * 60 * 1000;
|
|
54
|
+
const PANEL_WAIT_CAP_MS = 30 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
function parseWholeNumber(value, flag, min, max) {
|
|
57
|
+
if (value === undefined) return { value: undefined };
|
|
58
|
+
const n = Number(value);
|
|
59
|
+
if (!/^\d+$/.test(String(value).trim()) || !Number.isInteger(n) || n < min || n > max) {
|
|
60
|
+
return { error: `${flag} must be a whole number from ${min} to ${max}, got "${value}"` };
|
|
61
|
+
}
|
|
62
|
+
return { value: n };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatUsage(verdict) {
|
|
66
|
+
const u = verdict && verdict.usage;
|
|
67
|
+
if (!u) return `not available (${(verdict && verdict.usage_reason) || 'no reason given'})`;
|
|
68
|
+
const parts = [`input ${u.input_tokens}`, `output ${u.output_tokens}`];
|
|
69
|
+
if (u.cache_creation_input_tokens) parts.push(`cache write ${u.cache_creation_input_tokens}`);
|
|
70
|
+
if (u.cache_read_input_tokens) parts.push(`cache read ${u.cache_read_input_tokens}`);
|
|
71
|
+
const calls = `${u.calls} model call${u.calls === 1 ? '' : 's'}`;
|
|
72
|
+
const price = typeof u.list_price_usd === 'number' ? `, list price $${u.list_price_usd.toFixed(4)}` : '';
|
|
73
|
+
return `${parts.join(', ')} tokens over ${calls}${price}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerLensCommands(program, ctx) {
|
|
77
|
+
const { client, auth } = ctx;
|
|
78
|
+
|
|
79
|
+
// An error body is not data: a non-2xx is reported with its status and exits 1 (returns null).
|
|
80
|
+
async function guarded(request) {
|
|
81
|
+
if (auth && typeof auth.requireAuth === 'function') auth.requireAuth();
|
|
82
|
+
try {
|
|
83
|
+
return await request();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err && (err.status || err.statusCode)) {
|
|
86
|
+
console.error(`QuoxLens request failed: ${describeError(err)}`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lens = program
|
|
95
|
+
.command('lens')
|
|
96
|
+
.description('QuoxLens: apply a declared-stance Lens to a target and get evidence-quoted findings');
|
|
97
|
+
|
|
98
|
+
lens
|
|
99
|
+
.command('list')
|
|
100
|
+
.description('List every Lens with its stance, measured lift and unproven flag')
|
|
101
|
+
.action(async () => {
|
|
102
|
+
const ep = resolveEndpoint('lens.list');
|
|
103
|
+
const data = await guarded(() => client.get(ep.path, { service: ep.service }));
|
|
104
|
+
if (data === null) return;
|
|
105
|
+
const lenses = Array.isArray(data.lenses) ? data.lenses : [];
|
|
106
|
+
if (isJson(program)) { printJson(lenses); return; }
|
|
107
|
+
if (!lenses.length) { console.log('No lenses installed.'); return; }
|
|
108
|
+
for (const l of lenses) {
|
|
109
|
+
if (l.error) { console.log(`${l.id} [${l.category || '-'}] ERROR: ${l.error}`); continue; }
|
|
110
|
+
const proof = l.unproven ? 'unproven' : 'measured';
|
|
111
|
+
const ver = l.version ? ` v${l.version}` : '';
|
|
112
|
+
console.log(`${l.id}${ver} [${l.category || '-'}] ${proof} ${l.name || ''}`);
|
|
113
|
+
if (l.description) console.log(` ${l.description}`);
|
|
114
|
+
}
|
|
115
|
+
console.log(`\n${lenses.length} lens${lenses.length === 1 ? '' : 'es'}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
lens
|
|
119
|
+
.command('show <id>')
|
|
120
|
+
.description('Show one Lens in full: blind spots, finding classes, prohibited, eval results, body')
|
|
121
|
+
.action(async (id) => {
|
|
122
|
+
const ep = resolveEndpoint('lens.get', { id });
|
|
123
|
+
const data = await guarded(() => client.get(ep.path, { service: ep.service }));
|
|
124
|
+
if (data === null) return;
|
|
125
|
+
if (isJson(program)) { printJson(data); return; }
|
|
126
|
+
const list = (a) => (Array.isArray(a) && a.length ? a.join(', ') : '-');
|
|
127
|
+
console.log(`${data.name || data.id} (${data.id})`);
|
|
128
|
+
if (data.description) console.log(data.description);
|
|
129
|
+
console.log(`version: ${data.version || '-'}`);
|
|
130
|
+
console.log(`kind: ${data.kind || '-'}`);
|
|
131
|
+
if (data.edition) console.log(`edition: ${data.edition}`);
|
|
132
|
+
if (data.author) console.log(`author: ${data.author}`);
|
|
133
|
+
console.log(`unproven: ${data.unproven ? 'yes (no measured lift)' : 'no'}`);
|
|
134
|
+
console.log(`tags: ${list(data.tags)}`);
|
|
135
|
+
console.log(`finding classes: ${list(data.finding_classes)}`);
|
|
136
|
+
console.log(`blind spots: ${list(data.blind_spots)}`);
|
|
137
|
+
console.log(`prohibited: ${list(data.prohibited)}`);
|
|
138
|
+
console.log(`eval results: ${Array.isArray(data.results) ? data.results.length : 0}`);
|
|
139
|
+
if (data.body) console.log(`\n${data.body}`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
lens
|
|
143
|
+
.command('apply <lensId>')
|
|
144
|
+
.description('Apply one Lens to target text from a file, or stdin with --file -')
|
|
145
|
+
.requiredOption('--file <path>', 'read the target text from this file, or - for stdin')
|
|
146
|
+
.option('--target-label <label>', 'a human label for the target (sent as the artifact title)')
|
|
147
|
+
.action(async (lensId, options) => {
|
|
148
|
+
let text;
|
|
149
|
+
try {
|
|
150
|
+
text = await readTarget(options.file, ctx);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error(`Could not read target ${options.file === '-' ? 'from stdin' : `file ${options.file}`}: ${err.message}`);
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!text || !text.trim()) {
|
|
157
|
+
console.error('Not applied: the target text is empty. Nothing was sent.');
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const artifact = { kind: 'text', id: artifactIdFor(text), content: text };
|
|
162
|
+
if (options.targetLabel) artifact.title = options.targetLabel;
|
|
163
|
+
const ep = resolveEndpoint('lens.apply');
|
|
164
|
+
const body = { lens_id: lensId, mode: 'single', artifact };
|
|
165
|
+
// A lens apply is one model call (up to 5 min on the server); the client default (30s) gave up on real runs.
|
|
166
|
+
const data = await guarded(() => client.post(ep.path, { service: ep.service, body, timeout: PANEL_CALL_WAIT_MS, retries: 0 }));
|
|
167
|
+
if (data === null) return;
|
|
168
|
+
if (isJson(program)) { printJson(data); return; }
|
|
169
|
+
|
|
170
|
+
const v = data.verdict || {};
|
|
171
|
+
const a = data.applied || {};
|
|
172
|
+
console.log(`lens: ${a.lens_id || lensId}${a.version ? ` v${a.version}` : ''}${a.unproven ? ' (unproven: no measured lift)' : ''}`);
|
|
173
|
+
if (artifact.title) console.log(`target: ${artifact.title}`);
|
|
174
|
+
console.log(`disposition: ${v.disposition || '-'}`);
|
|
175
|
+
console.log(`confidence: ${v.confidence ?? '-'}`);
|
|
176
|
+
if (a.model) console.log(`model: ${a.model}`);
|
|
177
|
+
if (v.summary) console.log(`\n${v.summary}`);
|
|
178
|
+
const findings = Array.isArray(data.findings) ? data.findings : [];
|
|
179
|
+
console.log(`\nfindings: ${findings.length}`);
|
|
180
|
+
for (const f of findings) {
|
|
181
|
+
console.log(` [${f.severity || '-'}] ${f.class || '-'}: ${f.claim || ''}`);
|
|
182
|
+
for (const e of Array.isArray(f.evidence) ? f.evidence : []) {
|
|
183
|
+
if (e && e.quote) console.log(` "${e.quote}"${e.at ? ` (${e.at})` : ''}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(a.blind_spots) && a.blind_spots.length) console.log(`\nblind spots: ${a.blind_spots.join(', ')}`);
|
|
187
|
+
if (data.evidence && data.evidence.corr) console.log(`evidence: ${data.evidence.corr}`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
lens
|
|
191
|
+
.command('panel')
|
|
192
|
+
.description('The Critic panel: several Lenses over one target, repeated runs; keeps findings that recur across runs')
|
|
193
|
+
.requiredOption('--file <path>', 'read the target text from this file, or - for stdin')
|
|
194
|
+
.option('--lenses <ids>', 'comma-separated lens ids, 2 to 5 (server default: honesty-and-claims,enterprise-compliance-auditor)')
|
|
195
|
+
.option('--runs <n>', 'how many times to run the panel, 1 to 5 (server default 1)')
|
|
196
|
+
.option('--min-agreement <k>', 'runs a finding must appear in to be kept (server default: a majority of runs)')
|
|
197
|
+
.option('--depth <depth>', `depth profile: ${PANEL_DEPTHS.join(', ')} (server default standard)`)
|
|
198
|
+
.option('--target-label <label>', 'a human label for the target (sent as the artifact title)')
|
|
199
|
+
.option('--allow-unproven', 'run lenses with no measured lift anyway (the result says so)')
|
|
200
|
+
.action(async (options) => {
|
|
201
|
+
const runs = parseWholeNumber(options.runs, '--runs', 1, 5);
|
|
202
|
+
const minAgreement = parseWholeNumber(options.minAgreement, '--min-agreement', 1, 5);
|
|
203
|
+
const bad = runs.error || minAgreement.error
|
|
204
|
+
|| (options.depth !== undefined && !PANEL_DEPTHS.includes(options.depth) ? `--depth must be one of ${PANEL_DEPTHS.join(', ')}, got "${options.depth}"` : null);
|
|
205
|
+
if (bad) {
|
|
206
|
+
console.error(`Not run: ${bad}. Nothing was sent.`);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
let text;
|
|
211
|
+
try {
|
|
212
|
+
text = await readTarget(options.file, ctx);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error(`Could not read target ${options.file === '-' ? 'from stdin' : `file ${options.file}`}: ${err.message}`);
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!text || !text.trim()) {
|
|
219
|
+
console.error('Not run: the target text is empty. Nothing was sent.');
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const artifact = { kind: 'text', id: artifactIdFor(text), content: text };
|
|
224
|
+
if (options.targetLabel) artifact.title = options.targetLabel;
|
|
225
|
+
const body = { artifact };
|
|
226
|
+
const lensIds = options.lenses ? options.lenses.split(',').map((s) => s.trim()).filter(Boolean) : null;
|
|
227
|
+
if (lensIds) body.lens_ids = lensIds;
|
|
228
|
+
if (runs.value !== undefined) body.runs = runs.value;
|
|
229
|
+
if (minAgreement.value !== undefined) body.min_agreement = minAgreement.value;
|
|
230
|
+
if (options.depth) body.depth = options.depth;
|
|
231
|
+
if (options.allowUnproven) body.allow_unproven = true;
|
|
232
|
+
|
|
233
|
+
const calls = (lensIds ? lensIds.length : 2) * (runs.value || 1);
|
|
234
|
+
const timeout = Math.min(calls * PANEL_CALL_WAIT_MS, PANEL_WAIT_CAP_MS);
|
|
235
|
+
const ep = resolveEndpoint('lens.panel');
|
|
236
|
+
// retries: 0, a panel is a paid model run; a retried POST would run it twice.
|
|
237
|
+
const data = await guarded(() => client.post(ep.path, { service: ep.service, body, timeout, retries: 0 }));
|
|
238
|
+
if (data === null) return;
|
|
239
|
+
if (isJson(program)) { printJson(data); return; }
|
|
240
|
+
|
|
241
|
+
const v = data.verdict || {};
|
|
242
|
+
const lensList = (Array.isArray(data.lenses) ? data.lenses : []).map((l) => `${l.id}${l.version ? ` v${l.version}` : ''}${l.unproven ? ' (unproven)' : ''}`);
|
|
243
|
+
console.log(`lenses: ${lensList.join(', ') || '-'}`);
|
|
244
|
+
if (artifact.title) console.log(`target: ${artifact.title}`);
|
|
245
|
+
console.log(`disposition: ${v.disposition || '-'}`);
|
|
246
|
+
console.log(`runs: ${data.runs_completed ?? '-'} of ${data.runs_requested ?? '-'} completed, a finding needs ${data.min_agreement ?? '-'} to be kept${data.depth ? `, depth ${data.depth}` : ''}`);
|
|
247
|
+
if (data.partial) console.log(`PARTIAL: stopped early (${data.stop_reason || 'unknown'}${data.stop_detail ? `: ${data.stop_detail}` : ''})`);
|
|
248
|
+
if (Array.isArray(data.unproven_lenses) && data.unproven_lenses.length) console.log(`unproven: ${data.unproven_lenses.join(', ')} (no measured lift; run because --allow-unproven was given)`);
|
|
249
|
+
if (v.summary) console.log(`\n${v.summary}`);
|
|
250
|
+
const findings = Array.isArray(data.findings) ? data.findings : [];
|
|
251
|
+
console.log(`\nfindings: ${findings.length}`);
|
|
252
|
+
for (const f of findings) {
|
|
253
|
+
const agreement = f.agreement ? `${f.agreement.label || `${f.agreement.runs}/${f.agreement.of}`} runs` : '-';
|
|
254
|
+
const by = Array.isArray(f.lens_ids) && f.lens_ids.length ? ` [${f.lens_ids.join(', ')}]` : '';
|
|
255
|
+
console.log(` [${f.severity || '-'}] ${f.class || '-'}: ${f.claim || ''} (${agreement})${by}`);
|
|
256
|
+
for (const e of Array.isArray(f.evidence) ? f.evidence : []) {
|
|
257
|
+
if (e && e.quote) console.log(` "${e.quote}"${e.at ? ` (${e.at})` : ''}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const below = Array.isArray(data.below_agreement) ? data.below_agreement : [];
|
|
261
|
+
if (below.length) {
|
|
262
|
+
console.log(`\nbelow agreement (not kept): ${below.length}`);
|
|
263
|
+
for (const f of below) console.log(` ${f.class || '-'}: ${f.claim || ''} (${f.agreement ? f.agreement.label : '-'} runs)`);
|
|
264
|
+
}
|
|
265
|
+
console.log(`\nusage: ${formatUsage(v)}`);
|
|
266
|
+
if (data.evidence && data.evidence.corr) console.log(`evidence: ${data.evidence.corr}`);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
@@ -272,10 +272,11 @@ export function registerLucidInsightsCommands(lu, program, ctx) {
|
|
|
272
272
|
const o = cmd.optsWithGlobals ? cmd.optsWithGlobals() : cmd.opts();
|
|
273
273
|
return Boolean(o.json) || program.opts().output === 'json';
|
|
274
274
|
};
|
|
275
|
-
async function get(key, { params, query } = {}) {
|
|
275
|
+
async function get(key, { params, query, body } = {}) {
|
|
276
276
|
const ep = resolveEndpoint(key, params);
|
|
277
|
+
const method = ep.method === 'POST' ? 'post' : 'get';
|
|
277
278
|
try {
|
|
278
|
-
return await client
|
|
279
|
+
return await client[method](ep.path, { service: ep.service, query: { org_id: resolveOrgId(auth, program), ...query }, ...(body !== undefined ? { body } : {}) });
|
|
279
280
|
} catch (err) {
|
|
280
281
|
const status = err && (err.status || err.statusCode);
|
|
281
282
|
if (status) {
|
|
@@ -334,8 +335,32 @@ export function registerLucidInsightsCommands(lu, program, ctx) {
|
|
|
334
335
|
for (const l of (i.interpretation && i.interpretation.limitations) || []) console.log(` Limit: ${l}`);
|
|
335
336
|
if (i.priority) console.log(` Why it ranks ${i.priority.band}: ${i.priority.why}`);
|
|
336
337
|
console.log(` ${insightStanding(i)}${i.retracted_at ? `, resolved ${i.retracted_at}` : ''}`);
|
|
338
|
+
const rz = data.reasoner;
|
|
339
|
+
if (rz && rz.plain) console.log(` In plain words: ${rz.plain}`);
|
|
340
|
+
for (const p of (rz && rz.proposals) || []) {
|
|
341
|
+
console.log(` Suggested link to goal ${p.goal_id}: ${p.why} (a model's guess; quox lucid insights accept ${i.id} ${p.goal_id}, or reject)`);
|
|
342
|
+
}
|
|
337
343
|
});
|
|
338
344
|
|
|
345
|
+
for (const verb of ['accept', 'reject']) {
|
|
346
|
+
ins
|
|
347
|
+
.command(`${verb} <id> <goalId>`)
|
|
348
|
+
.description(verb === 'accept'
|
|
349
|
+
? 'Accept a link the model suggested: records that this depends on the goal, as your own statement'
|
|
350
|
+
: 'Reject a link the model suggested; it stays rejected if suggested again')
|
|
351
|
+
.option('--json', 'print the full JSON response')
|
|
352
|
+
.action(async (id, goalId, _o, cmd) => {
|
|
353
|
+
auth.requireAuth();
|
|
354
|
+
const data = await get('lucid.insights.decide', { params: { id, goalId }, body: { decision: verb } });
|
|
355
|
+
if (data === null) return;
|
|
356
|
+
if (wantJson(cmd)) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
357
|
+
const l = data.link || {};
|
|
358
|
+
printSuccess(l.status === 'accepted'
|
|
359
|
+
? `Linked: ${id} now counts against goal ${l.goal_id} (dependency ${l.entity_id}). LUCID uses it from its next pass.`
|
|
360
|
+
: `Rejected: LUCID will not suggest goal ${l.goal_id} for ${id} again.`);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
339
364
|
ins
|
|
340
365
|
.command('status')
|
|
341
366
|
.description("The shadow worker's health for your org: runs, skips, failures, last error")
|
|
@@ -349,6 +374,15 @@ export function registerLucidInsightsCommands(lu, program, ctx) {
|
|
|
349
374
|
console.log(`Runs ${w.runs}, skipped (nothing new) ${w.skipped}, failures ${w.failures}${w.consecutive_failures ? ` (${w.consecutive_failures} in a row)` : ''}.`);
|
|
350
375
|
console.log(`Last run ${w.last_run_at || 'never'}: found ${w.last_found}, rejected for bad evidence ${w.last_rejected}.`);
|
|
351
376
|
if (w.last_error) console.log(`Last error: ${w.last_error}${w.next_run_at ? ` (next try ${w.next_run_at})` : ''}`);
|
|
377
|
+
const rz = data.reasoner;
|
|
378
|
+
if (rz) {
|
|
379
|
+
if (!rz.enabled) console.log('Model wording: off (quox lucid herald set reasoner=true).');
|
|
380
|
+
else if (!rz.cloud_processing) console.log('Model wording: switched on, but nothing is sent until cloud processing is on (quox lucid herald set synth=true).');
|
|
381
|
+
else {
|
|
382
|
+
console.log(`Model wording: on, ${rz.calls_today} of ${rz.daily_cap} calls today. Worded ${rz.worded}, rejected by the brief check ${rz.lint_rejected}, empty ${rz.empty}, failures ${rz.failures}${rz.consecutive_failures ? ` (${rz.consecutive_failures} in a row)` : ''}, retired ${rz.retired}, links proposed ${rz.proposals}.`);
|
|
383
|
+
if (rz.last_error) console.log(`Model wording last error: ${rz.last_error}${rz.next_run_at ? ` (next try ${rz.next_run_at})` : ''}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
352
386
|
});
|
|
353
387
|
}
|
|
354
388
|
|
package/lib/commands/mail.js
CHANGED
|
@@ -185,9 +185,11 @@ export function registerMailCommands(program, ctx) {
|
|
|
185
185
|
.command('connect')
|
|
186
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 ...')
|
|
187
187
|
.option('--credential <id>', 'reuse a stored email_account credential ID')
|
|
188
|
-
.
|
|
188
|
+
.option('--email <address>', 'mailbox address (also the IMAP username unless --username is given); required except with --provider fixture')
|
|
189
189
|
.option('--username <user>', 'IMAP username, if different from --email')
|
|
190
|
-
.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}})')
|
|
191
193
|
.option('--imap-host <host>', 'IMAP host (creating a new credential; not required with --provider)')
|
|
192
194
|
.option('--imap-port <n>', 'IMAP port', '993')
|
|
193
195
|
.option('--smtp-host <host>', 'SMTP host (defaults to --imap-host)')
|
|
@@ -215,6 +217,23 @@ export function registerMailCommands(program, ctx) {
|
|
|
215
217
|
);
|
|
216
218
|
}
|
|
217
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
|
+
|
|
218
237
|
let credentialId = options.credential;
|
|
219
238
|
|
|
220
239
|
// Inline credential creation: turns the ugly `vault create --credentials-stdin`
|
|
@@ -117,7 +117,11 @@ export function registerMonitoringCommands(program, ctx) {
|
|
|
117
117
|
.action(async (id) => {
|
|
118
118
|
auth.requireAuth();
|
|
119
119
|
const data = await client.get(resolveEndpoint('governance.incidents.get', { id }).path, { service: resolveEndpoint('governance.incidents.get', { id }).service });
|
|
120
|
-
|
|
120
|
+
const inc = data.incident || data;
|
|
121
|
+
// WAZ-11 (audit batch 5, N3): on a Wazuh incident the governance `escalated` flag means escalated as UNOWNED
|
|
122
|
+
// (inbox item + LUCID ask), not sent to QuoxSecurity; the human label says so. The field (and --json) is unchanged.
|
|
123
|
+
const labels = inc && inc.incident_type === 'wazuh_alert' ? { escalated: 'unowned-escalated' } : undefined;
|
|
124
|
+
formatDetail(inc, { format: program.opts().output, title: `Incident: ${id}`, ...(labels ? { labels } : {}) });
|
|
121
125
|
});
|
|
122
126
|
|
|
123
127
|
incidents
|
package/lib/commands/plan.js
CHANGED
|
@@ -669,8 +669,15 @@ function coerceUpdateValue(field, value) {
|
|
|
669
669
|
|
|
670
670
|
// ── registration ─────────────────────────────────────────────────────────
|
|
671
671
|
|
|
672
|
+
async function readProcessStdin() {
|
|
673
|
+
const chunks = [];
|
|
674
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
675
|
+
return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c))).toString('utf8');
|
|
676
|
+
}
|
|
677
|
+
|
|
672
678
|
export function registerPlanCommands(program, ctx) {
|
|
673
679
|
const { client, auth } = ctx;
|
|
680
|
+
const readStdin = ctx.readStdin || readProcessStdin;
|
|
674
681
|
|
|
675
682
|
const plan = program
|
|
676
683
|
.command('plan')
|
|
@@ -1188,12 +1195,17 @@ export function registerPlanCommands(program, ctx) {
|
|
|
1188
1195
|
|
|
1189
1196
|
plan
|
|
1190
1197
|
.command('plan <id>')
|
|
1191
|
-
.description('Print raw plan markdown; --edit opens $EDITOR (PUT on save if changed); --history lists versions')
|
|
1198
|
+
.description('Print raw plan markdown; --file writes a new version from a file or stdin; --edit opens $EDITOR (PUT on save if changed); --history lists versions')
|
|
1199
|
+
.option('--file <path>', "write the plan body from a file ('-' reads stdin); a new version only if it changed")
|
|
1192
1200
|
.option('--edit', 'open $EDITOR on the plan body, PUT on save if changed')
|
|
1193
1201
|
.option('--history', 'list plan versions instead of printing the body')
|
|
1194
1202
|
.action(async (id, options) => {
|
|
1195
1203
|
auth.requireAuth();
|
|
1196
1204
|
|
|
1205
|
+
if (options.file !== undefined && (options.edit || options.history)) {
|
|
1206
|
+
throw new Error('--file cannot be combined with --edit or --history');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1197
1209
|
if (options.history) {
|
|
1198
1210
|
const ep = resolveEndpoint('quoxplan.stream.plan.versions.list', { id });
|
|
1199
1211
|
const data = await client.get(ep.path, { service: ep.service });
|
|
@@ -1213,6 +1225,29 @@ export function registerPlanCommands(program, ctx) {
|
|
|
1213
1225
|
const detail = await client.get(detailEp.path, { service: detailEp.service });
|
|
1214
1226
|
const currentBody = (detail.plan && detail.plan.body) || '';
|
|
1215
1227
|
|
|
1228
|
+
if (options.file !== undefined) {
|
|
1229
|
+
let newBody;
|
|
1230
|
+
if (options.file === '-') {
|
|
1231
|
+
newBody = await readStdin();
|
|
1232
|
+
} else {
|
|
1233
|
+
try {
|
|
1234
|
+
newBody = readFileSync(options.file, 'utf8');
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
throw new Error(`cannot read plan file ${options.file}: ${err.code || err.message}`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (!String(newBody).trim()) throw new Error('plan body is empty; refusing to write an empty plan');
|
|
1240
|
+
if (newBody === currentBody) {
|
|
1241
|
+
console.log(chalk.dim(' No changes.'));
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const putEp = resolveEndpoint('quoxplan.stream.plan.put', { id });
|
|
1245
|
+
const data = await client.put(putEp.path, { service: putEp.service, body: { body: newBody, actor: 'quox-cli' } });
|
|
1246
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
1247
|
+
printSuccess(`${id}: plan updated to v${data.version}`);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1216
1251
|
if (options.edit) {
|
|
1217
1252
|
const dir = mkdtempSync(join(tmpdir(), 'quox-plan-'));
|
|
1218
1253
|
const file = join(dir, `${id}.md`);
|