dual-brain 6.1.0 → 6.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dual-brain.mjs +65 -4
- package/hooks/head-guard.sh +7 -77
- package/install.mjs +38 -27
- package/package.json +1 -1
- package/src/install-hooks.mjs +100 -0
package/bin/dual-brain.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// dual-brain — CLI entry point. Commands: init, go, status, remember, forget
|
|
3
3
|
|
|
4
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
@@ -67,7 +67,17 @@ Options:
|
|
|
67
67
|
// ─── Card command (default) ──────────────────────────────────────────────────
|
|
68
68
|
|
|
69
69
|
async function cmdCard() {
|
|
70
|
-
const cwd
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
const { homedir } = await import('node:os');
|
|
72
|
+
const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
|
|
73
|
+
const projectPath = join(cwd, '.dualbrain', 'profile.json');
|
|
74
|
+
|
|
75
|
+
if (!existsSync(projectPath) && !existsSync(globalPath)) {
|
|
76
|
+
console.log('Welcome to dual-brain! Let\'s set up your profile.\n');
|
|
77
|
+
await cmdInit();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
const repo = loadRepoCache(cwd);
|
|
72
82
|
const session = loadSession(cwd);
|
|
73
83
|
const health = getHealth(cwd);
|
|
@@ -274,6 +284,39 @@ async function cmdStatus(args = []) {
|
|
|
274
284
|
vtrace(`Raw profile:\n${JSON.stringify(profile, null, 2)}`);
|
|
275
285
|
}
|
|
276
286
|
|
|
287
|
+
// Enforcement health check
|
|
288
|
+
console.log('\nEnforcement:');
|
|
289
|
+
try {
|
|
290
|
+
const { readFileSync: rfs, existsSync: exs } = await import('node:fs');
|
|
291
|
+
const settingsFile = join(cwd, '.claude', 'settings.json');
|
|
292
|
+
if (!exs(settingsFile)) {
|
|
293
|
+
console.log(' NOT INSTALLED — run: dual-brain install');
|
|
294
|
+
} else {
|
|
295
|
+
const settings = JSON.parse(rfs(settingsFile, 'utf8'));
|
|
296
|
+
const preToolUse = settings?.hooks?.PreToolUse ?? [];
|
|
297
|
+
const guardCmd = 'bash .claude/hooks/head-guard.sh';
|
|
298
|
+
const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
|
|
299
|
+
const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
|
|
300
|
+
const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
|
|
301
|
+
const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
|
|
302
|
+
const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
|
|
303
|
+
const activeCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
|
|
304
|
+
if (activeCount === 4) {
|
|
305
|
+
console.log(` active (${activeCount} guards: Edit, Write, Bash, Agent)`);
|
|
306
|
+
} else {
|
|
307
|
+
const missing = [
|
|
308
|
+
!hasEdit && 'Edit',
|
|
309
|
+
!hasWrite && 'Write',
|
|
310
|
+
!hasBash && 'Bash',
|
|
311
|
+
!hasAgent && 'Agent',
|
|
312
|
+
].filter(Boolean);
|
|
313
|
+
console.log(` PARTIAL — missing guards: ${missing.join(', ')} — run: dual-brain install`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
console.log(' unknown (could not read .claude/settings.json)');
|
|
318
|
+
}
|
|
319
|
+
|
|
277
320
|
// Update check
|
|
278
321
|
try {
|
|
279
322
|
const localVer = readVersion();
|
|
@@ -320,9 +363,27 @@ function cmdCool(providerArg) {
|
|
|
320
363
|
}
|
|
321
364
|
|
|
322
365
|
async function cmdInstall() {
|
|
366
|
+
const cwd = process.cwd();
|
|
367
|
+
|
|
368
|
+
// Run the main install.mjs (orchestrator config, all hooks, CLAUDE.md, etc.)
|
|
323
369
|
const { spawnSync } = await import('child_process');
|
|
324
|
-
const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd
|
|
325
|
-
process.exit(result.status ||
|
|
370
|
+
const result = spawnSync('node', [join(__dirname, '..', 'install.mjs')], { stdio: 'inherit', cwd });
|
|
371
|
+
if (result.status !== 0) { process.exit(result.status || 1); }
|
|
372
|
+
|
|
373
|
+
// Additionally merge enforcement hooks into .claude/settings.json
|
|
374
|
+
const { installHooks } = await import('../src/install-hooks.mjs');
|
|
375
|
+
const { installed, skipped } = installHooks(cwd);
|
|
376
|
+
|
|
377
|
+
if (installed.length > 0) {
|
|
378
|
+
console.log(`\nEnforcement hooks installed (${installed.length}):`);
|
|
379
|
+
for (const item of installed) console.log(` + ${item}`);
|
|
380
|
+
}
|
|
381
|
+
if (skipped.length > 0) {
|
|
382
|
+
console.log(`Enforcement hooks already present (${skipped.length}):`);
|
|
383
|
+
for (const item of skipped) console.log(` = ${item}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
process.exit(0);
|
|
326
387
|
}
|
|
327
388
|
|
|
328
389
|
function cmdRemember(text) {
|
package/hooks/head-guard.sh
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
# Exit 0 → allow
|
|
9
9
|
# Exit 2 → block (stderr message is shown to Claude)
|
|
10
10
|
|
|
11
|
+
BLOCK_MSG='[dual-brain] HEAD cannot use this tool directly. Dispatch via: dual-brain go "task description"'
|
|
12
|
+
|
|
11
13
|
# ── 1. Role check ────────────────────────────────────────────────────────────
|
|
12
14
|
# Only enforce when the session has been explicitly marked as the HEAD agent.
|
|
13
15
|
# If the env var is unset we allow everything (backward compat for non-dual-brain usage).
|
|
@@ -24,86 +26,14 @@ fi
|
|
|
24
26
|
# ── 2. Tool name check ───────────────────────────────────────────────────────
|
|
25
27
|
TOOL="${CLAUDE_TOOL_NAME:-}"
|
|
26
28
|
|
|
27
|
-
# Block direct file-editing tools unconditionally for HEAD.
|
|
29
|
+
# Block direct file-editing tools and Bash unconditionally for HEAD.
|
|
30
|
+
# HEAD should use Read tool for reading and Agent (via dual-brain go) for all other work.
|
|
28
31
|
case "${TOOL}" in
|
|
29
|
-
Edit|Write|NotebookEdit)
|
|
30
|
-
echo "
|
|
32
|
+
Edit|Write|NotebookEdit|Bash)
|
|
33
|
+
echo "${BLOCK_MSG}" >&2
|
|
31
34
|
exit 2
|
|
32
35
|
;;
|
|
33
36
|
esac
|
|
34
37
|
|
|
35
|
-
# ── 3.
|
|
36
|
-
# For Bash calls, read stdin JSON and extract the "command" field, then scan for
|
|
37
|
-
# write-side shell patterns. Pure bash + standard POSIX utilities — no node
|
|
38
|
-
# startup, no network.
|
|
39
|
-
|
|
40
|
-
if [[ "${TOOL}" == "Bash" ]]; then
|
|
41
|
-
# Read the full JSON input from stdin.
|
|
42
|
-
INPUT="$(cat)"
|
|
43
|
-
|
|
44
|
-
# Extract the value of "command" from the JSON.
|
|
45
|
-
# Strategy: grep for the key+value pair, then strip key prefix with sed.
|
|
46
|
-
# Handles normal ASCII command strings (not escaped unicode — acceptable for a guard).
|
|
47
|
-
CMD="$(printf '%s' "${INPUT}" \
|
|
48
|
-
| grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
49
|
-
| head -1 \
|
|
50
|
-
| sed 's/^"command"[[:space:]]*:[[:space:]]*"//;s/"$//')"
|
|
51
|
-
|
|
52
|
-
# If we couldn't extract a command (unusual JSON shape), allow through.
|
|
53
|
-
if [[ -z "${CMD}" ]]; then
|
|
54
|
-
exit 0
|
|
55
|
-
fi
|
|
56
|
-
|
|
57
|
-
# ── Blocked patterns ─────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
# sed with in-place flag (-i or combined flags like -ni)
|
|
60
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])sed[[:space:]].*-[a-zA-Z]*i'; then
|
|
61
|
-
echo "HEAD cannot implement directly (sed -i). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
62
|
-
exit 2
|
|
63
|
-
fi
|
|
64
|
-
|
|
65
|
-
# Redirect-write: cat > file, echo > file, printf > file (single > only, not >>)
|
|
66
|
-
if printf '%s' "${CMD}" | grep -qE '(cat|echo|printf)[^|]*>[^>]'; then
|
|
67
|
-
echo "HEAD cannot implement directly (redirect write). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
68
|
-
exit 2
|
|
69
|
-
fi
|
|
70
|
-
|
|
71
|
-
# tee writing to a file path (tee /path or tee ./path or tee filename)
|
|
72
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])tee[[:space:]]+[^-]'; then
|
|
73
|
-
echo "HEAD cannot implement directly (tee). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
74
|
-
exit 2
|
|
75
|
-
fi
|
|
76
|
-
|
|
77
|
-
# patch command
|
|
78
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])patch[[:space:]]'; then
|
|
79
|
-
echo "HEAD cannot implement directly (patch). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
80
|
-
exit 2
|
|
81
|
-
fi
|
|
82
|
-
|
|
83
|
-
# Interpreter one-liners that can write files (node -e, python -c, perl -e, ruby -e)
|
|
84
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])(node[[:space:]]+(--eval|-e)|python3?[[:space:]]+-c|perl[[:space:]]+-e|ruby[[:space:]]+-e)[[:space:]]'; then
|
|
85
|
-
echo "HEAD cannot implement directly (interpreter one-liner). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
86
|
-
exit 2
|
|
87
|
-
fi
|
|
88
|
-
|
|
89
|
-
# mv / cp where the destination looks like a source code file
|
|
90
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])(mv|cp)[[:space:]].*\.(js|mjs|cjs|ts|tsx|py|sh|json|yaml|yml|toml|rb|go|rs|java|c|cpp|h|css|html|sql)([[:space:]]|$)'; then
|
|
91
|
-
echo "HEAD cannot implement directly (mv/cp to source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
92
|
-
exit 2
|
|
93
|
-
fi
|
|
94
|
-
|
|
95
|
-
# rm on source files
|
|
96
|
-
if printf '%s' "${CMD}" | grep -qE '(^|[[:space:];|&])rm[[:space:]].*\.(js|mjs|cjs|ts|tsx|py|sh|json|yaml|yml|toml|rb|go|rs|java|c|cpp|h|css|html|sql)([[:space:]]|$)'; then
|
|
97
|
-
echo "HEAD cannot implement directly (rm on source file). Use: node hooks/dispatch.mjs --task \"description\"" >&2
|
|
98
|
-
exit 2
|
|
99
|
-
fi
|
|
100
|
-
|
|
101
|
-
# Explicitly allowed (read-only) patterns — documented here for clarity.
|
|
102
|
-
# The checks above are specific enough that these don't need explicit allow rules,
|
|
103
|
-
# but listing them makes the intent clear:
|
|
104
|
-
# grep, find, cat <file (no redirect), git status/log/diff/show,
|
|
105
|
-
# node --check, ls, wc, head, tail, jq (read), curl (read), etc.
|
|
106
|
-
fi
|
|
107
|
-
|
|
108
|
-
# ── 4. Default: allow ────────────────────────────────────────────────────────
|
|
38
|
+
# ── 3. Default: allow ────────────────────────────────────────────────────────
|
|
109
39
|
exit 0
|
package/install.mjs
CHANGED
|
@@ -744,39 +744,50 @@ function generateSettings(workspace) {
|
|
|
744
744
|
let existing = {};
|
|
745
745
|
try { existing = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {}
|
|
746
746
|
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
},
|
|
759
|
-
{
|
|
760
|
-
matcher: '',
|
|
761
|
-
hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }],
|
|
762
|
-
},
|
|
763
|
-
],
|
|
764
|
-
};
|
|
747
|
+
const HEAD_GUARD_CMD = 'bash .claude/hooks/head-guard.sh';
|
|
748
|
+
const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
|
|
749
|
+
|
|
750
|
+
// All dual-brain PreToolUse hooks we manage
|
|
751
|
+
const DESIRED_PRE = [
|
|
752
|
+
{ matcher: 'Edit', command: HEAD_GUARD_CMD },
|
|
753
|
+
{ matcher: 'Write', command: HEAD_GUARD_CMD },
|
|
754
|
+
{ matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
|
|
755
|
+
{ matcher: 'Bash', command: HEAD_GUARD_CMD },
|
|
756
|
+
{ matcher: 'Agent', command: ENFORCE_TIER_CMD },
|
|
757
|
+
];
|
|
765
758
|
|
|
766
759
|
const DUAL_BRAIN_CMDS = [
|
|
767
|
-
|
|
760
|
+
HEAD_GUARD_CMD,
|
|
761
|
+
ENFORCE_TIER_CMD,
|
|
768
762
|
'node .claude/hooks/cost-logger.mjs',
|
|
769
763
|
'node .claude/hooks/auto-update-wrapper.mjs',
|
|
770
764
|
];
|
|
771
765
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
766
|
+
// Build merged PreToolUse: keep user entries that aren't ours, then add ours
|
|
767
|
+
const existingPre = (existing.hooks?.PreToolUse || []).filter(e =>
|
|
768
|
+
!e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
769
|
+
);
|
|
770
|
+
const mergedPre = [...existingPre];
|
|
771
|
+
for (const { matcher, command } of DESIRED_PRE) {
|
|
772
|
+
mergedPre.push({ matcher, hooks: [{ type: 'command', command }] });
|
|
778
773
|
}
|
|
779
774
|
|
|
775
|
+
// Build merged PostToolUse
|
|
776
|
+
const postHooks = [
|
|
777
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/cost-logger.mjs' }] },
|
|
778
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'node .claude/hooks/auto-update-wrapper.mjs' }] },
|
|
779
|
+
];
|
|
780
|
+
const existingPost = (existing.hooks?.PostToolUse || []).filter(e =>
|
|
781
|
+
!e.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
|
|
782
|
+
);
|
|
783
|
+
const mergedPost = [...existingPost, ...postHooks];
|
|
784
|
+
|
|
785
|
+
const merged = {
|
|
786
|
+
...(existing.hooks || {}),
|
|
787
|
+
PreToolUse: mergedPre,
|
|
788
|
+
PostToolUse: mergedPost,
|
|
789
|
+
};
|
|
790
|
+
|
|
780
791
|
return { ...existing, hooks: merged };
|
|
781
792
|
}
|
|
782
793
|
|
|
@@ -875,8 +886,8 @@ function install(workspace, env, mode) {
|
|
|
875
886
|
];
|
|
876
887
|
for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
|
|
877
888
|
|
|
878
|
-
// Copy bash hooks (auto-update.sh
|
|
879
|
-
const BASH_HOOKS = ['auto-update.sh'];
|
|
889
|
+
// Copy bash hooks (auto-update.sh and head-guard.sh live alongside .mjs hooks in the package)
|
|
890
|
+
const BASH_HOOKS = ['auto-update.sh', 'head-guard.sh'];
|
|
880
891
|
for (const h of BASH_HOOKS) {
|
|
881
892
|
const src = join(__dirname, 'hooks', h);
|
|
882
893
|
const dst = join(target, 'hooks', h);
|
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install-hooks.mjs — Merge dual-brain PreToolUse hooks into .claude/settings.json.
|
|
3
|
+
*
|
|
4
|
+
* Exported function: installHooks(cwd)
|
|
5
|
+
* Returns: { installed: string[], skipped: string[] }
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
const PKG_ROOT = join(__dirname, '..');
|
|
15
|
+
|
|
16
|
+
// The hook commands we want present in .claude/settings.json PreToolUse
|
|
17
|
+
const HEAD_GUARD_CMD = 'bash .claude/hooks/head-guard.sh';
|
|
18
|
+
const ENFORCE_TIER_CMD = 'node .claude/hooks/enforce-tier.mjs';
|
|
19
|
+
|
|
20
|
+
const DESIRED_HOOKS = [
|
|
21
|
+
{ matcher: 'Edit', command: HEAD_GUARD_CMD },
|
|
22
|
+
{ matcher: 'Write', command: HEAD_GUARD_CMD },
|
|
23
|
+
{ matcher: 'NotebookEdit', command: HEAD_GUARD_CMD },
|
|
24
|
+
{ matcher: 'Bash', command: HEAD_GUARD_CMD },
|
|
25
|
+
{ matcher: 'Agent', command: ENFORCE_TIER_CMD },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install dual-brain enforcement hooks into a project's .claude/settings.json.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} cwd - Project root directory (where .claude/ should live)
|
|
32
|
+
* @returns {{ installed: string[], skipped: string[] }}
|
|
33
|
+
*/
|
|
34
|
+
export function installHooks(cwd) {
|
|
35
|
+
const claudeDir = join(cwd, '.claude');
|
|
36
|
+
const hooksDir = join(claudeDir, 'hooks');
|
|
37
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
38
|
+
|
|
39
|
+
const installed = [];
|
|
40
|
+
const skipped = [];
|
|
41
|
+
|
|
42
|
+
// Ensure directories exist
|
|
43
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
// Copy hook files from package into project's .claude/hooks/
|
|
46
|
+
const filesToCopy = [
|
|
47
|
+
{ name: 'head-guard.sh', exec: true },
|
|
48
|
+
{ name: 'enforce-tier.mjs', exec: false },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
for (const { name, exec } of filesToCopy) {
|
|
52
|
+
const src = join(PKG_ROOT, 'hooks', name);
|
|
53
|
+
const dst = join(hooksDir, name);
|
|
54
|
+
if (existsSync(src)) {
|
|
55
|
+
cpSync(src, dst);
|
|
56
|
+
if (exec) {
|
|
57
|
+
try { chmodSync(dst, 0o755); } catch {}
|
|
58
|
+
}
|
|
59
|
+
installed.push(`hooks/${name}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read existing settings (or start fresh)
|
|
64
|
+
let settings = {};
|
|
65
|
+
try {
|
|
66
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
// File doesn't exist or is malformed — start empty
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Ensure hooks.PreToolUse array exists
|
|
72
|
+
if (!settings.hooks) settings.hooks = {};
|
|
73
|
+
if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
|
|
74
|
+
|
|
75
|
+
const preToolUse = settings.hooks.PreToolUse;
|
|
76
|
+
|
|
77
|
+
// Merge: for each desired hook, add only if command is not already registered for that matcher
|
|
78
|
+
for (const { matcher, command } of DESIRED_HOOKS) {
|
|
79
|
+
const alreadyPresent = preToolUse.some(entry =>
|
|
80
|
+
entry.matcher === matcher &&
|
|
81
|
+
Array.isArray(entry.hooks) &&
|
|
82
|
+
entry.hooks.some(h => h.command === command)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (alreadyPresent) {
|
|
86
|
+
skipped.push(`PreToolUse[${matcher}]`);
|
|
87
|
+
} else {
|
|
88
|
+
preToolUse.push({
|
|
89
|
+
matcher,
|
|
90
|
+
hooks: [{ type: 'command', command }],
|
|
91
|
+
});
|
|
92
|
+
installed.push(`PreToolUse[${matcher}]`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Write back merged settings
|
|
97
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
98
|
+
|
|
99
|
+
return { installed, skipped };
|
|
100
|
+
}
|