@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,209 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { installedGuardlinkSkills } from './guardlink.js';
4
+ import { readLock, sha256, writeLock } from './lock.js';
5
+ import { skillsDir as bundledSkillsDir, version } from './paths.js';
6
+ import { listBundledSkills, MARKER, renderSkillFile, skillsRoot } from './skills.js';
7
+ // @comment -- "Writes the bundled skills into each harness skills root, rendering placeholders and marking SKILL.md only; overwrites only files bravos wrote and nobody edited (lock hash, or the marker for an unlocked SKILL.md) unless forced, and records hashes in the skills lock."
8
+ export function installSkills(root, harnesses, opts = {}) {
9
+ const report = { written: [], updated: [], unchanged: [], skipped: [] };
10
+ const lock = readLock(root);
11
+ const record = {};
12
+ for (const skill of listBundledSkills(opts.skillsDir)) {
13
+ for (const harness of harnesses) {
14
+ for (const file of skill.files) {
15
+ const path = `${skillsRoot(harness)}/${skill.name}/${file.rel}`;
16
+ const isMd = file.rel.endsWith('.md');
17
+ // Only SKILL.md carries the marker, so only SKILL.md can be recognised by it when unlocked.
18
+ const isSkillMd = file.rel === 'SKILL.md';
19
+ const content = isMd
20
+ ? Buffer.from((opts.render ?? renderSkillFile)(file.content.toString('utf8'), harness, skill.name, file.rel))
21
+ : file.content;
22
+ const newHash = sha256(content);
23
+ const abs = join(root, path);
24
+ try {
25
+ let status = 'written';
26
+ if (existsSync(abs)) {
27
+ const disk = readFileSync(abs);
28
+ const diskHash = sha256(disk);
29
+ if (diskHash === newHash) {
30
+ report.unchanged.push(path);
31
+ record[path] = newHash;
32
+ continue;
33
+ }
34
+ const locked = lock.files[path];
35
+ if (locked !== undefined) {
36
+ if (diskHash !== locked && !opts.force) {
37
+ report.skipped.push({ path, reason: 'edited since last install (use --force to overwrite)', source: join(opts.skillsDir ?? bundledSkillsDir(), skill.name, file.rel) });
38
+ continue;
39
+ }
40
+ }
41
+ else if ((!isSkillMd || !disk.toString('utf8').includes(MARKER)) && !opts.force) {
42
+ report.skipped.push({ path, reason: 'authored file, not managed by bravos (use --force to overwrite)' });
43
+ continue;
44
+ }
45
+ status = 'updated';
46
+ }
47
+ if (opts.dryRun) {
48
+ report[status].push(path);
49
+ continue;
50
+ }
51
+ mkdirSync(dirname(abs), { recursive: true });
52
+ writeFileSync(abs, content);
53
+ // A sandbox can accept a write and keep the old file; count the file only when it really changed,
54
+ // or the lock would claim content that is not on disk and the next sync would call it edited.
55
+ if (sha256(readFileSync(abs)) !== newHash)
56
+ throw new Error('the write did not reach the disk (a sandbox or read-only folder?)');
57
+ report[status].push(path);
58
+ record[path] = newHash;
59
+ }
60
+ catch (err) {
61
+ // One failing file must not lose the lock entries for files already written this run.
62
+ report.skipped.push({ path, reason: `error: ${err.message}` });
63
+ }
64
+ }
65
+ }
66
+ }
67
+ if (!opts.dryRun && Object.keys(record).length > 0) {
68
+ // The lock claims this version only when every file was written; a skipped or failed file keeps
69
+ // the old version, so a stale copy stays visible to the shadow check and the next sync retries it.
70
+ const complete = report.skipped.length === 0;
71
+ writeLock(root, { ...lock, bravos_version: complete ? version() : lock.bravos_version, files: { ...lock.files, ...record } });
72
+ }
73
+ return report;
74
+ }
75
+ // @comment -- "True when .agents/skills and .claude/skills resolve to the same directory (one is a symlink to the other), so the skills must be written once, not rendered twice into one folder."
76
+ export function skillRootsLinked(root) {
77
+ try {
78
+ return realpathSync(join(root, skillsRoot('agents'))) === realpathSync(join(root, skillsRoot('claude')));
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ // @comment -- "Marker guardlink writes into each playbook skill it generates (src/playbooks/skills.ts); only files carrying it are mirrored or replaced."
85
+ export const GUARDLINK_MARKER = '<!-- guardlink:generated -->';
86
+ function filesUnder(dir, prefix = '') {
87
+ const out = [];
88
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
89
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
90
+ if (e.isDirectory())
91
+ out.push(...filesUnder(join(dir, e.name), rel));
92
+ else if (e.isFile())
93
+ out.push(rel);
94
+ }
95
+ return out.sort();
96
+ }
97
+ /** Copies guardlink's generated playbook skills from .claude/skills into .agents/skills, verbatim, so Codex can open them too. */
98
+ // @comment -- "guardlink init writes its guardlink-annotate-* / guardlink-report-* skills under .claude/skills only; this mirrors each one whose SKILL.md carries guardlink's marker into .agents/skills byte for byte. A destination file is replaced only when it is unchanged since bravos last wrote it (lock hash) or, unlocked, carries guardlink's marker, unless forced; every write is recorded in the lock."
99
+ export function mirrorGuardlinkSkills(root, opts = {}) {
100
+ const report = { written: [], updated: [], unchanged: [], skipped: [] };
101
+ const lock = readLock(root);
102
+ const record = {};
103
+ const from = skillsRoot('claude');
104
+ const to = skillsRoot('agents');
105
+ let names;
106
+ try {
107
+ names = installedGuardlinkSkills(root, from);
108
+ }
109
+ catch {
110
+ return report;
111
+ }
112
+ for (const name of names) {
113
+ const srcDir = join(root, from, name);
114
+ if (!readFileSync(join(srcDir, 'SKILL.md'), 'utf8').includes(GUARDLINK_MARKER))
115
+ continue;
116
+ for (const rel of filesUnder(srcDir)) {
117
+ const path = `${to}/${name}/${rel}`;
118
+ const abs = join(root, path);
119
+ const content = readFileSync(join(srcDir, rel));
120
+ const hash = sha256(content);
121
+ let status = 'written';
122
+ if (existsSync(abs)) {
123
+ const disk = readFileSync(abs);
124
+ const diskHash = sha256(disk);
125
+ if (diskHash === hash) {
126
+ report.unchanged.push(path);
127
+ record[path] = hash;
128
+ continue;
129
+ }
130
+ const locked = lock.files[path];
131
+ const managed = locked !== undefined ? diskHash === locked : disk.toString('utf8').includes(GUARDLINK_MARKER);
132
+ if (!managed && !opts.force) {
133
+ report.skipped.push({ path, reason: locked !== undefined ? 'edited since last install (use --force to overwrite)' : 'authored file, not generated by guardlink (use --force to overwrite)' });
134
+ continue;
135
+ }
136
+ status = 'updated';
137
+ }
138
+ report[status].push(path);
139
+ if (opts.dryRun)
140
+ continue;
141
+ mkdirSync(dirname(abs), { recursive: true });
142
+ writeFileSync(abs, content);
143
+ record[path] = hash;
144
+ }
145
+ }
146
+ if (!opts.dryRun && Object.keys(record).length > 0) {
147
+ const fresh = readLock(root);
148
+ writeLock(root, { ...fresh, files: { ...fresh.files, ...record } });
149
+ }
150
+ return report;
151
+ }
152
+ // @comment -- "Replaces .agents/skills with a relative link to .claude/skills so every skill exists once (tools that read both roots, such as Warp, stop listing each twice). Refuses, removing nothing, when .agents/skills holds a file bravos did not write unchanged, or a guardlink skill with no copy under .claude/skills; bravos's own copies are removed and their lock entries dropped."
153
+ export function linkSkillRoots(root, opts = {}) {
154
+ if (skillRootsLinked(root))
155
+ return { status: 'already-linked' };
156
+ const agents = skillsRoot('agents');
157
+ const claude = skillsRoot('claude');
158
+ if (!existsSync(join(root, claude)))
159
+ return { status: 'refused', reason: `${claude} does not exist yet; install for claude first`, unknown: [] };
160
+ const lock = readLock(root);
161
+ let stat = null;
162
+ try {
163
+ stat = lstatSync(join(root, agents));
164
+ }
165
+ catch {
166
+ stat = null;
167
+ }
168
+ const present = stat !== null;
169
+ // Every entry, not only regular files, so a link or socket inside counts as unknown.
170
+ const files = stat?.isDirectory() ? allUnder(join(root, agents)).map((r) => `${agents}/${r}`) : [];
171
+ if (stat && !stat.isDirectory()) {
172
+ return { status: 'refused', reason: `${agents} exists and is not a folder or a link to ${claude}`, unknown: [agents] };
173
+ }
174
+ const unknown = files.filter((f) => {
175
+ if (!lstatSync(join(root, f)).isFile())
176
+ return true;
177
+ const disk = sha256(readFileSync(join(root, f)));
178
+ if (lock.files[f] === disk) {
179
+ // A mirrored guardlink skill is safe to drop only when its original is under .claude/skills.
180
+ const sub = f.slice(agents.length + 1);
181
+ return sub.startsWith('guardlink-') && !existsSync(join(root, claude, sub));
182
+ }
183
+ return true;
184
+ });
185
+ if (unknown.length > 0) {
186
+ return { status: 'refused', reason: `${agents} holds files bravos did not write (or that were edited); move them into ${claude} first, then rerun`, unknown };
187
+ }
188
+ if (!opts.dryRun) {
189
+ if (present)
190
+ rmSync(join(root, agents), { recursive: true, force: true });
191
+ mkdirSync(dirname(join(root, agents)), { recursive: true });
192
+ symlinkSync('../.claude/skills', join(root, agents), 'dir');
193
+ for (const f of files)
194
+ delete lock.files[f];
195
+ writeLock(root, lock);
196
+ }
197
+ return { status: 'linked', removed: files };
198
+ }
199
+ function allUnder(dir, prefix = '') {
200
+ const out = [];
201
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
202
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
203
+ if (e.isDirectory())
204
+ out.push(...allUnder(join(dir, e.name), rel));
205
+ else
206
+ out.push(rel);
207
+ }
208
+ return out;
209
+ }
@@ -0,0 +1,41 @@
1
+ // @comment -- "The CLI's view of the licence: a typed wrapper over hooks/lib/licence.mjs (the one implementation, shared with the session hooks), plus the gate every paid command calls. Nothing here verifies on its own; it asks the library and turns a refusal into a BravosError the person can act on."
2
+ import { pathToFileURL } from 'node:url';
3
+ import { join } from 'node:path';
4
+ import { hooksDir } from './paths.js';
5
+ import { BravosError } from './types.js';
6
+ let cached = null;
7
+ export const licenceLib = () => (cached ??= import(pathToFileURL(join(hooksDir(), 'lib', 'licence.mjs')).href));
8
+ export async function licenceStatus(env) {
9
+ return (await licenceLib()).status(env);
10
+ }
11
+ // @comment -- "The gate: returns the status when `feature` is unlocked, else throws a BravosError whose message says what `what` does, that it is on the Team plan, and how to start the trial or renew. Every paid command and flag goes through here, so a refusal always reads the same."
12
+ export async function requireFeature(env, feature, what) {
13
+ const L = await licenceLib();
14
+ const v = L.status(env);
15
+ if (L.entitled(v, feature))
16
+ return v;
17
+ throw new BravosError(L.refusal(v, feature, what));
18
+ }
19
+ /** The JSON document `bravos licence --json` prints: the status without the tenant id (an account id is not for logs). */
20
+ export function licenceDocument(v) {
21
+ const { tenant: _tenant, ...rest } = v;
22
+ return { schema: 'bravos.licence/v1', ...rest, expiresAt: v.expiresAt === null ? null : new Date(v.expiresAt * 1000).toISOString(), graceUntil: v.graceUntil === null ? null : new Date(v.graceUntil * 1000).toISOString(), trial: v.trial ? { startedAt: new Date(v.trial.startedAt * 1000).toISOString(), endsAt: new Date(v.trial.endsAt * 1000).toISOString() } : null };
23
+ }
24
+ // @comment -- "The human form of bravos licence: the plan line, then where the licence is, what it unlocks and what it does not, and the one next step."
25
+ export function licenceLines(L, v) {
26
+ const lines = [L.planLine(v)];
27
+ lines.push(`Licence: ${v.state === 'absent' ? 'none' : v.source}${v.kid ? ` (key ${v.kid})` : ''}`);
28
+ if (v.state === 'valid' || v.state === 'grace' || v.state === 'expired') {
29
+ lines.push(` tier ${v.tier} · ${v.seats} seat${v.seats === 1 ? '' : 's'} · expires ${new Date((v.expiresAt ?? 0) * 1000).toISOString().slice(0, 10)}${v.trial ? ' (trial)' : ''}${v.features.length ? ` · features ${v.features.join(', ')}` : ''}`);
30
+ }
31
+ else if (v.state === 'invalid' || v.state === 'unreadable')
32
+ lines.push(` ${v.message}`);
33
+ lines.push(`Unlocked: ${v.unlocked.length ? v.unlocked.map((f) => L.PAID_FEATURE_LABELS[f]).join(', ') : 'the research loop (context, annotate, templatize, env, prove, recheck, report, watch), in one repository'}`);
34
+ if (v.locked.length)
35
+ lines.push(`On Team: ${v.locked.map((f) => L.PAID_FEATURE_LABELS[f]).join(', ')}`);
36
+ if (!v.licensed)
37
+ lines.push(v.state === 'expired' || v.state === 'grace' ? `Renew: ${L.ACCOUNT_LICENCES}` : `Next: bravos account activate (30-day Team trial, no card) · ${L.ACCOUNT_UPGRADE}`);
38
+ else if (v.state === 'grace')
39
+ lines.push(`Renew: ${L.ACCOUNT_LICENCES} — the grace window ends ${new Date((v.graceUntil ?? 0) * 1000).toISOString().slice(0, 10)}`);
40
+ return lines;
41
+ }
package/dist/lock.js ADDED
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { version } from './paths.js';
5
+ import { BravosError } from './types.js';
6
+ // @comment -- "Schema id of the skills lock file."
7
+ export const LOCK_SCHEMA = 'bravos.skills-lock/v1';
8
+ // @comment -- "Repo-relative path of the skills lock that records the hash of every file bravos wrote."
9
+ export const LOCK_PATH = '.bravos/skills.lock';
10
+ // @comment -- "Content hash used in the skills lock, prefixed sha256:."
11
+ export function sha256(buf) {
12
+ return 'sha256:' + createHash('sha256').update(buf).digest('hex');
13
+ }
14
+ // @comment -- "Reads and validates <root>/.bravos/skills.lock, returning an empty lock when absent and refusing a malformed one with a BravosError; non-string hashes are dropped."
15
+ export function readLock(root) {
16
+ const abs = join(root, LOCK_PATH);
17
+ if (!existsSync(abs))
18
+ return { schema: LOCK_SCHEMA, bravos_version: version(), files: {} };
19
+ const bad = (why) => new BravosError(`${LOCK_PATH} ${why}; fix it or delete it, nothing was written`);
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(readFileSync(abs, 'utf8'));
23
+ }
24
+ catch {
25
+ throw bad('is not valid JSON');
26
+ }
27
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
28
+ throw bad('is not a JSON object');
29
+ const p = parsed;
30
+ if (p.schema !== LOCK_SCHEMA)
31
+ throw bad(`has schema ${JSON.stringify(p.schema)}, expected ${LOCK_SCHEMA}`);
32
+ if (typeof p.files !== 'object' || p.files === null || Array.isArray(p.files))
33
+ throw bad('has a "files" field that is not an object');
34
+ // Entries whose hash is not a string are dropped (treated as unmanaged), not refused.
35
+ const files = {};
36
+ for (const [k, v] of Object.entries(p.files))
37
+ if (typeof v === 'string')
38
+ files[k] = v;
39
+ return {
40
+ schema: LOCK_SCHEMA,
41
+ bravos_version: typeof p.bravos_version === 'string' ? p.bravos_version : version(),
42
+ files,
43
+ };
44
+ }
45
+ // @comment -- "Writes the skills lock as 2-space JSON, creating .bravos/ if needed."
46
+ export function writeLock(root, lock) {
47
+ const abs = join(root, LOCK_PATH);
48
+ mkdirSync(dirname(abs), { recursive: true });
49
+ writeFileSync(abs, JSON.stringify(lock, null, 2) + '\n');
50
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,139 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { parse as parseToml } from 'smol-toml';
4
+ import { whichBin } from './guardlink.js';
5
+ import { graphBin, graphStore } from './codegraph.js';
6
+ import { isInstallGraphCommand } from './graphbin.js';
7
+ import { BravosError } from './types.js';
8
+ // @comment -- "The launch argv (command and args) for each of the three MCP servers bravos knows: guardlink, cxg and codegraph; frozen so no caller can mutate a shared entry."
9
+ export const SERVERS = Object.freeze({
10
+ guardlink: Object.freeze({ command: 'guardlink', args: ['mcp'] }),
11
+ cxg: Object.freeze({ command: 'cxg', args: ['mcp'] }),
12
+ codegraph: Object.freeze({ command: 'codegraph-mcp', args: ['--project-path', '.'] }),
13
+ });
14
+ const BIN = { cxg: 'cxg', codegraph: 'codegraph-mcp' };
15
+ // @comment -- "Picks the servers to register: guardlink always, cxg when on PATH, codegraph when codegraph-mcp is on PATH or inside the bravos install (registered by full path, with the graph store the bravos launcher uses as BRAVOS_CODEGRAPH_DIR so the MCP route reads the graphs bravos graph build writes); the rest are reported as deferred."
16
+ export function selectServers(env = process.env) {
17
+ const selected = { guardlink: SERVERS.guardlink };
18
+ const deferred = [];
19
+ if (whichBin(BIN.cxg, env))
20
+ selected.cxg = SERVERS.cxg;
21
+ else
22
+ deferred.push('cxg');
23
+ // The graph server may live inside the bravos install rather than on PATH: register it by its full path then.
24
+ const mcpBin = graphBin('codegraph-mcp', env);
25
+ if (mcpBin && mcpBin === whichBin('codegraph-mcp', env))
26
+ selected.codegraph = SERVERS.codegraph;
27
+ else if (mcpBin) {
28
+ const store = graphStore(env);
29
+ selected.codegraph = { command: mcpBin, args: [...SERVERS.codegraph.args], ...(store ? { env: { BRAVOS_CODEGRAPH_DIR: store } } : {}) };
30
+ }
31
+ else
32
+ deferred.push('codegraph');
33
+ return { selected, deferred };
34
+ }
35
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
36
+ // @comment -- "Whether an existing entry is bravos's own codegraph registration into a bravos install that no longer matches what bravos would write now (a replaced install, or a graph store it lacks); such an entry is refreshed, every other existing entry is kept as it is."
37
+ export function staleGraphEntry(name, existing, next) {
38
+ if (name !== 'codegraph' || !next || !isObject(existing) || !isInstallGraphCommand(existing.command))
39
+ return false;
40
+ return JSON.stringify({ c: existing.command, a: existing.args, e: existing.env ?? null }) !== JSON.stringify({ c: next.command, a: next.args, e: next.env ?? null });
41
+ }
42
+ // @comment -- "Adds missing servers to <root>/.mcp.json for Claude Code and refreshes bravos's own stale codegraph entry (staleGraphEntry); never overwrites any other existing entry and refuses malformed files without writing. A write rewrites the whole file as 2-space JSON: existing values are preserved, their formatting is not."
43
+ export function mergeMcpJson(root, servers, opts = {}) {
44
+ const rel = '.mcp.json';
45
+ const file = join(root, rel);
46
+ const names = Object.keys(servers);
47
+ let doc = {};
48
+ if (existsSync(file)) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
52
+ }
53
+ catch {
54
+ throw new BravosError(`${rel} is not valid JSON; fix it or remove it, nothing was written`);
55
+ }
56
+ if (!isObject(parsed)) {
57
+ throw new BravosError(`${rel} is not a JSON object; fix it or remove it, nothing was written`);
58
+ }
59
+ doc = parsed;
60
+ }
61
+ if (doc.mcpServers !== undefined && !isObject(doc.mcpServers)) {
62
+ throw new BravosError(`${rel} has an mcpServers value that is not an object; fix it or remove it, nothing was written`);
63
+ }
64
+ const existing = (doc.mcpServers ?? {});
65
+ const updated = names.filter((n) => Object.hasOwn(existing, n) && staleGraphEntry(n, existing[n], servers[n]));
66
+ const kept = names.filter((n) => Object.hasOwn(existing, n) && !updated.includes(n));
67
+ const added = names.filter((n) => !Object.hasOwn(existing, n));
68
+ if (added.length === 0 && updated.length === 0)
69
+ return { path: rel, changed: false, added, kept };
70
+ for (const n of [...added, ...updated])
71
+ existing[n] = servers[n];
72
+ doc.mcpServers = existing;
73
+ if (!opts.dryRun)
74
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
75
+ return { path: rel, changed: true, added, kept, ...(updated.length ? { updated } : {}) };
76
+ }
77
+ const tomlString = (v) => JSON.stringify(v);
78
+ const tomlBlock = (name, s) => `\n[mcp_servers.${name}]\ncommand = ${tomlString(s.command)}\nargs = [${s.args.map(tomlString).join(', ')}]\n` +
79
+ (s.env ? `env = { ${Object.entries(s.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(', ')} }\n` : '');
80
+ /** The text of a `[mcp_servers.<name>]` table bravos wrote, from its header up to the next table or the end; null when the file holds no such table. */
81
+ function tomlTable(text, name) {
82
+ const header = new RegExp(`^\\[mcp_servers\\.${name}\\]\\s*$`, 'm').exec(text);
83
+ if (!header)
84
+ return null;
85
+ const rest = text.slice(header.index + header[0].length);
86
+ const next = /^\[/m.exec(rest);
87
+ return { start: header.index, end: next ? header.index + header[0].length + next.index : text.length };
88
+ }
89
+ // @comment -- "Appends missing [mcp_servers.<name>] tables to <root>/.codex/config.toml for Codex, and replaces bravos's own stale codegraph table (staleGraphEntry) in place; no other existing bytes are rewritten, and unparsable files are refused."
90
+ export function mergeCodexConfig(root, servers, opts = {}) {
91
+ const rel = '.codex/config.toml';
92
+ const file = join(root, rel);
93
+ const names = Object.keys(servers);
94
+ const present = existsSync(file);
95
+ const text = present ? readFileSync(file, 'utf8') : '';
96
+ let tables = {};
97
+ if (present) {
98
+ try {
99
+ const parsed = parseToml(text);
100
+ if (parsed.mcp_servers !== undefined) {
101
+ if (!isObject(parsed.mcp_servers)) {
102
+ throw new BravosError(`${rel} has an mcp_servers value that is not a table; fix it or remove it, nothing was written`);
103
+ }
104
+ tables = parsed.mcp_servers;
105
+ }
106
+ }
107
+ catch (e) {
108
+ if (e instanceof BravosError)
109
+ throw e;
110
+ throw new BravosError(`${rel} is not valid TOML; fix it or remove it, nothing was written`);
111
+ }
112
+ }
113
+ const updated = names.filter((n) => Object.hasOwn(tables, n) && staleGraphEntry(n, tables[n], servers[n]) && tomlTable(text, n));
114
+ const kept = names.filter((n) => Object.hasOwn(tables, n) && !updated.includes(n));
115
+ const added = names.filter((n) => !Object.hasOwn(tables, n));
116
+ if (added.length === 0 && updated.length === 0)
117
+ return { path: rel, changed: false, added, kept };
118
+ let base = text;
119
+ for (const n of updated) {
120
+ const at = tomlTable(base, n);
121
+ base = base.slice(0, at.start) + tomlBlock(n, servers[n]).replace(/^\n/, '') + base.slice(at.end).replace(/^\n?/, at.end < base.length ? '\n' : '');
122
+ }
123
+ let body = added.map((n) => tomlBlock(n, servers[n])).join('');
124
+ if (!present || base === '')
125
+ body = body.replace(/^\n/, '');
126
+ else if (body && !base.endsWith('\n'))
127
+ body = '\n' + body;
128
+ try {
129
+ parseToml(base + body);
130
+ }
131
+ catch {
132
+ throw new BravosError(`${rel} would not parse after adding the bravos servers; fix it or remove it, nothing was written`);
133
+ }
134
+ if (!opts.dryRun) {
135
+ mkdirSync(dirname(file), { recursive: true });
136
+ writeFileSync(file, base + body);
137
+ }
138
+ return { path: rel, changed: true, added, kept, ...(updated.length ? { updated } : {}) };
139
+ }