brainclaw 1.8.0 → 1.9.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/README.md +12 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +138 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +285 -22
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +588 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +79 -5
- package/dist/core/dispatcher.js +64 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/facade-schema.js +38 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +114 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/staleness.js +72 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +72 -8
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +29 -2
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- package/package.json +8 -6
package/dist/core/agent-files.js
CHANGED
|
@@ -2,180 +2,14 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import yaml from 'yaml';
|
|
7
|
-
import { MCP_HEADLESS_AUTO_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from '../commands/mcp.js';
|
|
6
|
+
import { MCP_HEADLESS_AUTO_TOOL_NAMES, MCP_CANONICAL_GRAMMAR_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from '../commands/mcp.js';
|
|
8
7
|
import { renderToml, tomlArrayTableHasEntry } from './toml-writer.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* Strategy:
|
|
15
|
-
* 1. Find the brainclaw bin via which/where
|
|
16
|
-
* 2. Trace from the bin/shim to the actual cli.js entry point
|
|
17
|
-
* 3. Pair it with the absolute node path
|
|
18
|
-
* Falls back to 'npx brainclaw mcp' if resolution fails.
|
|
19
|
-
*/
|
|
20
|
-
function resolveBrainclawMcpCommand() {
|
|
21
|
-
const nodeBin = process.execPath;
|
|
22
|
-
// 1. Try to resolve the cli.js from the installed brainclaw binary
|
|
23
|
-
const cliJs = resolveBrainclawCliJs();
|
|
24
|
-
if (cliJs) {
|
|
25
|
-
return { command: nodeBin, args: [cliJs, 'mcp'] };
|
|
26
|
-
}
|
|
27
|
-
// 2. Fallback: npx (relies on PATH, may resolve wrong version)
|
|
28
|
-
return { command: 'npx', args: ['brainclaw', 'mcp'] };
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Trace from the brainclaw bin/shim to the actual dist/cli.js file.
|
|
32
|
-
* Works on Windows (.cmd shim), macOS/Linux (symlink to bin stub).
|
|
33
|
-
*/
|
|
34
|
-
function resolveBrainclawCliJs() {
|
|
35
|
-
// Strategy A: find via which/where and trace to cli.js
|
|
36
|
-
const whichCmd = os.platform() === 'win32' ? 'where' : 'which';
|
|
37
|
-
try {
|
|
38
|
-
const result = spawnSync(whichCmd, ['brainclaw'], { encoding: 'utf-8', timeout: 3000 });
|
|
39
|
-
if (result.status === 0) {
|
|
40
|
-
const resolved = result.stdout.trim().split(/\r?\n/)[0]?.trim();
|
|
41
|
-
if (resolved) {
|
|
42
|
-
const cliJs = traceToCliJs(resolved);
|
|
43
|
-
if (cliJs)
|
|
44
|
-
return cliJs;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
// Non-fatal — try next strategy
|
|
50
|
-
}
|
|
51
|
-
// Strategy B: resolve from this file's own package (we ARE brainclaw)
|
|
52
|
-
try {
|
|
53
|
-
const ownCliJs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
|
|
54
|
-
if (fs.existsSync(ownCliJs))
|
|
55
|
-
return ownCliJs;
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// Non-fatal
|
|
59
|
-
}
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Given a bin path (shim or symlink), trace to the dist/cli.js entry point.
|
|
64
|
-
*
|
|
65
|
-
* Windows: .cmd shim contains a line like `"%_prog%" "%dp0%\node_modules\brainclaw\dist\cli.js" %*`
|
|
66
|
-
* Unix: bin is a symlink → resolve to real path → go up to package root → dist/cli.js
|
|
67
|
-
*/
|
|
68
|
-
function traceToCliJs(binPath) {
|
|
69
|
-
const isWindows = os.platform() === 'win32';
|
|
70
|
-
if (isWindows) {
|
|
71
|
-
// Read the .cmd shim and extract the cli.js path
|
|
72
|
-
const cmdPath = binPath.endsWith('.cmd') ? binPath : `${binPath}.cmd`;
|
|
73
|
-
try {
|
|
74
|
-
const content = fs.readFileSync(cmdPath, 'utf-8');
|
|
75
|
-
// Match patterns like: "%dp0%\node_modules\brainclaw\dist\cli.js"
|
|
76
|
-
const match = content.match(/%dp0%\\([^\s"]+cli\.js)/);
|
|
77
|
-
if (match) {
|
|
78
|
-
const shimDir = path.dirname(cmdPath);
|
|
79
|
-
const cliJs = path.resolve(shimDir, match[1]);
|
|
80
|
-
if (fs.existsSync(cliJs))
|
|
81
|
-
return cliJs;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// Fall through
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
// Unix: follow symlink chain to the real bin, then find cli.js
|
|
90
|
-
try {
|
|
91
|
-
const realBin = fs.realpathSync(binPath);
|
|
92
|
-
// Typical layout: .../node_modules/.bin/brainclaw → ../brainclaw/dist/cli.js
|
|
93
|
-
// Or: .../node_modules/brainclaw/dist/cli.js (direct)
|
|
94
|
-
if (realBin.endsWith('cli.js') && fs.existsSync(realBin))
|
|
95
|
-
return realBin;
|
|
96
|
-
// The bin stub typically lives at node_modules/brainclaw/dist/cli.js
|
|
97
|
-
// or node_modules/.bin/brainclaw → ../brainclaw/dist/cli.js
|
|
98
|
-
const packageRoot = findPackageRoot(realBin);
|
|
99
|
-
if (packageRoot) {
|
|
100
|
-
const cliJs = path.join(packageRoot, 'dist', 'cli.js');
|
|
101
|
-
if (fs.existsSync(cliJs))
|
|
102
|
-
return cliJs;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
// Fall through
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return undefined;
|
|
110
|
-
}
|
|
111
|
-
/** Walk up from a file to find the nearest directory containing package.json with name "brainclaw". */
|
|
112
|
-
function findPackageRoot(from) {
|
|
113
|
-
let dir = path.dirname(from);
|
|
114
|
-
for (let i = 0; i < 10; i++) {
|
|
115
|
-
const pkgPath = path.join(dir, 'package.json');
|
|
116
|
-
try {
|
|
117
|
-
if (fs.existsSync(pkgPath)) {
|
|
118
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
119
|
-
if (pkg.name === 'brainclaw')
|
|
120
|
-
return dir;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
catch { /* continue */ }
|
|
124
|
-
const parent = path.dirname(dir);
|
|
125
|
-
if (parent === dir)
|
|
126
|
-
break;
|
|
127
|
-
dir = parent;
|
|
128
|
-
}
|
|
129
|
-
return undefined;
|
|
130
|
-
}
|
|
131
|
-
/** Cached MCP command — resolved once per process. */
|
|
132
|
-
let cachedMcpCommand;
|
|
133
|
-
function getBrainclawMcpCommand() {
|
|
134
|
-
if (!cachedMcpCommand) {
|
|
135
|
-
cachedMcpCommand = resolveBrainclawMcpCommand();
|
|
136
|
-
}
|
|
137
|
-
return cachedMcpCommand;
|
|
138
|
-
}
|
|
139
|
-
/** Reset the cached MCP command so it gets re-resolved on next access. */
|
|
140
|
-
export function resetMcpCommandCache() {
|
|
141
|
-
cachedMcpCommand = undefined;
|
|
142
|
-
}
|
|
143
|
-
/** Module-level flag: when true, brainclawMcpEntry overwrites existing paths. */
|
|
144
|
-
let _forceResolve = false;
|
|
145
|
-
/**
|
|
146
|
-
* Build a complete MCP server entry with relay model env injection.
|
|
147
|
-
* Merges with the existing entry to preserve manual edits (e.g. custom command
|
|
148
|
-
* path, additional env vars, extra args). Only sets defaults for missing fields.
|
|
149
|
-
*
|
|
150
|
-
* When `workspacePath` is provided, injects BRAINCLAW_CWD into the env so
|
|
151
|
-
* the MCP server resolves the correct workspace root regardless of the IDE's
|
|
152
|
-
* process.cwd() at launch time.
|
|
153
|
-
*/
|
|
154
|
-
function brainclawMcpEntry(agentName, existing, workspacePath) {
|
|
155
|
-
const defaults = getBrainclawMcpCommand();
|
|
156
|
-
const ex = isJsonObject(existing) ? existing : {};
|
|
157
|
-
const exEnv = isJsonObject(ex.env) ? ex.env : {};
|
|
158
|
-
// When _forceResolve is true (post-upgrade), always use newly resolved paths.
|
|
159
|
-
// Otherwise preserve existing command if it's an absolute path (manual edit).
|
|
160
|
-
// CRITICAL: once we decide to preserve the command, we MUST also preserve
|
|
161
|
-
// the args. Previously args was always overwritten, which silently clobbered
|
|
162
|
-
// manual customizations (--cwd, --debug, etc.) and broke setups on DGX.
|
|
163
|
-
// See trp#12 + pln#450.
|
|
164
|
-
const useExisting = !_forceResolve && typeof ex.command === 'string' && ex.command !== 'npx';
|
|
165
|
-
const existingArgs = Array.isArray(ex.args) ? ex.args : undefined;
|
|
166
|
-
return {
|
|
167
|
-
command: useExisting ? ex.command : defaults.command,
|
|
168
|
-
args: useExisting && existingArgs ? existingArgs : defaults.args,
|
|
169
|
-
// Merge env: preserve user-added vars, ensure BRAINCLAW_AGENT is set
|
|
170
|
-
env: {
|
|
171
|
-
...exEnv,
|
|
172
|
-
BRAINCLAW_AGENT: agentName,
|
|
173
|
-
...(workspacePath ? { BRAINCLAW_CWD: workspacePath } : {}),
|
|
174
|
-
},
|
|
175
|
-
// Preserve timeout if set
|
|
176
|
-
...(typeof ex.timeout === 'number' ? { timeout: ex.timeout } : {}),
|
|
177
|
-
};
|
|
178
|
-
}
|
|
8
|
+
import { PROTOCOL_SKILLS, renderProtocolSkill } from './protocol-skills.js';
|
|
9
|
+
import { getInstalledBrainclawVersion } from './brainclaw-version.js';
|
|
10
|
+
import { isAgentInstalledPerInventory } from './agent-inventory.js';
|
|
11
|
+
import { brainclawMcpEntry, buildHookCommand, getBclawCliParts, getBrainclawMcpCommand, isForceResolveEnabled, quoteShellArg, resetMcpCommandCache, withForcedResolve, } from './mcp-command-resolution.js';
|
|
12
|
+
export { resetMcpCommandCache };
|
|
179
13
|
export const BRAINCLAW_SECTION_START = '<!-- brainclaw:start -->';
|
|
180
14
|
export const BRAINCLAW_SECTION_END = '<!-- brainclaw:end -->';
|
|
181
15
|
export function buildBrainclawSection(storageDir) {
|
|
@@ -295,8 +129,10 @@ export function ensureGitignoreEntries(cwd, entries) {
|
|
|
295
129
|
const toAdd = entries.filter((e) => !lines.has(e));
|
|
296
130
|
if (toAdd.length === 0)
|
|
297
131
|
return;
|
|
132
|
+
const banner = '# Agent instruction files (generated by brainclaw)';
|
|
133
|
+
const bannerBlock = lines.has(banner) ? '' : `${banner}\n`;
|
|
298
134
|
const separator = current.trimEnd().length > 0 ? '\n' : '';
|
|
299
|
-
const next = `${current.trimEnd()}${separator}\n
|
|
135
|
+
const next = `${current.trimEnd()}${separator}\n${bannerBlock}${toAdd.join('\n')}\n`;
|
|
300
136
|
fs.writeFileSync(gitignorePath, next, 'utf-8');
|
|
301
137
|
}
|
|
302
138
|
export function collectWorkspaceGitignoreEntries(cwd, results) {
|
|
@@ -506,32 +342,289 @@ export const LOCAL_ONLY_AGENT_WORKSPACE_FILES = [
|
|
|
506
342
|
function isJsonObject(value) {
|
|
507
343
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
508
344
|
}
|
|
345
|
+
function stripBom(text) {
|
|
346
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Read a JSON config file. Returns `{}` when the file doesn't exist, and
|
|
350
|
+
* `undefined` when the file exists but cannot be parsed as a JSON object.
|
|
351
|
+
* Callers MUST treat `undefined` as "abort the write" — overwriting a file we
|
|
352
|
+
* could not parse destroys user-owned configuration (a UTF-8 BOM or JSONC
|
|
353
|
+
* comments used to wipe entire settings files this way).
|
|
354
|
+
*/
|
|
509
355
|
function readJsonObject(filePath) {
|
|
510
356
|
if (!fs.existsSync(filePath)) {
|
|
511
357
|
return {};
|
|
512
358
|
}
|
|
513
359
|
try {
|
|
514
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
515
|
-
return isJsonObject(parsed) ? parsed :
|
|
360
|
+
const parsed = JSON.parse(stripBom(fs.readFileSync(filePath, 'utf-8')));
|
|
361
|
+
return isJsonObject(parsed) ? parsed : undefined;
|
|
516
362
|
}
|
|
517
363
|
catch {
|
|
518
|
-
return
|
|
364
|
+
return undefined;
|
|
519
365
|
}
|
|
520
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Same contract as readJsonObject, but tolerates JSONC comments and trailing
|
|
369
|
+
* commas (VS Code files). Token-based, so comments inside string values
|
|
370
|
+
* ("https://…") never corrupt the parse.
|
|
371
|
+
*/
|
|
521
372
|
function readJsoncObject(filePath) {
|
|
522
373
|
if (!fs.existsSync(filePath)) {
|
|
523
374
|
return {};
|
|
524
375
|
}
|
|
525
376
|
try {
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
const withoutLineComments = withoutBlockComments.replace(/^\s*\/\/.*$/gm, '');
|
|
529
|
-
const parsed = JSON.parse(withoutLineComments);
|
|
530
|
-
return isJsonObject(parsed) ? parsed : {};
|
|
377
|
+
const parsed = parseJsonc(stripBom(fs.readFileSync(filePath, 'utf-8')));
|
|
378
|
+
return isJsonObject(parsed) ? parsed : undefined;
|
|
531
379
|
}
|
|
532
380
|
catch {
|
|
533
|
-
return
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Result for a writer that found an unparseable existing file: warn once and
|
|
386
|
+
* leave the file exactly as it is.
|
|
387
|
+
*/
|
|
388
|
+
function skippedAutoConfigResult(kind, label, filePath, relativePath) {
|
|
389
|
+
const warning = `cannot parse ${filePath} — file left untouched. Fix its syntax (or remove it) and re-run.`;
|
|
390
|
+
process.stderr.write(`[brainclaw] Warning: ${warning}\n`);
|
|
391
|
+
return {
|
|
392
|
+
kind,
|
|
393
|
+
label,
|
|
394
|
+
created: false,
|
|
395
|
+
updated: false,
|
|
396
|
+
filePath,
|
|
397
|
+
...(relativePath ? { relativePath } : {}),
|
|
398
|
+
skipped: true,
|
|
399
|
+
warning,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** Tokenize JSONC, preserving offsets. Returns undefined on malformed input. */
|
|
403
|
+
function tokenizeJsonc(text) {
|
|
404
|
+
const tokens = [];
|
|
405
|
+
let i = 0;
|
|
406
|
+
const n = text.length;
|
|
407
|
+
while (i < n) {
|
|
408
|
+
const ch = text[i];
|
|
409
|
+
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
|
|
410
|
+
i++;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
414
|
+
while (i < n && text[i] !== '\n')
|
|
415
|
+
i++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
419
|
+
const close = text.indexOf('*/', i + 2);
|
|
420
|
+
if (close === -1)
|
|
421
|
+
return undefined;
|
|
422
|
+
i = close + 2;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (ch === '"') {
|
|
426
|
+
const start = i;
|
|
427
|
+
i++;
|
|
428
|
+
while (i < n && text[i] !== '"') {
|
|
429
|
+
i += text[i] === '\\' ? 2 : 1;
|
|
430
|
+
}
|
|
431
|
+
if (i >= n)
|
|
432
|
+
return undefined;
|
|
433
|
+
i++;
|
|
434
|
+
let value;
|
|
435
|
+
try {
|
|
436
|
+
value = JSON.parse(text.slice(start, i));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
tokens.push({ type: 'string', start, end: i, value });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if ('{}[]:,'.includes(ch)) {
|
|
445
|
+
tokens.push({ type: 'punct', start: i, end: i + 1, value: ch });
|
|
446
|
+
i++;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const start = i;
|
|
450
|
+
while (i < n && !' \t\r\n{}[]:,/"'.includes(text[i]))
|
|
451
|
+
i++;
|
|
452
|
+
if (i === start)
|
|
453
|
+
return undefined;
|
|
454
|
+
tokens.push({ type: 'literal', start, end: i });
|
|
455
|
+
}
|
|
456
|
+
return tokens;
|
|
457
|
+
}
|
|
458
|
+
/** Parse JSONC text (comments + trailing commas) into a JS value. Throws on malformed input. */
|
|
459
|
+
function parseJsonc(text) {
|
|
460
|
+
const tokens = tokenizeJsonc(text);
|
|
461
|
+
if (!tokens)
|
|
462
|
+
throw new Error('malformed JSONC');
|
|
463
|
+
let pos = 0;
|
|
464
|
+
const next = () => {
|
|
465
|
+
const t = tokens[pos];
|
|
466
|
+
if (!t)
|
|
467
|
+
throw new Error('unexpected end of JSONC');
|
|
468
|
+
return t;
|
|
469
|
+
};
|
|
470
|
+
const parseValue = () => {
|
|
471
|
+
const t = next();
|
|
472
|
+
if (t.type === 'string') {
|
|
473
|
+
pos++;
|
|
474
|
+
return t.value;
|
|
475
|
+
}
|
|
476
|
+
if (t.type === 'literal') {
|
|
477
|
+
pos++;
|
|
478
|
+
return JSON.parse(text.slice(t.start, t.end));
|
|
479
|
+
}
|
|
480
|
+
if (t.value === '{') {
|
|
481
|
+
pos++;
|
|
482
|
+
const obj = {};
|
|
483
|
+
if (next().value === '}') {
|
|
484
|
+
pos++;
|
|
485
|
+
return obj;
|
|
486
|
+
}
|
|
487
|
+
for (;;) {
|
|
488
|
+
const key = next();
|
|
489
|
+
if (key.type !== 'string')
|
|
490
|
+
throw new Error('expected object key');
|
|
491
|
+
pos++;
|
|
492
|
+
if (next().value !== ':')
|
|
493
|
+
throw new Error('expected colon');
|
|
494
|
+
pos++;
|
|
495
|
+
obj[key.value] = parseValue();
|
|
496
|
+
const sep = next();
|
|
497
|
+
if (sep.value === ',') {
|
|
498
|
+
pos++;
|
|
499
|
+
if (next().value === '}') {
|
|
500
|
+
pos++;
|
|
501
|
+
return obj;
|
|
502
|
+
}
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (sep.value === '}') {
|
|
506
|
+
pos++;
|
|
507
|
+
return obj;
|
|
508
|
+
}
|
|
509
|
+
throw new Error('expected comma or closing brace');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (t.value === '[') {
|
|
513
|
+
pos++;
|
|
514
|
+
const arr = [];
|
|
515
|
+
if (next().value === ']') {
|
|
516
|
+
pos++;
|
|
517
|
+
return arr;
|
|
518
|
+
}
|
|
519
|
+
for (;;) {
|
|
520
|
+
arr.push(parseValue());
|
|
521
|
+
const sep = next();
|
|
522
|
+
if (sep.value === ',') {
|
|
523
|
+
pos++;
|
|
524
|
+
if (next().value === ']') {
|
|
525
|
+
pos++;
|
|
526
|
+
return arr;
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (sep.value === ']') {
|
|
531
|
+
pos++;
|
|
532
|
+
return arr;
|
|
533
|
+
}
|
|
534
|
+
throw new Error('expected comma or closing bracket');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
throw new Error('unexpected token');
|
|
538
|
+
};
|
|
539
|
+
const value = parseValue();
|
|
540
|
+
if (pos !== tokens.length)
|
|
541
|
+
throw new Error('trailing content after JSONC value');
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
/** Returns the token index just past the value starting at tokens[idx], or undefined. */
|
|
545
|
+
function skipJsoncValue(tokens, idx) {
|
|
546
|
+
const tok = tokens[idx];
|
|
547
|
+
if (!tok)
|
|
548
|
+
return undefined;
|
|
549
|
+
if (tok.type === 'string' || tok.type === 'literal')
|
|
550
|
+
return idx + 1;
|
|
551
|
+
if (tok.value === '{' || tok.value === '[') {
|
|
552
|
+
const close = tok.value === '{' ? '}' : ']';
|
|
553
|
+
let depth = 0;
|
|
554
|
+
for (let i = idx; i < tokens.length; i++) {
|
|
555
|
+
const t = tokens[i];
|
|
556
|
+
if (t.type !== 'punct')
|
|
557
|
+
continue;
|
|
558
|
+
if (t.value === tok.value)
|
|
559
|
+
depth++;
|
|
560
|
+
else if (t.value === close && --depth === 0)
|
|
561
|
+
return i + 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Set `keyPath` to `valueJson` (a rendered JSON snippet) inside a JSONC text,
|
|
568
|
+
* preserving all comments, whitespace, and unrelated content byte-for-byte.
|
|
569
|
+
* Missing intermediate objects are created. Returns undefined when the input
|
|
570
|
+
* cannot be edited safely (malformed, non-object root, non-object intermediate).
|
|
571
|
+
*/
|
|
572
|
+
function setJsoncValue(text, keyPath, valueJson) {
|
|
573
|
+
if (keyPath.length === 0)
|
|
574
|
+
return undefined;
|
|
575
|
+
const tokens = tokenizeJsonc(text);
|
|
576
|
+
if (!tokens || tokens.length === 0 || tokens[0].value !== '{')
|
|
577
|
+
return undefined;
|
|
578
|
+
let objStart = 0;
|
|
579
|
+
for (let depth = 0; depth < keyPath.length; depth++) {
|
|
580
|
+
const key = keyPath[depth];
|
|
581
|
+
const openTok = tokens[objStart];
|
|
582
|
+
if (openTok.value !== '{')
|
|
583
|
+
return undefined;
|
|
584
|
+
const objEnd = skipJsoncValue(tokens, objStart);
|
|
585
|
+
if (objEnd === undefined)
|
|
586
|
+
return undefined;
|
|
587
|
+
// Scan direct members of this object for `key`
|
|
588
|
+
let i = objStart + 1;
|
|
589
|
+
let found = -1;
|
|
590
|
+
while (i < objEnd - 1) {
|
|
591
|
+
const keyTok = tokens[i];
|
|
592
|
+
if (!keyTok || keyTok.type !== 'string')
|
|
593
|
+
return undefined;
|
|
594
|
+
const colon = tokens[i + 1];
|
|
595
|
+
if (!colon || colon.value !== ':')
|
|
596
|
+
return undefined;
|
|
597
|
+
const valueIdx = i + 2;
|
|
598
|
+
const valueEnd = skipJsoncValue(tokens, valueIdx);
|
|
599
|
+
if (valueEnd === undefined)
|
|
600
|
+
return undefined;
|
|
601
|
+
if (keyTok.value === key) {
|
|
602
|
+
found = valueIdx;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
i = valueEnd;
|
|
606
|
+
if (tokens[i]?.value === ',')
|
|
607
|
+
i++;
|
|
608
|
+
}
|
|
609
|
+
if (found === -1) {
|
|
610
|
+
// Key absent — insert it (with the remaining path nested) after the brace.
|
|
611
|
+
let snippet = valueJson;
|
|
612
|
+
for (let d = keyPath.length - 1; d > depth; d--) {
|
|
613
|
+
snippet = `{ ${JSON.stringify(keyPath[d])}: ${snippet} }`;
|
|
614
|
+
}
|
|
615
|
+
const hasMembers = tokens[objStart + 1]?.value !== '}';
|
|
616
|
+
const insertAt = openTok.end;
|
|
617
|
+
const member = `${JSON.stringify(key)}: ${snippet}`;
|
|
618
|
+
const insertion = hasMembers ? ` ${member},` : ` ${member} `;
|
|
619
|
+
return text.slice(0, insertAt) + insertion + text.slice(insertAt);
|
|
620
|
+
}
|
|
621
|
+
if (depth === keyPath.length - 1) {
|
|
622
|
+
const valueEnd = skipJsoncValue(tokens, found);
|
|
623
|
+
return text.slice(0, tokens[found].start) + valueJson + text.slice(tokens[valueEnd - 1].end);
|
|
624
|
+
}
|
|
625
|
+
objStart = found;
|
|
534
626
|
}
|
|
627
|
+
return undefined;
|
|
535
628
|
}
|
|
536
629
|
function writeTextFileIfChanged(filePath, content) {
|
|
537
630
|
const existed = fs.existsSync(filePath);
|
|
@@ -548,7 +641,17 @@ function writeJsonFileIfChanged(filePath, next) {
|
|
|
548
641
|
return writeTextFileIfChanged(filePath, serialized);
|
|
549
642
|
}
|
|
550
643
|
function resolveHomeDir(env) {
|
|
551
|
-
|
|
644
|
+
const fromEnv = env.HOME?.trim() || env.USERPROFILE?.trim();
|
|
645
|
+
if (fromEnv && fs.existsSync(fromEnv))
|
|
646
|
+
return fromEnv;
|
|
647
|
+
// Injected envs (tests, embedders) opt out of the machine fallback. For the
|
|
648
|
+
// real process env, fall back to os.homedir(): Git Bash exports HOME as a
|
|
649
|
+
// POSIX path (/c/Users/x) that breaks every user-level writer on Windows,
|
|
650
|
+
// and some CI shells unset HOME/USERPROFILE entirely.
|
|
651
|
+
if (env !== process.env)
|
|
652
|
+
return undefined;
|
|
653
|
+
const fallback = os.homedir();
|
|
654
|
+
return fallback && fs.existsSync(fallback) ? fallback : undefined;
|
|
552
655
|
}
|
|
553
656
|
function runGit(cwd, args, input) {
|
|
554
657
|
const result = spawnSync('git', args, {
|
|
@@ -635,7 +738,10 @@ Steps:
|
|
|
635
738
|
}
|
|
636
739
|
export function ensureClineMcpConfig(cwd) {
|
|
637
740
|
const filePath = path.join(cwd, '.vscode', 'cline_mcp_settings.json');
|
|
638
|
-
const existing =
|
|
741
|
+
const existing = readJsoncObject(filePath);
|
|
742
|
+
if (existing === undefined) {
|
|
743
|
+
return skippedAutoConfigResult('mcp', 'Cline MCP settings', filePath, CLINE_MCP_RELATIVE_PATH);
|
|
744
|
+
}
|
|
639
745
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
640
746
|
mcpServers.brainclaw = {
|
|
641
747
|
...brainclawMcpEntry('cline', mcpServers.brainclaw, cwd),
|
|
@@ -661,6 +767,9 @@ export function ensureWindsurfMcpConfig(homeDir) {
|
|
|
661
767
|
}
|
|
662
768
|
const filePath = path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json');
|
|
663
769
|
const existing = readJsonObject(filePath);
|
|
770
|
+
if (existing === undefined) {
|
|
771
|
+
return skippedAutoConfigResult('mcp', 'Windsurf MCP settings', filePath, WINDSURF_MCP_RELATIVE_PATH);
|
|
772
|
+
}
|
|
664
773
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
665
774
|
mcpServers.brainclaw = {
|
|
666
775
|
...brainclawMcpEntry('windsurf', mcpServers.brainclaw),
|
|
@@ -797,9 +906,35 @@ CLI fallback only: \`brainclaw context --json\` / \`brainclaw claim create\` / \
|
|
|
797
906
|
relativePath: UNIVERSAL_SKILL_RELATIVE_PATH,
|
|
798
907
|
};
|
|
799
908
|
}
|
|
909
|
+
/**
|
|
910
|
+
* Write the protocol-skills pack (pln#519) to the universal `.agents/skills/`
|
|
911
|
+
* path — one SKILL.md per workflow (session / memory-capture / multi-agent).
|
|
912
|
+
* Orthogonal to the agent-PROFILE skill above; same agents discover both via
|
|
913
|
+
* the shared `.agents/skills/` convention, so no per-agent branching is needed.
|
|
914
|
+
* Idempotent (writeTextFileIfChanged). Called only for skill-capable agents.
|
|
915
|
+
*/
|
|
916
|
+
export function ensureProtocolSkills(cwd) {
|
|
917
|
+
const version = getInstalledBrainclawVersion();
|
|
918
|
+
return PROTOCOL_SKILLS.map((skill) => {
|
|
919
|
+
const relativePath = `.agents/skills/${skill.id}/SKILL.md`;
|
|
920
|
+
const filePath = path.join(cwd, '.agents', 'skills', skill.id, 'SKILL.md');
|
|
921
|
+
const { created, updated } = writeTextFileIfChanged(filePath, renderProtocolSkill(skill, version));
|
|
922
|
+
return {
|
|
923
|
+
kind: 'skill',
|
|
924
|
+
label: `Protocol-skill ${skill.id} (${relativePath})`,
|
|
925
|
+
created,
|
|
926
|
+
updated,
|
|
927
|
+
filePath,
|
|
928
|
+
relativePath,
|
|
929
|
+
};
|
|
930
|
+
});
|
|
931
|
+
}
|
|
800
932
|
export function ensureCopilotMcpConfig(cwd) {
|
|
801
933
|
const filePath = path.join(cwd, '.vscode', 'settings.json');
|
|
802
|
-
const existing =
|
|
934
|
+
const existing = readJsoncObject(filePath);
|
|
935
|
+
if (existing === undefined) {
|
|
936
|
+
return skippedAutoConfigResult('mcp', 'Copilot MCP settings (.vscode/settings.json)', filePath, COPILOT_MCP_RELATIVE_PATH);
|
|
937
|
+
}
|
|
803
938
|
const copilotMcpKey = 'github.copilot.chat.mcpServers';
|
|
804
939
|
const mcpServers = isJsonObject(existing[copilotMcpKey]) ? { ...existing[copilotMcpKey] } : {};
|
|
805
940
|
mcpServers.brainclaw = brainclawMcpEntry('github-copilot', mcpServers.brainclaw, cwd);
|
|
@@ -823,7 +958,10 @@ export function ensureCopilotMcpConfig(cwd) {
|
|
|
823
958
|
*/
|
|
824
959
|
export function ensureVscodeMcpConfig(cwd) {
|
|
825
960
|
const filePath = path.join(cwd, '.vscode', 'mcp.json');
|
|
826
|
-
const existing =
|
|
961
|
+
const existing = readJsoncObject(filePath);
|
|
962
|
+
if (existing === undefined) {
|
|
963
|
+
return skippedAutoConfigResult('mcp', 'VS Code MCP config (.vscode/mcp.json)', filePath, VSCODE_MCP_RELATIVE_PATH);
|
|
964
|
+
}
|
|
827
965
|
const servers = isJsonObject(existing.servers) ? { ...existing.servers } : {};
|
|
828
966
|
servers.brainclaw = brainclawMcpEntry('github-copilot', servers.brainclaw, cwd);
|
|
829
967
|
const { created, updated } = writeJsonFileIfChanged(filePath, {
|
|
@@ -842,7 +980,10 @@ export function ensureVscodeMcpConfig(cwd) {
|
|
|
842
980
|
const BRAINCLAW_EXTENSION_ID = 'brainclaw.brainclaw-vscode';
|
|
843
981
|
export function ensureVscodeExtensionRecommendation(cwd) {
|
|
844
982
|
const filePath = path.join(cwd, '.vscode', 'extensions.json');
|
|
845
|
-
const existing =
|
|
983
|
+
const existing = readJsoncObject(filePath);
|
|
984
|
+
if (existing === undefined) {
|
|
985
|
+
return skippedAutoConfigResult('recommendation', 'VS Code extension recommendation (.vscode/extensions.json)', filePath, VSCODE_EXTENSIONS_RELATIVE_PATH);
|
|
986
|
+
}
|
|
846
987
|
const recommendations = Array.isArray(existing.recommendations)
|
|
847
988
|
? [...existing.recommendations]
|
|
848
989
|
: [];
|
|
@@ -900,57 +1041,47 @@ function buildMatchedCommandHookEntry(matcher, command) {
|
|
|
900
1041
|
hooks: [{ type: 'command', command }],
|
|
901
1042
|
};
|
|
902
1043
|
}
|
|
903
|
-
function containsCommandHook(entries, command) {
|
|
904
|
-
return entries.some((entry) => isJsonObject(entry) &&
|
|
905
|
-
Array.isArray(entry.hooks) &&
|
|
906
|
-
entry.hooks.some((h) => isJsonObject(h) && h.command === command));
|
|
907
|
-
}
|
|
908
1044
|
/**
|
|
909
|
-
*
|
|
910
|
-
*
|
|
1045
|
+
* Recognize a hook command emitted by any brainclaw version: `npx brainclaw …`,
|
|
1046
|
+
* absolute bin paths ending in /brainclaw, the `.bclaw-session` marker wrapper,
|
|
1047
|
+
* and the brainclaw-specific `check-events` subcommand (whose broken legacy
|
|
1048
|
+
* form — bare node.exe, cli.js arg dropped — contained no other marker).
|
|
911
1049
|
*/
|
|
912
|
-
function
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
//
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
// Neither new nor legacy found — add fresh
|
|
928
|
-
entries.push(buildCommandHookEntry(newCommand));
|
|
929
|
-
}
|
|
1050
|
+
function isBrainclawHookCommand(command) {
|
|
1051
|
+
// Review follow-up L2 (lop_e2d566765b8b4ce3): match brainclaw/bclaw only in
|
|
1052
|
+
// COMMAND position (start / path separator / shell delimiter, optional binary
|
|
1053
|
+
// extension) and check-events only as a standalone shell word — the old
|
|
1054
|
+
// substring regex ate any user hook that merely MENTIONED these words.
|
|
1055
|
+
if (command.includes('.bclaw-session'))
|
|
1056
|
+
return true;
|
|
1057
|
+
if (/(^|\s)check-events(\s|$)/.test(command))
|
|
1058
|
+
return true;
|
|
1059
|
+
return /(^|[\s/\\"'`;&|(])(brainclaw|bclaw)(\.(cmd|exe|js|mjs|ps1))?([\s"')`;&|]|$)/.test(command);
|
|
1060
|
+
}
|
|
1061
|
+
/** Test-only export — hook recognition is the L2 contract worth pinning. */
|
|
1062
|
+
export const __agentFilesTesting = { isBrainclawHookCommand };
|
|
930
1063
|
/**
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1064
|
+
* Remove every brainclaw-emitted hook from `entries`, then append exactly one
|
|
1065
|
+
* canonical entry. Keyed on recognition, not exact command text, so upgrades
|
|
1066
|
+
* replace stale/broken variants instead of accumulating duplicates (we observed
|
|
1067
|
+
* 2× UserPromptSubmit + 3× Stop hooks piled up across upgrades in the wild).
|
|
1068
|
+
* User-authored hooks are preserved untouched.
|
|
933
1069
|
*/
|
|
934
|
-
function
|
|
935
|
-
|
|
936
|
-
if (entries.some(entry => isJsonObject(entry) && Array.isArray(entry.hooks) &&
|
|
937
|
-
entry.hooks.some(h => isJsonObject(h) && typeof h.command === 'string' && h.command === newCommand)))
|
|
938
|
-
return;
|
|
939
|
-
// Find and replace any entry containing a legacy pattern substring
|
|
1070
|
+
function replaceBrainclawHooks(entries, canonical) {
|
|
1071
|
+
const kept = [];
|
|
940
1072
|
for (const entry of entries) {
|
|
941
|
-
if (!isJsonObject(entry) || !Array.isArray(entry.hooks))
|
|
1073
|
+
if (!isJsonObject(entry) || !Array.isArray(entry.hooks)) {
|
|
1074
|
+
kept.push(entry);
|
|
942
1075
|
continue;
|
|
943
|
-
for (const h of entry.hooks) {
|
|
944
|
-
if (!isJsonObject(h) || typeof h.command !== 'string')
|
|
945
|
-
continue;
|
|
946
|
-
if (legacyPatterns.some(p => h.command.includes(p))) {
|
|
947
|
-
h.command = newCommand;
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
1076
|
}
|
|
1077
|
+
const hooks = entry.hooks;
|
|
1078
|
+
const remaining = hooks.filter((h) => !(isJsonObject(h) && typeof h.command === 'string' && isBrainclawHookCommand(h.command)));
|
|
1079
|
+
if (remaining.length === 0)
|
|
1080
|
+
continue;
|
|
1081
|
+
kept.push(remaining.length === hooks.length ? entry : { ...entry, hooks: remaining });
|
|
951
1082
|
}
|
|
952
|
-
|
|
953
|
-
|
|
1083
|
+
kept.push(canonical);
|
|
1084
|
+
return kept;
|
|
954
1085
|
}
|
|
955
1086
|
export function ensureProjectDevDependency(cwd) {
|
|
956
1087
|
const filePath = path.join(cwd, 'package.json');
|
|
@@ -984,6 +1115,9 @@ export function ensureProjectDevDependency(cwd) {
|
|
|
984
1115
|
export function ensureClaudeCodeMcpConfig(cwd) {
|
|
985
1116
|
const filePath = path.join(cwd, '.mcp.json');
|
|
986
1117
|
const existing = readJsonObject(filePath);
|
|
1118
|
+
if (existing === undefined) {
|
|
1119
|
+
return skippedAutoConfigResult('mcp', 'Claude Code MCP server', filePath, CLAUDE_CODE_MCP_RELATIVE_PATH);
|
|
1120
|
+
}
|
|
987
1121
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
988
1122
|
mcpServers.brainclaw = brainclawMcpEntry('claude-code', mcpServers.brainclaw, cwd);
|
|
989
1123
|
const { created, updated } = writeJsonFileIfChanged(filePath, {
|
|
@@ -1017,6 +1151,9 @@ export function ensureClaudeCodeUserSettings(homeDir, env = process.env) {
|
|
|
1017
1151
|
return undefined;
|
|
1018
1152
|
const filePath = path.join(homeDir, '.claude', 'settings.json');
|
|
1019
1153
|
const existing = readJsonObject(filePath);
|
|
1154
|
+
if (existing === undefined) {
|
|
1155
|
+
return skippedAutoConfigResult('mcp', 'Claude Code user settings — MCP + permissions (global, all projects)', filePath);
|
|
1156
|
+
}
|
|
1020
1157
|
// MCP server
|
|
1021
1158
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
1022
1159
|
mcpServers.brainclaw = brainclawMcpEntry('claude-code', mcpServers.brainclaw);
|
|
@@ -1058,6 +1195,9 @@ export function ensureClaudeCodeUserCommand(homeDir) {
|
|
|
1058
1195
|
export function ensureClaudeCodeSettings(cwd) {
|
|
1059
1196
|
const filePath = path.join(cwd, '.claude', 'settings.local.json');
|
|
1060
1197
|
const existing = readJsonObject(filePath);
|
|
1198
|
+
if (existing === undefined) {
|
|
1199
|
+
return skippedAutoConfigResult('rule', 'Claude Code settings (permissions + session hooks)', filePath, CLAUDE_CODE_SETTINGS_RELATIVE_PATH);
|
|
1200
|
+
}
|
|
1061
1201
|
// Merge permissions.allow
|
|
1062
1202
|
const permissions = isJsonObject(existing.permissions) ? { ...existing.permissions } : {};
|
|
1063
1203
|
const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
|
|
@@ -1084,40 +1224,19 @@ export function ensureClaudeCodeSettings(cwd) {
|
|
|
1084
1224
|
}
|
|
1085
1225
|
}
|
|
1086
1226
|
permissions.additionalDirectories = additionalDirs;
|
|
1087
|
-
// Merge hooks — UserPromptSubmit opens a session on first prompt, diff on subsequent
|
|
1227
|
+
// Merge hooks — UserPromptSubmit opens a session on first prompt, diff on subsequent.
|
|
1228
|
+
// getBclawCliParts() keeps the cli.js argument; the previous builder used only
|
|
1229
|
+
// the bare command, emitting broken `node.exe session-start` hooks whenever
|
|
1230
|
+
// binary resolution succeeded (hidden by 2>/dev/null).
|
|
1088
1231
|
const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
|
|
1089
|
-
const
|
|
1090
|
-
// For shell hooks, normalize Windows backslashes to forward slashes and quote if needed
|
|
1091
|
-
const bclawBin = mcpCmd.command === 'npx'
|
|
1092
|
-
? 'npx brainclaw'
|
|
1093
|
-
: `"${mcpCmd.command.replace(/\\/g, '/')}"`;
|
|
1232
|
+
const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
|
|
1094
1233
|
const sessionCommand = `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context 2>/dev/null; else ${bclawBin} context-diff 2>/dev/null; fi`;
|
|
1095
1234
|
const stopCommand = `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review 2>/dev/null`;
|
|
1096
|
-
// Legacy commands to replace on upgrade (substring patterns to match old hooks)
|
|
1097
|
-
const legacyPatterns = [
|
|
1098
|
-
'brainclaw context 2>/dev/null',
|
|
1099
|
-
'brainclaw session-start --include-context 2>/dev/null',
|
|
1100
|
-
'brainclaw session-end --auto-release',
|
|
1101
|
-
'brainclaw context-diff 2>/dev/null',
|
|
1102
|
-
];
|
|
1103
|
-
const userPromptHooks = Array.isArray(hooks.UserPromptSubmit) ? [...hooks.UserPromptSubmit] : [];
|
|
1104
|
-
replaceOrAddCommandHookByPattern(userPromptHooks, sessionCommand, legacyPatterns);
|
|
1105
|
-
hooks.UserPromptSubmit = userPromptHooks;
|
|
1106
|
-
const stopHooks = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
|
|
1107
|
-
replaceOrAddCommandHookByPattern(stopHooks, stopCommand, legacyPatterns);
|
|
1108
|
-
hooks.Stop = stopHooks;
|
|
1109
1235
|
// PostToolUse — check for unseen events after any brainclaw MCP tool call
|
|
1110
1236
|
const checkEventsCommand = `${bclawBin} check-events 2>/dev/null`;
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
for (const entry of postToolHooks) {
|
|
1115
|
-
if (isJsonObject(entry) && Array.isArray(entry.hooks) &&
|
|
1116
|
-
entry.hooks.some(h => isJsonObject(h) && typeof h.command === 'string' && h.command.includes('check-events'))) {
|
|
1117
|
-
entry.matcher = 'mcp__brainclaw__';
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
hooks.PostToolUse = postToolHooks;
|
|
1237
|
+
hooks.UserPromptSubmit = replaceBrainclawHooks(Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [], buildCommandHookEntry(sessionCommand));
|
|
1238
|
+
hooks.Stop = replaceBrainclawHooks(Array.isArray(hooks.Stop) ? hooks.Stop : [], buildCommandHookEntry(stopCommand));
|
|
1239
|
+
hooks.PostToolUse = replaceBrainclawHooks(Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [], buildMatchedCommandHookEntry('mcp__brainclaw__', checkEventsCommand));
|
|
1121
1240
|
const { created, updated } = writeJsonFileIfChanged(filePath, {
|
|
1122
1241
|
...existing,
|
|
1123
1242
|
permissions,
|
|
@@ -1138,6 +1257,9 @@ export function ensureCursorMcpConfig(homeDir) {
|
|
|
1138
1257
|
}
|
|
1139
1258
|
const filePath = path.join(homeDir, '.cursor', 'mcp.json');
|
|
1140
1259
|
const existing = readJsonObject(filePath);
|
|
1260
|
+
if (existing === undefined) {
|
|
1261
|
+
return skippedAutoConfigResult('mcp', 'Cursor MCP settings', filePath, CURSOR_MCP_RELATIVE_PATH);
|
|
1262
|
+
}
|
|
1141
1263
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
1142
1264
|
mcpServers.brainclaw = brainclawMcpEntry('cursor', mcpServers.brainclaw);
|
|
1143
1265
|
const { created, updated } = writeJsonFileIfChanged(filePath, {
|
|
@@ -1156,6 +1278,9 @@ export function ensureCursorMcpConfig(homeDir) {
|
|
|
1156
1278
|
export function ensureRooMcpConfig(cwd) {
|
|
1157
1279
|
const filePath = path.join(cwd, '.roo', 'mcp.json');
|
|
1158
1280
|
const existing = readJsonObject(filePath);
|
|
1281
|
+
if (existing === undefined) {
|
|
1282
|
+
return skippedAutoConfigResult('mcp', 'Roo Code MCP settings', filePath, ROO_MCP_RELATIVE_PATH);
|
|
1283
|
+
}
|
|
1159
1284
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
1160
1285
|
mcpServers.brainclaw = {
|
|
1161
1286
|
...brainclawMcpEntry('roo', mcpServers.brainclaw, cwd),
|
|
@@ -1176,22 +1301,46 @@ export function ensureRooMcpConfig(cwd) {
|
|
|
1176
1301
|
}
|
|
1177
1302
|
export function ensureKilocodeConfig(cwd) {
|
|
1178
1303
|
const filePath = path.join(cwd, KILOCODE_CONFIG_RELATIVE_PATH);
|
|
1304
|
+
const label = 'Kilo Code permissions (kilo.jsonc)';
|
|
1179
1305
|
const existing = readJsoncObject(filePath);
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1306
|
+
if (existing === undefined) {
|
|
1307
|
+
return skippedAutoConfigResult('permissions', label, filePath, KILOCODE_CONFIG_RELATIVE_PATH);
|
|
1308
|
+
}
|
|
1309
|
+
const noop = {
|
|
1184
1310
|
kind: 'permissions',
|
|
1185
|
-
label
|
|
1186
|
-
created,
|
|
1187
|
-
updated,
|
|
1311
|
+
label,
|
|
1312
|
+
created: false,
|
|
1313
|
+
updated: false,
|
|
1188
1314
|
filePath,
|
|
1189
1315
|
relativePath: KILOCODE_CONFIG_RELATIVE_PATH,
|
|
1190
1316
|
};
|
|
1317
|
+
const permission = isJsonObject(existing.permission) ? existing.permission : {};
|
|
1318
|
+
if (permission.external_directory === 'deny') {
|
|
1319
|
+
return noop;
|
|
1320
|
+
}
|
|
1321
|
+
const existed = fs.existsSync(filePath);
|
|
1322
|
+
if (!existed) {
|
|
1323
|
+
const { created, updated } = writeTextFileIfChanged(filePath, `${JSON.stringify({ permission: { external_directory: 'deny' } }, null, 2)}\n`);
|
|
1324
|
+
return { ...noop, created, updated };
|
|
1325
|
+
}
|
|
1326
|
+
// Surgical JSONC edit — kilo.jsonc is a user-owned file where comments are
|
|
1327
|
+
// part of the official format; a parse→stringify round-trip would strip them.
|
|
1328
|
+
const raw = stripBom(fs.readFileSync(filePath, 'utf-8'));
|
|
1329
|
+
const next = raw.trim().length === 0
|
|
1330
|
+
? `${JSON.stringify({ permission: { external_directory: 'deny' } }, null, 2)}\n`
|
|
1331
|
+
: setJsoncValue(raw, ['permission', 'external_directory'], '"deny"');
|
|
1332
|
+
if (next === undefined) {
|
|
1333
|
+
return skippedAutoConfigResult('permissions', label, filePath, KILOCODE_CONFIG_RELATIVE_PATH);
|
|
1334
|
+
}
|
|
1335
|
+
fs.writeFileSync(filePath, next, 'utf-8');
|
|
1336
|
+
return { ...noop, updated: true };
|
|
1191
1337
|
}
|
|
1192
1338
|
export function ensureKilocodeMcpConfig(cwd) {
|
|
1193
1339
|
const filePath = path.join(cwd, '.kilo', 'mcp.json');
|
|
1194
1340
|
const existing = readJsonObject(filePath);
|
|
1341
|
+
if (existing === undefined) {
|
|
1342
|
+
return skippedAutoConfigResult('mcp', 'Kilo Code MCP settings', filePath, KILOCODE_MCP_RELATIVE_PATH);
|
|
1343
|
+
}
|
|
1195
1344
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
1196
1345
|
mcpServers.brainclaw = {
|
|
1197
1346
|
...brainclawMcpEntry('kilocode', mcpServers.brainclaw, cwd),
|
|
@@ -1282,48 +1431,71 @@ export function ensureMistralVibeMcpConfig(cwd) {
|
|
|
1282
1431
|
relativePath: MISTRAL_VIBE_CONFIG_RELATIVE_PATH,
|
|
1283
1432
|
};
|
|
1284
1433
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1434
|
+
// Hermes' MCP `tools.include` array — narrow canonical-grammar surface. Derived
|
|
1435
|
+
// from MCP_CANONICAL_GRAMMAR_TOOL_NAMES (which is itself ALL_TOOLS-derived) so
|
|
1436
|
+
// new facade tools or canonical grammar verbs propagate without a manual edit
|
|
1437
|
+
// here (pln#546 step 2). REMOVED_IN_V1_TOOLS are stripped so deprecated names
|
|
1438
|
+
// don't reappear in user-facing configs.
|
|
1439
|
+
//
|
|
1440
|
+
// LAZY (pln#564 coordinator fix): computed on first call, NOT at module init.
|
|
1441
|
+
// agent-files.ts ↔ commands/mcp.ts form an import cycle; reading the imported
|
|
1442
|
+
// MCP_CANONICAL_GRAMMAR_TOOL_NAMES at module-eval time threw a TDZ
|
|
1443
|
+
// ("Cannot access 'MCP_CANONICAL_GRAMMAR_TOOL_NAMES' before initialization")
|
|
1444
|
+
// when agent-files loaded mid-mcp-init — which broke the MCP server. tsc does
|
|
1445
|
+
// not catch this (runtime-only). Deferring the read to call time fixes it.
|
|
1446
|
+
let hermesBrainclawMcpToolsCache;
|
|
1447
|
+
function getHermesBrainclawMcpTools() {
|
|
1448
|
+
if (!hermesBrainclawMcpToolsCache) {
|
|
1449
|
+
hermesBrainclawMcpToolsCache = MCP_CANONICAL_GRAMMAR_TOOL_NAMES
|
|
1450
|
+
.filter((name) => !REMOVED_IN_V1_TOOLS.has(name));
|
|
1451
|
+
}
|
|
1452
|
+
return hermesBrainclawMcpToolsCache;
|
|
1453
|
+
}
|
|
1294
1454
|
export function ensureHermesMcpConfig(homeDir, workspacePath) {
|
|
1295
1455
|
if (!homeDir)
|
|
1296
1456
|
return undefined;
|
|
1297
1457
|
const filePath = path.join(homeDir, HERMES_CONFIG_RELATIVE_PATH);
|
|
1458
|
+
const label = 'Hermes MCP settings';
|
|
1459
|
+
// Parse the existing file as a YAML *document* so we can update only the
|
|
1460
|
+
// brainclaw-managed subtree. A parse→stringify round-trip of the whole file
|
|
1461
|
+
// destroys user comments, anchors, and key order; a parse failure must
|
|
1462
|
+
// abort instead of replacing the user's Hermes config with a stub.
|
|
1463
|
+
let doc;
|
|
1298
1464
|
let existing = {};
|
|
1299
|
-
let existed = false;
|
|
1300
1465
|
if (fs.existsSync(filePath)) {
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1466
|
+
doc = yaml.parseDocument(stripBom(fs.readFileSync(filePath, 'utf-8')));
|
|
1467
|
+
if (doc.errors.length > 0) {
|
|
1468
|
+
return skippedAutoConfigResult('mcp', label, filePath, HERMES_CONFIG_RELATIVE_PATH);
|
|
1469
|
+
}
|
|
1470
|
+
const parsed = doc.toJS();
|
|
1471
|
+
if (parsed == null) {
|
|
1472
|
+
doc = undefined; // empty file — treat as fresh create
|
|
1305
1473
|
}
|
|
1306
|
-
|
|
1307
|
-
existing =
|
|
1474
|
+
else if (isJsonObject(parsed)) {
|
|
1475
|
+
existing = parsed;
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
return skippedAutoConfigResult('mcp', label, filePath, HERMES_CONFIG_RELATIVE_PATH);
|
|
1308
1479
|
}
|
|
1309
1480
|
}
|
|
1310
|
-
const
|
|
1311
|
-
const current = isJsonObject(
|
|
1481
|
+
const mcpServersJs = isJsonObject(existing.mcp_servers) ? existing.mcp_servers : {};
|
|
1482
|
+
const current = isJsonObject(mcpServersJs.brainclaw) ? { ...mcpServersJs.brainclaw } : {};
|
|
1312
1483
|
const currentEnv = isJsonObject(current.env) ? { ...current.env } : {};
|
|
1313
1484
|
const currentTools = isJsonObject(current.tools) ? { ...current.tools } : {};
|
|
1314
|
-
const skills = isJsonObject(existing.skills) ?
|
|
1485
|
+
const skills = isJsonObject(existing.skills) ? existing.skills : {};
|
|
1315
1486
|
const externalDirs = Array.isArray(skills.external_dirs)
|
|
1316
1487
|
? skills.external_dirs.filter((value) => typeof value === 'string')
|
|
1317
1488
|
: [];
|
|
1489
|
+
const newExternalDirs = [];
|
|
1318
1490
|
if (workspacePath) {
|
|
1319
1491
|
const projectSkillsDir = path.resolve(workspacePath, HERMES_EXTERNAL_SKILLS_RELATIVE_PATH);
|
|
1320
1492
|
const normalized = projectSkillsDir.replace(/\\/g, '/').toLowerCase();
|
|
1321
1493
|
if (!externalDirs.some((dir) => dir.replace(/\\/g, '/').toLowerCase() === normalized)) {
|
|
1322
|
-
|
|
1494
|
+
newExternalDirs.push(projectSkillsDir);
|
|
1323
1495
|
}
|
|
1324
1496
|
}
|
|
1325
1497
|
const mcpCmd = getBrainclawMcpCommand();
|
|
1326
|
-
|
|
1498
|
+
const desiredEntry = {
|
|
1327
1499
|
...current,
|
|
1328
1500
|
command: typeof current.command === 'string' ? current.command : mcpCmd.command,
|
|
1329
1501
|
args: Array.isArray(current.args) ? current.args : mcpCmd.args,
|
|
@@ -1333,23 +1505,67 @@ export function ensureHermesMcpConfig(homeDir, workspacePath) {
|
|
|
1333
1505
|
},
|
|
1334
1506
|
tools: {
|
|
1335
1507
|
...currentTools,
|
|
1336
|
-
include: Array.isArray(currentTools.include) ? currentTools.include :
|
|
1508
|
+
include: Array.isArray(currentTools.include) ? currentTools.include : getHermesBrainclawMcpTools(),
|
|
1337
1509
|
prompts: typeof currentTools.prompts === 'boolean' ? currentTools.prompts : false,
|
|
1338
1510
|
resources: typeof currentTools.resources === 'boolean' ? currentTools.resources : false,
|
|
1339
1511
|
},
|
|
1340
1512
|
};
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1513
|
+
if (!doc) {
|
|
1514
|
+
const nextConfig = {
|
|
1515
|
+
mcp_servers: { brainclaw: desiredEntry },
|
|
1516
|
+
...(externalDirs.length + newExternalDirs.length > 0
|
|
1517
|
+
? { skills: { ...skills, external_dirs: [...externalDirs, ...newExternalDirs] } }
|
|
1518
|
+
: {}),
|
|
1519
|
+
};
|
|
1520
|
+
const content = `# brainclaw manages the mcp_servers.brainclaw entry below\n${yaml.stringify(nextConfig)}`;
|
|
1521
|
+
const { created, updated } = writeTextFileIfChanged(filePath, content);
|
|
1522
|
+
return {
|
|
1523
|
+
kind: 'mcp',
|
|
1524
|
+
label,
|
|
1525
|
+
created,
|
|
1526
|
+
updated,
|
|
1527
|
+
filePath,
|
|
1528
|
+
relativePath: HERMES_CONFIG_RELATIVE_PATH,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
let changed = false;
|
|
1532
|
+
try {
|
|
1533
|
+
const currentRaw = isJsonObject(mcpServersJs.brainclaw) ? mcpServersJs.brainclaw : undefined;
|
|
1534
|
+
if (JSON.stringify(currentRaw ?? null) !== JSON.stringify(desiredEntry)) {
|
|
1535
|
+
doc.setIn(['mcp_servers', 'brainclaw'], desiredEntry);
|
|
1536
|
+
changed = true;
|
|
1537
|
+
}
|
|
1538
|
+
if (newExternalDirs.length > 0) {
|
|
1539
|
+
if (doc.getIn(['skills', 'external_dirs']) === undefined) {
|
|
1540
|
+
doc.setIn(['skills', 'external_dirs'], [...externalDirs, ...newExternalDirs]);
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
for (const dir of newExternalDirs) {
|
|
1544
|
+
doc.addIn(['skills', 'external_dirs'], dir);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
changed = true;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
return skippedAutoConfigResult('mcp', label, filePath, HERMES_CONFIG_RELATIVE_PATH);
|
|
1552
|
+
}
|
|
1553
|
+
if (!changed) {
|
|
1554
|
+
return {
|
|
1555
|
+
kind: 'mcp',
|
|
1556
|
+
label,
|
|
1557
|
+
created: false,
|
|
1558
|
+
updated: false,
|
|
1559
|
+
filePath,
|
|
1560
|
+
relativePath: HERMES_CONFIG_RELATIVE_PATH,
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
fs.writeFileSync(filePath, doc.toString(), 'utf-8');
|
|
1348
1564
|
return {
|
|
1349
1565
|
kind: 'mcp',
|
|
1350
|
-
label
|
|
1351
|
-
created:
|
|
1352
|
-
updated:
|
|
1566
|
+
label,
|
|
1567
|
+
created: false,
|
|
1568
|
+
updated: true,
|
|
1353
1569
|
filePath,
|
|
1354
1570
|
relativePath: HERMES_CONFIG_RELATIVE_PATH,
|
|
1355
1571
|
};
|
|
@@ -1472,7 +1688,7 @@ export function ensureCodexMcpConfig(homeDir, env = process.env) {
|
|
|
1472
1688
|
content = content + brainclawBlock + '\n';
|
|
1473
1689
|
changed = true;
|
|
1474
1690
|
}
|
|
1475
|
-
else if (
|
|
1691
|
+
else if (isForceResolveEnabled()) {
|
|
1476
1692
|
const replaced = replaceTomlSection(content, 'mcp_servers.brainclaw', brainclawBlock.slice(1) + '\n');
|
|
1477
1693
|
if (replaced !== content) {
|
|
1478
1694
|
content = replaced;
|
|
@@ -1480,8 +1696,8 @@ export function ensureCodexMcpConfig(homeDir, env = process.env) {
|
|
|
1480
1696
|
}
|
|
1481
1697
|
}
|
|
1482
1698
|
// Per-tool approval blocks: ALWAYS sync to the current catalog, regardless
|
|
1483
|
-
// of
|
|
1484
|
-
// expected) and must match the narrowed headless-auto catalog.
|
|
1699
|
+
// of force-resolve state. These sections are purely machine-managed (no user
|
|
1700
|
+
// edits expected) and must match the narrowed headless-auto catalog.
|
|
1485
1701
|
const hasToolSections = /^\[mcp_servers\.brainclaw\.tools\./m.test(content);
|
|
1486
1702
|
if (hasToolSections) {
|
|
1487
1703
|
const replaced = replaceTomlSection(content, 'mcp_servers.brainclaw.tools', toolsBlock.slice(1));
|
|
@@ -1555,6 +1771,9 @@ function replaceTomlSection(fileContent, sectionName, newBlock) {
|
|
|
1555
1771
|
export function ensureContinueMcpConfig(cwd) {
|
|
1556
1772
|
const filePath = path.join(cwd, '.continue', 'config.json');
|
|
1557
1773
|
const existing = readJsonObject(filePath);
|
|
1774
|
+
if (existing === undefined) {
|
|
1775
|
+
return skippedAutoConfigResult('mcp', 'Continue MCP settings', filePath, CONTINUE_CONFIG_RELATIVE_PATH);
|
|
1776
|
+
}
|
|
1558
1777
|
// Continue uses an array for mcpServers, not a keyed object
|
|
1559
1778
|
const mcpServers = Array.isArray(existing.mcpServers) ? [...existing.mcpServers] : [];
|
|
1560
1779
|
const existingIdx = mcpServers.findIndex((entry) => isJsonObject(entry) && entry.name === 'brainclaw');
|
|
@@ -1583,6 +1802,9 @@ export function ensureContinueUserMcpConfig(homeDir) {
|
|
|
1583
1802
|
return undefined;
|
|
1584
1803
|
const filePath = path.join(homeDir, '.continue', 'config.json');
|
|
1585
1804
|
const existing = readJsonObject(filePath);
|
|
1805
|
+
if (existing === undefined) {
|
|
1806
|
+
return skippedAutoConfigResult('mcp', 'Continue MCP settings (global, all projects)', filePath);
|
|
1807
|
+
}
|
|
1586
1808
|
const mcpServers = Array.isArray(existing.mcpServers) ? [...existing.mcpServers] : [];
|
|
1587
1809
|
const existingIdx = mcpServers.findIndex((entry) => isJsonObject(entry) && entry.name === 'brainclaw');
|
|
1588
1810
|
if (existingIdx >= 0) {
|
|
@@ -1622,40 +1844,69 @@ export function ensureContinueUserPermissions(homeDir) {
|
|
|
1622
1844
|
if (!homeDir)
|
|
1623
1845
|
return undefined;
|
|
1624
1846
|
const filePath = path.join(homeDir, CONTINUE_PERMISSIONS_RELATIVE_PATH);
|
|
1625
|
-
|
|
1847
|
+
const label = 'Continue tool permissions';
|
|
1848
|
+
const noop = { kind: 'permissions', label, created: false, updated: false, filePath };
|
|
1849
|
+
// Update only the per-tool `allow` flags in the YAML document; never
|
|
1850
|
+
// round-trip the whole user file (comments/anchors/key order survive) and
|
|
1851
|
+
// never replace an unparseable file with a stub.
|
|
1852
|
+
let doc;
|
|
1853
|
+
let existingTools = {};
|
|
1626
1854
|
if (fs.existsSync(filePath)) {
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1855
|
+
doc = yaml.parseDocument(stripBom(fs.readFileSync(filePath, 'utf-8')));
|
|
1856
|
+
if (doc.errors.length > 0) {
|
|
1857
|
+
return skippedAutoConfigResult('permissions', label, filePath);
|
|
1630
1858
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1859
|
+
const parsed = doc.toJS();
|
|
1860
|
+
if (parsed == null) {
|
|
1861
|
+
doc = undefined;
|
|
1862
|
+
}
|
|
1863
|
+
else if (isJsonObject(parsed)) {
|
|
1864
|
+
existingTools = isJsonObject(parsed.tools) ? parsed.tools : {};
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
return skippedAutoConfigResult('permissions', label, filePath);
|
|
1633
1868
|
}
|
|
1634
1869
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
};
|
|
1870
|
+
if (!doc) {
|
|
1871
|
+
const toolsObj = {};
|
|
1872
|
+
for (const name of getHeadlessAutoApprovedToolNames()) {
|
|
1873
|
+
toolsObj[name] = { allow: true };
|
|
1874
|
+
}
|
|
1875
|
+
const content = `# brainclaw manages the per-tool allow flags below\n${yaml.stringify({ tools: toolsObj })}`;
|
|
1876
|
+
const { created, updated } = writeTextFileIfChanged(filePath, content);
|
|
1877
|
+
return { ...noop, created, updated };
|
|
1642
1878
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1879
|
+
let changed = false;
|
|
1880
|
+
try {
|
|
1881
|
+
for (const name of getHeadlessAutoApprovedToolNames()) {
|
|
1882
|
+
const current = existingTools[name];
|
|
1883
|
+
const allow = isJsonObject(current) ? current.allow : undefined;
|
|
1884
|
+
if (allow !== true) {
|
|
1885
|
+
if (current !== undefined && !isJsonObject(current)) {
|
|
1886
|
+
doc.setIn(['tools', name], { allow: true });
|
|
1887
|
+
}
|
|
1888
|
+
else {
|
|
1889
|
+
doc.setIn(['tools', name, 'allow'], true);
|
|
1890
|
+
}
|
|
1891
|
+
changed = true;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
catch {
|
|
1896
|
+
return skippedAutoConfigResult('permissions', label, filePath);
|
|
1897
|
+
}
|
|
1898
|
+
if (!changed) {
|
|
1899
|
+
return noop;
|
|
1900
|
+
}
|
|
1901
|
+
fs.writeFileSync(filePath, doc.toString(), 'utf-8');
|
|
1902
|
+
return { ...noop, updated: true };
|
|
1655
1903
|
}
|
|
1656
1904
|
export function ensureOpenCodeMcpConfig(cwd) {
|
|
1657
1905
|
const filePath = path.join(cwd, 'opencode.json');
|
|
1658
1906
|
const existing = readJsonObject(filePath);
|
|
1907
|
+
if (existing === undefined) {
|
|
1908
|
+
return skippedAutoConfigResult('mcp', 'OpenCode MCP config', filePath, OPENCODE_CONFIG_RELATIVE_PATH);
|
|
1909
|
+
}
|
|
1659
1910
|
const mcp = isJsonObject(existing.mcp) ? { ...existing.mcp } : {};
|
|
1660
1911
|
const mcpCmd = getBrainclawMcpCommand();
|
|
1661
1912
|
mcp.brainclaw = {
|
|
@@ -1683,6 +1934,9 @@ export function ensureAntigravityMcpConfig(homeDir) {
|
|
|
1683
1934
|
}
|
|
1684
1935
|
const filePath = path.join(homeDir, '.gemini', 'antigravity', 'mcp_config.json');
|
|
1685
1936
|
const existing = readJsonObject(filePath);
|
|
1937
|
+
if (existing === undefined) {
|
|
1938
|
+
return skippedAutoConfigResult('mcp', 'Antigravity MCP config', filePath, ANTIGRAVITY_MCP_RELATIVE_PATH);
|
|
1939
|
+
}
|
|
1686
1940
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
1687
1941
|
mcpServers.brainclaw = brainclawMcpEntry('antigravity', mcpServers.brainclaw);
|
|
1688
1942
|
const { created, updated } = writeJsonFileIfChanged(filePath, {
|
|
@@ -1698,35 +1952,6 @@ export function ensureAntigravityMcpConfig(homeDir) {
|
|
|
1698
1952
|
relativePath: ANTIGRAVITY_MCP_RELATIVE_PATH,
|
|
1699
1953
|
};
|
|
1700
1954
|
}
|
|
1701
|
-
function quoteShellArg(arg) {
|
|
1702
|
-
if (/^[A-Za-z0-9_./:=+-]+$/.test(arg))
|
|
1703
|
-
return arg;
|
|
1704
|
-
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
1705
|
-
}
|
|
1706
|
-
/**
|
|
1707
|
-
* Resolve the brainclaw CLI invocation for hook configs.
|
|
1708
|
-
* Returns shell-safe parts like `["<node>", "<cli.js>"]` or `["npx", "brainclaw"]`.
|
|
1709
|
-
*/
|
|
1710
|
-
function getBclawCliParts() {
|
|
1711
|
-
const mcpCmd = getBrainclawMcpCommand();
|
|
1712
|
-
if (mcpCmd.command === 'npx')
|
|
1713
|
-
return ['npx', 'brainclaw'];
|
|
1714
|
-
const argsWithoutMcp = [...mcpCmd.args];
|
|
1715
|
-
if (argsWithoutMcp[argsWithoutMcp.length - 1] === 'mcp') {
|
|
1716
|
-
argsWithoutMcp.pop();
|
|
1717
|
-
}
|
|
1718
|
-
return [
|
|
1719
|
-
mcpCmd.command.replace(/\\/g, '/'),
|
|
1720
|
-
...argsWithoutMcp.map((arg) => arg.replace(/\\/g, '/')),
|
|
1721
|
-
];
|
|
1722
|
-
}
|
|
1723
|
-
function buildHookCommand(args, shell = os.platform() === 'win32' ? 'powershell' : 'bash') {
|
|
1724
|
-
const rendered = [...getBclawCliParts(), ...args].map(quoteShellArg).join(' ');
|
|
1725
|
-
if (shell === 'powershell') {
|
|
1726
|
-
return `& ${rendered} 2>$null`;
|
|
1727
|
-
}
|
|
1728
|
-
return `${rendered} 2>/dev/null`;
|
|
1729
|
-
}
|
|
1730
1955
|
/**
|
|
1731
1956
|
* Writes `.cursor/hooks.json` — Cursor's native hooks config.
|
|
1732
1957
|
* Events: sessionStart, beforeSubmitPrompt, stop (Cursor uses camelCase).
|
|
@@ -1735,6 +1960,9 @@ function buildHookCommand(args, shell = os.platform() === 'win32' ? 'powershell'
|
|
|
1735
1960
|
export function ensureCursorHooks(cwd) {
|
|
1736
1961
|
const filePath = path.join(cwd, CURSOR_HOOKS_RELATIVE_PATH);
|
|
1737
1962
|
const existing = readJsonObject(filePath);
|
|
1963
|
+
if (existing === undefined) {
|
|
1964
|
+
return skippedAutoConfigResult('rule', 'Cursor session hooks', filePath, CURSOR_HOOKS_RELATIVE_PATH);
|
|
1965
|
+
}
|
|
1738
1966
|
const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
|
|
1739
1967
|
const sessionStartCmd = buildHookCommand(['session-start', '--include-context']);
|
|
1740
1968
|
const contextDiffCmd = buildHookCommand(['context-diff']);
|
|
@@ -1765,6 +1993,9 @@ export function ensureAntigravityHooks(homeDir) {
|
|
|
1765
1993
|
return undefined;
|
|
1766
1994
|
const filePath = path.join(homeDir, ANTIGRAVITY_HOOKS_RELATIVE_PATH);
|
|
1767
1995
|
const existing = readJsonObject(filePath);
|
|
1996
|
+
if (existing === undefined) {
|
|
1997
|
+
return skippedAutoConfigResult('rule', 'Antigravity session hooks', filePath, ANTIGRAVITY_HOOKS_RELATIVE_PATH);
|
|
1998
|
+
}
|
|
1768
1999
|
const sessionStartCmd = buildHookCommand(['session-start', '--include-context']);
|
|
1769
2000
|
const contextDiffCmd = buildHookCommand(['context-diff']);
|
|
1770
2001
|
const sessionEndCmd = buildHookCommand(['session-end', '--auto-release', '--reflect', '--reflect-handoff', '--dispatch-review']);
|
|
@@ -1792,6 +2023,9 @@ export function ensureAntigravityHooks(homeDir) {
|
|
|
1792
2023
|
export function ensureCopilotHooks(cwd) {
|
|
1793
2024
|
const filePath = path.join(cwd, COPILOT_HOOKS_RELATIVE_PATH);
|
|
1794
2025
|
const existing = readJsonObject(filePath);
|
|
2026
|
+
if (existing === undefined) {
|
|
2027
|
+
return skippedAutoConfigResult('rule', 'Copilot session hooks', filePath, COPILOT_HOOKS_RELATIVE_PATH);
|
|
2028
|
+
}
|
|
1795
2029
|
const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
|
|
1796
2030
|
hooks.sessionStart = [{
|
|
1797
2031
|
type: 'command',
|
|
@@ -1831,6 +2065,9 @@ export function ensureOpenClawMcpConfig(homeDir) {
|
|
|
1831
2065
|
}
|
|
1832
2066
|
const filePath = path.join(homeDir, '.openclaw', 'mcp.json');
|
|
1833
2067
|
const existing = readJsonObject(filePath);
|
|
2068
|
+
if (existing === undefined) {
|
|
2069
|
+
return skippedAutoConfigResult('mcp', 'OpenClaw MCP config', filePath, OPENCLAW_MCP_RELATIVE_PATH);
|
|
2070
|
+
}
|
|
1834
2071
|
const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
|
|
1835
2072
|
mcpServers.brainclaw = brainclawMcpEntry('openclaw', mcpServers.brainclaw);
|
|
1836
2073
|
const { created, updated } = writeJsonFileIfChanged(filePath, {
|
|
@@ -1846,212 +2083,272 @@ export function ensureOpenClawMcpConfig(homeDir) {
|
|
|
1846
2083
|
relativePath: OPENCLAW_MCP_RELATIVE_PATH,
|
|
1847
2084
|
};
|
|
1848
2085
|
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2086
|
+
// Shared writer builders — referenced across multiple agents.
|
|
2087
|
+
const writeUniversalSkill = (ctx) => ensureUniversalBrainclawSkill(ctx.cwd);
|
|
2088
|
+
const writeProtocolSkills = (ctx) => ensureProtocolSkills(ctx.cwd);
|
|
2089
|
+
const writeVscodeExtensionRec = (ctx) => ensureVscodeExtensionRecommendation(ctx.cwd);
|
|
2090
|
+
/**
|
|
2091
|
+
* Per-agent writer wiring. The keys are canonical agent names from
|
|
2092
|
+
* `AgentName` (see agent-capability.ts) — keep in sync with AGENT_EXPORT_REGISTRY.
|
|
2093
|
+
* Agents missing from this map yield an empty writer list (no-op detection),
|
|
2094
|
+
* which the drift test below guards against.
|
|
2095
|
+
*/
|
|
2096
|
+
export const AGENT_WIRING_REGISTRY = {
|
|
2097
|
+
'claude-code': {
|
|
2098
|
+
// .claude/settings.local.json bundles permissions + session/Stop/PostToolUse
|
|
2099
|
+
// hooks in one file, so it lives in workspaceWriters — not duplicated in
|
|
2100
|
+
// hookWriters (which would double-count the result).
|
|
2101
|
+
workspaceWriters: [
|
|
2102
|
+
(ctx) => ensureClaudeCodeMcpConfig(ctx.cwd),
|
|
2103
|
+
(ctx) => ensureClaudeCodeCommand(ctx.cwd),
|
|
2104
|
+
(ctx) => ensureClaudeCodeSettings(ctx.cwd),
|
|
2105
|
+
writeVscodeExtensionRec,
|
|
2106
|
+
(ctx) => ensureProjectDevDependency(ctx.cwd),
|
|
2107
|
+
],
|
|
2108
|
+
userWriters: [
|
|
2109
|
+
(ctx) => ensureClaudeCodeUserSettings(ctx.homeDir, ctx.env),
|
|
2110
|
+
(ctx) => ensureClaudeCodeUserCommand(ctx.homeDir),
|
|
2111
|
+
],
|
|
2112
|
+
hookWriters: [],
|
|
2113
|
+
},
|
|
2114
|
+
cline: {
|
|
2115
|
+
workspaceWriters: [(ctx) => ensureClineMcpConfig(ctx.cwd)],
|
|
2116
|
+
userWriters: [],
|
|
2117
|
+
hookWriters: [],
|
|
2118
|
+
},
|
|
2119
|
+
windsurf: {
|
|
2120
|
+
workspaceWriters: [(ctx) => ensureWindsurfModernRules(ctx.cwd)],
|
|
2121
|
+
userWriters: [(ctx) => ensureWindsurfMcpConfig(ctx.homeDir)],
|
|
2122
|
+
hookWriters: [],
|
|
2123
|
+
},
|
|
2124
|
+
'github-copilot': {
|
|
2125
|
+
workspaceWriters: [
|
|
2126
|
+
(ctx) => ensureCopilotMcpConfig(ctx.cwd),
|
|
2127
|
+
(ctx) => ensureCopilotSkill(ctx.cwd),
|
|
2128
|
+
writeUniversalSkill,
|
|
2129
|
+
writeProtocolSkills,
|
|
2130
|
+
writeVscodeExtensionRec,
|
|
2131
|
+
],
|
|
2132
|
+
userWriters: [],
|
|
2133
|
+
hookWriters: [(ctx) => ensureCopilotHooks(ctx.cwd)],
|
|
2134
|
+
},
|
|
2135
|
+
cursor: {
|
|
2136
|
+
workspaceWriters: [
|
|
2137
|
+
(ctx) => ensureCursorMdc(ctx.cwd),
|
|
2138
|
+
writeUniversalSkill,
|
|
2139
|
+
writeProtocolSkills,
|
|
2140
|
+
],
|
|
2141
|
+
userWriters: [(ctx) => ensureCursorMcpConfig(ctx.homeDir)],
|
|
2142
|
+
hookWriters: [(ctx) => ensureCursorHooks(ctx.cwd)],
|
|
2143
|
+
},
|
|
2144
|
+
roo: {
|
|
2145
|
+
workspaceWriters: [
|
|
2146
|
+
(ctx) => ensureRooMcpConfig(ctx.cwd),
|
|
2147
|
+
writeUniversalSkill,
|
|
2148
|
+
writeProtocolSkills,
|
|
2149
|
+
],
|
|
2150
|
+
userWriters: [],
|
|
2151
|
+
hookWriters: [],
|
|
2152
|
+
},
|
|
2153
|
+
kilocode: {
|
|
2154
|
+
workspaceWriters: [
|
|
2155
|
+
(ctx) => ensureKilocodeMcpConfig(ctx.cwd),
|
|
2156
|
+
(ctx) => ensureKilocodeConfig(ctx.cwd),
|
|
2157
|
+
writeUniversalSkill,
|
|
2158
|
+
writeProtocolSkills,
|
|
2159
|
+
],
|
|
2160
|
+
userWriters: [],
|
|
2161
|
+
hookWriters: [],
|
|
2162
|
+
},
|
|
2163
|
+
'mistral-vibe': {
|
|
2164
|
+
workspaceWriters: [
|
|
2165
|
+
(ctx) => ensureMistralVibeMcpConfig(ctx.cwd),
|
|
2166
|
+
writeUniversalSkill,
|
|
2167
|
+
writeProtocolSkills,
|
|
2168
|
+
],
|
|
2169
|
+
userWriters: [],
|
|
2170
|
+
hookWriters: [],
|
|
2171
|
+
},
|
|
2172
|
+
hermes: {
|
|
2173
|
+
workspaceWriters: [
|
|
2174
|
+
writeUniversalSkill,
|
|
2175
|
+
writeProtocolSkills,
|
|
2176
|
+
],
|
|
2177
|
+
userWriters: [(ctx) => ensureHermesMcpConfig(ctx.homeDir, ctx.workspacePath ?? ctx.cwd)],
|
|
2178
|
+
hookWriters: [],
|
|
2179
|
+
},
|
|
2180
|
+
codex: {
|
|
2181
|
+
workspaceWriters: [
|
|
2182
|
+
writeUniversalSkill,
|
|
2183
|
+
writeProtocolSkills,
|
|
2184
|
+
],
|
|
2185
|
+
userWriters: [(ctx) => ensureCodexMcpConfig(ctx.homeDir, ctx.env)],
|
|
2186
|
+
hookWriters: [],
|
|
2187
|
+
},
|
|
2188
|
+
continue: {
|
|
2189
|
+
workspaceWriters: [(ctx) => ensureContinueMcpConfig(ctx.cwd)],
|
|
2190
|
+
userWriters: [
|
|
2191
|
+
(ctx) => ensureContinueUserMcpConfig(ctx.homeDir),
|
|
2192
|
+
(ctx) => ensureContinueUserPermissions(ctx.homeDir),
|
|
2193
|
+
],
|
|
2194
|
+
hookWriters: [],
|
|
2195
|
+
},
|
|
2196
|
+
opencode: {
|
|
2197
|
+
workspaceWriters: [
|
|
2198
|
+
(ctx) => ensureOpenCodeMcpConfig(ctx.cwd),
|
|
2199
|
+
writeUniversalSkill,
|
|
2200
|
+
writeProtocolSkills,
|
|
2201
|
+
],
|
|
2202
|
+
userWriters: [],
|
|
2203
|
+
hookWriters: [],
|
|
2204
|
+
},
|
|
2205
|
+
antigravity: {
|
|
2206
|
+
workspaceWriters: [],
|
|
2207
|
+
userWriters: [(ctx) => ensureAntigravityMcpConfig(ctx.homeDir)],
|
|
2208
|
+
// Antigravity hook config lives under the user home — keep it in
|
|
2209
|
+
// hookWriters (semantic grouping) but skip with the inventory gate like
|
|
2210
|
+
// the other user-level fabricators.
|
|
2211
|
+
hookWriters: [(ctx) => ensureAntigravityHooks(ctx.homeDir)],
|
|
2212
|
+
},
|
|
2213
|
+
openclaw: {
|
|
2214
|
+
workspaceWriters: [],
|
|
2215
|
+
userWriters: [(ctx) => ensureOpenClawMcpConfig(ctx.homeDir)],
|
|
2216
|
+
hookWriters: [],
|
|
2217
|
+
},
|
|
2218
|
+
// Pure SKILL.md surfaces — nanoclaw/nemoclaw/picoclaw/zeroclaw have no MCP
|
|
2219
|
+
// config and their instruction file is written by the export pipeline.
|
|
2220
|
+
nanoclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
|
|
2221
|
+
nemoclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
|
|
2222
|
+
picoclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
|
|
2223
|
+
zeroclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
|
|
2224
|
+
// claude-sonnet: shares CLAUDE.md surface with claude-code; the workspace
|
|
2225
|
+
// and user wiring is identical, so detection should reuse claude-code's
|
|
2226
|
+
// descriptor rather than duplicate it.
|
|
2227
|
+
'claude-sonnet': {
|
|
2228
|
+
workspaceWriters: [
|
|
2229
|
+
(ctx) => ensureClaudeCodeMcpConfig(ctx.cwd),
|
|
2230
|
+
(ctx) => ensureClaudeCodeCommand(ctx.cwd),
|
|
2231
|
+
(ctx) => ensureClaudeCodeSettings(ctx.cwd),
|
|
2232
|
+
writeVscodeExtensionRec,
|
|
2233
|
+
(ctx) => ensureProjectDevDependency(ctx.cwd),
|
|
2234
|
+
],
|
|
2235
|
+
userWriters: [
|
|
2236
|
+
(ctx) => ensureClaudeCodeUserSettings(ctx.homeDir, ctx.env),
|
|
2237
|
+
(ctx) => ensureClaudeCodeUserCommand(ctx.homeDir),
|
|
2238
|
+
],
|
|
2239
|
+
hookWriters: [],
|
|
2240
|
+
},
|
|
2241
|
+
};
|
|
2242
|
+
/**
|
|
2243
|
+
* Drain a writer's return value into the result list (skip null/undefined,
|
|
2244
|
+
* flatten arrays from writers like ensureProtocolSkills).
|
|
2245
|
+
*/
|
|
2246
|
+
function pushWriterResult(results, value) {
|
|
2247
|
+
if (!value)
|
|
2248
|
+
return;
|
|
2249
|
+
if (Array.isArray(value)) {
|
|
2250
|
+
for (const r of value)
|
|
2251
|
+
results.push(r);
|
|
2252
|
+
}
|
|
2253
|
+
else {
|
|
2254
|
+
results.push(value);
|
|
1937
2255
|
}
|
|
1938
2256
|
}
|
|
2257
|
+
/**
|
|
2258
|
+
* Run a descriptor's writers against the given context. The `skipUserIfNotInstalled`
|
|
2259
|
+
* flag consults `isAgentInstalledPerInventory` and drops user-level writers when
|
|
2260
|
+
* the agent isn't present on this machine — preventing init from fabricating
|
|
2261
|
+
* `~/.codex/config.toml` (etc.) on machines that never had codex installed.
|
|
2262
|
+
*/
|
|
2263
|
+
function runAgentWriters(descriptor, ctx, agentName, opts = {}) {
|
|
2264
|
+
const out = [];
|
|
2265
|
+
for (const fn of descriptor.workspaceWriters)
|
|
2266
|
+
pushWriterResult(out, fn(ctx));
|
|
2267
|
+
for (const fn of descriptor.hookWriters)
|
|
2268
|
+
pushWriterResult(out, fn(ctx));
|
|
2269
|
+
// User-level writers fabricate machine-wide config. When agents-inventory is
|
|
2270
|
+
// available and reports the agent as NOT installed, skip them — see the
|
|
2271
|
+
// brief's "consult agent-inventory before fabricating user-level configs".
|
|
2272
|
+
const installed = opts.skipUserIfNotInstalled
|
|
2273
|
+
? isAgentInstalledPerInventory(agentName)
|
|
2274
|
+
: undefined;
|
|
2275
|
+
const skipUser = opts.skipUserIfNotInstalled && installed === false;
|
|
2276
|
+
if (!skipUser) {
|
|
2277
|
+
for (const fn of descriptor.userWriters)
|
|
2278
|
+
pushWriterResult(out, fn(ctx));
|
|
2279
|
+
}
|
|
2280
|
+
if (opts.kindFilter) {
|
|
2281
|
+
return out.filter((r) => r.kind === opts.kindFilter);
|
|
2282
|
+
}
|
|
2283
|
+
return out;
|
|
2284
|
+
}
|
|
2285
|
+
export function writeDetectedAgentAutoConfig(agentName, cwd, env = process.env) {
|
|
2286
|
+
const descriptor = AGENT_WIRING_REGISTRY[agentName];
|
|
2287
|
+
if (!descriptor)
|
|
2288
|
+
return [];
|
|
2289
|
+
const ctx = { cwd, homeDir: resolveHomeDir(env), env, workspacePath: cwd };
|
|
2290
|
+
return runAgentWriters(descriptor, ctx, agentName);
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Map an ExportFormat to the agent whose wiring should run. For formats shared
|
|
2294
|
+
* by multiple agents (e.g. agents-md is reused by codex / opencode / mistral /
|
|
2295
|
+
* hermes), the registry order in AGENT_EXPORT_REGISTRY determines the winner —
|
|
2296
|
+
* matching the existing dedupe behaviour in `brainclaw export --all`.
|
|
2297
|
+
*/
|
|
2298
|
+
function resolveAgentForFormat(format) {
|
|
2299
|
+
return AGENT_EXPORT_REGISTRY.find((t) => t.format === format)?.agentName;
|
|
2300
|
+
}
|
|
1939
2301
|
export function writeExportCompanionFiles(format, cwd, env = process.env) {
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
const userCmd = ensureClaudeCodeUserCommand(resolveHomeDir(env));
|
|
1951
|
-
if (userCmd)
|
|
1952
|
-
results.push(userCmd);
|
|
1953
|
-
const dep = ensureProjectDevDependency(cwd);
|
|
1954
|
-
if (dep)
|
|
1955
|
-
results.push(dep);
|
|
1956
|
-
return results;
|
|
1957
|
-
}
|
|
1958
|
-
case 'cline':
|
|
1959
|
-
return [ensureClineMcpConfig(cwd)];
|
|
1960
|
-
case 'windsurf': {
|
|
1961
|
-
const results = [ensureWindsurfModernRules(cwd)];
|
|
1962
|
-
const mcp = ensureWindsurfMcpConfig(resolveHomeDir(env));
|
|
1963
|
-
if (mcp)
|
|
1964
|
-
results.push(mcp);
|
|
1965
|
-
return results;
|
|
1966
|
-
}
|
|
1967
|
-
case 'copilot-instructions':
|
|
1968
|
-
return [ensureVscodeMcpConfig(cwd), ensureCopilotMcpConfig(cwd), ensureCopilotSkill(cwd), ensureCopilotHooks(cwd)];
|
|
1969
|
-
case 'cursor-rules': {
|
|
1970
|
-
const results = [ensureCursorMdc(cwd), ensureCursorHooks(cwd)];
|
|
1971
|
-
const mcp = ensureCursorMcpConfig(resolveHomeDir(env));
|
|
1972
|
-
if (mcp)
|
|
1973
|
-
results.push(mcp);
|
|
1974
|
-
return results;
|
|
1975
|
-
}
|
|
1976
|
-
case 'roo':
|
|
1977
|
-
return [ensureRooMcpConfig(cwd)];
|
|
1978
|
-
case 'kilocode':
|
|
1979
|
-
return [ensureKilocodeMcpConfig(cwd), ensureKilocodeConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
|
|
1980
|
-
case 'continue': {
|
|
1981
|
-
const results = [ensureContinueMcpConfig(cwd)];
|
|
1982
|
-
const homeDir = resolveHomeDir(env);
|
|
1983
|
-
const userMcp = ensureContinueUserMcpConfig(homeDir);
|
|
1984
|
-
if (userMcp)
|
|
1985
|
-
results.push(userMcp);
|
|
1986
|
-
const perms = ensureContinueUserPermissions(homeDir);
|
|
1987
|
-
if (perms)
|
|
1988
|
-
results.push(perms);
|
|
1989
|
-
return results;
|
|
1990
|
-
}
|
|
1991
|
-
case 'gemini-md': {
|
|
1992
|
-
const homeDir = resolveHomeDir(env);
|
|
1993
|
-
const results = [];
|
|
1994
|
-
const mcp = ensureAntigravityMcpConfig(homeDir);
|
|
1995
|
-
if (mcp)
|
|
1996
|
-
results.push(mcp);
|
|
1997
|
-
const hooks = ensureAntigravityHooks(homeDir);
|
|
1998
|
-
if (hooks)
|
|
1999
|
-
results.push(hooks);
|
|
2000
|
-
return results;
|
|
2001
|
-
}
|
|
2002
|
-
case 'agents-md':
|
|
2003
|
-
return [ensureUniversalBrainclawSkill(cwd)];
|
|
2004
|
-
default:
|
|
2005
|
-
return [];
|
|
2006
|
-
}
|
|
2302
|
+
const agentName = resolveAgentForFormat(format);
|
|
2303
|
+
if (!agentName)
|
|
2304
|
+
return [];
|
|
2305
|
+
const descriptor = AGENT_WIRING_REGISTRY[agentName];
|
|
2306
|
+
if (!descriptor)
|
|
2307
|
+
return [];
|
|
2308
|
+
const ctx = { cwd, homeDir: resolveHomeDir(env), env, workspacePath: cwd };
|
|
2309
|
+
// Export is "I want this surface even if the agent isn't installed yet" —
|
|
2310
|
+
// the user explicitly asked for it, so we don't gate on the inventory.
|
|
2311
|
+
return runAgentWriters(descriptor, ctx, agentName);
|
|
2007
2312
|
}
|
|
2008
2313
|
/**
|
|
2009
2314
|
* Patch all MCP config files to use the currently resolved brainclaw binary.
|
|
2010
2315
|
*
|
|
2011
2316
|
* Called after upgrade / version --publish-local to fix stale paths.
|
|
2012
|
-
* Re-resolves the brainclaw command, then
|
|
2013
|
-
*
|
|
2317
|
+
* Re-resolves the brainclaw command, then iterates AGENT_WIRING_REGISTRY with
|
|
2318
|
+
* force-resolve enabled, filtering writer output to `kind: 'mcp'`. Agents that
|
|
2319
|
+
* aren't installed on this machine skip their user-level writers (avoids
|
|
2320
|
+
* minting unrelated user configs as a side effect of an upgrade).
|
|
2014
2321
|
*
|
|
2015
2322
|
* Returns the list of configs that were actually updated (not just created).
|
|
2016
2323
|
*/
|
|
2017
2324
|
export function patchAllMcpConfigs(cwd, env = process.env) {
|
|
2018
|
-
//
|
|
2325
|
+
// Clear cached path so resolution picks up the new install location
|
|
2019
2326
|
resetMcpCommandCache();
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
results.push(ensureContinueMcpConfig(cwd));
|
|
2033
|
-
results.push(ensureOpenCodeMcpConfig(cwd));
|
|
2034
|
-
// Machine-level configs (in ~ or platform-specific)
|
|
2035
|
-
const userConfigs = [
|
|
2036
|
-
ensureClaudeCodeUserSettings(homeDir, env),
|
|
2037
|
-
ensureCursorMcpConfig(homeDir),
|
|
2038
|
-
ensureWindsurfMcpConfig(homeDir),
|
|
2039
|
-
ensureContinueUserMcpConfig(homeDir),
|
|
2040
|
-
ensureContinueUserPermissions(homeDir),
|
|
2041
|
-
ensureAntigravityMcpConfig(homeDir),
|
|
2042
|
-
ensureOpenClawMcpConfig(homeDir),
|
|
2043
|
-
ensureCodexMcpConfig(homeDir, env),
|
|
2044
|
-
ensureHermesMcpConfig(homeDir),
|
|
2045
|
-
];
|
|
2046
|
-
for (const r of userConfigs) {
|
|
2047
|
-
if (r)
|
|
2048
|
-
results.push(r);
|
|
2327
|
+
const ctx = { cwd, homeDir: resolveHomeDir(env), env, workspacePath: cwd };
|
|
2328
|
+
// Run inside withForcedResolve so brainclawMcpEntry overwrites existing
|
|
2329
|
+
// absolute paths in user configs (the whole point of the patch pass).
|
|
2330
|
+
const results = withForcedResolve(() => {
|
|
2331
|
+
const acc = [];
|
|
2332
|
+
for (const [agentName, descriptor] of Object.entries(AGENT_WIRING_REGISTRY)) {
|
|
2333
|
+
const agentResults = runAgentWriters(descriptor, ctx, agentName, {
|
|
2334
|
+
skipUserIfNotInstalled: true,
|
|
2335
|
+
kindFilter: 'mcp',
|
|
2336
|
+
});
|
|
2337
|
+
for (const r of agentResults)
|
|
2338
|
+
acc.push(r);
|
|
2049
2339
|
}
|
|
2340
|
+
return acc;
|
|
2341
|
+
});
|
|
2342
|
+
// Dedupe by filePath — claude-code and claude-sonnet share writers; running
|
|
2343
|
+
// each one twice would emit duplicate "Updated …/.mcp.json" lines.
|
|
2344
|
+
const seen = new Set();
|
|
2345
|
+
const deduped = [];
|
|
2346
|
+
for (const r of results) {
|
|
2347
|
+
if (seen.has(r.filePath))
|
|
2348
|
+
continue;
|
|
2349
|
+
seen.add(r.filePath);
|
|
2350
|
+
deduped.push(r);
|
|
2050
2351
|
}
|
|
2051
|
-
|
|
2052
|
-
// Always reset force-resolve mode
|
|
2053
|
-
_forceResolve = false;
|
|
2054
|
-
}
|
|
2055
|
-
return results.filter(r => r.created || r.updated);
|
|
2352
|
+
return deduped.filter((r) => r.created || r.updated);
|
|
2056
2353
|
}
|
|
2057
2354
|
//# sourceMappingURL=agent-files.js.map
|