@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/LICENSE +21 -0
- package/README.md +118 -0
- package/agents/reagent-orchestrator.md +66 -0
- package/bin/init.js +818 -0
- package/commands/rea.md +76 -0
- package/commands/restart.md +105 -0
- package/cursor/rules/001-no-hallucination.mdc +28 -0
- package/cursor/rules/002-verify-before-act.mdc +28 -0
- package/cursor/rules/003-attribution.mdc +36 -0
- package/hooks/attribution-advisory.sh +74 -0
- package/hooks/dangerous-bash-interceptor.sh +287 -0
- package/hooks/env-file-protection.sh +110 -0
- package/hooks/secret-scanner.sh +229 -0
- package/husky/commit-msg.sh +50 -0
- package/husky/pre-commit.sh +57 -0
- package/husky/pre-push.sh +75 -0
- package/package.json +60 -0
- package/profiles/bst-internal.json +30 -0
- package/profiles/client-engagement.json +30 -0
- package/templates/CLAUDE.md +55 -0
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
|
+
}
|