@bugb-technologies/bravos 0.0.0-stage → 1.0.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/LICENSE +70 -0
- package/README.md +225 -2
- package/dist/account.js +140 -0
- package/dist/artifacts.js +450 -0
- package/dist/ci.js +52 -0
- package/dist/cli.js +963 -0
- package/dist/codegraph.js +268 -0
- package/dist/cursor.js +70 -0
- package/dist/detect.js +26 -0
- package/dist/doctor.js +99 -0
- package/dist/global.js +95 -0
- package/dist/graphbin.js +136 -0
- package/dist/guardlink.js +123 -0
- package/dist/guide.js +255 -0
- package/dist/help.js +334 -0
- package/dist/hooks.js +167 -0
- package/dist/install.js +209 -0
- package/dist/licence.js +41 -0
- package/dist/lock.js +50 -0
- package/dist/mcp.js +139 -0
- package/dist/migrate.js +275 -0
- package/dist/omp.js +46 -0
- package/dist/opencode.js +118 -0
- package/dist/paths.js +33 -0
- package/dist/plugin.js +101 -0
- package/dist/report/check.js +252 -0
- package/dist/report/data.js +401 -0
- package/dist/report/export.js +105 -0
- package/dist/report/markdown.js +310 -0
- package/dist/report/meta.js +48 -0
- package/dist/report/pdf.js +96 -0
- package/dist/review-server.js +7 -0
- package/dist/review.js +274 -0
- package/dist/setup.js +121 -0
- package/dist/skills.js +81 -0
- package/dist/types.js +9 -0
- package/dist/uninstall.js +192 -0
- package/dist/versions.js +8 -0
- package/dist/watch-frame.js +593 -0
- package/dist/watch.js +873 -0
- package/dist/workspace.js +316 -0
- package/docs/GUIDE.md +323 -0
- package/hooks/autopilot.mjs +45 -0
- package/hooks/lib/autopilot.mjs +191 -0
- package/hooks/lib/briefing.mjs +750 -0
- package/hooks/lib/licence.mjs +452 -0
- package/hooks/lib/pre-pr.mjs +209 -0
- package/hooks/lib/sast.mjs +207 -0
- package/hooks/lib/setup.mjs +107 -0
- package/hooks/lib/workspace.mjs +78 -0
- package/hooks/pre-pr.mjs +54 -0
- package/hooks/session-start.mjs +82 -0
- package/package.json +49 -4
- package/skills/bravos/SKILL.md +63 -0
- package/skills/bravos-annotate/SKILL.md +56 -0
- package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
- package/skills/bravos-annotate/scripts/slice.mjs +141 -0
- package/skills/bravos-campaign/SKILL.md +49 -0
- package/skills/bravos-cicd/SKILL.md +48 -0
- package/skills/bravos-codegraph/SKILL.md +57 -0
- package/skills/bravos-context/SKILL.md +51 -0
- package/skills/bravos-context/intent.example.md +48 -0
- package/skills/bravos-env/SKILL.md +48 -0
- package/skills/bravos-env/scope.example.yaml +50 -0
- package/skills/bravos-prove/SKILL.md +52 -0
- package/skills/bravos-recheck/SKILL.md +47 -0
- package/skills/bravos-report/SKILL.md +53 -0
- package/skills/bravos-report/assessment.md +54 -0
- package/skills/bravos-report/brief.md +23 -0
- package/skills/bravos-report/finding.md +74 -0
- package/skills/bravos-report/retest.md +29 -0
- package/skills/bravos-report/style.md +44 -0
- package/skills/bravos-sast/SKILL.md +48 -0
- package/skills/bravos-schedule/SKILL.md +44 -0
- package/skills/bravos-schedule/loop.md +16 -0
- package/skills/bravos-ship/SKILL.md +58 -0
- package/skills/bravos-templatize/SKILL.md +48 -0
- package/skills/bravos-templatize/operator-guidance.md +20 -0
- package/templates/omp/bravos.js +62 -0
- package/templates/opencode/bravos.js +33 -0
- package/templates/report/report.html +88 -0
- package/templates/review/review.html +327 -0
- package/templates/workflows/bravos-nightly-claude.yml +173 -0
- package/templates/workflows/bravos-nightly-codex.yml +172 -0
- package/templates/workflows/bravos-pr-gate.yml +142 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { DOCTOR_HELP, INIT_HELP, PROGRAM_HELP, SYNC_HELP } from './help.js';
|
|
7
|
+
import { Command, CommanderError, InvalidArgumentError, Option } from 'commander';
|
|
8
|
+
import { writeWorkflows } from './ci.js';
|
|
9
|
+
import { writeCursorPointer } from './cursor.js';
|
|
10
|
+
import { writeOmpExtension } from './omp.js';
|
|
11
|
+
import { homeOf, installGlobal } from './global.js';
|
|
12
|
+
import { uninstall } from './uninstall.js';
|
|
13
|
+
import { checkKind, newToken, readAllAnswers, readDraft, renderReview, summarize, writePage } from './review.js';
|
|
14
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
15
|
+
import { createServer as netServer } from 'node:net';
|
|
16
|
+
import { dirname as pathDirname, join as pathJoin } from 'node:path';
|
|
17
|
+
import { OPENCODE_CONFIG, writeOpencode } from './opencode.js';
|
|
18
|
+
import { GUIDE_TOPIC_IDS, renderGuide, renderGuideMarkdown } from './guide.js';
|
|
19
|
+
import { detectHarnesses } from './detect.js';
|
|
20
|
+
import { formatDoctor, runDoctor, withLicence } from './doctor.js';
|
|
21
|
+
import { ensureGuardlink, registerPrePr, whichBin, writeScope } from './guardlink.js';
|
|
22
|
+
import { installHook } from './hooks.js';
|
|
23
|
+
import { activate, importLicence } from './account.js';
|
|
24
|
+
import * as graph from './codegraph.js';
|
|
25
|
+
import { askGraph } from './watch.js';
|
|
26
|
+
import { plan as setupPlan, runSast, runSetup } from './setup.js';
|
|
27
|
+
import { licenceDocument, licenceLib, licenceLines, requireFeature } from './licence.js';
|
|
28
|
+
import { migrateFromOldName, reportMigration } from './migrate.js';
|
|
29
|
+
import { listBundledSkills } from './skills.js';
|
|
30
|
+
import { installSkills, linkSkillRoots, mirrorGuardlinkSkills, skillRootsLinked } from './install.js';
|
|
31
|
+
import { readLock, sha256, writeLock } from './lock.js';
|
|
32
|
+
import { mergeCodexConfig, mergeMcpJson, selectServers } from './mcp.js';
|
|
33
|
+
import { hooksDir, skillsDir as bundledSkillsDir, version } from './paths.js';
|
|
34
|
+
import { BravosError } from './types.js';
|
|
35
|
+
const GUARDLINK_HINT = 'guardlink: not found on PATH — npm install -g guardlink';
|
|
36
|
+
// Antigravity reads the shared .agents/ root; opencode and omp get their own files on top of a skills root.
|
|
37
|
+
const AGENT_MAP = { claude: 'claude', codex: 'agents', agy: 'agents', opencode: 'opencode', omp: 'omp' };
|
|
38
|
+
// @comment -- "Parses --agent <claude,codex>; codex maps to the .agents harness and anything else is a usage error."
|
|
39
|
+
function parseAgents(value) {
|
|
40
|
+
const out = [];
|
|
41
|
+
// 'all' is every harness bravos knows: both skill roots plus the opencode and omp files.
|
|
42
|
+
const raws = value.split(',').map((s) => s.trim()).flatMap((r) => (r === 'all' ? ['claude', 'codex', 'opencode', 'omp'] : [r]));
|
|
43
|
+
for (const raw of raws) {
|
|
44
|
+
const h = AGENT_MAP[raw];
|
|
45
|
+
if (!h)
|
|
46
|
+
throw new InvalidArgumentError(`unknown agent '${raw}' (expected claude, codex, agy, opencode, omp or all)`);
|
|
47
|
+
if (!out.includes(h))
|
|
48
|
+
out.push(h);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const names = (list) => (list.length ? list.join(', ') : 'none');
|
|
53
|
+
// @comment -- "Asks git (one `git check-ignore --stdin`, argv array, cwd root) which of the repo-relative paths bravos wrote are ignored and returns one warning per ignored top-level directory; silent when git is absent, the dir is not a repo, or nothing is ignored."
|
|
54
|
+
function gitignoreWarnings(root, paths, env) {
|
|
55
|
+
if (paths.length === 0)
|
|
56
|
+
return [];
|
|
57
|
+
let out;
|
|
58
|
+
try {
|
|
59
|
+
out = execFileSync('git', ['check-ignore', '--stdin'], {
|
|
60
|
+
cwd: root,
|
|
61
|
+
env,
|
|
62
|
+
input: paths.join('\n') + '\n',
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
65
|
+
timeout: 10000,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Exit 1 (nothing ignored), 128 (not a repo) and ENOENT (no git) all mean: say nothing.
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const dirs = [...new Set(out.split('\n').filter(Boolean).map((p) => p.split('/')[0]))];
|
|
73
|
+
return dirs.map((d) => `warning: ${d}/ is gitignored in this repo; files under it will not reach teammates via git`);
|
|
74
|
+
}
|
|
75
|
+
// @comment -- "Runs the install pipeline (detect, guardlink init unless sync, skills, hook, lock, MCP) and pushes summary lines as it goes, so a mid-way BravosError still leaves the summary so far in `lines`."
|
|
76
|
+
function pipeline(root, io, opts, withGuardlink, lines) {
|
|
77
|
+
const dryRun = !!opts.dryRun;
|
|
78
|
+
if (dryRun)
|
|
79
|
+
lines.push('dry-run: nothing written');
|
|
80
|
+
reportMigration(migrateFromOldName(root, { dryRun, ownSkills: listBundledSkills(io.skillsDir).map((s) => s.name) }), lines);
|
|
81
|
+
const detected = detectHarnesses(root);
|
|
82
|
+
const chosen = opts.agent?.filter((a) => a === 'claude' || a === 'agents');
|
|
83
|
+
const harnesses = chosen && chosen.length > 0 ? chosen : detected.harnesses;
|
|
84
|
+
const defaulted = !(chosen && chosen.length > 0) && detected.defaulted;
|
|
85
|
+
const forceOpencode = !!opts.agent?.includes('opencode');
|
|
86
|
+
const forceOmp = !!opts.agent?.includes('omp');
|
|
87
|
+
lines.push(`Harnesses: ${harnesses.join(', ')}${defaulted ? ' (default: no evidence found)' : ''}`);
|
|
88
|
+
let guardlinkPending = false;
|
|
89
|
+
if (withGuardlink) {
|
|
90
|
+
const g = ensureGuardlink(root, harnesses, {
|
|
91
|
+
mode: opts.guardlinkMode,
|
|
92
|
+
skip: opts.guardlinkInit === false,
|
|
93
|
+
dryRun,
|
|
94
|
+
env: io.env,
|
|
95
|
+
});
|
|
96
|
+
switch (g.status) {
|
|
97
|
+
case 'present':
|
|
98
|
+
lines.push('guardlink: present');
|
|
99
|
+
break;
|
|
100
|
+
case 'initialised':
|
|
101
|
+
lines.push(`guardlink: initialised (${g.command.join(' ')})`);
|
|
102
|
+
break;
|
|
103
|
+
case 'skipped':
|
|
104
|
+
lines.push('guardlink: skipped');
|
|
105
|
+
break;
|
|
106
|
+
case 'guardlink-missing':
|
|
107
|
+
lines.push(GUARDLINK_HINT);
|
|
108
|
+
break;
|
|
109
|
+
case 'dry-run':
|
|
110
|
+
guardlinkPending = true;
|
|
111
|
+
lines.push(`guardlink: dry-run — would run ${g.command.join(' ')}`);
|
|
112
|
+
if (!whichBin('guardlink', io.env))
|
|
113
|
+
lines.push(GUARDLINK_HINT);
|
|
114
|
+
break;
|
|
115
|
+
default: {
|
|
116
|
+
const unreachable = g;
|
|
117
|
+
throw new Error(`unknown guardlink status ${JSON.stringify(unreachable)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// One folder behind both roots: render once (for Claude Code) instead of twice into the same files.
|
|
122
|
+
let linkedNow = false;
|
|
123
|
+
if (opts.linkSkills) {
|
|
124
|
+
if (!(harnesses.includes('claude') && harnesses.includes('agents'))) {
|
|
125
|
+
lines.push('link-skills: only one skills root is installed here, so there is nothing to link');
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const l = linkSkillRoots(root, { dryRun });
|
|
129
|
+
if (l.status === 'linked')
|
|
130
|
+
linkedNow = true;
|
|
131
|
+
if (l.status === 'linked')
|
|
132
|
+
lines.push(`link-skills: .agents/skills is now a link to .claude/skills (${l.removed.length} duplicate files removed)`);
|
|
133
|
+
else if (l.status === 'already-linked')
|
|
134
|
+
lines.push('link-skills: .agents/skills already links to .claude/skills');
|
|
135
|
+
else {
|
|
136
|
+
lines.push(`link-skills: not linked — ${l.reason}`);
|
|
137
|
+
for (const u of l.unknown.slice(0, 10))
|
|
138
|
+
lines.push(` ${u}`);
|
|
139
|
+
if (l.unknown.length > 10)
|
|
140
|
+
lines.push(` … and ${l.unknown.length - 10} more`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const linked = harnesses.includes('claude') && harnesses.includes('agents') && (skillRootsLinked(root) || linkedNow);
|
|
145
|
+
if (linked)
|
|
146
|
+
lines.push('skills: .agents/skills is a link to .claude/skills — installed once');
|
|
147
|
+
const skillHarnesses = linked ? harnesses.filter((h) => h !== 'agents') : harnesses;
|
|
148
|
+
const skills = installSkills(root, skillHarnesses, { skillsDir: io.skillsDir, force: opts.force, dryRun });
|
|
149
|
+
lines.push(`Skills: ${skills.written.length} written, ${skills.updated.length} updated, ${skills.unchanged.length} unchanged, ${skills.skipped.length} skipped`);
|
|
150
|
+
for (const s of skills.skipped) {
|
|
151
|
+
lines.push(` skipped ${s.path} — ${s.reason}`);
|
|
152
|
+
if (s.source)
|
|
153
|
+
lines.push(` see: git diff --no-index ${s.path} ${s.source}`);
|
|
154
|
+
}
|
|
155
|
+
// guardlink writes its playbook skills under .claude/skills only; Codex reads .agents/skills.
|
|
156
|
+
const mirror = harnesses.includes('agents') && !linked
|
|
157
|
+
? mirrorGuardlinkSkills(root, { force: opts.force, dryRun })
|
|
158
|
+
: { written: [], updated: [], unchanged: [], skipped: [] };
|
|
159
|
+
if (mirror.written.length + mirror.updated.length + mirror.unchanged.length + mirror.skipped.length > 0) {
|
|
160
|
+
lines.push(`Guardlink skills mirrored to .agents/skills: ${mirror.written.length} written, ${mirror.updated.length} updated, ${mirror.unchanged.length} unchanged, ${mirror.skipped.length} skipped`);
|
|
161
|
+
for (const s of mirror.skipped)
|
|
162
|
+
lines.push(` skipped ${s.path} — ${s.reason}`);
|
|
163
|
+
}
|
|
164
|
+
const cursor = writeCursorPointer(root, harnesses, { force: opts.force, dryRun });
|
|
165
|
+
if (cursor.status === 'skipped')
|
|
166
|
+
lines.push(`Cursor: skipped ${cursor.path} — ${cursor.reason}`);
|
|
167
|
+
else if (cursor.status === 'no-skills')
|
|
168
|
+
lines.push(`Cursor: ${cursor.path} not written — no skills root was installed for it to point at`);
|
|
169
|
+
else if (cursor.status !== 'absent')
|
|
170
|
+
lines.push(`Cursor: ${cursor.path} ${cursor.status}`);
|
|
171
|
+
const omp = writeOmpExtension(root, io.env, { force: opts.force, dryRun, always: forceOmp });
|
|
172
|
+
if (omp.status === 'skipped')
|
|
173
|
+
lines.push(`omp: skipped ${omp.path} — ${omp.reason}`);
|
|
174
|
+
else if (omp.status !== 'absent')
|
|
175
|
+
lines.push(`omp: ${omp.path} ${omp.status} (banner and briefing at session start)`);
|
|
176
|
+
// Autopilot is the pre-PR gate plus the end-of-turn check.
|
|
177
|
+
if (opts.autopilot)
|
|
178
|
+
opts.prePrHook = true;
|
|
179
|
+
const hook = installHook(root, harnesses, { hooksDir: io.hooksDir, dryRun, prePr: !!opts.prePrHook, autopilot: !!opts.autopilot });
|
|
180
|
+
if (!dryRun) {
|
|
181
|
+
const lock = readLock(root);
|
|
182
|
+
for (const rel of hook.copied)
|
|
183
|
+
lock.files[rel] = sha256(readFileSync(join(root, rel)));
|
|
184
|
+
writeLock(root, lock);
|
|
185
|
+
}
|
|
186
|
+
for (const r of hook.registered)
|
|
187
|
+
lines.push(`Hooks: ${r.file} ${r.changed ? 'registered' : 'already registered'}`);
|
|
188
|
+
const guardlinkWritten = [];
|
|
189
|
+
if (opts.prePrHook) {
|
|
190
|
+
const p = registerPrePr(root, { dryRun });
|
|
191
|
+
if (p.status === 'no-config')
|
|
192
|
+
lines.push(`pre_pr: ${p.path} absent, bravos's gate not listed in guardlink's agent block (run guardlink init, then bravos sync --pre-pr-hook)`);
|
|
193
|
+
else if (p.status === 'already-registered')
|
|
194
|
+
lines.push(`pre_pr: ${p.path} already lists bravos's gate`);
|
|
195
|
+
else {
|
|
196
|
+
lines.push(`pre_pr: ${p.path} lists bravos's gate; run guardlink sync to render it into the agent instruction files`);
|
|
197
|
+
guardlinkWritten.push(p.path);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (opts.scope) {
|
|
201
|
+
const s = writeScope(root, io.skillsDir ?? bundledSkillsDir(), { dryRun, guardlinkPending });
|
|
202
|
+
if (s.status === 'present')
|
|
203
|
+
lines.push('scope: present');
|
|
204
|
+
else if (s.status === 'written') {
|
|
205
|
+
lines.push(`scope: written ${s.path}`);
|
|
206
|
+
guardlinkWritten.push(s.path);
|
|
207
|
+
}
|
|
208
|
+
else if (s.status === 'no-guardlink')
|
|
209
|
+
lines.push(`scope: not written — .guardlink/ is absent (run guardlink init first)`);
|
|
210
|
+
else
|
|
211
|
+
lines.push('scope: not written — the bravos package has no bravos-env/scope.example.yaml');
|
|
212
|
+
}
|
|
213
|
+
const { selected, deferred } = selectServers(io.env);
|
|
214
|
+
// opencode lists skills only through its skill tool and runs no hooks: commands, a plugin and MCP.
|
|
215
|
+
const oc = writeOpencode(root, io.env, selected, { force: opts.force, dryRun, skillsDir: io.skillsDir, always: forceOpencode });
|
|
216
|
+
if (oc.status === 'done') {
|
|
217
|
+
lines.push(`opencode: ${oc.written.length} written, ${oc.unchanged.length} unchanged, ${oc.skipped.length} skipped (plugin, /bravos commands)${oc.mcpAdded.length ? `; ${OPENCODE_CONFIG} mcp added ${oc.mcpAdded.join(', ')}` : ''}${oc.mcpUpdated?.length ? `; ${OPENCODE_CONFIG} mcp refreshed ${oc.mcpUpdated.join(', ')}` : ''}`);
|
|
218
|
+
for (const s of oc.skipped)
|
|
219
|
+
lines.push(` skipped ${s.path} — ${s.reason}`);
|
|
220
|
+
}
|
|
221
|
+
const mcpLine = (m) => lines.push(`MCP: ${m.path} added ${names(m.added)}; kept ${names(m.kept)}${m.updated?.length ? `; refreshed ${names(m.updated)} (it pointed at another bravos install or graph store)` : ''}`);
|
|
222
|
+
mcpLine(mergeMcpJson(root, selected, { dryRun }));
|
|
223
|
+
if (harnesses.includes('agents'))
|
|
224
|
+
mcpLine(mergeCodexConfig(root, selected, { dryRun }));
|
|
225
|
+
if (deferred.length) {
|
|
226
|
+
const tools = runDoctor(root, io.env).tools;
|
|
227
|
+
const bin = { cxg: 'cxg', codegraph: 'codegraph-mcp' };
|
|
228
|
+
for (const d of deferred)
|
|
229
|
+
lines.push(`MCP deferred: ${d} — ${tools.find((t) => t.name === bin[d])?.hint ?? ''}`);
|
|
230
|
+
}
|
|
231
|
+
let workflowWritten = [];
|
|
232
|
+
if (opts.ci) {
|
|
233
|
+
const wf = writeWorkflows(root, { force: opts.force, dryRun });
|
|
234
|
+
lines.push(`Workflows: ${wf.written.length} written, ${wf.updated.length} updated, ${wf.unchanged.length} unchanged, ${wf.skipped.length} skipped`);
|
|
235
|
+
for (const s of wf.skipped)
|
|
236
|
+
lines.push(` skipped ${s.path} — ${s.reason}`);
|
|
237
|
+
workflowWritten = [...wf.written, ...wf.updated];
|
|
238
|
+
}
|
|
239
|
+
const cursorWritten = cursor.status === 'written' || cursor.status === 'updated' ? [cursor.path] : [];
|
|
240
|
+
const ompWritten = omp.status === 'written' || omp.status === 'updated' ? [omp.path] : [];
|
|
241
|
+
const ocWritten = oc.status === 'done' ? [...oc.written, ...(oc.mcpAdded.length ? [OPENCODE_CONFIG] : [])] : [];
|
|
242
|
+
const written = [...skills.written, ...skills.updated, ...mirror.written, ...mirror.updated, ...cursorWritten, ...ompWritten, ...ocWritten, ...workflowWritten, ...hook.copied, ...hook.registered.map((r) => r.file), ...guardlinkWritten];
|
|
243
|
+
lines.push(...gitignoreWarnings(root, written, io.env));
|
|
244
|
+
if (opts.autopilot) {
|
|
245
|
+
lines.push('autopilot on: an end-of-turn check (Stop hook) sends the agent back once when changed code lacks annotations, sits under a confirmed or stale claim, or adds exposures; the pre-PR gate checks every gh pr create / gh pr ready.');
|
|
246
|
+
lines.push('autopilot: the first time proving comes up, the agent asks you once how you want it handled and records your answer in .guardlink/proving.md. BRAVOS_AUTOPILOT=off pauses the check.');
|
|
247
|
+
}
|
|
248
|
+
if (harnesses.includes('agents'))
|
|
249
|
+
lines.push('In Codex run /hooks once to trust .codex/hooks.json');
|
|
250
|
+
// A user-level install of another version silently replaces these skills in Claude Code, so a
|
|
251
|
+
// repo sync brings an existing one up to date too (never creates one; a dry run only reports;
|
|
252
|
+
// BRAVOS_SYNC_GLOBAL=off keeps the two separate).
|
|
253
|
+
// @comment -- "When the person already has a user-level install (bravos init --global) of another version, a repo sync re-applies it through installGlobal, so an older ~/.claude/skills copy never shadows the repo's; it never creates a user-level install, and BRAVOS_SYNC_GLOBAL=off opts out."
|
|
254
|
+
let userVersion = null;
|
|
255
|
+
try {
|
|
256
|
+
userVersion = JSON.parse(readFileSync(pathJoin(homeOf(io.env), '.bravos', 'skills.lock'), 'utf8'))?.bravos_version ?? null;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
userVersion = null;
|
|
260
|
+
}
|
|
261
|
+
if (userVersion && userVersion !== version() && resolve(homeOf(io.env)) !== resolve(root)) {
|
|
262
|
+
if (dryRun)
|
|
263
|
+
lines.push(`user-level bravos is ${userVersion}: a real sync would bring it to ${version()} too`);
|
|
264
|
+
else if (io.env.BRAVOS_SYNC_GLOBAL === 'off') {
|
|
265
|
+
lines.push(`warning: your user-level bravos (~/.claude/skills) is ${userVersion}; Claude Code prefers it over this repo's ${version()}. Run: bravos sync --global`);
|
|
266
|
+
}
|
|
267
|
+
else if (io.env.CLAUDECODE) {
|
|
268
|
+
// Inside a Claude Code session, writes under ~/.claude can be held back by its sandbox while the
|
|
269
|
+
// lock in ~/.bravos still lands, which would mark a stale copy current. Leave the user install alone.
|
|
270
|
+
lines.push(`warning: your user-level bravos is ${userVersion}, older than ${version()}; this runs inside a Claude Code session, which may not write ~/.claude, so it was left alone. In your own terminal run: bravos sync --global`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
try {
|
|
274
|
+
const sub = [];
|
|
275
|
+
installGlobal(io.env, sub, { skillsDir: io.skillsDir, hooksDir: io.hooksDir });
|
|
276
|
+
const skipped = sub.filter((l) => l.startsWith(' skipped'));
|
|
277
|
+
if (skipped.length) {
|
|
278
|
+
lines.push(`warning: user-level bravos only partly updated; ${skipped.length} file(s) were not written. Run: bravos sync --global`);
|
|
279
|
+
lines.push(...skipped);
|
|
280
|
+
}
|
|
281
|
+
else
|
|
282
|
+
lines.push(`user-level bravos updated ${userVersion} → ${version()} (~/.claude/skills, ~/.bravos/hooks), so Claude Code does not use an older copy. BRAVOS_SYNC_GLOBAL=off keeps them separate.`);
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
lines.push(`warning: could not update your user-level bravos (${e instanceof Error ? e.message : String(e)}). Run: bravos sync --global`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
lines.push('Next: open a session. The banner and briefing load by themselves; /bravos is only for the next step.');
|
|
290
|
+
}
|
|
291
|
+
// @comment -- "Builds the commander program (init, sync, doctor); exit-code mapping lives in run()."
|
|
292
|
+
export function buildProgram(io, result) {
|
|
293
|
+
const program = new Command('bravos')
|
|
294
|
+
.description('Makes Claude Code and Codex power users of guardlink, cxg, the code graph and SAST: installs skills, a session-start briefing, MCP wiring and optional CI into a repository.')
|
|
295
|
+
.addHelpText('after', PROGRAM_HELP)
|
|
296
|
+
.version(version())
|
|
297
|
+
.exitOverride()
|
|
298
|
+
.configureOutput({ writeOut: (s) => io.stdout(s.replace(/\n$/, '')), writeErr: (s) => io.stderr(s.replace(/\n$/, '')) });
|
|
299
|
+
const guarded = (fn) => {
|
|
300
|
+
const lines = [];
|
|
301
|
+
try {
|
|
302
|
+
fn(lines);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (!(err instanceof BravosError))
|
|
306
|
+
throw err;
|
|
307
|
+
result.code = err.exitCode;
|
|
308
|
+
for (const l of lines)
|
|
309
|
+
io.stdout(l);
|
|
310
|
+
io.stderr(err.message);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
for (const l of lines)
|
|
314
|
+
io.stdout(l);
|
|
315
|
+
};
|
|
316
|
+
const dirArg = (dir) => resolve(dir ?? process.cwd());
|
|
317
|
+
// @comment -- "The paid install flags (--pre-pr-hook, --autopilot, --ci) are checked against the licence before the pipeline writes anything, so a refusal leaves the repo untouched and names the flag."
|
|
318
|
+
const gateInstallFlags = async (opts, cmd) => {
|
|
319
|
+
const flags = [
|
|
320
|
+
[opts.autopilot, 'pre-pr', `bravos ${cmd} --autopilot`],
|
|
321
|
+
[opts.prePrHook, 'pre-pr', `bravos ${cmd} --pre-pr-hook`],
|
|
322
|
+
[opts.ci, 'ci', `bravos ${cmd} --ci`],
|
|
323
|
+
];
|
|
324
|
+
for (const [on, feature, what] of flags)
|
|
325
|
+
if (on)
|
|
326
|
+
await requireFeature(io.env, feature, what);
|
|
327
|
+
};
|
|
328
|
+
const guardedAsync = async (fn) => {
|
|
329
|
+
const lines = [];
|
|
330
|
+
try {
|
|
331
|
+
await fn(lines);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
if (!(err instanceof BravosError))
|
|
335
|
+
throw err;
|
|
336
|
+
result.code = err.exitCode;
|
|
337
|
+
for (const l of lines)
|
|
338
|
+
io.stdout(l);
|
|
339
|
+
io.stderr(err.message);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const l of lines)
|
|
343
|
+
io.stdout(l);
|
|
344
|
+
};
|
|
345
|
+
program
|
|
346
|
+
.command('init [dir]')
|
|
347
|
+
.description('First install: detect agents, run guardlink init if needed, install skills, hooks and MCP wiring.')
|
|
348
|
+
.addHelpText('after', INIT_HELP)
|
|
349
|
+
.option('--agent <list>', 'comma-separated agents: claude,codex,agy,opencode,omp, or all', parseAgents)
|
|
350
|
+
.addOption(new Option('--guardlink-mode <mode>', 'guardlink annotation mode').choices(['inline', 'external']).default('inline'))
|
|
351
|
+
.option('--no-guardlink-init', 'do not run guardlink init')
|
|
352
|
+
.option('--force', 'overwrite edited or authored skill files')
|
|
353
|
+
.option('--pre-pr-hook', 'also register the PreToolUse gate that checks gh pr create / gh pr ready')
|
|
354
|
+
.option('--ci', 'also write the PR gate and nightly GitHub Actions workflows into .github/workflows/')
|
|
355
|
+
.option('--scope', 'also write the loopback-only cxg scope starter to .guardlink/scope.yaml when absent')
|
|
356
|
+
.option('--link-skills', 'make .agents/skills a link to .claude/skills so each skill exists once')
|
|
357
|
+
.option('--global', 'install for your user (home folder) instead of a repository')
|
|
358
|
+
.option('--autopilot', 'end-of-turn check plus the pre-PR gate, so upkeep happens without being asked')
|
|
359
|
+
.option('--dry-run', 'report what would change without writing')
|
|
360
|
+
.action((dir, opts) => guardedAsync(async (lines) => {
|
|
361
|
+
await gateInstallFlags(opts, 'init');
|
|
362
|
+
if (opts.global)
|
|
363
|
+
installGlobal(io.env, lines, { force: opts.force, dryRun: opts.dryRun, skillsDir: io.skillsDir, hooksDir: io.hooksDir });
|
|
364
|
+
else
|
|
365
|
+
pipeline(dirArg(dir), io, opts, true, lines);
|
|
366
|
+
}));
|
|
367
|
+
program
|
|
368
|
+
.command('sync [dir]')
|
|
369
|
+
.description('Re-apply the installed bravos version to a repository (never runs guardlink init).')
|
|
370
|
+
.addHelpText('after', SYNC_HELP)
|
|
371
|
+
.option('--agent <list>', 'comma-separated agents: claude,codex,agy,opencode,omp, or all', parseAgents)
|
|
372
|
+
.option('--force', 'overwrite edited or authored skill files')
|
|
373
|
+
.option('--pre-pr-hook', 'also register the PreToolUse gate that checks gh pr create / gh pr ready')
|
|
374
|
+
.option('--ci', 'also write the PR gate and nightly GitHub Actions workflows into .github/workflows/')
|
|
375
|
+
.option('--scope', 'also write the loopback-only cxg scope starter to .guardlink/scope.yaml when absent')
|
|
376
|
+
.option('--link-skills', 'make .agents/skills a link to .claude/skills so each skill exists once')
|
|
377
|
+
.option('--global', 'install for your user (home folder) instead of a repository')
|
|
378
|
+
.option('--autopilot', 'end-of-turn check plus the pre-PR gate, so upkeep happens without being asked')
|
|
379
|
+
.option('--dry-run', 'report what would change without writing')
|
|
380
|
+
.action((dir, opts) => guardedAsync(async (lines) => {
|
|
381
|
+
await gateInstallFlags(opts, 'sync');
|
|
382
|
+
if (opts.global)
|
|
383
|
+
installGlobal(io.env, lines, { force: opts.force, dryRun: opts.dryRun, skillsDir: io.skillsDir, hooksDir: io.hooksDir });
|
|
384
|
+
else
|
|
385
|
+
pipeline(dirArg(dir), io, opts, false, lines);
|
|
386
|
+
}));
|
|
387
|
+
program
|
|
388
|
+
.command('licence')
|
|
389
|
+
.alias('license')
|
|
390
|
+
.description('Which plan this machine is on, where the licence is, and what it unlocks.')
|
|
391
|
+
.option('--json', 'print the status as JSON (bravos.licence/v1)')
|
|
392
|
+
.action((opts) => guardedAsync(async (lines) => {
|
|
393
|
+
const L = await licenceLib();
|
|
394
|
+
const v = L.status(io.env);
|
|
395
|
+
if (opts.json)
|
|
396
|
+
lines.push(JSON.stringify(licenceDocument(v), null, 2));
|
|
397
|
+
else
|
|
398
|
+
lines.push(...licenceLines(L, v));
|
|
399
|
+
}));
|
|
400
|
+
program
|
|
401
|
+
.command('account <action> [file]')
|
|
402
|
+
.description('activate: sign in once in the browser and get this machine its licence (a 30-day Team trial when the account has no plan); import FILE: install a downloaded licence; status: same as bravos licence.')
|
|
403
|
+
.action((action, file) => guardedAsync(async (lines) => {
|
|
404
|
+
if (action === 'activate')
|
|
405
|
+
lines.push(...(await activate({ env: io.env, stdout: io.stdout })));
|
|
406
|
+
else if (action === 'import') {
|
|
407
|
+
if (!file)
|
|
408
|
+
throw new BravosError('usage: bravos account import <licence file>');
|
|
409
|
+
lines.push(...(await importLicence(resolve(file), { env: io.env, stdout: io.stdout })));
|
|
410
|
+
}
|
|
411
|
+
else if (action === 'status') {
|
|
412
|
+
const L = await licenceLib();
|
|
413
|
+
lines.push(...licenceLines(L, L.status(io.env)));
|
|
414
|
+
}
|
|
415
|
+
else
|
|
416
|
+
throw new BravosError(`unknown account action ${JSON.stringify(action)}: activate, import <file> or status`);
|
|
417
|
+
}));
|
|
418
|
+
program
|
|
419
|
+
.command('setup')
|
|
420
|
+
.description('Put the binaries bravos drives on this machine: the analyzer (bravos-cli, SAST) and the code graph (codegraph-build, codegraph-mcp) are licensed bundles fetched through the account portal, verified and staged under $BRAVOS_HOME; --tools adds guardlink and cxg through npm and cargo.')
|
|
421
|
+
.option('--analyzer', 'only the analyzer bundle')
|
|
422
|
+
.option('--codegraph', 'only the code graph bundle')
|
|
423
|
+
.option('--tools', 'also guardlink (npm) and cxg (cargo); needs -y')
|
|
424
|
+
.option('--check', 'report what is here and what setup would do; changes nothing')
|
|
425
|
+
.option('--dry-run', 'say what would be fetched or run; changes nothing')
|
|
426
|
+
.option('-y, --yes', 'run the package-manager installs, and refetch a bundle that is already staged')
|
|
427
|
+
.option('--json', 'with --check: print the plan as JSON (bravos.setup-plan/v1)')
|
|
428
|
+
.action((opts) => guardedAsync(async (lines) => {
|
|
429
|
+
if (opts.check && opts.json) {
|
|
430
|
+
const p = await setupPlan(io.env);
|
|
431
|
+
lines.push(JSON.stringify({ schema: 'bravos.setup-plan/v1', plan: p.licence.licensed ? 'licensed' : p.licence.state, items: p.items }, null, 2));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
lines.push(...(await runSetup(io.env, opts, { echo: io.stdout })));
|
|
435
|
+
}));
|
|
436
|
+
program
|
|
437
|
+
.command('graph [question] [args...]')
|
|
438
|
+
.description('Ask the code graph (Team plan): `graph build [DIR] [--scip MODE]` indexes a repository; `graph <question> [args] --json` asks it (attack-surface, entry-points, callers <file::symbol>, reachable-sinks, call-chain, …; the same answers as the codegraph_ MCP tools); `graph --json` lists every question this machine\'s binary answers.')
|
|
439
|
+
.option('-C, --dir <dir>', 'the repository (default: the current directory)')
|
|
440
|
+
.option('--json', 'print the answer as JSON')
|
|
441
|
+
.option('--scip <mode>', 'build: how the type checkers run — no-exec (default), auto, trusted, isolate, off')
|
|
442
|
+
.option('--mode <mode>', 'build: auto (default), build, reuse')
|
|
443
|
+
.allowUnknownOption()
|
|
444
|
+
.action((question, args, opts) => guardedAsync(async (lines) => {
|
|
445
|
+
const L = await licenceLib();
|
|
446
|
+
const lic = L.status(io.env);
|
|
447
|
+
if (!L.entitled(lic, 'codegraph')) {
|
|
448
|
+
// In --json the refusal is a document too, so a probe (the briefing, watch) reads 'locked' rather than 'broken'.
|
|
449
|
+
if (opts.json) {
|
|
450
|
+
lines.push(JSON.stringify({ error: 'licence_required', message: L.refusal(lic, 'codegraph', `bravos graph ${question ?? ''}`.trim()), remedy: 'bravos account activate' }));
|
|
451
|
+
result.code = 1;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
throw new BravosError(L.refusal(lic, 'codegraph', `bravos graph ${question ?? ''}`.trim()));
|
|
455
|
+
}
|
|
456
|
+
const root = dirArg(opts.dir ?? (question === 'build' && args[0] && !args[0].startsWith('--') ? args.shift() : undefined));
|
|
457
|
+
if (question === 'build') {
|
|
458
|
+
const r = graph.build(root, io.env, { scip: opts.scip, mode: opts.mode, json: opts.json });
|
|
459
|
+
lines.push((r.stdout || r.stderr).trim());
|
|
460
|
+
if (r.status !== 0)
|
|
461
|
+
result.code = 1;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!question) {
|
|
465
|
+
const tools = await graph.listTools(root, io.env);
|
|
466
|
+
if (opts.json)
|
|
467
|
+
lines.push(JSON.stringify({ tools }, null, 2));
|
|
468
|
+
else
|
|
469
|
+
for (const t of tools)
|
|
470
|
+
lines.push(`${t.name.replace(/^codegraph_/, '').replace(/_/g, '-').padEnd(26)} ${(t.description ?? '').split('\n')[0].slice(0, 100)}`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const extra = args.filter((a) => a !== '--json');
|
|
474
|
+
// The same document the Python CLI printed: {result} on an answer, {error, message, remedy} on a refusal, exit 2 when the graph is not built.
|
|
475
|
+
const a = await askGraph(root, io.env, question, extra);
|
|
476
|
+
if (!a)
|
|
477
|
+
throw new BravosError('codegraph-mcp is not installed: bravos setup --codegraph fetches it');
|
|
478
|
+
if (a.error) {
|
|
479
|
+
if (opts.json)
|
|
480
|
+
lines.push(JSON.stringify(a, null, 2));
|
|
481
|
+
else
|
|
482
|
+
lines.push(`no answer: ${a.message}${a.remedy ? `\nfix: ${a.remedy}` : ''}`);
|
|
483
|
+
result.code = a.error === 'codegraph_unavailable' ? 2 : 1;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
lines.push(JSON.stringify(opts.json ? a : a.result, null, 2));
|
|
487
|
+
}));
|
|
488
|
+
program
|
|
489
|
+
.command('doctor [dir]')
|
|
490
|
+
.description('Read-only check of the tools, licence, skills and hooks bravos relies on.')
|
|
491
|
+
.addHelpText('after', DOCTOR_HELP)
|
|
492
|
+
.action((dir) => guardedAsync(async (lines) => {
|
|
493
|
+
const L = await licenceLib();
|
|
494
|
+
const v = L.status(io.env);
|
|
495
|
+
lines.push(formatDoctor(withLicence(runDoctor(dirArg(dir), io.env), L.planLine(v), v.licensed)));
|
|
496
|
+
}));
|
|
497
|
+
program
|
|
498
|
+
.command('uninstall [dir]')
|
|
499
|
+
.description('Remove what bravos installed in a repository (or, with --global, for your user): its unchanged files, hook entries and .bravos/.')
|
|
500
|
+
.option('--global', 'remove the user-level install from your home folder instead')
|
|
501
|
+
.option('--force', 'also remove bravos files you edited')
|
|
502
|
+
.option('--dry-run', 'list what would be removed without removing it')
|
|
503
|
+
.action((dir, opts) => guarded((lines) => {
|
|
504
|
+
const root = opts.global ? homeOf(io.env) : dirArg(dir);
|
|
505
|
+
const r = uninstall(root, { force: opts.force, dryRun: opts.dryRun, home: opts.global });
|
|
506
|
+
if (opts.dryRun)
|
|
507
|
+
lines.push('dry-run: nothing removed');
|
|
508
|
+
lines.push(`${opts.global ? 'User-level install' : 'Repository'}: ${root}`);
|
|
509
|
+
lines.push(`Removed: ${r.removed.length} file(s)${opts.dryRun ? ' (would be)' : ''}`);
|
|
510
|
+
for (const p of r.removed)
|
|
511
|
+
lines.push(` ${p}`);
|
|
512
|
+
for (const u of r.unregistered)
|
|
513
|
+
lines.push(`Unregistered bravos's hooks from ${u}`);
|
|
514
|
+
for (const k of r.kept)
|
|
515
|
+
lines.push(`Kept ${k.path} — ${k.reason}`);
|
|
516
|
+
for (const n of r.notes)
|
|
517
|
+
lines.push(`Note: ${n}`);
|
|
518
|
+
if (r.unregistered.some((u) => u.includes('.codex')))
|
|
519
|
+
lines.push('Codex keeps its own trust record for removed hooks; nothing to do there.');
|
|
520
|
+
}));
|
|
521
|
+
program
|
|
522
|
+
.command('review <kind> [dir]')
|
|
523
|
+
.description("Open bravos's questions (.guardlink/<kind>-draft.json, e.g. kind 'intent') as a branded page people answer in; answers are saved to .guardlink/<kind>-answers/<name>.json. Runs no model.")
|
|
524
|
+
.option('--summary', 'print everyone\'s answers, agreements and disagreements, as plain text')
|
|
525
|
+
.option('--no-serve', 'write the page as a file instead of serving it; Save then downloads the answers')
|
|
526
|
+
.option('--no-open', 'do not open a browser, only print the URL')
|
|
527
|
+
.option('--share', 'render the page for a shared claude.ai artifact (Claude Code only; the agent publishes it)')
|
|
528
|
+
.action((kind, dir, opts) => guardedAsync(async (lines) => {
|
|
529
|
+
const root = dirArg(dir);
|
|
530
|
+
checkKind(kind);
|
|
531
|
+
const draft = readDraft(root, kind);
|
|
532
|
+
if (opts.summary) {
|
|
533
|
+
const { files, skipped } = readAllAnswers(root, draft);
|
|
534
|
+
lines.push(summarize(draft, files, skipped));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (opts.share) {
|
|
538
|
+
const rel = writePage(root, `${kind}-share`, await renderReview(draft, { kind: 'artifact' }));
|
|
539
|
+
lines.push(`Share page: ${rel}`);
|
|
540
|
+
lines.push('Sharing publishes your security intent to claude.ai, private until you share the link; teammates need access there.');
|
|
541
|
+
lines.push('In Claude Code: publish that file with the Artifact tool and capabilities {db: {}}; share the link with the team.');
|
|
542
|
+
lines.push(`Each person's answers land as a document in the "answers" collection; read them with ArtifactData list "answers" and write each one to .guardlink/${kind}-answers/<name>.json, then run bravos review ${kind} --summary.`);
|
|
543
|
+
lines.push(`Other harnesses cannot publish artifacts: use bravos review ${kind} and git instead.`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (opts.serve === false) {
|
|
547
|
+
const rel = writePage(root, kind, await renderReview(draft, { kind: 'file' }));
|
|
548
|
+
lines.push(`Review page: ${rel} (Save downloads your answers; put them in .guardlink/${kind}-answers/ and commit)`);
|
|
549
|
+
if (opts.open !== false)
|
|
550
|
+
openInBrowser(pathJoin(root, rel));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const port = await freePort();
|
|
554
|
+
const token = newToken();
|
|
555
|
+
const child = spawnChild(process.execPath, [pathJoin(pathDirname(fileURLToPath(import.meta.url)), 'review-server.js'), root, kind, token, String(port)], { detached: true, stdio: 'ignore' });
|
|
556
|
+
child.unref();
|
|
557
|
+
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
558
|
+
if (!(await waitUp(url)))
|
|
559
|
+
throw new BravosError('the review server did not start; try bravos review --no-serve');
|
|
560
|
+
lines.push(`Review page: ${url}`);
|
|
561
|
+
lines.push(`Answers save to .guardlink/${kind}-answers/<name>.json. Teammates run bravos review ${kind} after pulling. The page stops on Done or after 30 minutes idle.`);
|
|
562
|
+
lines.push(`When everyone is done: bravos review ${kind} --summary`);
|
|
563
|
+
if (opts.open !== false)
|
|
564
|
+
openInBrowser(url);
|
|
565
|
+
}));
|
|
566
|
+
program
|
|
567
|
+
.command('next [dir]')
|
|
568
|
+
.description("bravos's setup score for a repository: what is switched on, what each step unlocks, the exact command for the next one. Read-only; runs guardlink ci and git, no model.")
|
|
569
|
+
.addOption(new Option('--harness <name>', 'show skills in this harness syntax').choices(['claude', 'codex', 'omp', 'opencode', 'other']).default('claude'))
|
|
570
|
+
.option('--json', 'print the setup as JSON (bravos.setup/v1)')
|
|
571
|
+
.action((dir, opts) => guardedAsync(async (lines) => {
|
|
572
|
+
const root = dirArg(dir);
|
|
573
|
+
const briefing = await import(pathToFileURL(pathJoin(hooksDir(), 'lib', 'briefing.mjs')).href);
|
|
574
|
+
const setupLib = await import(pathToFileURL(pathJoin(hooksDir(), 'lib', 'setup.mjs')).href);
|
|
575
|
+
const inputs = await briefing.collect(root, io.env);
|
|
576
|
+
const s = setupLib.readSetup(root, inputs, opts.harness);
|
|
577
|
+
if (opts.json) {
|
|
578
|
+
lines.push(JSON.stringify(s, null, 2));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
lines.push(`bravos setup ${s.done}/${s.total} for ${inputs.repo}`, '');
|
|
582
|
+
for (const item of s.items) {
|
|
583
|
+
const mark = item.on ? '✔' : item.unavailable ? '·' : item === s.next ? '→' : '✘';
|
|
584
|
+
lines.push(`${mark} ${item.label}${item.on ? '' : item.unavailable ? ' (not available here)' : ''}`);
|
|
585
|
+
if (!item.on)
|
|
586
|
+
lines.push(` unlocks: ${item.unlocks}`, ` how: ${item.how}`);
|
|
587
|
+
}
|
|
588
|
+
lines.push('', s.next ? `Next: ${s.next.label} — ${s.next.how}` : 'Everything bravos can use here is on.', 'More: bravos guide · bravos guide roles · bravos guide tasks', 'Live view: bravos watch (in a second terminal pane)');
|
|
589
|
+
}));
|
|
590
|
+
program
|
|
591
|
+
.command('watch [dir]')
|
|
592
|
+
.description('A live, read-only view of the repository\'s security work for a second terminal pane beside any agent: setup, open and confirmed exposures, what to test next, stale claims, the code graph, SAST, teammates\' PRs and autopilot nudges. Redraws when files change. Runs no model.')
|
|
593
|
+
.option('--interval <seconds>', 'how often to re-read PRs, boundaries and the model\'s size', seconds(10), 60)
|
|
594
|
+
.option('--graph-interval <seconds>', 'how often to ask the code graph again (each ask takes a few seconds)', seconds(30), 300)
|
|
595
|
+
.option('--once', 'print one frame and exit (also what happens when stdout is not a terminal)')
|
|
596
|
+
.option('--json', 'print one snapshot as JSON (bravos.watch/v1) and exit')
|
|
597
|
+
.option('--compact', 'use the narrow layout whatever the width')
|
|
598
|
+
.action((dir, opts) => guardedAsync(async (lines) => {
|
|
599
|
+
const root = dirArg(dir);
|
|
600
|
+
const watch = await import('./watch.js');
|
|
601
|
+
const color = !io.env.NO_COLOR && io.env.BRAVOS_WATCH_COLOR !== 'off';
|
|
602
|
+
const live = !opts.once && !opts.json && process.stdout.isTTY && process.stdin.isTTY;
|
|
603
|
+
if (live) {
|
|
604
|
+
await watch.runWatch(root, io.env, { intervalSec: opts.interval, graphIntervalSec: opts.graphInterval, color, compact: !!opts.compact });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const sn = await watch.snapshot(root, io.env, { refreshGraph: true, refreshSlow: true });
|
|
608
|
+
if (opts.json) {
|
|
609
|
+
lines.push(JSON.stringify(sn, null, 2));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const width = Number(io.env.COLUMNS) || process.stdout.columns || 100;
|
|
613
|
+
const events = watch.readAutopilotEvents(root).slice(-8);
|
|
614
|
+
lines.push(watch.buildFrame(sn, events, { width, color: color && !!process.stdout.isTTY, status: 'once', compact: !!opts.compact }));
|
|
615
|
+
}));
|
|
616
|
+
program
|
|
617
|
+
.command('workspace <action> [args...]')
|
|
618
|
+
.description("One application made of several repositories. create NAME --member PATH[=service[@url]]... makes a bravos workspace, links the members with guardlink link-project, installs bravos in each member and in the app folder (the parent they share, or --folder), and keeps a snapshot bravos's hooks, watch and reports read; add NAME PATH[=service[@url]], remove NAME MEMBER, target NAME SERVICE=URL, show [NAME], sync [NAME]. Runs no model; nothing is pushed.")
|
|
619
|
+
.option('--member <spec>', 'create: a member, PATH[=service[@url]]; repeatable', (v, acc) => [...acc, v], [])
|
|
620
|
+
.option('--folder <dir>', 'create: the app folder (default: the parent every member sits under, if they share one)')
|
|
621
|
+
.option('--json', 'show: print the snapshot and export as JSON')
|
|
622
|
+
.action((action, args, opts) => guardedAsync(async (lines) => {
|
|
623
|
+
// show only reads the snapshot; every verb that changes membership is on the Team plan.
|
|
624
|
+
if (action !== 'show')
|
|
625
|
+
await requireFeature(io.env, 'workspace', `bravos workspace ${action}`);
|
|
626
|
+
const ws = await import('./workspace.js');
|
|
627
|
+
const env = io.env;
|
|
628
|
+
const cwd = process.cwd();
|
|
629
|
+
// bravos init (no bravos yet) or sync in one folder, in-process, summarised to one line.
|
|
630
|
+
const install = async (path, appFolder = false) => {
|
|
631
|
+
const out = [];
|
|
632
|
+
const fresh = !appFolder && !existsSync(pathJoin(path, '.bravos', 'skills.lock'));
|
|
633
|
+
const code = await run([fresh ? 'init' : 'sync', path], { ...io, stdout: (s) => out.push(s), stderr: (s) => out.push(s) });
|
|
634
|
+
return `${code === 0 ? '✔' : '✘'} bravos ${fresh ? 'init' : 'sync'} ${path}${code === 0 ? '' : `: ${out.filter(Boolean).slice(-1)[0] ?? `exit ${code}`}`}`;
|
|
635
|
+
};
|
|
636
|
+
const known = (name) => {
|
|
637
|
+
const members = ws.getWorkspace(env, name).members.map((m) => m.path);
|
|
638
|
+
const prior = members.map((m) => ws.readSnapshot(m)).find(Boolean) ?? null;
|
|
639
|
+
return { members, appFolder: prior?.appFolder ?? ws.appFolderFor(members) };
|
|
640
|
+
};
|
|
641
|
+
const finish = (name, appFolder) => {
|
|
642
|
+
const { snap, exp, exportError } = ws.refresh(name, env, appFolder);
|
|
643
|
+
lines.push('', ...ws.describe(snap, exp));
|
|
644
|
+
if (exportError)
|
|
645
|
+
lines.push(`(export: ${exportError})`);
|
|
646
|
+
};
|
|
647
|
+
if (action === 'create') {
|
|
648
|
+
const name = args[0];
|
|
649
|
+
if (!name)
|
|
650
|
+
throw new BravosError('usage: bravos workspace create NAME --member PATH[=service[@url]] ...');
|
|
651
|
+
if (!opts.member.length)
|
|
652
|
+
throw new BravosError('give each repository with --member PATH[=service[@url]]');
|
|
653
|
+
const specs = opts.member.map((m) => ws.parseMemberSpec(m, cwd));
|
|
654
|
+
for (const s of specs)
|
|
655
|
+
ws.checkMemberPath(s.path);
|
|
656
|
+
const appFolder = ws.appFolderFor(specs.map((s) => s.path), opts.folder ?? null);
|
|
657
|
+
ws.createWorkspace(env, name, specs);
|
|
658
|
+
lines.push(`bravos workspace ${name}: ${specs.length} member${specs.length === 1 ? '' : 's'} (registry: ${ws.registryFile(env)})`);
|
|
659
|
+
lines.push(specs.length > 1 ? `guardlink: ${ws.linkProject([...specs.map((s) => s.path), '-w', name], env, cwd)}` : 'guardlink: one member, nothing to link yet');
|
|
660
|
+
for (const s of specs)
|
|
661
|
+
lines.push(await install(s.path));
|
|
662
|
+
if (appFolder) {
|
|
663
|
+
mkdirSync(appFolder, { recursive: true });
|
|
664
|
+
lines.push(await install(appFolder, true));
|
|
665
|
+
}
|
|
666
|
+
finish(name, appFolder);
|
|
667
|
+
lines.push('', appFolder ? `Open your agent in ${appFolder} for questions across services, or in a service for changes to it.` : 'No app folder: the members do not share a parent. Pass --folder DIR to make one.');
|
|
668
|
+
lines.push(`For the graph to link services, build it over the app folder: bravos graph build ${appFolder ?? '<app folder>'} --scip no-exec`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (action === 'add') {
|
|
672
|
+
const [name, spec] = args;
|
|
673
|
+
if (!name || !spec)
|
|
674
|
+
throw new BravosError('usage: bravos workspace add NAME PATH[=service[@url]]');
|
|
675
|
+
const s = ws.parseMemberSpec(spec, cwd);
|
|
676
|
+
ws.checkMemberPath(s.path);
|
|
677
|
+
const { members, appFolder } = known(name);
|
|
678
|
+
ws.addMember(env, name, s);
|
|
679
|
+
lines.push(members.length ? `guardlink: ${ws.linkProject(['--add', s.path, '--from', members[0]], env, cwd)}` : 'guardlink: first member, nothing to link yet');
|
|
680
|
+
lines.push(await install(s.path));
|
|
681
|
+
finish(name, appFolder);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (action === 'remove') {
|
|
685
|
+
const [name, member] = args;
|
|
686
|
+
if (!name || !member)
|
|
687
|
+
throw new BravosError('usage: bravos workspace remove NAME MEMBER');
|
|
688
|
+
const { members, appFolder } = known(name);
|
|
689
|
+
const gone = ws.removeMember(env, name, member, cwd);
|
|
690
|
+
const leaving = gone.path;
|
|
691
|
+
const from = members.find((m) => m !== leaving);
|
|
692
|
+
if (from && members.length > 2)
|
|
693
|
+
lines.push(`guardlink: ${ws.linkProject(['--remove', member, '--from', from], env, cwd)}`);
|
|
694
|
+
if (leaving)
|
|
695
|
+
rmSync(pathJoin(leaving, ws.WORKSPACE_DIR), { recursive: true, force: true });
|
|
696
|
+
finish(name, appFolder);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (action === 'target') {
|
|
700
|
+
const [name, pair] = args;
|
|
701
|
+
if (!name || !pair || !/^[^=]+=https?:\/\//.test(pair))
|
|
702
|
+
throw new BravosError('usage: bravos workspace target NAME SERVICE=http://host:port');
|
|
703
|
+
const [service, url] = [pair.slice(0, pair.indexOf('=')), pair.slice(pair.indexOf('=') + 1)];
|
|
704
|
+
ws.setTarget(env, name, service, url);
|
|
705
|
+
finish(name, known(name).appFolder);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (action === 'show' || action === 'sync') {
|
|
709
|
+
const here = ws.readSnapshot(dirArg(undefined));
|
|
710
|
+
const name = args[0] ?? here?.name;
|
|
711
|
+
if (!name)
|
|
712
|
+
throw new BravosError('no workspace here: give its name, or create one with bravos workspace create');
|
|
713
|
+
const { members, appFolder } = known(name);
|
|
714
|
+
if (action === 'sync') {
|
|
715
|
+
for (const m of members)
|
|
716
|
+
lines.push(await install(m));
|
|
717
|
+
if (appFolder)
|
|
718
|
+
lines.push(await install(appFolder, true));
|
|
719
|
+
}
|
|
720
|
+
const { snap, exp, exportError } = ws.refresh(name, env, appFolder);
|
|
721
|
+
if (opts.json) {
|
|
722
|
+
lines.push(JSON.stringify({ workspace: snap, export: exp }, null, 2));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
lines.push(...ws.describe(snap, exp));
|
|
726
|
+
if (exportError)
|
|
727
|
+
lines.push(`(export: ${exportError})`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
throw new BravosError(`unknown workspace action '${action}'; use create, add, remove, target, show or sync`);
|
|
731
|
+
}));
|
|
732
|
+
program
|
|
733
|
+
.command('report <action> [id]')
|
|
734
|
+
.description("Evidence-bound security write-ups (the bravos-report skill writes the prose). 'data' gathers the facts into .bravos/reports/<id>/evidence.json; 'check' gates the report.md written from them; 'export' renders it to md, txt, html and pdf; 'list' shows the reports. Runs no model; nothing under .bravos/reports is committed.")
|
|
735
|
+
.option('-C, --dir <dir>', 'repository root (default: the current directory)')
|
|
736
|
+
.addOption(new Option('--kind <kind>', "data: assessment, finding, retest or brief").choices(['assessment', 'finding', 'retest', 'brief']).default('assessment'))
|
|
737
|
+
.option('--finding <ref>', 'data, kind finding: the claim key prefix, file:line or EXP id of the one finding')
|
|
738
|
+
.option('--no-graph', 'data: skip asking the code graph')
|
|
739
|
+
.option('--workspace <name>', 'data: one report across every service of a bravos workspace (the default at an app folder)')
|
|
740
|
+
.option('--format <list>', "export: comma-separated md, txt, html, pdf", 'md,html')
|
|
741
|
+
.option('--open', 'export: open the pdf (or html) when done')
|
|
742
|
+
.option('--force', 'export: export even when the check fails')
|
|
743
|
+
.option('--json', 'print JSON (data: the bundle; check: the verdict; list: the reports)')
|
|
744
|
+
.action((action, id, opts) => guardedAsync(async (lines) => {
|
|
745
|
+
const root = dirArg(opts.dir);
|
|
746
|
+
const data = await import('./report/data.js');
|
|
747
|
+
if (action === 'data') {
|
|
748
|
+
// A workspace report: named with --workspace, or implied at an app folder.
|
|
749
|
+
const wsLib = await import(pathToFileURL(pathJoin(hooksDir(), 'lib', 'workspace.mjs')).href);
|
|
750
|
+
const here = wsLib.readWorkspace(root);
|
|
751
|
+
const wsName = opts.workspace ?? (here?.isAppFolder && opts.kind !== 'finding' ? here.name : null);
|
|
752
|
+
if (opts.workspace && here && here.name !== opts.workspace)
|
|
753
|
+
throw new BravosError(`this folder belongs to workspace ${here.name}, not ${opts.workspace}`);
|
|
754
|
+
if (wsName && !here)
|
|
755
|
+
throw new BravosError(`no snapshot of workspace ${wsName} here: run bravos workspace show ${wsName} in this folder's app folder or a member`);
|
|
756
|
+
const bundle = wsName
|
|
757
|
+
? await data.gatherWorkspaceEvidence(here.appFolder ?? root, io.env, { name: here.name, members: here.members, edges: here.export?.edges ?? null, unresolved: here.export?.unresolved ?? [] }, { kind: opts.kind, graph: opts.graph })
|
|
758
|
+
: await data.gatherEvidence(root, io.env, { kind: opts.kind, finding: opts.finding ?? id ?? null, graph: opts.graph });
|
|
759
|
+
const { evidence } = data.writeEvidence(wsName && here?.appFolder ? here.appFolder : root, bundle);
|
|
760
|
+
if (opts.json) {
|
|
761
|
+
lines.push(JSON.stringify(bundle, null, 2));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const count = (l) => bundle.candidates.filter((c) => c.level === l).length;
|
|
765
|
+
const rel = (p) => p.replace(root + '/', '');
|
|
766
|
+
lines.push(`Report ${bundle.id} (${bundle.kind}) for ${bundle.repo} @ ${bundle.git.short ?? '?'}`, `Evidence: ${rel(evidence)}`, `${bundle.candidates.length} exposure${bundle.candidates.length === 1 ? '' : 's'}: ${count('reproduced')} reproduced at runtime, ${count('held')} control held, ${count('code-review')} code review, ${count('suspected')} suspected`, `${bundle.runs.length} cxg run${bundle.runs.length === 1 ? '' : 's'}, ${bundle.items.length} citable facts`, ...(bundle.gaps.length ? ['Not available:', ...bundle.gaps.map((g) => ` - ${g}`)] : []), `Next: write ${rel(join(data.reportDir(root, bundle.id), 'report.md'))} from the bravos-report template, then bravos report check ${bundle.id}`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (action === 'list') {
|
|
770
|
+
const ids = data.listReports(root);
|
|
771
|
+
const rows = ids.map((r) => ({ id: r, meta: data.readMeta(root, r) }));
|
|
772
|
+
if (opts.json) {
|
|
773
|
+
lines.push(JSON.stringify(rows.map((r) => ({ id: r.id, ...r.meta })), null, 2));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (!rows.length)
|
|
777
|
+
lines.push('No reports yet: bravos report data, then /bravos-report writes report.md');
|
|
778
|
+
for (const r of rows) {
|
|
779
|
+
const m = r.meta;
|
|
780
|
+
const check = m?.check ? (m.check.ok ? `check passes, ${m.check.findings} findings` : `check: ${m.check.errors} problems`) : 'not checked';
|
|
781
|
+
lines.push(`${r.id} ${m?.kind ?? '?'} ${m?.title ?? '(no report.md yet)'} · ${check}${m && Object.keys(m.exports).length ? ` · ${Object.keys(m.exports).join(', ')}` : ''}`);
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const ref = data.resolveReport(root, id);
|
|
786
|
+
const exp = await import('./report/export.js');
|
|
787
|
+
if (action === 'check') {
|
|
788
|
+
const { result } = exp.checkAndRecord(root, ref);
|
|
789
|
+
if (opts.json) {
|
|
790
|
+
lines.push(JSON.stringify(result, null, 2));
|
|
791
|
+
if (!result.ok)
|
|
792
|
+
throw new BravosError(`bravos report check ${ref}: ${result.errors.length} problems`);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
lines.push(`bravos report check ${ref}: ${result.ok ? 'passes' : `${result.errors.length} problem${result.errors.length === 1 ? '' : 's'}`}${result.warnings.length ? `, ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'}` : ''}; ${result.findings.length} finding${result.findings.length === 1 ? '' : 's'}`);
|
|
796
|
+
for (const e of result.errors)
|
|
797
|
+
lines.push(` error ${e.line ? `line ${e.line} ` : ''}[${e.rule}] ${e.message}`);
|
|
798
|
+
for (const w of result.warnings)
|
|
799
|
+
lines.push(` warn ${w.line ? `line ${w.line} ` : ''}[${w.rule}] ${w.message}`);
|
|
800
|
+
if (!result.ok)
|
|
801
|
+
throw new BravosError(`fix the problems above in .bravos/reports/${ref}/report.md and run bravos report check ${ref} again`);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (action === 'export') {
|
|
805
|
+
const formats = opts.format.split(',').map((f) => f.trim().toLowerCase()).filter(Boolean);
|
|
806
|
+
const bad = formats.filter((f) => !exp.FORMATS.includes(f));
|
|
807
|
+
if (bad.length || !formats.length)
|
|
808
|
+
throw new BravosError(`unknown format ${bad.join(', ') || '(none)'}; use md, txt, html, pdf`);
|
|
809
|
+
const r = await exp.exportReport(root, ref, formats, { env: io.env, force: opts.force });
|
|
810
|
+
if (!r.check.ok)
|
|
811
|
+
lines.push(`Exported with --force: bravos report check found ${r.check.errors.length} problems.`);
|
|
812
|
+
for (const w of r.written)
|
|
813
|
+
lines.push(`${w.format.padEnd(4)} ${w.path}`);
|
|
814
|
+
for (const s of r.skipped)
|
|
815
|
+
lines.push(`${s.format.padEnd(4)} not written: ${s.reason}`);
|
|
816
|
+
if (opts.open) {
|
|
817
|
+
const target = r.written.find((w) => w.format === 'pdf') ?? r.written.find((w) => w.format === 'html');
|
|
818
|
+
if (target)
|
|
819
|
+
openInBrowser(target.path);
|
|
820
|
+
}
|
|
821
|
+
lines.push('These files quote assessment evidence and are not committed; share them deliberately.');
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
throw new BravosError(`unknown report action '${action}'; use data, check, export or list`);
|
|
825
|
+
}));
|
|
826
|
+
program
|
|
827
|
+
.command('sast <action> [dir]')
|
|
828
|
+
.description("SAST (Team plan): 'run' analyzes the repository with the staged bravos-cli into .bravos/sast/<run>/ (not committed); 'status' prints one line about the newest run; 'candidates' lists its reachable findings joined to a claim, not yet in the threat model, dependencies and secrets. Runs no model.")
|
|
829
|
+
.option('--json', 'print candidates as JSON (bravos.sast-candidates/v1)')
|
|
830
|
+
.option('--all', 'include findings whose reach is unknown or false')
|
|
831
|
+
.option('--scope <paths>', 'run: comma-separated paths to analyze (default: the whole repository)')
|
|
832
|
+
.option('--exclude <paths>', 'run: comma-separated paths to skip')
|
|
833
|
+
.option('--min-severity <level>', 'run: lowest severity to report')
|
|
834
|
+
.option('--timeout <seconds>', 'run: ceiling for the analysis', (v) => Number(v))
|
|
835
|
+
.action((action, dir, opts) => guardedAsync(async (lines) => {
|
|
836
|
+
const root = dirArg(dir);
|
|
837
|
+
const lib = await import(pathToFileURL(pathJoin(hooksDir(), 'lib', 'sast.mjs')).href);
|
|
838
|
+
if (action === 'run') {
|
|
839
|
+
const r = await runSast(root, io.env, { scope: opts.scope?.split(',').filter(Boolean), exclude: opts.exclude?.split(',').filter(Boolean), minSeverity: opts.minSeverity, timeoutS: opts.timeout });
|
|
840
|
+
lines.push(...r.lines);
|
|
841
|
+
if (r.exit !== 0 && r.exit !== 1)
|
|
842
|
+
result.code = 1;
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (action === 'status') {
|
|
846
|
+
lines.push(lib.sastLine(lib.readSast(root, io.env), lib.sastAvailable(io.env)));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (action !== 'candidates')
|
|
850
|
+
throw new BravosError(`unknown sast action '${action}'; use run, status or candidates`);
|
|
851
|
+
const c = lib.sastCandidates(root, { all: !!opts.all });
|
|
852
|
+
if (!c) {
|
|
853
|
+
lines.push(`no SAST report in .bravos/sast/ — ${lib.sastAvailable(io.env).ok ? 'run bravos-sast first' : 'SAST unavailable here (bravos doctor)'}`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (opts.json) {
|
|
857
|
+
lines.push(JSON.stringify(c, null, 2));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const row = (f) => ` ${(f.severity ?? '').toLowerCase().padEnd(8)} ${f.rule ?? '?'} ${f.file ?? '?'}:${f.line ?? '?'}${f.route ? ` ${f.route}` : f.entryPoint ? ` via ${f.entryPoint}` : ''}`;
|
|
861
|
+
lines.push(`SAST run ${c.run}${c.sha ? ` at ${String(c.sha).slice(0, 9)}` : ''}${opts.all ? '' : ' (reachable only; --all for the rest)'}`);
|
|
862
|
+
lines.push('', `Joined to a claim (${c.joined.length}) — taint evidence for bravos-templatize and bravos-prove:`, ...c.joined.map((f) => `${row(f)} [${f.claimState}]`));
|
|
863
|
+
lines.push('', `Not in the threat model yet (${c.unjoined.length}) — leads for bravos-annotate:`, ...c.unjoined.map(row));
|
|
864
|
+
lines.push('', `Dependencies (${c.dependencies.length}):`, ...c.dependencies.map((f) => `${row(f)} vulnerable function reached: ${String(f.vulnerableFunctionReached ?? 'unknown')}`));
|
|
865
|
+
lines.push('', `Secrets (${c.secrets.length}) — rotate; never pentest candidates:`, ...c.secrets.map((f) => ` ${f.rule ?? '?'} ${f.file ?? '?'}:${f.line ?? '?'}${f.inHistory ? ' (in git history)' : ''}`));
|
|
866
|
+
}));
|
|
867
|
+
program
|
|
868
|
+
.command('guide [topic]')
|
|
869
|
+
.description(`How to use bravos by role and by task, every skill, the safety rules (topics: ${GUIDE_TOPIC_IDS.join(', ')}, all). Free: runs no model.`)
|
|
870
|
+
.addOption(new Option('--harness <name>', 'show skills in this harness syntax').choices(['claude', 'codex', 'omp']).default('claude'))
|
|
871
|
+
.option('--markdown', 'print the whole guide as Markdown (docs/GUIDE.md)')
|
|
872
|
+
.action((topic, opts) => {
|
|
873
|
+
if (opts.markdown)
|
|
874
|
+
return io.stdout(renderGuideMarkdown().replace(/\n$/, ''));
|
|
875
|
+
if (topic !== undefined && topic !== 'all' && !GUIDE_TOPIC_IDS.includes(topic)) {
|
|
876
|
+
result.code = 2;
|
|
877
|
+
io.stderr(`unknown topic '${topic}'; topics: ${GUIDE_TOPIC_IDS.join(', ')}, all`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
io.stdout(renderGuide(topic, { sigil: { claude: '/', codex: '$', omp: '/skill:' }[opts.harness] }));
|
|
881
|
+
});
|
|
882
|
+
return program;
|
|
883
|
+
}
|
|
884
|
+
/** A free loopback port: listen on 0, read it, close. */
|
|
885
|
+
function freePort() {
|
|
886
|
+
return new Promise((res, rej) => {
|
|
887
|
+
const s = netServer();
|
|
888
|
+
s.once('error', rej);
|
|
889
|
+
s.listen(0, '127.0.0.1', () => {
|
|
890
|
+
const a = s.address();
|
|
891
|
+
const p = typeof a === 'object' && a ? a.port : 0;
|
|
892
|
+
s.close(() => res(p));
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
/** Polls the review page until it answers (about 5 s at most). */
|
|
897
|
+
async function waitUp(url) {
|
|
898
|
+
for (let i = 0; i < 50; i++) {
|
|
899
|
+
try {
|
|
900
|
+
const r = await fetch(url);
|
|
901
|
+
if (r.ok)
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
/* not up yet */
|
|
906
|
+
}
|
|
907
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
908
|
+
}
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
// @comment -- "A commander parser for a whole number of seconds no smaller than min; anything else is a usage error."
|
|
912
|
+
function seconds(min) {
|
|
913
|
+
return (v) => {
|
|
914
|
+
const n = Number(v);
|
|
915
|
+
if (!Number.isFinite(n) || n < min)
|
|
916
|
+
throw new InvalidArgumentError(`give a number of seconds, ${min} or more`);
|
|
917
|
+
return n;
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
// @comment -- "Opens a URL or file in the person's browser with the OS opener (argv, no shell); failure is silent because the URL is printed anyway."
|
|
921
|
+
function openInBrowser(target) {
|
|
922
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [target]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', target]] : ['xdg-open', [target]];
|
|
923
|
+
try {
|
|
924
|
+
spawnChild(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => { }).unref();
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
/* printed URL is enough */
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// @comment -- "Runs the CLI in-process and returns the exit code: 0 ok, 1 BravosError, 2 commander usage error."
|
|
931
|
+
export async function run(argv, io = {}) {
|
|
932
|
+
const full = {
|
|
933
|
+
stdout: (s) => process.stdout.write(s + '\n'),
|
|
934
|
+
stderr: (s) => process.stderr.write(s + '\n'),
|
|
935
|
+
env: process.env,
|
|
936
|
+
...io,
|
|
937
|
+
};
|
|
938
|
+
const result = { code: 0 };
|
|
939
|
+
try {
|
|
940
|
+
await buildProgram(full, result).parseAsync(argv, { from: 'user' });
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
if (err instanceof CommanderError)
|
|
944
|
+
return err.exitCode === 0 ? 0 : 2;
|
|
945
|
+
full.stderr(err instanceof Error ? err.message : String(err));
|
|
946
|
+
return 1;
|
|
947
|
+
}
|
|
948
|
+
return result.code;
|
|
949
|
+
}
|
|
950
|
+
// @comment -- "Entry guard compares real paths so a bin symlink (npm i -g, npx, node_modules/.bin) still runs the CLI."
|
|
951
|
+
function isEntry() {
|
|
952
|
+
try {
|
|
953
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (isEntry()) {
|
|
960
|
+
run(process.argv.slice(2)).then((code) => {
|
|
961
|
+
process.exitCode = code;
|
|
962
|
+
});
|
|
963
|
+
}
|