@bookedsolid/reagent 0.1.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/bin/init.js ADDED
@@ -0,0 +1,818 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ // ── Package metadata ─────────────────────────────────────────────────────────
8
+
9
+ const PKG_ROOT = path.join(__dirname, '..');
10
+ const PKG_VERSION = (() => {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
13
+ } catch {
14
+ return '0.0.0';
15
+ }
16
+ })();
17
+
18
+ // ── CLI routing ──────────────────────────────────────────────────────────────
19
+
20
+ const [, , cmd, ...rest] = process.argv;
21
+
22
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
23
+ printHelp();
24
+ process.exit(0);
25
+ }
26
+
27
+ if (cmd === 'init') {
28
+ runInit(rest);
29
+ } else if (cmd === 'check') {
30
+ runCheck(rest);
31
+ } else if (cmd === 'freeze') {
32
+ runFreeze(rest);
33
+ } else if (cmd === 'unfreeze') {
34
+ runUnfreeze(rest);
35
+ } else {
36
+ console.error(`\nUnknown command: ${cmd}`);
37
+ printHelp();
38
+ process.exit(1);
39
+ }
40
+
41
+ // ── Commands ─────────────────────────────────────────────────────────────────
42
+
43
+ function runInit(args) {
44
+ const profileName = parseFlag(args, '--profile') || 'client-engagement';
45
+ const targetDir = process.cwd();
46
+ const dryRun = args.includes('--dry-run');
47
+
48
+ console.log(`\n@bookedsolid/reagent v${PKG_VERSION} init`);
49
+ console.log(` Profile: ${profileName}`);
50
+ console.log(` Target: ${targetDir}`);
51
+ if (dryRun) console.log(` Mode: dry-run (no changes written)`);
52
+ console.log('');
53
+
54
+ // Load profile — validate name to prevent path traversal
55
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(profileName)) {
56
+ console.error(
57
+ `Invalid profile name: "${profileName}" (only lowercase letters, numbers, hyphens allowed)`
58
+ );
59
+ process.exit(1);
60
+ }
61
+ const profilesDir = path.join(PKG_ROOT, 'profiles');
62
+ const profilePath = path.resolve(profilesDir, `${profileName}.json`);
63
+ if (!profilePath.startsWith(profilesDir + path.sep)) {
64
+ console.error(`Invalid profile name: "${profileName}" (path traversal detected)`);
65
+ process.exit(1);
66
+ }
67
+ if (!fs.existsSync(profilePath)) {
68
+ const available = fs
69
+ .readdirSync(path.join(PKG_ROOT, 'profiles'))
70
+ .filter((f) => f.endsWith('.json'))
71
+ .map((f) => f.replace('.json', ''));
72
+ console.error(`Profile not found: ${profileName}`);
73
+ console.error(`Available profiles: ${available.join(', ')}`);
74
+ process.exit(1);
75
+ }
76
+ const profile = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
77
+
78
+ const results = [];
79
+
80
+ // ── Step 1: .gitignore entries ─────────────────────────────────────────────
81
+ if (profile.gitignoreEntries?.length) {
82
+ const r = installGitignoreEntries(targetDir, profile.gitignoreEntries, dryRun);
83
+ results.push(...r);
84
+ }
85
+
86
+ // ── Step 2: Cursor rules ───────────────────────────────────────────────────
87
+ if (profile.cursorRules?.length) {
88
+ const r = installCursorRules(targetDir, profile.cursorRules, dryRun);
89
+ results.push(...r);
90
+ }
91
+
92
+ // ── Step 3: Husky commit-msg hook ──────────────────────────────────────────
93
+ if (profile.huskyCommitMsg) {
94
+ const r = installHuskyHook(targetDir, 'commit-msg', 'commit-msg.sh', dryRun);
95
+ results.push(...r);
96
+ }
97
+
98
+ // ── Step 4: Husky pre-commit hook ─────────────────────────────────────────
99
+ if (profile.huskyPreCommit) {
100
+ const r = installHuskyHook(targetDir, 'pre-commit', 'pre-commit.sh', dryRun);
101
+ results.push(...r);
102
+ }
103
+
104
+ // ── Step 5: Husky pre-push hook ───────────────────────────────────────────
105
+ if (profile.huskyPrePush) {
106
+ const r = installHuskyHook(targetDir, 'pre-push', 'pre-push.sh', dryRun);
107
+ results.push(...r);
108
+ }
109
+
110
+ // ── Step 6: Claude hooks + settings.json ──────────────────────────────────
111
+ if (profile.claudeHooks) {
112
+ const r = installClaudeHooks(targetDir, profile.claudeHooks, dryRun);
113
+ results.push(...r);
114
+ }
115
+
116
+ // ── Step 7: CLAUDE.md ─────────────────────────────────────────────────────
117
+ if (profile.claudeMd) {
118
+ const r = installClaudeMd(targetDir, profile.claudeMd, profileName, dryRun);
119
+ results.push(...r);
120
+ }
121
+
122
+ // ── Step 8: .reagent/policy.yaml ──────────────────────────────────────────
123
+ const r = installPolicy(targetDir, profileName, dryRun);
124
+ results.push(...r);
125
+
126
+ // ── Step 9: Orchestrator agent ────────────────────────────────────────────
127
+ const ra = installOrchestratorAgent(targetDir, dryRun);
128
+ results.push(...ra);
129
+
130
+ // ── Step 10: Claude commands (/restart, /rea) ───────────────────────────
131
+ const rc = installClaudeCommands(targetDir, dryRun);
132
+ results.push(...rc);
133
+
134
+ // ── Summary ────────────────────────────────────────────────────────────────
135
+ console.log('');
136
+ const installed = results.filter((r) => r.status === 'installed');
137
+ const updated = results.filter((r) => r.status === 'updated');
138
+ const skipped = results.filter((r) => r.status === 'skipped');
139
+ const warned = results.filter((r) => r.status === 'warn');
140
+
141
+ if (installed.length) {
142
+ console.log('Installed:');
143
+ installed.forEach((r) => console.log(` + ${r.file}`));
144
+ }
145
+ if (updated.length) {
146
+ console.log('Updated:');
147
+ updated.forEach((r) => console.log(` ~ ${r.file}`));
148
+ }
149
+ if (skipped.length) {
150
+ console.log('Already up-to-date:');
151
+ skipped.forEach((r) => console.log(` = ${r.file}`));
152
+ }
153
+ if (warned.length) {
154
+ console.log('Warnings:');
155
+ warned.forEach((r) => console.log(` ! ${r.file}`));
156
+ }
157
+
158
+ if (!dryRun) {
159
+ console.log('\n✓ reagent init complete');
160
+ console.log('\nCommit these files (safe to commit):');
161
+ console.log(
162
+ ' git add .cursor/rules/ .husky/ .claude/commands/ CLAUDE.md .reagent/policy.yaml && git commit -m "chore: add reagent zero-trust config"'
163
+ );
164
+ console.log('');
165
+ console.log('Do NOT commit (gitignored — stays on your machine):');
166
+ console.log(' .claude/hooks/');
167
+ console.log(' .claude/settings.json');
168
+ console.log(' .claude/agents/');
169
+ console.log('');
170
+ console.log('Test attribution stripping:');
171
+ console.log(
172
+ ' git commit --allow-empty -m "test\\n\\nCo-Authored-By: Claude <noreply@anthropic.com>"'
173
+ );
174
+ console.log(' git log -1 --format="%B" | grep "Co-Authored" # should return nothing');
175
+ console.log('');
176
+ console.log('Test kill switch:');
177
+ console.log(' reagent freeze --reason "testing"');
178
+ console.log(' reagent unfreeze');
179
+ console.log('');
180
+ }
181
+ }
182
+
183
+ function runCheck(_args) {
184
+ const targetDir = process.cwd();
185
+ console.log(`\n@bookedsolid/reagent v${PKG_VERSION} check`);
186
+ console.log(` Target: ${targetDir}\n`);
187
+
188
+ const checks = [
189
+ {
190
+ label: '.cursor/rules/ installed',
191
+ pass: () =>
192
+ fs.existsSync(path.join(targetDir, '.cursor', 'rules', '001-no-hallucination.mdc')),
193
+ },
194
+ {
195
+ label: '.husky/commit-msg installed',
196
+ pass: () => fs.existsSync(path.join(targetDir, '.husky', 'commit-msg')),
197
+ },
198
+ {
199
+ label: '.husky/pre-commit installed',
200
+ pass: () => fs.existsSync(path.join(targetDir, '.husky', 'pre-commit')),
201
+ },
202
+ {
203
+ label: '.husky/pre-push installed',
204
+ pass: () => fs.existsSync(path.join(targetDir, '.husky', 'pre-push')),
205
+ },
206
+ {
207
+ label: '.git/hooks/commit-msg installed (fallback)',
208
+ pass: () => fs.existsSync(path.join(targetDir, '.git', 'hooks', 'commit-msg')),
209
+ },
210
+ {
211
+ label: '.claude/hooks/ installed',
212
+ pass: () =>
213
+ fs.existsSync(path.join(targetDir, '.claude', 'hooks', 'dangerous-bash-interceptor.sh')),
214
+ },
215
+ {
216
+ label: '.claude/settings.json installed',
217
+ pass: () => fs.existsSync(path.join(targetDir, '.claude', 'settings.json')),
218
+ },
219
+ {
220
+ label: 'CLAUDE.md has reagent block',
221
+ pass: () => {
222
+ const p = path.join(targetDir, 'CLAUDE.md');
223
+ if (!fs.existsSync(p)) return false;
224
+ return fs.readFileSync(p, 'utf8').includes('<!-- reagent-managed:start -->');
225
+ },
226
+ },
227
+ {
228
+ label: '.reagent/policy.yaml installed',
229
+ pass: () => fs.existsSync(path.join(targetDir, '.reagent', 'policy.yaml')),
230
+ },
231
+ {
232
+ label: '.gitignore has .claude/agents/',
233
+ pass: () => gitignoreHasEntry(targetDir, '.claude/agents/'),
234
+ },
235
+ {
236
+ label: '.claude/commands/restart.md installed',
237
+ pass: () => fs.existsSync(path.join(targetDir, '.claude', 'commands', 'restart.md')),
238
+ },
239
+ {
240
+ label: '.claude/commands/rea.md installed',
241
+ pass: () => fs.existsSync(path.join(targetDir, '.claude', 'commands', 'rea.md')),
242
+ },
243
+ ];
244
+
245
+ let allPass = true;
246
+ checks.forEach(({ label, pass }) => {
247
+ const ok = pass();
248
+ console.log(` ${ok ? '✓' : '✗'} ${label}`);
249
+ if (!ok) allPass = false;
250
+ });
251
+
252
+ // Check HALT status
253
+ const haltFile = path.join(targetDir, '.reagent', 'HALT');
254
+ if (fs.existsSync(haltFile)) {
255
+ const reason = fs.readFileSync(haltFile, 'utf8').trim();
256
+ console.log(`\n ⚠ HALT ACTIVE: ${reason}`);
257
+ console.log(` Run 'reagent unfreeze' to resume agent operations.`);
258
+ }
259
+
260
+ console.log('');
261
+ if (allPass) {
262
+ console.log('All checks passed.');
263
+ } else {
264
+ console.log('Some checks failed. Run: npx @bookedsolid/reagent init');
265
+ process.exit(1);
266
+ }
267
+ }
268
+
269
+ function runFreeze(args) {
270
+ const targetDir = process.cwd();
271
+ const rawReason =
272
+ parseFlag(args, '--reason') || args.find((a) => !a.startsWith('--')) || 'Manual freeze';
273
+ // Strip control characters (terminal escape injection defense)
274
+ const reason = rawReason.replace(/[\x00-\x1f\x7f]/g, '');
275
+
276
+ const reagentDir = path.join(targetDir, '.reagent');
277
+ const haltFile = path.join(reagentDir, 'HALT');
278
+
279
+ if (!fs.existsSync(reagentDir)) {
280
+ fs.mkdirSync(reagentDir, { recursive: true });
281
+ }
282
+
283
+ const timestamp = new Date().toISOString();
284
+ const content = `${reason} (frozen at ${timestamp})`;
285
+ fs.writeFileSync(haltFile, content, 'utf8');
286
+
287
+ console.log(`\nREAGENT FROZEN`);
288
+ console.log(` Reason: ${reason}`);
289
+ console.log(` File: .reagent/HALT`);
290
+ console.log(` Effect: All PreToolUse hooks will exit 2 — agent operations blocked.`);
291
+ console.log(`\n To resume: reagent unfreeze`);
292
+ console.log('');
293
+ }
294
+
295
+ function runUnfreeze(_args) {
296
+ const targetDir = process.cwd();
297
+ const haltFile = path.join(targetDir, '.reagent', 'HALT');
298
+
299
+ if (!fs.existsSync(haltFile)) {
300
+ console.log('\nNot frozen — no .reagent/HALT file found.\n');
301
+ return;
302
+ }
303
+
304
+ fs.unlinkSync(haltFile);
305
+ console.log('\nREAGENT UNFROZEN');
306
+ console.log(' .reagent/HALT removed — agent operations resumed.\n');
307
+ }
308
+
309
+ // ── Installation helpers ──────────────────────────────────────────────────────
310
+
311
+ function installGitignoreEntries(targetDir, entries, dryRun) {
312
+ const gitignorePath = path.join(targetDir, '.gitignore');
313
+ const missing = entries.filter((e) => !gitignoreHasEntry(targetDir, e));
314
+
315
+ if (!missing.length) {
316
+ return [{ file: '.gitignore', status: 'skipped' }];
317
+ }
318
+
319
+ if (!dryRun) {
320
+ const additions = [
321
+ '',
322
+ '# reagent — AI tooling (stays on developer machine, not committed)',
323
+ ...missing,
324
+ ].join('\n');
325
+ fs.appendFileSync(gitignorePath, additions + '\n');
326
+ }
327
+
328
+ return [{ file: `.gitignore (+${missing.length} entries)`, status: 'updated' }];
329
+ }
330
+
331
+ function installCursorRules(targetDir, ruleNames, dryRun) {
332
+ const rulesDir = path.join(targetDir, '.cursor', 'rules');
333
+ if (!dryRun) {
334
+ fs.mkdirSync(rulesDir, { recursive: true });
335
+ }
336
+
337
+ const results = [];
338
+ for (const name of ruleNames) {
339
+ const srcFile = path.join(PKG_ROOT, 'cursor', 'rules', `${name}.mdc`);
340
+ const destFile = path.join(rulesDir, `${name}.mdc`);
341
+
342
+ if (!fs.existsSync(srcFile)) {
343
+ console.warn(` Warning: cursor rule not found in package: ${name}.mdc`);
344
+ continue;
345
+ }
346
+
347
+ const srcContent = fs.readFileSync(srcFile, 'utf8');
348
+ const exists = fs.existsSync(destFile);
349
+ const same = exists && fs.readFileSync(destFile, 'utf8') === srcContent;
350
+
351
+ if (!same && !dryRun) {
352
+ fs.writeFileSync(destFile, srcContent);
353
+ }
354
+
355
+ results.push({
356
+ file: `.cursor/rules/${name}.mdc`,
357
+ status: same ? 'skipped' : exists ? 'updated' : 'installed',
358
+ });
359
+ }
360
+ return results;
361
+ }
362
+
363
+ function installHuskyHook(targetDir, hookName, srcFileName, dryRun) {
364
+ const srcFile = path.join(PKG_ROOT, 'husky', srcFileName);
365
+ const huskyDir = path.join(targetDir, '.husky');
366
+ const huskyHook = path.join(huskyDir, hookName);
367
+
368
+ if (!fs.existsSync(srcFile)) {
369
+ console.error(` ERROR: husky hook source not found in package: husky/${srcFileName}`);
370
+ return [{ file: `.husky/${hookName}`, status: 'warn' }];
371
+ }
372
+
373
+ const srcContent = fs.readFileSync(srcFile, 'utf8');
374
+ const results = [];
375
+
376
+ if (!dryRun) {
377
+ fs.mkdirSync(huskyDir, { recursive: true });
378
+ }
379
+
380
+ const huskyExists = fs.existsSync(huskyHook);
381
+ const hussySame = huskyExists && fs.readFileSync(huskyHook, 'utf8') === srcContent;
382
+
383
+ if (!hussySame && !dryRun) {
384
+ fs.writeFileSync(huskyHook, srcContent, { mode: 0o755 });
385
+ }
386
+ results.push({
387
+ file: `.husky/${hookName}`,
388
+ status: hussySame ? 'skipped' : huskyExists ? 'updated' : 'installed',
389
+ });
390
+
391
+ // For commit-msg: also install to .git/hooks/ as fallback (works without node_modules)
392
+ if (hookName === 'commit-msg') {
393
+ const gitHooksDir = path.join(targetDir, '.git', 'hooks');
394
+ if (fs.existsSync(gitHooksDir)) {
395
+ const gitHook = path.join(gitHooksDir, hookName);
396
+ const gitHookExists = fs.existsSync(gitHook);
397
+ const gitHookSame = gitHookExists && fs.readFileSync(gitHook, 'utf8') === srcContent;
398
+
399
+ if (!gitHookSame && !dryRun) {
400
+ fs.writeFileSync(gitHook, srcContent, { mode: 0o755 });
401
+ }
402
+ results.push({
403
+ file: '.git/hooks/commit-msg (active git hook)',
404
+ status: gitHookSame ? 'skipped' : gitHookExists ? 'updated' : 'installed',
405
+ });
406
+ }
407
+ }
408
+
409
+ // Ensure package.json has husky devDependency and prepare script
410
+ if (hookName === 'commit-msg') {
411
+ const pkgJsonPath = path.join(targetDir, 'package.json');
412
+ if (fs.existsSync(pkgJsonPath) && !dryRun) {
413
+ try {
414
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
415
+ const scripts = pkg.scripts || {};
416
+ let changed = false;
417
+ if (!scripts.prepare || !scripts.prepare.includes('husky')) {
418
+ scripts.prepare = scripts.prepare ? `${scripts.prepare} && husky` : 'husky';
419
+ pkg.scripts = scripts;
420
+ changed = true;
421
+ }
422
+ const devDeps = pkg.devDependencies || {};
423
+ if (!devDeps.husky) {
424
+ devDeps.husky = '^9.1.7';
425
+ pkg.devDependencies = devDeps;
426
+ changed = true;
427
+ }
428
+ if (changed) {
429
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n');
430
+ results.push({ file: 'package.json (added husky)', status: 'updated' });
431
+ }
432
+ } catch (err) {
433
+ console.warn(` Warning: Could not update package.json: ${err.message}`);
434
+ }
435
+ }
436
+ }
437
+
438
+ return results;
439
+ }
440
+
441
+ function installClaudeHooks(targetDir, hooksConfig, dryRun) {
442
+ const claudeHooksDir = path.join(targetDir, '.claude', 'hooks');
443
+ if (!dryRun) {
444
+ fs.mkdirSync(claudeHooksDir, { recursive: true });
445
+ }
446
+
447
+ const results = [];
448
+ const installedHookNames = new Set();
449
+
450
+ // Collect all hook names from all matchers
451
+ const allHookEntries = [...(hooksConfig.PreToolUse || []), ...(hooksConfig.PostToolUse || [])];
452
+ for (const entry of allHookEntries) {
453
+ for (const hookName of entry.hooks || []) {
454
+ const srcFile = path.join(PKG_ROOT, 'hooks', `${hookName}.sh`);
455
+
456
+ if (!fs.existsSync(srcFile)) {
457
+ // LOUDLY warn: hook referenced in profile does not exist in package
458
+ console.error(
459
+ ` ERROR: Hook '${hookName}' referenced in profile but not found in package.`
460
+ );
461
+ console.error(` Skipping — will NOT be written to .claude/settings.json.`);
462
+ results.push({
463
+ file: `.claude/hooks/${hookName}.sh (MISSING — not installed)`,
464
+ status: 'warn',
465
+ });
466
+ continue;
467
+ }
468
+
469
+ installedHookNames.add(hookName);
470
+
471
+ const srcContent = fs.readFileSync(srcFile, 'utf8');
472
+ const destFile = path.join(claudeHooksDir, `${hookName}.sh`);
473
+ const exists = fs.existsSync(destFile);
474
+ const same = exists && fs.readFileSync(destFile, 'utf8') === srcContent;
475
+
476
+ if (!same && !dryRun) {
477
+ fs.writeFileSync(destFile, srcContent, { mode: 0o755 });
478
+ }
479
+
480
+ results.push({
481
+ file: `.claude/hooks/${hookName}.sh`,
482
+ status: same ? 'skipped' : exists ? 'updated' : 'installed',
483
+ });
484
+ }
485
+ }
486
+
487
+ // Write settings.json with ONLY hooks that actually exist
488
+ const settingsPath = path.join(targetDir, '.claude', 'settings.json');
489
+ const settings = buildSettingsJson(hooksConfig, installedHookNames);
490
+ const settingsContent = JSON.stringify(settings, null, 2) + '\n';
491
+
492
+ const settingsExists = fs.existsSync(settingsPath);
493
+ const settingsSame = settingsExists && fs.readFileSync(settingsPath, 'utf8') === settingsContent;
494
+
495
+ if (!settingsSame && !dryRun) {
496
+ fs.writeFileSync(settingsPath, settingsContent);
497
+ }
498
+
499
+ results.push({
500
+ file: '.claude/settings.json',
501
+ status: settingsSame ? 'skipped' : settingsExists ? 'updated' : 'installed',
502
+ });
503
+
504
+ return results;
505
+ }
506
+
507
+ function installClaudeMd(targetDir, claudeMdConfig, profileName, dryRun) {
508
+ const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
509
+ const templatePath = path.join(PKG_ROOT, 'templates', 'CLAUDE.md');
510
+
511
+ if (!fs.existsSync(templatePath)) {
512
+ console.error(' ERROR: templates/CLAUDE.md not found in package.');
513
+ return [{ file: 'CLAUDE.md', status: 'warn' }];
514
+ }
515
+
516
+ let template = fs.readFileSync(templatePath, 'utf8');
517
+
518
+ // Sanitize profile values to prevent template double-substitution
519
+ const safe = (val) => String(val).replace(/\{\{[^}]*\}\}/g, '');
520
+
521
+ // Interpolate profile-specific values
522
+ template = template
523
+ .replace(/\{\{VERSION\}\}/g, PKG_VERSION)
524
+ .replace(/\{\{PREFLIGHT_CMD\}\}/g, safe(claudeMdConfig.preflightCmd || 'pnpm preflight'))
525
+ .replace(
526
+ /\{\{ATTRIBUTION_RULE\}\}/g,
527
+ safe(
528
+ claudeMdConfig.attributionRule || 'Do not include AI attribution in client-facing content.'
529
+ )
530
+ );
531
+
532
+ const MARKER_START = '<!-- reagent-managed:start -->';
533
+ const MARKER_END = '<!-- reagent-managed:end -->';
534
+
535
+ const existingContent = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, 'utf8') : '';
536
+
537
+ // Check if reagent block already exists
538
+ const hasBlock = existingContent.includes(MARKER_START);
539
+
540
+ let newContent;
541
+ if (hasBlock) {
542
+ const startIdx = existingContent.indexOf(MARKER_START);
543
+ const endIdx = existingContent.indexOf(MARKER_END);
544
+ if (endIdx === -1) {
545
+ // Orphaned start marker — strip it, prepend fresh block
546
+ const stripped = (
547
+ existingContent.slice(0, startIdx) + existingContent.slice(startIdx + MARKER_START.length)
548
+ ).trim();
549
+ newContent = stripped ? template.trimEnd() + '\n\n' + stripped.trimStart() : template;
550
+ } else {
551
+ // Remove old block entirely, prepend new template
552
+ const endAfter = endIdx + MARKER_END.length;
553
+ const withoutBlock = (
554
+ existingContent.slice(0, startIdx) + existingContent.slice(endAfter)
555
+ ).trim();
556
+ newContent = withoutBlock ? template.trimEnd() + '\n\n' + withoutBlock.trimStart() : template;
557
+ }
558
+ } else {
559
+ // Prepend to existing CLAUDE.md (or create new)
560
+ newContent = existingContent
561
+ ? template.trimEnd() + '\n\n' + existingContent.trimStart()
562
+ : template;
563
+ }
564
+
565
+ const same = existingContent === newContent;
566
+ if (!same && !dryRun) {
567
+ fs.writeFileSync(claudeMdPath, newContent, 'utf8');
568
+ }
569
+
570
+ return [
571
+ {
572
+ file: 'CLAUDE.md',
573
+ status: same ? 'skipped' : existingContent ? 'updated' : 'installed',
574
+ },
575
+ ];
576
+ }
577
+
578
+ function installPolicy(targetDir, profileName, dryRun) {
579
+ const reagentDir = path.join(targetDir, '.reagent');
580
+ const policyPath = path.join(reagentDir, 'policy.yaml');
581
+
582
+ if (fs.existsSync(policyPath)) {
583
+ return [{ file: '.reagent/policy.yaml', status: 'skipped' }];
584
+ }
585
+
586
+ if (!dryRun) {
587
+ fs.mkdirSync(reagentDir, { recursive: true });
588
+ const now = new Date().toISOString();
589
+ const content = `# .reagent/policy.yaml — generated by @bookedsolid/reagent v${PKG_VERSION}
590
+ # Commit this file. Edit autonomy_level and max_autonomy_level as needed.
591
+ # Run 'reagent freeze --reason "..."' to halt all agent operations.
592
+
593
+ version: "1"
594
+ profile: "${profileName}"
595
+ installed_by: "reagent@${PKG_VERSION}"
596
+ installed_at: "${now}"
597
+
598
+ # Autonomy levels:
599
+ # L0 — Read-only; every write requires explicit user approval
600
+ # L1 — Writes allowed to non-blocked paths; destructive operations blocked
601
+ # L2 — Writes + PR creation allowed; destructive tier blocked
602
+ # L3 — All writes allowed; advisory on anomalous patterns
603
+ autonomy_level: L1
604
+ max_autonomy_level: L2
605
+
606
+ # Human must approve any autonomy level increase
607
+ promotion_requires_human_approval: true
608
+
609
+ # Paths hooks and agents must never modify
610
+ blocked_paths:
611
+ - ".reagent/"
612
+ - ".github/workflows/"
613
+ - ".env"
614
+ - ".env.*"
615
+
616
+ # Optional: Discord webhook for halt/promote notifications
617
+ notification_channel: ""
618
+ `;
619
+ fs.writeFileSync(policyPath, content, 'utf8');
620
+ }
621
+
622
+ return [{ file: '.reagent/policy.yaml', status: 'installed' }];
623
+ }
624
+
625
+ function installOrchestratorAgent(targetDir, dryRun) {
626
+ const agentsSrcDir = path.join(PKG_ROOT, 'agents');
627
+ const agentsDestDir = path.join(targetDir, '.claude', 'agents');
628
+ const srcFile = path.join(agentsSrcDir, 'reagent-orchestrator.md');
629
+ const destFile = path.join(agentsDestDir, 'reagent-orchestrator.md');
630
+
631
+ if (!fs.existsSync(srcFile)) {
632
+ return [{ file: '.claude/agents/reagent-orchestrator.md (MISSING)', status: 'warn' }];
633
+ }
634
+
635
+ if (!dryRun) {
636
+ fs.mkdirSync(agentsDestDir, { recursive: true });
637
+ }
638
+
639
+ const srcContent = fs.readFileSync(srcFile, 'utf8');
640
+ const exists = fs.existsSync(destFile);
641
+ const same = exists && fs.readFileSync(destFile, 'utf8') === srcContent;
642
+
643
+ if (!same && !dryRun) {
644
+ fs.writeFileSync(destFile, srcContent, 'utf8');
645
+ }
646
+
647
+ return [
648
+ {
649
+ file: '.claude/agents/reagent-orchestrator.md',
650
+ status: same ? 'skipped' : exists ? 'updated' : 'installed',
651
+ },
652
+ ];
653
+ }
654
+
655
+ function installClaudeCommands(targetDir, dryRun) {
656
+ const commandsSrcDir = path.join(PKG_ROOT, 'commands');
657
+ const commandsDestDir = path.join(targetDir, '.claude', 'commands');
658
+
659
+ if (!fs.existsSync(commandsSrcDir)) {
660
+ return [];
661
+ }
662
+
663
+ if (!dryRun) {
664
+ fs.mkdirSync(commandsDestDir, { recursive: true });
665
+ }
666
+
667
+ const results = [];
668
+ const commandFiles = fs.readdirSync(commandsSrcDir).filter((f) => f.endsWith('.md'));
669
+
670
+ for (const fileName of commandFiles) {
671
+ const srcFile = path.join(commandsSrcDir, fileName);
672
+ const destFile = path.join(commandsDestDir, fileName);
673
+
674
+ const srcContent = fs.readFileSync(srcFile, 'utf8');
675
+ const exists = fs.existsSync(destFile);
676
+ const same = exists && fs.readFileSync(destFile, 'utf8') === srcContent;
677
+
678
+ if (!same && !dryRun) {
679
+ fs.writeFileSync(destFile, srcContent, 'utf8');
680
+ }
681
+
682
+ results.push({
683
+ file: `.claude/commands/${fileName}`,
684
+ status: same ? 'skipped' : exists ? 'updated' : 'installed',
685
+ });
686
+ }
687
+
688
+ return results;
689
+ }
690
+
691
+ function buildSettingsJson(hooksConfig, installedHookNames) {
692
+ const settings = {
693
+ env: {
694
+ ENABLE_TOOL_SEARCH: 'auto:5',
695
+ },
696
+ hooks: {},
697
+ };
698
+
699
+ function buildHookEntries(entries) {
700
+ const result = [];
701
+ for (const entry of entries) {
702
+ // Only include hooks that were actually installed (exist in package)
703
+ const availableHooks = entry.hooks.filter((h) => installedHookNames.has(h));
704
+ if (!availableHooks.length) continue;
705
+
706
+ result.push({
707
+ matcher: entry.matcher,
708
+ hooks: availableHooks.map((hookName) => ({
709
+ type: 'command',
710
+ command: `"$CLAUDE_PROJECT_DIR"/.claude/hooks/${hookName}.sh`,
711
+ timeout: getHookTimeout(hookName),
712
+ statusMessage: getHookStatusMessage(hookName),
713
+ })),
714
+ });
715
+ }
716
+ return result;
717
+ }
718
+
719
+ if (hooksConfig.PreToolUse?.length) {
720
+ const merged = mergeByMatcher(hooksConfig.PreToolUse);
721
+ const entries = buildHookEntries(merged);
722
+ if (entries.length) settings.hooks.PreToolUse = entries;
723
+ }
724
+
725
+ if (hooksConfig.PostToolUse?.length) {
726
+ const merged = mergeByMatcher(hooksConfig.PostToolUse);
727
+ const entries = buildHookEntries(merged);
728
+ if (entries.length) settings.hooks.PostToolUse = entries;
729
+ }
730
+
731
+ return settings;
732
+ }
733
+
734
+ function mergeByMatcher(entries) {
735
+ const map = new Map();
736
+ for (const entry of entries) {
737
+ if (map.has(entry.matcher)) {
738
+ map.get(entry.matcher).hooks.push(...entry.hooks);
739
+ } else {
740
+ map.set(entry.matcher, { matcher: entry.matcher, hooks: [...entry.hooks] });
741
+ }
742
+ }
743
+ return Array.from(map.values());
744
+ }
745
+
746
+ function getHookTimeout(hookName) {
747
+ const timeouts = {
748
+ 'secret-scanner': 15000,
749
+ 'dangerous-bash-interceptor': 10000,
750
+ 'env-file-protection': 5000,
751
+ 'attribution-advisory': 5000,
752
+ };
753
+ return timeouts[hookName] || 10000;
754
+ }
755
+
756
+ function getHookStatusMessage(hookName) {
757
+ const messages = {
758
+ 'dangerous-bash-interceptor': 'Checking command safety...',
759
+ 'env-file-protection': 'Checking for .env file reads...',
760
+ 'secret-scanner': 'Scanning for credentials...',
761
+ 'attribution-advisory': 'Checking for AI attribution...',
762
+ };
763
+ return messages[hookName] || `Running ${hookName}...`;
764
+ }
765
+
766
+ // ── Utility functions ─────────────────────────────────────────────────────────
767
+
768
+ function parseFlag(args, flag) {
769
+ const eqForm = args.find((a) => a.startsWith(`${flag}=`));
770
+ if (eqForm) return eqForm.split('=').slice(1).join('=');
771
+ const idx = args.indexOf(flag);
772
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) {
773
+ return args[idx + 1];
774
+ }
775
+ return null;
776
+ }
777
+
778
+ function gitignoreHasEntry(targetDir, entry) {
779
+ const gitignorePath = path.join(targetDir, '.gitignore');
780
+ if (!fs.existsSync(gitignorePath)) return false;
781
+ const content = fs.readFileSync(gitignorePath, 'utf8');
782
+ return content.split('\n').some((line) => line.trim() === entry.trim());
783
+ }
784
+
785
+ function printHelp() {
786
+ console.log(`
787
+ @bookedsolid/reagent v${PKG_VERSION} — zero-trust agentic infrastructure
788
+
789
+ Usage:
790
+ npx @bookedsolid/reagent <command> [options]
791
+
792
+ Commands:
793
+ init Install reagent config into the current directory
794
+ check Check what reagent components are installed
795
+ freeze Create .reagent/HALT to suspend all agent operations
796
+ unfreeze Remove .reagent/HALT to resume agent operations
797
+ help Show this help
798
+
799
+ Options for init:
800
+ --profile <name> Profile to install (default: client-engagement)
801
+ --dry-run Preview what would be installed without writing files
802
+
803
+ Options for freeze:
804
+ --reason <text> Reason for freeze (stored in HALT file)
805
+
806
+ Available profiles:
807
+ client-engagement Zero-trust setup for client engagements (default)
808
+ bst-internal BST internal project setup
809
+
810
+ Examples:
811
+ npx @bookedsolid/reagent init
812
+ npx @bookedsolid/reagent init --profile bst-internal
813
+ npx @bookedsolid/reagent init --dry-run
814
+ npx @bookedsolid/reagent check
815
+ npx @bookedsolid/reagent freeze --reason "security incident"
816
+ npx @bookedsolid/reagent unfreeze
817
+ `);
818
+ }