delimit-cli 3.11.10 → 3.12.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/CHANGELOG.md +62 -0
- package/README.md +9 -3
- package/bin/delimit-cli.js +304 -84
- package/bin/delimit-setup.js +56 -8
- package/lib/cross-model-hooks.js +706 -0
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LED-202: Cross-Model Hook System
|
|
5
|
+
*
|
|
6
|
+
* Detects installed AI coding assistants (Claude Code, Codex, Gemini CLI)
|
|
7
|
+
* and installs Delimit governance hooks into each one's native config format.
|
|
8
|
+
*
|
|
9
|
+
* Hook commands:
|
|
10
|
+
* delimit hook session-start -- ledger context + gov health
|
|
11
|
+
* delimit hook pre-tool <name> -- lint/test checks before edits
|
|
12
|
+
* delimit hook pre-commit -- repo diagnostics before commits
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { execSync } = require('child_process');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
// Use process.env.HOME to allow test overrides; fall back to os.homedir()
|
|
21
|
+
function getHome() { return process.env.HOME || os.homedir(); }
|
|
22
|
+
function getDelimitHome() { return path.join(getHome(), '.delimit'); }
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Hook configuration (user-overridable via delimit.yml)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function loadHookConfig() {
|
|
29
|
+
const defaults = {
|
|
30
|
+
session_start: true,
|
|
31
|
+
pre_tool: true,
|
|
32
|
+
pre_commit: true,
|
|
33
|
+
deliberate_on_commit: false,
|
|
34
|
+
show_strategy_items: true,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Check project-level delimit.yml, then global
|
|
38
|
+
const candidates = [
|
|
39
|
+
path.join(process.cwd(), 'delimit.yml'),
|
|
40
|
+
path.join(process.cwd(), '.delimit.yml'),
|
|
41
|
+
path.join(getDelimitHome(), 'delimit.yml'),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (fs.existsSync(candidate)) {
|
|
46
|
+
try {
|
|
47
|
+
const yaml = require('js-yaml');
|
|
48
|
+
const doc = yaml.load(fs.readFileSync(candidate, 'utf-8'));
|
|
49
|
+
if (doc && doc.hooks) {
|
|
50
|
+
return { ...defaults, ...doc.hooks };
|
|
51
|
+
}
|
|
52
|
+
} catch { /* ignore parse errors */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return defaults;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// AI tool detection
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function detectAITools() {
|
|
63
|
+
const detected = [];
|
|
64
|
+
|
|
65
|
+
// Claude Code
|
|
66
|
+
const claudeSettings = path.join(getHome(), '.claude', 'settings.json');
|
|
67
|
+
const claudeSettingsLocal = path.join(getHome(), '.claude', 'settings.local.json');
|
|
68
|
+
let hasClaude = fs.existsSync(claudeSettings) || fs.existsSync(claudeSettingsLocal);
|
|
69
|
+
if (!hasClaude) {
|
|
70
|
+
try {
|
|
71
|
+
execSync('claude --version 2>/dev/null', { stdio: 'pipe' });
|
|
72
|
+
hasClaude = true;
|
|
73
|
+
} catch { /* not installed */ }
|
|
74
|
+
}
|
|
75
|
+
if (hasClaude) {
|
|
76
|
+
detected.push({
|
|
77
|
+
id: 'claude',
|
|
78
|
+
name: 'Claude Code',
|
|
79
|
+
configPath: claudeSettings,
|
|
80
|
+
format: 'claude-hooks',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Codex CLI
|
|
85
|
+
const codexDir = path.join(getHome(), '.codex');
|
|
86
|
+
let hasCodex = fs.existsSync(codexDir);
|
|
87
|
+
if (!hasCodex) {
|
|
88
|
+
try {
|
|
89
|
+
execSync('codex --version 2>/dev/null', { stdio: 'pipe' });
|
|
90
|
+
hasCodex = true;
|
|
91
|
+
} catch { /* not installed */ }
|
|
92
|
+
}
|
|
93
|
+
if (hasCodex) {
|
|
94
|
+
detected.push({
|
|
95
|
+
id: 'codex',
|
|
96
|
+
name: 'Codex CLI',
|
|
97
|
+
configPath: path.join(codexDir, 'config.json'),
|
|
98
|
+
instructionsPath: path.join(codexDir, 'instructions.md'),
|
|
99
|
+
format: 'codex',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Gemini CLI
|
|
104
|
+
const geminiDir = path.join(getHome(), '.gemini');
|
|
105
|
+
let hasGemini = fs.existsSync(geminiDir);
|
|
106
|
+
if (!hasGemini) {
|
|
107
|
+
try {
|
|
108
|
+
execSync('gemini --version 2>/dev/null', { stdio: 'pipe' });
|
|
109
|
+
hasGemini = true;
|
|
110
|
+
} catch { /* not installed */ }
|
|
111
|
+
}
|
|
112
|
+
if (hasGemini) {
|
|
113
|
+
detected.push({
|
|
114
|
+
id: 'gemini',
|
|
115
|
+
name: 'Gemini CLI',
|
|
116
|
+
configPath: path.join(geminiDir, 'settings.json'),
|
|
117
|
+
format: 'gemini-mcp',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return detected;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Hook installers per tool
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Install hooks into Claude Code's ~/.claude/settings.json
|
|
130
|
+
* Claude Code supports native hooks: SessionStart, PreToolUse, PostToolUse, etc.
|
|
131
|
+
*/
|
|
132
|
+
function installClaudeHooks(tool, hookConfig) {
|
|
133
|
+
const configPath = tool.configPath;
|
|
134
|
+
const configDir = path.dirname(configPath);
|
|
135
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
136
|
+
|
|
137
|
+
let config = {};
|
|
138
|
+
if (fs.existsSync(configPath)) {
|
|
139
|
+
try {
|
|
140
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
141
|
+
} catch { config = {}; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!config.hooks) {
|
|
145
|
+
config.hooks = {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const npxCmd = 'npx delimit-cli';
|
|
149
|
+
const changes = [];
|
|
150
|
+
|
|
151
|
+
// SessionStart hook
|
|
152
|
+
if (hookConfig.session_start) {
|
|
153
|
+
const sessionHook = {
|
|
154
|
+
type: 'command',
|
|
155
|
+
command: `${npxCmd} hook session-start`,
|
|
156
|
+
};
|
|
157
|
+
if (!config.hooks.SessionStart) {
|
|
158
|
+
config.hooks.SessionStart = [];
|
|
159
|
+
}
|
|
160
|
+
// Check if already installed
|
|
161
|
+
const existing = config.hooks.SessionStart.find(
|
|
162
|
+
h => h.command && h.command.includes('delimit-cli hook session-start')
|
|
163
|
+
);
|
|
164
|
+
if (!existing) {
|
|
165
|
+
config.hooks.SessionStart.push(sessionHook);
|
|
166
|
+
changes.push('SessionStart');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// PreToolUse hook for file edits
|
|
171
|
+
if (hookConfig.pre_tool) {
|
|
172
|
+
const preToolHook = {
|
|
173
|
+
type: 'command',
|
|
174
|
+
command: `${npxCmd} hook pre-tool`,
|
|
175
|
+
matcher: 'Edit|Write|Bash',
|
|
176
|
+
};
|
|
177
|
+
if (!config.hooks.PreToolUse) {
|
|
178
|
+
config.hooks.PreToolUse = [];
|
|
179
|
+
}
|
|
180
|
+
const existing = config.hooks.PreToolUse.find(
|
|
181
|
+
h => h.command && h.command.includes('delimit-cli hook pre-tool')
|
|
182
|
+
);
|
|
183
|
+
if (!existing) {
|
|
184
|
+
config.hooks.PreToolUse.push(preToolHook);
|
|
185
|
+
changes.push('PreToolUse');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
190
|
+
return changes;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Install hooks for Codex CLI.
|
|
195
|
+
* Codex uses instructions.md for session-start equivalent and config.json for settings.
|
|
196
|
+
* We add governance instructions and a pre-commit hook reference.
|
|
197
|
+
*/
|
|
198
|
+
function installCodexHooks(tool, hookConfig) {
|
|
199
|
+
const changes = [];
|
|
200
|
+
const codexDir = path.dirname(tool.configPath);
|
|
201
|
+
fs.mkdirSync(codexDir, { recursive: true });
|
|
202
|
+
|
|
203
|
+
// Codex instructions.md -- acts as the session-start equivalent
|
|
204
|
+
if (hookConfig.session_start) {
|
|
205
|
+
const instructionsPath = tool.instructionsPath || path.join(codexDir, 'instructions.md');
|
|
206
|
+
const delimitBlock = `<!-- delimit:hooks-start -->
|
|
207
|
+
## Delimit Governance
|
|
208
|
+
|
|
209
|
+
On every session start, run this command to check governance status:
|
|
210
|
+
\`\`\`
|
|
211
|
+
npx delimit-cli hook session-start
|
|
212
|
+
\`\`\`
|
|
213
|
+
|
|
214
|
+
Before committing changes, run:
|
|
215
|
+
\`\`\`
|
|
216
|
+
npx delimit-cli hook pre-commit
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
219
|
+
After editing OpenAPI specs, run:
|
|
220
|
+
\`\`\`
|
|
221
|
+
npx delimit-cli hook pre-tool Edit
|
|
222
|
+
\`\`\`
|
|
223
|
+
<!-- delimit:hooks-end -->`;
|
|
224
|
+
|
|
225
|
+
let content = '';
|
|
226
|
+
if (fs.existsSync(instructionsPath)) {
|
|
227
|
+
content = fs.readFileSync(instructionsPath, 'utf-8');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (content.includes('delimit:hooks-start')) {
|
|
231
|
+
// Replace existing block
|
|
232
|
+
content = content.replace(
|
|
233
|
+
/<!-- delimit:hooks-start -->[\s\S]*?<!-- delimit:hooks-end -->/,
|
|
234
|
+
delimitBlock
|
|
235
|
+
);
|
|
236
|
+
} else {
|
|
237
|
+
content = content ? content + '\n\n' + delimitBlock : delimitBlock;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fs.writeFileSync(instructionsPath, content);
|
|
241
|
+
changes.push('instructions.md');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Codex config.json -- add hook commands
|
|
245
|
+
let config = {};
|
|
246
|
+
if (fs.existsSync(tool.configPath)) {
|
|
247
|
+
try {
|
|
248
|
+
config = JSON.parse(fs.readFileSync(tool.configPath, 'utf-8'));
|
|
249
|
+
} catch { config = {}; }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!config.hooks) {
|
|
253
|
+
config.hooks = {};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (hookConfig.pre_commit && !config.hooks['pre-commit']) {
|
|
257
|
+
config.hooks['pre-commit'] = 'npx delimit-cli hook pre-commit';
|
|
258
|
+
changes.push('pre-commit hook');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
|
|
262
|
+
return changes;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Install hooks for Gemini CLI.
|
|
267
|
+
* Gemini CLI uses MCP (already handled by setup) but we add governance
|
|
268
|
+
* instructions to settings.json and a GEMINI.md equivalent.
|
|
269
|
+
*/
|
|
270
|
+
function installGeminiHooks(tool, hookConfig) {
|
|
271
|
+
const changes = [];
|
|
272
|
+
const geminiDir = path.dirname(tool.configPath);
|
|
273
|
+
fs.mkdirSync(geminiDir, { recursive: true });
|
|
274
|
+
|
|
275
|
+
// Update settings.json with custom instructions
|
|
276
|
+
let config = {};
|
|
277
|
+
if (fs.existsSync(tool.configPath)) {
|
|
278
|
+
try {
|
|
279
|
+
config = JSON.parse(fs.readFileSync(tool.configPath, 'utf-8'));
|
|
280
|
+
} catch { config = {}; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const govInstructions = [
|
|
284
|
+
'Delimit governance is active.',
|
|
285
|
+
'On session start, run: npx delimit-cli hook session-start',
|
|
286
|
+
'Before committing, run: npx delimit-cli hook pre-commit',
|
|
287
|
+
'After editing API specs, run: npx delimit-cli hook pre-tool Edit',
|
|
288
|
+
'Call delimit_ledger_context and delimit_gov_health via MCP when available.',
|
|
289
|
+
].join(' ');
|
|
290
|
+
|
|
291
|
+
if (!config.customInstructions || !config.customInstructions.includes('delimit-cli hook')) {
|
|
292
|
+
config.customInstructions = govInstructions;
|
|
293
|
+
changes.push('customInstructions');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
|
|
297
|
+
|
|
298
|
+
// Write a GEMINI.md governance file (equivalent of CLAUDE.md)
|
|
299
|
+
const geminiMd = path.join(geminiDir, 'GEMINI.md');
|
|
300
|
+
if (!fs.existsSync(geminiMd) || !fs.readFileSync(geminiMd, 'utf-8').includes('delimit')) {
|
|
301
|
+
const content = `# Delimit Governance
|
|
302
|
+
|
|
303
|
+
On every session start:
|
|
304
|
+
1. Run \`npx delimit-cli hook session-start\` to check open tasks and governance status
|
|
305
|
+
|
|
306
|
+
After editing code:
|
|
307
|
+
- After editing API specs: run \`npx delimit-cli hook pre-tool Edit\`
|
|
308
|
+
- After editing tests: run \`npx delimit-cli hook pre-tool Edit\`
|
|
309
|
+
|
|
310
|
+
Before committing:
|
|
311
|
+
- Run \`npx delimit-cli hook pre-commit\` to check for issues
|
|
312
|
+
`;
|
|
313
|
+
fs.writeFileSync(geminiMd, content);
|
|
314
|
+
changes.push('GEMINI.md');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return changes;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Install hooks for a detected tool.
|
|
322
|
+
* Returns { tool, changes } describing what was installed.
|
|
323
|
+
*/
|
|
324
|
+
function installHooksForTool(tool, hookConfig) {
|
|
325
|
+
switch (tool.id) {
|
|
326
|
+
case 'claude':
|
|
327
|
+
return { tool, changes: installClaudeHooks(tool, hookConfig) };
|
|
328
|
+
case 'codex':
|
|
329
|
+
return { tool, changes: installCodexHooks(tool, hookConfig) };
|
|
330
|
+
case 'gemini':
|
|
331
|
+
return { tool, changes: installGeminiHooks(tool, hookConfig) };
|
|
332
|
+
default:
|
|
333
|
+
return { tool, changes: [] };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Install hooks for all detected AI tools.
|
|
339
|
+
*/
|
|
340
|
+
function installAllHooks(hookConfig) {
|
|
341
|
+
const tools = detectAITools();
|
|
342
|
+
const results = [];
|
|
343
|
+
for (const tool of tools) {
|
|
344
|
+
results.push(installHooksForTool(tool, hookConfig));
|
|
345
|
+
}
|
|
346
|
+
return { tools, results };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Hook removal (for uninstall)
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
function removeClaudeHooks() {
|
|
354
|
+
const configPath = path.join(getHome(), '.claude', 'settings.json');
|
|
355
|
+
if (!fs.existsSync(configPath)) return false;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
359
|
+
if (!config.hooks) return false;
|
|
360
|
+
|
|
361
|
+
let changed = false;
|
|
362
|
+
|
|
363
|
+
for (const event of ['SessionStart', 'PreToolUse', 'PostToolUse']) {
|
|
364
|
+
if (Array.isArray(config.hooks[event])) {
|
|
365
|
+
const before = config.hooks[event].length;
|
|
366
|
+
config.hooks[event] = config.hooks[event].filter(
|
|
367
|
+
h => !(h.command && h.command.includes('delimit-cli'))
|
|
368
|
+
);
|
|
369
|
+
if (config.hooks[event].length === 0) {
|
|
370
|
+
delete config.hooks[event];
|
|
371
|
+
}
|
|
372
|
+
if (config.hooks[event] === undefined || config.hooks[event].length < before) {
|
|
373
|
+
changed = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
379
|
+
delete config.hooks;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (changed) {
|
|
383
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
384
|
+
}
|
|
385
|
+
return changed;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function removeCodexHooks() {
|
|
392
|
+
let changed = false;
|
|
393
|
+
|
|
394
|
+
// Remove from instructions.md
|
|
395
|
+
const instructionsPath = path.join(getHome(), '.codex', 'instructions.md');
|
|
396
|
+
if (fs.existsSync(instructionsPath)) {
|
|
397
|
+
let content = fs.readFileSync(instructionsPath, 'utf-8');
|
|
398
|
+
if (content.includes('delimit:hooks-start')) {
|
|
399
|
+
content = content.replace(
|
|
400
|
+
/\n*<!-- delimit:hooks-start -->[\s\S]*?<!-- delimit:hooks-end -->\n*/,
|
|
401
|
+
''
|
|
402
|
+
);
|
|
403
|
+
fs.writeFileSync(instructionsPath, content);
|
|
404
|
+
changed = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Remove hooks from config.json
|
|
409
|
+
const configPath = path.join(getHome(), '.codex', 'config.json');
|
|
410
|
+
if (fs.existsSync(configPath)) {
|
|
411
|
+
try {
|
|
412
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
413
|
+
if (config.hooks) {
|
|
414
|
+
for (const [key, val] of Object.entries(config.hooks)) {
|
|
415
|
+
if (typeof val === 'string' && val.includes('delimit-cli')) {
|
|
416
|
+
delete config.hooks[key];
|
|
417
|
+
changed = true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (Object.keys(config.hooks).length === 0) {
|
|
421
|
+
delete config.hooks;
|
|
422
|
+
}
|
|
423
|
+
if (changed) {
|
|
424
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch { /* ignore */ }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return changed;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function removeGeminiHooks() {
|
|
434
|
+
let changed = false;
|
|
435
|
+
|
|
436
|
+
// Remove custom instructions referencing delimit
|
|
437
|
+
const configPath = path.join(getHome(), '.gemini', 'settings.json');
|
|
438
|
+
if (fs.existsSync(configPath)) {
|
|
439
|
+
try {
|
|
440
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
441
|
+
if (config.customInstructions && config.customInstructions.includes('delimit-cli hook')) {
|
|
442
|
+
delete config.customInstructions;
|
|
443
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
444
|
+
changed = true;
|
|
445
|
+
}
|
|
446
|
+
} catch { /* ignore */ }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Remove GEMINI.md if it's ours
|
|
450
|
+
const geminiMd = path.join(getHome(), '.gemini', 'GEMINI.md');
|
|
451
|
+
if (fs.existsSync(geminiMd)) {
|
|
452
|
+
const content = fs.readFileSync(geminiMd, 'utf-8');
|
|
453
|
+
if (content.includes('Delimit Governance')) {
|
|
454
|
+
fs.unlinkSync(geminiMd);
|
|
455
|
+
changed = true;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return changed;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function removeAllHooks() {
|
|
463
|
+
const results = [];
|
|
464
|
+
|
|
465
|
+
if (removeClaudeHooks()) {
|
|
466
|
+
results.push('Claude Code');
|
|
467
|
+
}
|
|
468
|
+
if (removeCodexHooks()) {
|
|
469
|
+
results.push('Codex CLI');
|
|
470
|
+
}
|
|
471
|
+
if (removeGeminiHooks()) {
|
|
472
|
+
results.push('Gemini CLI');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return results;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Hook execution commands
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* session-start: Show ledger context and governance health.
|
|
484
|
+
* Output goes to stdout for the AI tool to read.
|
|
485
|
+
*/
|
|
486
|
+
async function hookSessionStart() {
|
|
487
|
+
const config = loadHookConfig();
|
|
488
|
+
if (!config.session_start) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const lines = [];
|
|
493
|
+
lines.push('[Delimit] Governance check');
|
|
494
|
+
lines.push('');
|
|
495
|
+
|
|
496
|
+
// Check for delimit.yml or .delimit.yml
|
|
497
|
+
const cwd = process.cwd();
|
|
498
|
+
const hasPolicy = fs.existsSync(path.join(cwd, 'delimit.yml'))
|
|
499
|
+
|| fs.existsSync(path.join(cwd, '.delimit.yml'))
|
|
500
|
+
|| fs.existsSync(path.join(cwd, '.delimit', 'policies.yml'));
|
|
501
|
+
|
|
502
|
+
if (hasPolicy) {
|
|
503
|
+
lines.push('[Delimit] Policy file found -- governance active');
|
|
504
|
+
} else {
|
|
505
|
+
lines.push('[Delimit] No policy file found -- run "delimit init" to set up governance');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Check for OpenAPI specs
|
|
509
|
+
const specPatterns = ['openapi.yaml', 'openapi.yml', 'openapi.json', 'swagger.yaml', 'swagger.json'];
|
|
510
|
+
const foundSpecs = [];
|
|
511
|
+
for (const pattern of specPatterns) {
|
|
512
|
+
const specPath = path.join(cwd, pattern);
|
|
513
|
+
if (fs.existsSync(specPath)) {
|
|
514
|
+
foundSpecs.push(pattern);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// Also check api/ and specs/ directories
|
|
518
|
+
for (const dir of ['api', 'specs', 'spec']) {
|
|
519
|
+
const dirPath = path.join(cwd, dir);
|
|
520
|
+
if (fs.existsSync(dirPath)) {
|
|
521
|
+
try {
|
|
522
|
+
const files = fs.readdirSync(dirPath);
|
|
523
|
+
for (const f of files) {
|
|
524
|
+
if (/\.(yaml|yml|json)$/.test(f) && /openapi|swagger/i.test(f)) {
|
|
525
|
+
foundSpecs.push(path.join(dir, f));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} catch { /* ignore */ }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (foundSpecs.length > 0) {
|
|
533
|
+
lines.push(`[Delimit] OpenAPI specs detected: ${foundSpecs.join(', ')}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Check ledger
|
|
537
|
+
const ledgerDir = path.join(getDelimitHome(), 'ledger');
|
|
538
|
+
if (fs.existsSync(ledgerDir)) {
|
|
539
|
+
try {
|
|
540
|
+
const ledgerFiles = fs.readdirSync(ledgerDir).filter(f => f.endsWith('.json'));
|
|
541
|
+
let openItems = 0;
|
|
542
|
+
for (const f of ledgerFiles) {
|
|
543
|
+
try {
|
|
544
|
+
const items = JSON.parse(fs.readFileSync(path.join(ledgerDir, f), 'utf-8'));
|
|
545
|
+
if (Array.isArray(items)) {
|
|
546
|
+
openItems += items.filter(i => i.status === 'open' || i.status === 'in_progress').length;
|
|
547
|
+
}
|
|
548
|
+
} catch { /* ignore */ }
|
|
549
|
+
}
|
|
550
|
+
if (openItems > 0) {
|
|
551
|
+
lines.push(`[Delimit] Ledger: ${openItems} open item(s)`);
|
|
552
|
+
} else {
|
|
553
|
+
lines.push('[Delimit] Ledger: no open items');
|
|
554
|
+
}
|
|
555
|
+
} catch {
|
|
556
|
+
lines.push('[Delimit] Ledger: empty');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Git branch info
|
|
561
|
+
try {
|
|
562
|
+
const branch = execSync('git branch --show-current 2>/dev/null', { encoding: 'utf-8' }).trim();
|
|
563
|
+
if (branch) {
|
|
564
|
+
lines.push(`[Delimit] Branch: ${branch}`);
|
|
565
|
+
}
|
|
566
|
+
} catch { /* not in git repo */ }
|
|
567
|
+
|
|
568
|
+
lines.push('');
|
|
569
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* pre-tool: Check before file edits.
|
|
574
|
+
* If editing an OpenAPI spec, run a quick lint.
|
|
575
|
+
* If editing a test file, note it.
|
|
576
|
+
*/
|
|
577
|
+
async function hookPreTool(toolName) {
|
|
578
|
+
const config = loadHookConfig();
|
|
579
|
+
if (!config.pre_tool) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// The tool name comes from the AI tool (e.g., "Edit", "Write", "Bash")
|
|
584
|
+
// We check the DELIMIT_TOOL_INPUT env or just do lightweight checks
|
|
585
|
+
const cwd = process.cwd();
|
|
586
|
+
|
|
587
|
+
// Check if there are staged OpenAPI spec changes
|
|
588
|
+
try {
|
|
589
|
+
const stagedFiles = execSync('git diff --cached --name-only 2>/dev/null', {
|
|
590
|
+
encoding: 'utf-8',
|
|
591
|
+
timeout: 2000,
|
|
592
|
+
}).split('\n').filter(Boolean);
|
|
593
|
+
|
|
594
|
+
const specFiles = stagedFiles.filter(f =>
|
|
595
|
+
/openapi|swagger/i.test(f) && /\.(yaml|yml|json)$/.test(f)
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
if (specFiles.length > 0) {
|
|
599
|
+
process.stderr.write(`[Delimit] Warning: OpenAPI spec(s) staged for commit: ${specFiles.join(', ')}\n`);
|
|
600
|
+
process.stderr.write('[Delimit] Run "delimit lint" before committing to check for breaking changes.\n');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const testFiles = stagedFiles.filter(f =>
|
|
604
|
+
/\.(test|spec)\.(js|ts|py|rb)$/.test(f) || /test_.*\.py$/.test(f)
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
if (testFiles.length > 0) {
|
|
608
|
+
process.stderr.write(`[Delimit] Test files staged: ${testFiles.join(', ')}\n`);
|
|
609
|
+
process.stderr.write('[Delimit] Consider running tests before committing.\n');
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// Not in a git repo or no staged changes -- that is fine
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* pre-commit: Run repo diagnostics before committing.
|
|
618
|
+
*/
|
|
619
|
+
async function hookPreCommit() {
|
|
620
|
+
const config = loadHookConfig();
|
|
621
|
+
if (!config.pre_commit) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const cwd = process.cwd();
|
|
626
|
+
const warnings = [];
|
|
627
|
+
|
|
628
|
+
// Check for staged OpenAPI spec changes
|
|
629
|
+
try {
|
|
630
|
+
const stagedFiles = execSync('git diff --cached --name-only 2>/dev/null', {
|
|
631
|
+
encoding: 'utf-8',
|
|
632
|
+
timeout: 2000,
|
|
633
|
+
}).split('\n').filter(Boolean);
|
|
634
|
+
|
|
635
|
+
const specFiles = stagedFiles.filter(f =>
|
|
636
|
+
/openapi|swagger/i.test(f) && /\.(yaml|yml|json)$/.test(f)
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
if (specFiles.length > 0) {
|
|
640
|
+
// Try to find a previous version to diff against
|
|
641
|
+
for (const specFile of specFiles) {
|
|
642
|
+
try {
|
|
643
|
+
// Get the HEAD version
|
|
644
|
+
const oldContent = execSync(`git show HEAD:${specFile} 2>/dev/null`, {
|
|
645
|
+
encoding: 'utf-8',
|
|
646
|
+
timeout: 3000,
|
|
647
|
+
});
|
|
648
|
+
if (oldContent) {
|
|
649
|
+
warnings.push(`[Delimit] OpenAPI spec changed: ${specFile}`);
|
|
650
|
+
warnings.push('[Delimit] Run "delimit diff <old> <new>" to review API changes before committing.');
|
|
651
|
+
}
|
|
652
|
+
} catch {
|
|
653
|
+
// New file, no previous version
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Check for secrets patterns in staged files
|
|
659
|
+
const sensitivePatterns = [
|
|
660
|
+
/password\s*[:=]\s*['"][^'"]+['"]/i,
|
|
661
|
+
/api[_-]?key\s*[:=]\s*['"][^'"]+['"]/i,
|
|
662
|
+
/secret\s*[:=]\s*['"][^'"]+['"]/i,
|
|
663
|
+
];
|
|
664
|
+
|
|
665
|
+
for (const file of stagedFiles) {
|
|
666
|
+
if (/\.(env|key|pem|p12|pfx)$/.test(file)) {
|
|
667
|
+
warnings.push(`[Delimit] WARNING: Potentially sensitive file staged: ${file}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
// Not in git repo
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Check for policy file
|
|
675
|
+
const hasPolicy = fs.existsSync(path.join(cwd, 'delimit.yml'))
|
|
676
|
+
|| fs.existsSync(path.join(cwd, '.delimit.yml'));
|
|
677
|
+
|
|
678
|
+
if (!hasPolicy) {
|
|
679
|
+
warnings.push('[Delimit] No governance policy found. Run "delimit init" to create one.');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (warnings.length > 0) {
|
|
683
|
+
process.stderr.write(warnings.join('\n') + '\n');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
// Exports
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
module.exports = {
|
|
692
|
+
detectAITools,
|
|
693
|
+
installHooksForTool,
|
|
694
|
+
installAllHooks,
|
|
695
|
+
installClaudeHooks,
|
|
696
|
+
installCodexHooks,
|
|
697
|
+
installGeminiHooks,
|
|
698
|
+
removeAllHooks,
|
|
699
|
+
removeClaudeHooks,
|
|
700
|
+
removeCodexHooks,
|
|
701
|
+
removeGeminiHooks,
|
|
702
|
+
loadHookConfig,
|
|
703
|
+
hookSessionStart,
|
|
704
|
+
hookPreTool,
|
|
705
|
+
hookPreCommit,
|
|
706
|
+
};
|