@bugb-technologies/bravos 0.0.0-stage → 1.0.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.
Files changed (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +133 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
@@ -0,0 +1,268 @@
1
+ // @comment -- "The code graph, driven by this CLI: codegraph-build indexes a repository and codegraph-mcp answers questions about it over MCP on stdio. `bravos graph build` runs the builder; `bravos graph <question> [args] --json` starts the server for one call, speaks JSON-RPC to it (initialize, tools/list, tools/call) with no dependency, and prints the answer. The binaries come from the staged bundle under $BRAVOS_HOME/codegraph first, then PATH, then the Python CLI's wheel, so a machine that moved from the Python bravos keeps working. The graph store is BRAVOS_CODEGRAPH_DIR, else $BRAVOS_HOME/codegraph/store, else the Python launcher's own."
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { staged } from './artifacts.js';
6
+ import { graphBin as legacyGraphBin, graphStoreDir as legacyStoreDir } from './graphbin.js';
7
+ import { BravosError } from './types.js';
8
+ // @comment -- "Resolution order for a graph binary: the staged bundle this CLI installed, PATH, the Python CLI's wheel payload. Null when none has it."
9
+ export function graphBin(name, env = process.env) {
10
+ const dir = staged(env, 'codegraph');
11
+ if (dir)
12
+ return join(dir, name);
13
+ return legacyGraphBin(name, env);
14
+ }
15
+ /** Where graphs are kept: explicit, else this CLI's own store (created on demand), else the Python launcher's. */
16
+ export function graphStore(env = process.env) {
17
+ if (env.BRAVOS_CODEGRAPH_DIR)
18
+ return env.BRAVOS_CODEGRAPH_DIR;
19
+ if (staged(env, 'codegraph')) {
20
+ const home = env.BRAVOS_HOME || (env.HOME ? join(env.HOME, '.bravos') : null);
21
+ if (home) {
22
+ const store = join(home, 'codegraph', 'store');
23
+ try {
24
+ mkdirSync(store, { recursive: true, mode: 0o700 });
25
+ }
26
+ catch {
27
+ /* reported at use */
28
+ }
29
+ return store;
30
+ }
31
+ }
32
+ return legacyStoreDir(env);
33
+ }
34
+ export const graphEnv = (env) => {
35
+ const store = graphStore(env);
36
+ return store ? { ...env, BRAVOS_CODEGRAPH_DIR: store } : { ...env };
37
+ };
38
+ export const SCIP_MODES = ['auto', 'trusted', 'no-exec', 'isolate', 'off'];
39
+ // @comment -- "bravos graph build DIR [--scip MODE] [--mode auto|build|reuse] [--json]: runs codegraph-build --project-path DIR with the store in the environment. --scip auto/trusted runs the repository's own code (type checkers, build scripts), which is why the skills ask before using it; no-exec is the default here."
40
+ export function build(root, env, opts = {}) {
41
+ const bin = graphBin('codegraph-build', env);
42
+ if (!bin)
43
+ throw new BravosError('codegraph-build is not installed: bravos setup --codegraph fetches it (on the Team plan)');
44
+ const scip = opts.scip ?? 'no-exec';
45
+ if (!SCIP_MODES.includes(scip))
46
+ throw new BravosError(`--scip must be one of ${SCIP_MODES.join(', ')}, got ${JSON.stringify(scip)}`);
47
+ const args = ['--project-path', root, '--scip', scip];
48
+ if (opts.mode)
49
+ args.push('--mode', opts.mode);
50
+ if (opts.json)
51
+ args.push('--json');
52
+ const r = spawnSync(bin, args, { cwd: root, env: graphEnv(env), encoding: 'utf8', timeout: opts.timeoutMs ?? 3 * 600 * 1000, maxBuffer: 64 * 1024 * 1024 });
53
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
54
+ }
55
+ export class GraphUnavailable extends BravosError {
56
+ constructor(message) {
57
+ super(message);
58
+ }
59
+ }
60
+ // @comment -- "A minimal MCP stdio client: newline-delimited JSON-RPC 2.0 to a child codegraph-mcp --project-path ROOT. One session per question; the server is killed on close or timeout. stderr is kept for the error message; stdout is parsed line by line, with anything that is not a JSON-RPC message ignored."
61
+ export class GraphSession {
62
+ child;
63
+ buf = '';
64
+ nextId = 1;
65
+ pending = new Map();
66
+ stderr = '';
67
+ closed = null;
68
+ constructor(bin, root, env) {
69
+ this.child = spawn(bin, ['--project-path', root], { cwd: root, env: graphEnv(env), stdio: ['pipe', 'pipe', 'pipe'] });
70
+ this.child.stdout.setEncoding('utf8');
71
+ this.child.stderr.setEncoding('utf8');
72
+ this.child.stdout.on('data', (chunk) => this.feed(chunk));
73
+ this.child.stderr.on('data', (chunk) => {
74
+ this.stderr = (this.stderr + chunk).slice(-8192);
75
+ });
76
+ const fail = (e) => {
77
+ this.closed = e;
78
+ for (const p of this.pending.values())
79
+ p.reject(e);
80
+ this.pending.clear();
81
+ };
82
+ this.child.on('error', (e) => fail(new GraphUnavailable(`codegraph-mcp could not start (${bin}): ${e.message}`)));
83
+ this.child.on('exit', (code) => fail(new GraphUnavailable(`codegraph-mcp exited (${code ?? 'signal'})${this.stderr.trim() ? `: ${this.stderr.trim().split('\n').pop()}` : ''}`)));
84
+ }
85
+ feed(chunk) {
86
+ this.buf += chunk;
87
+ let nl;
88
+ while ((nl = this.buf.indexOf('\n')) >= 0) {
89
+ const line = this.buf.slice(0, nl).trim();
90
+ this.buf = this.buf.slice(nl + 1);
91
+ if (!line.startsWith('{'))
92
+ continue;
93
+ let msg;
94
+ try {
95
+ msg = JSON.parse(line);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (typeof msg.id !== 'number')
101
+ continue;
102
+ const p = this.pending.get(msg.id);
103
+ if (!p)
104
+ continue;
105
+ this.pending.delete(msg.id);
106
+ if (msg.error)
107
+ p.reject(new BravosError(msg.error.message ?? 'the graph answered an error'));
108
+ else
109
+ p.resolve(msg.result);
110
+ }
111
+ }
112
+ request(method, params, timeoutMs) {
113
+ if (this.closed)
114
+ return Promise.reject(this.closed);
115
+ const id = this.nextId++;
116
+ return new Promise((resolve, reject) => {
117
+ const t = setTimeout(() => {
118
+ this.pending.delete(id);
119
+ reject(new GraphUnavailable(`codegraph-mcp did not answer ${method} within ${Math.round(timeoutMs / 1000)}s`));
120
+ }, timeoutMs);
121
+ this.pending.set(id, {
122
+ resolve: (v) => {
123
+ clearTimeout(t);
124
+ resolve(v);
125
+ },
126
+ reject: (e) => {
127
+ clearTimeout(t);
128
+ reject(e);
129
+ },
130
+ });
131
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
132
+ });
133
+ }
134
+ notify(method, params) {
135
+ if (!this.closed)
136
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
137
+ }
138
+ async initialize(timeoutMs) {
139
+ await this.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'bravos', version: '1' } }, timeoutMs);
140
+ this.notify('notifications/initialized', {});
141
+ }
142
+ async tools(timeoutMs) {
143
+ const r = (await this.request('tools/list', {}, timeoutMs));
144
+ return Array.isArray(r?.tools) ? r.tools : [];
145
+ }
146
+ async call(name, args, timeoutMs) {
147
+ const r = (await this.request('tools/call', { name, arguments: args }, timeoutMs));
148
+ if (r?.structuredContent !== undefined)
149
+ return r.structuredContent;
150
+ const text = (r?.content ?? []).filter((c) => c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n');
151
+ if (r?.isError)
152
+ throw new BravosError(text || `the graph refused ${name}`);
153
+ try {
154
+ return JSON.parse(text);
155
+ }
156
+ catch {
157
+ return { text };
158
+ }
159
+ }
160
+ close() {
161
+ try {
162
+ this.child.stdin.end();
163
+ this.child.kill();
164
+ }
165
+ catch {
166
+ /* gone */
167
+ }
168
+ }
169
+ }
170
+ /** Opens a session for one repository; the caller closes it. */
171
+ export function open(root, env) {
172
+ const bin = graphBin('codegraph-mcp', env);
173
+ if (!bin)
174
+ throw new GraphUnavailable('codegraph-mcp is not installed: bravos setup --codegraph fetches it (on the Team plan)');
175
+ return new GraphSession(bin, root, env);
176
+ }
177
+ // @comment -- "One question, in and out: `bravos graph attack-surface --json` becomes tools/call codegraph_attack_surface. Positional words fill the tool's first required string argument (symbol, query); --name value pairs fill the rest, parsed as JSON when they look like it. The tool list comes from the binary on this machine, never from a stored copy."
178
+ export async function ask(root, env, question, argv, timeoutMs = 60000) {
179
+ const s = open(root, env);
180
+ try {
181
+ await s.initialize(Math.min(timeoutMs, 30000));
182
+ const tools = await s.tools(Math.min(timeoutMs, 30000));
183
+ const name = question.startsWith('codegraph_') ? question : `codegraph_${question.replace(/-/g, '_')}`;
184
+ const tool = tools.find((t) => t.name === name);
185
+ if (!tool)
186
+ throw new BravosError(`the graph has no question ${JSON.stringify(question)}; it answers: ${tools.map((t) => t.name.replace(/^codegraph_/, '').replace(/_/g, '-')).join(', ')}`);
187
+ return await s.call(name, bindArguments(tool, argv), timeoutMs);
188
+ }
189
+ finally {
190
+ s.close();
191
+ }
192
+ }
193
+ export async function listTools(root, env, timeoutMs = 30000) {
194
+ const s = open(root, env);
195
+ try {
196
+ await s.initialize(timeoutMs);
197
+ return await s.tools(timeoutMs);
198
+ }
199
+ finally {
200
+ s.close();
201
+ }
202
+ }
203
+ export function bindArguments(tool, argv) {
204
+ const props = tool.inputSchema?.properties ?? {};
205
+ const required = tool.inputSchema?.required ?? [];
206
+ const lead = required.find((n) => props[n]?.type === 'string') ?? '';
207
+ const out = {};
208
+ const positional = [];
209
+ for (let i = 0; i < argv.length; i++) {
210
+ const a = argv[i];
211
+ if (a.startsWith('--')) {
212
+ const eq = a.indexOf('=');
213
+ const key = (eq > 0 ? a.slice(2, eq) : a.slice(2)).replace(/-/g, '_');
214
+ const raw = eq > 0 ? a.slice(eq + 1) : i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
215
+ const type = props[key]?.type;
216
+ out[key] = type === 'string' ? raw : coerce(raw);
217
+ }
218
+ else
219
+ positional.push(a);
220
+ }
221
+ if (positional.length) {
222
+ if (!lead)
223
+ throw new BravosError(`${tool.name.replace(/^codegraph_/, '').replace(/_/g, '-')} takes no positional argument; its arguments are ${Object.keys(props).map((k) => `--${k.replace(/_/g, '-')}`).join(', ') || 'none'}`);
224
+ if (lead in out)
225
+ throw new BravosError(`${lead} was given twice — positionally and as --${lead.replace(/_/g, '-')}`);
226
+ out[lead] = positional.join(' ');
227
+ }
228
+ const missing = required.filter((n) => !(n in out));
229
+ if (missing.length)
230
+ throw new BravosError(`missing required argument${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
231
+ return out;
232
+ }
233
+ const coerce = (raw) => {
234
+ if (raw === 'true')
235
+ return true;
236
+ if (raw === 'false')
237
+ return false;
238
+ if (/^-?\d+(\.\d+)?$/.test(raw))
239
+ return Number(raw);
240
+ if (/^[[{]/.test(raw)) {
241
+ try {
242
+ return JSON.parse(raw);
243
+ }
244
+ catch {
245
+ return raw;
246
+ }
247
+ }
248
+ return raw;
249
+ };
250
+ /** Whether this repository has a graph the server can load: a cheap probe through one call. */
251
+ export async function probe(root, env, timeoutMs = 3000) {
252
+ if (!graphBin('codegraph-mcp', env))
253
+ return 'unavailable';
254
+ if (!existsSync(root))
255
+ return 'unavailable';
256
+ try {
257
+ await ask(root, env, 'acceptance-register', [], timeoutMs);
258
+ return 'current';
259
+ }
260
+ catch (e) {
261
+ const m = e.message ?? '';
262
+ if (/no graph|codegraph_unavailable|not built|no CodeGraph|nothing is there/i.test(m))
263
+ return 'not-built';
264
+ if (e instanceof GraphUnavailable && /within \d+s/.test(m))
265
+ return 'cli';
266
+ return 'cli';
267
+ }
268
+ }
package/dist/cursor.js ADDED
@@ -0,0 +1,70 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { readLock, sha256, writeLock } from './lock.js';
4
+ import { MARKER, skillsRoot } from './skills.js';
5
+ // @comment -- "Where bravos's Cursor rule lives; Cursor reads project rules from .cursor/rules/*.mdc."
6
+ export const CURSOR_RULE_PATH = '.cursor/rules/bravos.mdc';
7
+ // @comment -- "The rule body for one skills root: a pointer to the bravos skills there plus the two rules that hold everywhere; no method is copied here, so it cannot drift from the skills."
8
+ export const cursorRule = (root) => `---
9
+ description: bravos security workflow skills for this repo (guardlink, cxg, code graph)
10
+ globs:
11
+ alwaysApply: true
12
+ ---
13
+ ${MARKER}
14
+ # bravos
15
+
16
+ This repo uses bravos's skills for security work. They live in \`${root}/bravos*/SKILL.md\`.
17
+ Read \`${root}/bravos/SKILL.md\` first whenever a task touches security-relevant code
18
+ (endpoints, authentication and authorization, validation, queries, file I/O, external calls,
19
+ crypto, process spawning, configuration parsing) or before opening a pull request, and follow the
20
+ skill it routes you to.
21
+
22
+ Two rules hold everywhere:
23
+
24
+ - Never run \`guardlink annotate\` or \`guardlink translate\` with an agent flag. You are the agent:
25
+ follow the method in this session.
26
+ - Never write \`@accepts\`, \`@entitles\` or \`@confirmed\`. Acceptance and entitlement are a human's
27
+ decision, and only a reproduced run confirms an exposure.
28
+ `;
29
+ // @comment -- "The skills root the pointer names: .claude/skills when Claude Code's root was installed, else .agents/skills, else none."
30
+ export function cursorSkillsRoot(harnesses) {
31
+ if (harnesses.includes('claude'))
32
+ return skillsRoot('claude');
33
+ if (harnesses.includes('agents'))
34
+ return skillsRoot('agents');
35
+ return null;
36
+ }
37
+ // @comment -- "Writes the Cursor pointer only when the repo already has a .cursor/ folder and a skills root was installed, naming that root's path; an existing file is replaced only if bravos wrote it (lock hash or marker) unless --force, and the lock records what was written."
38
+ export function writeCursorPointer(root, harnesses, opts = {}) {
39
+ if (!existsSync(join(root, '.cursor')))
40
+ return { status: 'absent' };
41
+ const path = CURSOR_RULE_PATH;
42
+ const skills = cursorSkillsRoot(harnesses);
43
+ if (!skills)
44
+ return { status: 'no-skills', path };
45
+ const abs = join(root, path);
46
+ const content = Buffer.from(cursorRule(skills));
47
+ const hash = sha256(content);
48
+ const lock = readLock(root);
49
+ let status = 'written';
50
+ if (existsSync(abs)) {
51
+ const disk = readFileSync(abs);
52
+ const diskHash = sha256(disk);
53
+ if (diskHash === hash)
54
+ return { status: 'unchanged', path };
55
+ const locked = lock.files[path];
56
+ if (locked !== undefined && diskHash !== locked && !opts.force) {
57
+ return { status: 'skipped', path, reason: 'edited since last install (use --force to overwrite)' };
58
+ }
59
+ if (locked === undefined && !disk.toString('utf8').includes(MARKER) && !opts.force) {
60
+ return { status: 'skipped', path, reason: 'authored file, not managed by bravos (use --force to overwrite)' };
61
+ }
62
+ status = 'updated';
63
+ }
64
+ if (!opts.dryRun) {
65
+ mkdirSync(dirname(abs), { recursive: true });
66
+ writeFileSync(abs, content);
67
+ writeLock(root, { ...lock, files: { ...lock.files, [path]: hash } });
68
+ }
69
+ return { status, path };
70
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,26 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const CLAUDE_EVIDENCE = ['CLAUDE.md', '.claude/'];
4
+ // .cursor/ counts as evidence for the shared root, so a Cursor-only repo gets .agents/skills for the Cursor rule to point at (it names .claude/skills instead when that root is installed).
5
+ const AGENTS_EVIDENCE = ['AGENTS.md', 'GEMINI.md', '.agents/', '.codex/', '.cursor/'];
6
+ const present = (root, rel) => existsSync(join(root, rel));
7
+ /** Decides which skill locations to write for, from agent files already in the repo. */
8
+ // @comment -- "Picks the skill roots to write (claude, agents or both) from agent files already in the repo; with no evidence it defaults to both and says so."
9
+ export function detectHarnesses(root) {
10
+ const claude = CLAUDE_EVIDENCE.filter((p) => present(root, p));
11
+ const agents = AGENTS_EVIDENCE.filter((p) => present(root, p));
12
+ const evidence = [...claude, ...agents];
13
+ if (evidence.length === 0) {
14
+ return {
15
+ harnesses: ['claude', 'agents'],
16
+ evidence: ['(no agent files found — defaulting to both)'],
17
+ defaulted: true,
18
+ };
19
+ }
20
+ const harnesses = [];
21
+ if (claude.length)
22
+ harnesses.push('claude');
23
+ if (agents.length)
24
+ harnesses.push('agents');
25
+ return { harnesses, evidence, defaulted: false };
26
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,99 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { installedGuardlinkSkills, whichBin } from './guardlink.js';
5
+ import { graphBin } from './codegraph.js';
6
+ import { findAnalyzer, findLicence } from './graphbin.js';
7
+ import { AUTOPILOT_COMMAND, HOOK_COMMANDS, LEGACY_HOOK_COMMAND, PRE_PR_COMMAND } from './hooks.js';
8
+ import { skillsRoot } from './skills.js';
9
+ const TOOLS = [
10
+ { name: 'guardlink', hint: 'npm install -g guardlink' },
11
+ { name: 'cxg', hint: 'cargo install cert-x-gen (or: brew tap bugb-technologies/cxg && brew install cxg)' },
12
+ { name: 'codegraph-mcp', hint: 'bravos setup --codegraph fetches codegraph-build and codegraph-mcp (Team plan); then bravos graph build .' },
13
+ { name: 'bravos-cli', hint: 'the SAST analyzer: bravos setup --analyzer fetches it (Team plan; bravos account activate starts a trial)' },
14
+ ];
15
+ // @comment -- "Lists installed bravos* skill folders holding a SKILL.md; an unreadable skills root (absent, a file, no permission) yields [] so doctor never throws."
16
+ function bravosSkills(root, harness) {
17
+ const dir = join(root, skillsRoot(harness));
18
+ let names;
19
+ try {
20
+ names = readdirSync(dir);
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ return names
26
+ .filter((n) => n.startsWith('bravos') && existsSync(join(dir, n, 'SKILL.md')))
27
+ .sort();
28
+ }
29
+ // @comment -- "guardlink's playbook skills (guardlink-annotate-*, guardlink-report-*) installed under a harness root; an unreadable root yields [] so doctor never throws."
30
+ function guardlinkSkills(root, harness) {
31
+ try {
32
+ return installedGuardlinkSkills(root, skillsRoot(harness));
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ // @comment -- "True when a harness settings file registers one of bravos's commands under the given hook event (the per-harness SessionStart command or its pre-0.5.0 form); unreadable or malformed files read as not registered."
39
+ function hookRegistered(file, event, commands) {
40
+ try {
41
+ const json = JSON.parse(readFileSync(file, 'utf8'));
42
+ const groups = json?.hooks?.[event];
43
+ if (!Array.isArray(groups))
44
+ return false;
45
+ return groups.some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === 'string' && commands.includes(h.command)));
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /** Read-only environment check: PATH lookups and file reads only. */
52
+ // @comment -- "Read-only environment check: looks up guardlink, cxg, codegraph-mcp and bravos-cli on PATH, the optional licence, guardlink initialisation, installed bravos and guardlink playbook skills per root, and hook registration; runs nothing."
53
+ export function runDoctor(root, env = process.env) {
54
+ const tools = TOOLS.map(({ name, hint }) => {
55
+ // codegraph-mcp and bravos-cli also count when they sit inside the bravos install (bravos uses them from there).
56
+ const path = name === 'codegraph-mcp' ? graphBin('codegraph-mcp', env) : name === 'bravos-cli' ? findAnalyzer(env) : whichBin(name, env);
57
+ return path ? { name, found: true, path, hint } : { name, found: false, hint };
58
+ });
59
+ const found = findLicence({ ...env, HOME: env.HOME ?? homedir() });
60
+ const licence = found ? { present: true, source: found } : { present: false };
61
+ return {
62
+ tools,
63
+ licence,
64
+ guardlink_initialised: existsSync(join(root, '.guardlink')),
65
+ skills: { claude: bravosSkills(root, 'claude'), agents: bravosSkills(root, 'agents') },
66
+ guardlink_skills: { claude: guardlinkSkills(root, 'claude'), agents: guardlinkSkills(root, 'agents') },
67
+ hooks: {
68
+ claude: hookRegistered(join(root, '.claude', 'settings.json'), 'SessionStart', [HOOK_COMMANDS.claude, LEGACY_HOOK_COMMAND]),
69
+ codex: hookRegistered(join(root, '.codex', 'hooks.json'), 'SessionStart', [HOOK_COMMANDS.agents, LEGACY_HOOK_COMMAND]),
70
+ },
71
+ autopilot: {
72
+ claude: hookRegistered(join(root, '.claude', 'settings.json'), 'Stop', [AUTOPILOT_COMMAND]),
73
+ codex: hookRegistered(join(root, '.codex', 'hooks.json'), 'Stop', [AUTOPILOT_COMMAND]),
74
+ },
75
+ pre_pr: {
76
+ claude: hookRegistered(join(root, '.claude', 'settings.json'), 'PreToolUse', [PRE_PR_COMMAND]),
77
+ codex: hookRegistered(join(root, '.codex', 'hooks.json'), 'PreToolUse', [PRE_PR_COMMAND]),
78
+ },
79
+ };
80
+ }
81
+ // @comment -- "Renders a DoctorReport as one line per check, with an install hint for each missing tool and guardlink's playbook skills named per root."
82
+ // @comment -- "Adds the verified licence status (plan line, licensed) to a report built synchronously; the verifier lives in the hook library, which the CLI loads asynchronously."
83
+ export function withLicence(r, plan, licensed) {
84
+ return { ...r, licence: { ...r.licence, plan, licensed } };
85
+ }
86
+ export function formatDoctor(r) {
87
+ const yn = (b) => (b ? 'yes' : 'no');
88
+ const names = (l) => (l.length ? l.join(', ') : 'none');
89
+ return [
90
+ ...r.tools.map((t) => (t.found ? `✔ ${t.name} ${t.path}` : `✘ ${t.name} ${t.hint}`)),
91
+ r.licence.plan ? `${r.licence.licensed ? '✔' : '✘'} licence ${r.licence.plan}` : r.licence.present ? `✔ licence ${r.licence.source}` : '✘ licence not found — Free (researcher) plan; bravos account activate starts a 30-day Team trial',
92
+ r.guardlink_initialised ? '✔ guardlink initialised (.guardlink/)' : '✘ guardlink not initialised — run bravos init',
93
+ `skills claude: ${r.skills.claude.length} agents: ${r.skills.agents.length}`,
94
+ `guardlink skills claude: ${names(r.guardlink_skills.claude)} agents: ${names(r.guardlink_skills.agents)}`,
95
+ `hooks claude: ${yn(r.hooks.claude)} codex: ${yn(r.hooks.codex)}`,
96
+ `pre-pr claude: ${yn(r.pre_pr.claude)} codex: ${yn(r.pre_pr.codex)}${r.pre_pr.claude || r.pre_pr.codex ? '' : ' (opt-in: bravos init --pre-pr-hook)'}`,
97
+ `autopilot claude: ${yn(r.autopilot.claude)} codex: ${yn(r.autopilot.codex)}${r.autopilot.claude || r.autopilot.codex ? '' : ' (opt-in: bravos init --autopilot)'}`,
98
+ ].join('\n');
99
+ }
package/dist/global.js ADDED
@@ -0,0 +1,95 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { migrateFromOldName, reportMigration } from './migrate.js';
4
+ import { dirname, join } from 'node:path';
5
+ import { installHook } from './hooks.js';
6
+ import { installSkills } from './install.js';
7
+ import { readLock, sha256, writeLock } from './lock.js';
8
+ import { mergeCodexConfig, selectServers } from './mcp.js';
9
+ import { listBundledSkills, MARKER, renderGlobalSkillFile } from './skills.js';
10
+ import { BravosError } from './types.js';
11
+ // @comment -- "The home folder a user-level install writes into: HOME from the environment, else the OS home."
12
+ export const homeOf = (env) => env.HOME || homedir();
13
+ // @comment -- "Points ~/.agents/skills/<name> at ~/.claude/skills/<name> for one bravos skill, so Codex and other shared-root agents see it without a second copy; a real folder is replaced only when bravos wrote it (its SKILL.md carries the marker), anything else is left alone."
14
+ function linkUserSkill(home, name, dryRun) {
15
+ const link = join(home, '.agents', 'skills', name);
16
+ const target = `../../.claude/skills/${name}`;
17
+ let st = null;
18
+ try {
19
+ st = lstatSync(link);
20
+ }
21
+ catch {
22
+ st = null;
23
+ }
24
+ if (st?.isSymbolicLink()) {
25
+ if (readlinkSync(link) === target)
26
+ return 'unchanged';
27
+ return 'skipped';
28
+ }
29
+ if (st?.isDirectory()) {
30
+ const md = join(link, 'SKILL.md');
31
+ if (!existsSync(md) || !readFileSync(md, 'utf8').includes(MARKER))
32
+ return 'skipped';
33
+ if (!dryRun)
34
+ rmSync(link, { recursive: true, force: true });
35
+ }
36
+ else if (st)
37
+ return 'skipped';
38
+ if (!dryRun) {
39
+ mkdirSync(dirname(link), { recursive: true });
40
+ symlinkSync(target, link, 'dir');
41
+ }
42
+ return 'linked';
43
+ }
44
+ // @comment -- "User-level install (bravos init --global / sync --global): skills once in ~/.claude/skills with absolute paths, a link per skill from ~/.agents/skills, the hook scripts in ~/.bravos/hooks registered as user SessionStart hooks for Claude Code (~/.claude/settings.json) and Codex (~/.codex/hooks.json) in --global mode, and guardlink in ~/.codex/config.toml. Touches nothing in any repo; the lock is ~/.bravos/skills.lock."
45
+ export function installGlobal(env, lines, opts = {}) {
46
+ const home = homeOf(env);
47
+ const dryRun = !!opts.dryRun;
48
+ // Claude Code's sandbox can hold back writes under ~/.claude while ~/.bravos/skills.lock still lands,
49
+ // leaving a stale copy marked current; the user-level install runs only outside a Claude Code session.
50
+ if (env.CLAUDECODE && !dryRun && env.BRAVOS_ALLOW_SESSION_GLOBAL !== '1') {
51
+ throw new BravosError('the user-level install writes ~/.claude, which a Claude Code session may not reach; run bravos init --global / bravos sync --global in your own terminal (BRAVOS_ALLOW_SESSION_GLOBAL=1 overrides)');
52
+ }
53
+ if (dryRun)
54
+ lines.push('dry-run: nothing written');
55
+ lines.push(`User-level install into ${home}`);
56
+ reportMigration(migrateFromOldName(home, { dryRun, ownSkills: listBundledSkills(opts.skillsDir).map((s) => s.name) }), lines);
57
+ const skills = installSkills(home, ['claude'], { skillsDir: opts.skillsDir, force: opts.force, dryRun, render: renderGlobalSkillFile(home) });
58
+ lines.push(`Skills (~/.claude/skills): ${skills.written.length} written, ${skills.updated.length} updated, ${skills.unchanged.length} unchanged, ${skills.skipped.length} skipped`);
59
+ for (const s of skills.skipped)
60
+ lines.push(` skipped ${s.path} — ${s.reason}`);
61
+ // ~/.agents/skills as a whole may hold other skills, so only bravos's entries become links.
62
+ const agentsRootLinked = (() => {
63
+ try {
64
+ return lstatSync(join(home, '.agents', 'skills')).isSymbolicLink();
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ })();
70
+ if (agentsRootLinked)
71
+ lines.push('Skills (~/.agents/skills): a link already, nothing to do');
72
+ else {
73
+ const counts = { linked: [], unchanged: [], skipped: [] };
74
+ for (const s of listBundledSkills(opts.skillsDir))
75
+ counts[linkUserSkill(home, s.name, dryRun)].push(s.name);
76
+ lines.push(`Skills (~/.agents/skills): ${counts.linked.length} linked, ${counts.unchanged.length} unchanged, ${counts.skipped.length} skipped (links to ~/.claude/skills, one copy each)`);
77
+ for (const n of counts.skipped)
78
+ lines.push(` skipped ~/.agents/skills/${n} — not a bravos copy; left as it is`);
79
+ }
80
+ const hook = installHook(home, ['claude', 'agents'], { hooksDir: opts.hooksDir, dryRun, globalHome: home });
81
+ if (!dryRun) {
82
+ const lock = readLock(home);
83
+ for (const rel of hook.copied)
84
+ lock.files[rel] = sha256(readFileSync(join(home, rel)));
85
+ writeLock(home, lock);
86
+ }
87
+ for (const r of hook.registered)
88
+ lines.push(`Hooks: ~/${r.file} ${r.changed ? 'registered' : 'already registered'} (user level, quiet where a repo has its own bravos hook or no threat model)`);
89
+ const { selected } = selectServers(env);
90
+ const m = mergeCodexConfig(home, selected, { dryRun });
91
+ lines.push(`MCP: ~/${m.path} added ${m.added.length ? m.added.join(', ') : 'none'}`);
92
+ lines.push(`MCP for Claude Code at user level: claude mcp add --scope user guardlink -- guardlink mcp`);
93
+ lines.push('In Codex run /hooks once to trust ~/.codex/hooks.json');
94
+ lines.push('Repos with bravos init keep their own skills and hooks; bravos init there is still how a team shares bravos through git.');
95
+ }