xtrm-cli 2.1.18 → 2.1.19
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/dist/index.cjs +810 -425
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/clean.ts +323 -0
- package/src/commands/install-project.ts +1 -95
- package/src/utils/atomic-config.ts +100 -1
- package/test/hooks.test.ts +4 -49
package/package.json
CHANGED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { t, sym } from '../utils/theme.js';
|
|
7
|
+
|
|
8
|
+
// Canonical hooks (files in ~/.claude/hooks/)
|
|
9
|
+
const CANONICAL_HOOKS = new Set([
|
|
10
|
+
'agent_context.py',
|
|
11
|
+
'serena-workflow-reminder.py',
|
|
12
|
+
'main-guard.mjs',
|
|
13
|
+
'main-guard-post-push.mjs',
|
|
14
|
+
'beads-gate-core.mjs',
|
|
15
|
+
'beads-gate-utils.mjs',
|
|
16
|
+
'beads-gate-messages.mjs',
|
|
17
|
+
'beads-edit-gate.mjs',
|
|
18
|
+
'beads-commit-gate.mjs',
|
|
19
|
+
'beads-stop-gate.mjs',
|
|
20
|
+
'beads-memory-gate.mjs',
|
|
21
|
+
'beads-compact-save.mjs',
|
|
22
|
+
'beads-compact-restore.mjs',
|
|
23
|
+
'gitnexus', // directory
|
|
24
|
+
'statusline-starship.sh',
|
|
25
|
+
'README.md',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Canonical skills (directories in ~/.agents/skills/)
|
|
29
|
+
const CANONICAL_SKILLS = new Set([
|
|
30
|
+
'clean-code',
|
|
31
|
+
'delegating',
|
|
32
|
+
'docker-expert',
|
|
33
|
+
'documenting',
|
|
34
|
+
'find-skills',
|
|
35
|
+
'gitnexus-debugging',
|
|
36
|
+
'gitnexus-exploring',
|
|
37
|
+
'gitnexus-impact-analysis',
|
|
38
|
+
'gitnexus-refactoring',
|
|
39
|
+
'hook-development',
|
|
40
|
+
'obsidian-cli',
|
|
41
|
+
'orchestrating-agents',
|
|
42
|
+
'prompt-improving',
|
|
43
|
+
'python-testing',
|
|
44
|
+
'senior-backend',
|
|
45
|
+
'senior-data-scientist',
|
|
46
|
+
'senior-devops',
|
|
47
|
+
'senior-security',
|
|
48
|
+
'skill-creator',
|
|
49
|
+
'using-serena-lsp',
|
|
50
|
+
'using-TDD',
|
|
51
|
+
'using-xtrm',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
// Directories/files to always ignore
|
|
55
|
+
const IGNORED_ITEMS = new Set([
|
|
56
|
+
'__pycache__',
|
|
57
|
+
'.DS_Store',
|
|
58
|
+
'Thumbs.db',
|
|
59
|
+
'.gitkeep',
|
|
60
|
+
'node_modules',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
interface CleanResult {
|
|
64
|
+
hooksRemoved: string[];
|
|
65
|
+
skillsRemoved: string[];
|
|
66
|
+
cacheRemoved: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function cleanHooks(dryRun: boolean): Promise<{ removed: string[]; cache: string[] }> {
|
|
70
|
+
const hooksDir = path.join(homedir(), '.claude', 'hooks');
|
|
71
|
+
const removed: string[] = [];
|
|
72
|
+
const cache: string[] = [];
|
|
73
|
+
|
|
74
|
+
if (!await fs.pathExists(hooksDir)) {
|
|
75
|
+
return { removed, cache };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entries = await fs.readdir(hooksDir);
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
// Skip ignored items but track them for cache cleanup
|
|
82
|
+
if (IGNORED_ITEMS.has(entry)) {
|
|
83
|
+
if (!dryRun) {
|
|
84
|
+
const fullPath = path.join(hooksDir, entry);
|
|
85
|
+
await fs.remove(fullPath);
|
|
86
|
+
}
|
|
87
|
+
cache.push(entry);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if it's canonical
|
|
92
|
+
if (CANONICAL_HOOKS.has(entry)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if it's a file we should remove
|
|
97
|
+
const fullPath = path.join(hooksDir, entry);
|
|
98
|
+
const stat = await fs.stat(fullPath);
|
|
99
|
+
|
|
100
|
+
// Only remove files, not arbitrary directories (except cache dirs)
|
|
101
|
+
if (stat.isFile() || (stat.isDirectory() && IGNORED_ITEMS.has(entry))) {
|
|
102
|
+
if (!dryRun) {
|
|
103
|
+
await fs.remove(fullPath);
|
|
104
|
+
}
|
|
105
|
+
removed.push(entry);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { removed, cache };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function cleanSkills(dryRun: boolean): Promise<string[]> {
|
|
113
|
+
const skillsDir = path.join(homedir(), '.agents', 'skills');
|
|
114
|
+
const removed: string[] = [];
|
|
115
|
+
|
|
116
|
+
if (!await fs.pathExists(skillsDir)) {
|
|
117
|
+
return removed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entries = await fs.readdir(skillsDir);
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
// Skip ignored items
|
|
124
|
+
if (IGNORED_ITEMS.has(entry)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Skip README.txt
|
|
129
|
+
if (entry === 'README.txt') {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check if it's canonical
|
|
134
|
+
if (CANONICAL_SKILLS.has(entry)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Remove non-canonical directory
|
|
139
|
+
const fullPath = path.join(skillsDir, entry);
|
|
140
|
+
const stat = await fs.stat(fullPath);
|
|
141
|
+
|
|
142
|
+
if (stat.isDirectory()) {
|
|
143
|
+
if (!dryRun) {
|
|
144
|
+
await fs.remove(fullPath);
|
|
145
|
+
}
|
|
146
|
+
removed.push(entry);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cleanOrphanedHookEntries(dryRun: boolean): Promise<string[]> {
|
|
154
|
+
const settingsPath = path.join(homedir(), '.claude', 'settings.json');
|
|
155
|
+
const removed: string[] = [];
|
|
156
|
+
|
|
157
|
+
if (!await fs.pathExists(settingsPath)) {
|
|
158
|
+
return removed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let settings: any = {};
|
|
162
|
+
try {
|
|
163
|
+
settings = await fs.readJson(settingsPath);
|
|
164
|
+
} catch {
|
|
165
|
+
return removed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
169
|
+
return removed;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Collect canonical script names from CANONICAL_HOOKS
|
|
173
|
+
const canonicalScripts = new Set<string>();
|
|
174
|
+
for (const hook of CANONICAL_HOOKS) {
|
|
175
|
+
if (hook.endsWith('.py') || hook.endsWith('.mjs') || hook.endsWith('.cjs') || hook.endsWith('.js')) {
|
|
176
|
+
canonicalScripts.add(hook);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Add gitnexus hook
|
|
180
|
+
canonicalScripts.add('gitnexus/gitnexus-hook.cjs');
|
|
181
|
+
|
|
182
|
+
// Check each hook entry
|
|
183
|
+
let modified = false;
|
|
184
|
+
for (const [event, wrappers] of Object.entries(settings.hooks)) {
|
|
185
|
+
if (!Array.isArray(wrappers)) continue;
|
|
186
|
+
|
|
187
|
+
const keptWrappers: any[] = [];
|
|
188
|
+
for (const wrapper of wrappers) {
|
|
189
|
+
const innerHooks = wrapper.hooks || [wrapper];
|
|
190
|
+
const keptInner: any[] = [];
|
|
191
|
+
|
|
192
|
+
for (const hook of innerHooks) {
|
|
193
|
+
const cmd = hook?.command || '';
|
|
194
|
+
// Extract script filename
|
|
195
|
+
const m = cmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
|
|
196
|
+
const script = m?.[1];
|
|
197
|
+
|
|
198
|
+
if (!script || canonicalScripts.has(script)) {
|
|
199
|
+
keptInner.push(hook);
|
|
200
|
+
} else {
|
|
201
|
+
removed.push(`${event}:${script}`);
|
|
202
|
+
modified = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (keptInner.length > 0) {
|
|
207
|
+
if (wrapper.hooks) {
|
|
208
|
+
keptWrappers.push({ ...wrapper, hooks: keptInner });
|
|
209
|
+
} else if (keptInner.length === 1) {
|
|
210
|
+
keptWrappers.push(keptInner[0]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (keptWrappers.length > 0) {
|
|
216
|
+
settings.hooks[event] = keptWrappers;
|
|
217
|
+
} else {
|
|
218
|
+
delete settings.hooks[event];
|
|
219
|
+
modified = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (modified && !dryRun) {
|
|
224
|
+
await fs.writeJson(settingsPath, settings, { spaces: 2 });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return removed;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function createCleanCommand(): Command {
|
|
231
|
+
return new Command('clean')
|
|
232
|
+
.description('Remove orphaned hooks and skills not in the canonical repository')
|
|
233
|
+
.option('--dry-run', 'Preview what would be removed without making changes', false)
|
|
234
|
+
.option('--hooks-only', 'Only clean hooks, skip skills', false)
|
|
235
|
+
.option('--skills-only', 'Only clean skills, skip hooks', false)
|
|
236
|
+
.option('-y, --yes', 'Skip confirmation prompt', false)
|
|
237
|
+
.action(async (opts) => {
|
|
238
|
+
const { dryRun, hooksOnly, skillsOnly, yes } = opts;
|
|
239
|
+
|
|
240
|
+
console.log(t.bold('\n XTRM Clean — Remove Orphaned Components\n'));
|
|
241
|
+
|
|
242
|
+
if (dryRun) {
|
|
243
|
+
console.log(kleur.yellow(' DRY RUN — No changes will be made\n'));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result: CleanResult = {
|
|
247
|
+
hooksRemoved: [],
|
|
248
|
+
skillsRemoved: [],
|
|
249
|
+
cacheRemoved: [],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Clean hooks
|
|
253
|
+
if (!skillsOnly) {
|
|
254
|
+
console.log(kleur.bold(' Scanning ~/.claude/hooks/...'));
|
|
255
|
+
const { removed, cache } = await cleanHooks(dryRun);
|
|
256
|
+
result.hooksRemoved = removed;
|
|
257
|
+
result.cacheRemoved = cache;
|
|
258
|
+
|
|
259
|
+
if (removed.length > 0) {
|
|
260
|
+
for (const f of removed) {
|
|
261
|
+
console.log(kleur.red(` ✗ ${f}`));
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
console.log(kleur.dim(' ✓ No orphaned hooks found'));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (cache.length > 0) {
|
|
268
|
+
console.log(kleur.dim(` ↳ Cleaned ${cache.length} cache directory(ies)`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Clean orphaned hook entries in settings.json
|
|
272
|
+
console.log(kleur.bold('\n Scanning settings.json for orphaned hook entries...'));
|
|
273
|
+
const orphanedEntries = await cleanOrphanedHookEntries(dryRun);
|
|
274
|
+
if (orphanedEntries.length > 0) {
|
|
275
|
+
for (const entry of orphanedEntries) {
|
|
276
|
+
console.log(kleur.red(` ✗ ${entry}`));
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
console.log(kleur.dim(' ✓ No orphaned hook entries found'));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Clean skills
|
|
284
|
+
if (!hooksOnly) {
|
|
285
|
+
console.log(kleur.bold('\n Scanning ~/.agents/skills/...'));
|
|
286
|
+
result.skillsRemoved = await cleanSkills(dryRun);
|
|
287
|
+
|
|
288
|
+
if (result.skillsRemoved.length > 0) {
|
|
289
|
+
for (const d of result.skillsRemoved) {
|
|
290
|
+
console.log(kleur.red(` ✗ ${d}/`));
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
console.log(kleur.dim(' ✓ No orphaned skills found'));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Summary
|
|
298
|
+
const totalRemoved = result.hooksRemoved.length + result.skillsRemoved.length + result.cacheRemoved.length;
|
|
299
|
+
|
|
300
|
+
if (totalRemoved === 0) {
|
|
301
|
+
console.log(t.boldGreen('\n ✓ All components are canonical — nothing to clean\n'));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log(kleur.bold('\n Summary:'));
|
|
306
|
+
if (result.hooksRemoved.length > 0) {
|
|
307
|
+
console.log(kleur.red(` ${result.hooksRemoved.length} orphaned hook(s)`));
|
|
308
|
+
}
|
|
309
|
+
if (result.skillsRemoved.length > 0) {
|
|
310
|
+
console.log(kleur.red(` ${result.skillsRemoved.length} orphaned skill(s)`));
|
|
311
|
+
}
|
|
312
|
+
if (result.cacheRemoved.length > 0) {
|
|
313
|
+
console.log(kleur.dim(` ${result.cacheRemoved.length} cache director(y/ies)`));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!dryRun) {
|
|
317
|
+
console.log(t.boldGreen('\n ✓ Cleanup complete\n'));
|
|
318
|
+
console.log(kleur.dim(' Run `xtrm install all -y` to reinstall canonical components\n'));
|
|
319
|
+
} else {
|
|
320
|
+
console.log(kleur.yellow('\n ℹ Dry run — run without --dry-run to apply changes\n'));
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -139,92 +139,6 @@ export async function getAvailableProjectSkills(): Promise<string[]> {
|
|
|
139
139
|
* Deep merge settings.json hooks without overwriting existing user hooks.
|
|
140
140
|
* Appends new hooks to existing events intelligently.
|
|
141
141
|
*/
|
|
142
|
-
/**
|
|
143
|
-
* Extract script filename from a hook command.
|
|
144
|
-
*/
|
|
145
|
-
function getScriptFilename(hook: any): string | null {
|
|
146
|
-
const cmd = hook.command || hook.hooks?.[0]?.command || '';
|
|
147
|
-
if (typeof cmd !== 'string') return null;
|
|
148
|
-
// Match script filename (e.g., "beads-edit-gate.mjs" or "gitnexus/gitnexus-hook.cjs")
|
|
149
|
-
const m = cmd.match(/([A-Za-z0-9._/-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._/-]+\.(?:py|cjs|mjs|js))/);
|
|
150
|
-
return m?.[1] ?? null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Prune hooks from settings.json that are NOT in the canonical config.
|
|
155
|
-
* This removes stale entries from old versions before merging new ones.
|
|
156
|
-
*
|
|
157
|
-
* @param existing Current settings.json hooks
|
|
158
|
-
* @param canonical Canonical hooks config from hooks.json
|
|
159
|
-
* @returns Pruned settings with stale hooks removed
|
|
160
|
-
*/
|
|
161
|
-
export function pruneStaleHooks(
|
|
162
|
-
existing: Record<string, any>,
|
|
163
|
-
canonical: Record<string, any>,
|
|
164
|
-
): { result: Record<string, any>; removed: string[] } {
|
|
165
|
-
const result = { ...existing };
|
|
166
|
-
const removed: string[] = [];
|
|
167
|
-
|
|
168
|
-
if (!result.hooks || typeof result.hooks !== 'object') {
|
|
169
|
-
return { result, removed };
|
|
170
|
-
}
|
|
171
|
-
if (!canonical.hooks || typeof canonical.hooks !== 'object') {
|
|
172
|
-
return { result, removed };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Collect all canonical script filenames
|
|
176
|
-
const canonicalScripts = new Set<string>();
|
|
177
|
-
for (const [event, hooks] of Object.entries(canonical.hooks)) {
|
|
178
|
-
const hookList = Array.isArray(hooks) ? hooks : [hooks];
|
|
179
|
-
for (const wrapper of hookList) {
|
|
180
|
-
const innerHooks = wrapper.hooks || [wrapper];
|
|
181
|
-
for (const hook of innerHooks) {
|
|
182
|
-
const script = getScriptFilename(hook);
|
|
183
|
-
if (script) canonicalScripts.add(script);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Prune existing hooks not in canonical
|
|
189
|
-
for (const [event, hooks] of Object.entries(result.hooks)) {
|
|
190
|
-
if (!Array.isArray(hooks)) continue;
|
|
191
|
-
|
|
192
|
-
const prunedWrappers: any[] = [];
|
|
193
|
-
for (const wrapper of hooks) {
|
|
194
|
-
const innerHooks = wrapper.hooks || [wrapper];
|
|
195
|
-
const keptInner: any[] = [];
|
|
196
|
-
|
|
197
|
-
for (const hook of innerHooks) {
|
|
198
|
-
const script = getScriptFilename(hook);
|
|
199
|
-
// Keep if: no script (not a file-based hook) OR script is canonical
|
|
200
|
-
if (!script || canonicalScripts.has(script)) {
|
|
201
|
-
keptInner.push(hook);
|
|
202
|
-
} else {
|
|
203
|
-
removed.push(`${event}:${script}`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (keptInner.length > 0) {
|
|
208
|
-
if (wrapper.hooks) {
|
|
209
|
-
prunedWrappers.push({ ...wrapper, hooks: keptInner });
|
|
210
|
-
} else if (keptInner.length === 1) {
|
|
211
|
-
prunedWrappers.push(keptInner[0]);
|
|
212
|
-
} else {
|
|
213
|
-
prunedWrappers.push({ ...wrapper, hooks: keptInner });
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (prunedWrappers.length > 0) {
|
|
219
|
-
result.hooks[event] = prunedWrappers;
|
|
220
|
-
} else {
|
|
221
|
-
delete result.hooks[event];
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return { result, removed };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
142
|
export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
|
|
229
143
|
const result = { ...existing };
|
|
230
144
|
|
|
@@ -352,15 +266,7 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
|
|
|
352
266
|
}
|
|
353
267
|
|
|
354
268
|
const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
|
|
355
|
-
|
|
356
|
-
// First prune stale hooks not in canonical config
|
|
357
|
-
const { result: prunedSettings, removed } = pruneStaleHooks(existingSettings, incomingSettings);
|
|
358
|
-
if (removed.length > 0) {
|
|
359
|
-
console.log(kleur.yellow(` ↳ Pruned ${removed.length} stale hook(s): ${removed.join(', ')}`));
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Then merge canonical hooks
|
|
363
|
-
const mergedSettings = deepMergeHooks(prunedSettings, incomingSettings);
|
|
269
|
+
const mergedSettings = deepMergeHooks(existingSettings, incomingSettings);
|
|
364
270
|
|
|
365
271
|
await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
|
|
366
272
|
console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
|
|
@@ -53,6 +53,89 @@ function commandKey(command: string): string {
|
|
|
53
53
|
return m?.[1] || command.trim();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Extract script filename from a hook command for pruning purposes.
|
|
58
|
+
*/
|
|
59
|
+
function scriptKey(command: string): string | null {
|
|
60
|
+
// Match the script path relative to hooks directory
|
|
61
|
+
// Pattern: /hooks/<optional-subdir>/<filename>.<ext>
|
|
62
|
+
const m = command.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
|
|
63
|
+
if (m) return m[1];
|
|
64
|
+
|
|
65
|
+
// Fallback: match just the filename if no /hooks/ path
|
|
66
|
+
const m2 = command.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
|
|
67
|
+
return m2?.[1] || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect all canonical script filenames from incoming hooks.
|
|
72
|
+
*/
|
|
73
|
+
function collectCanonicalScripts(incomingHooks: any): Set<string> {
|
|
74
|
+
const scripts = new Set<string>();
|
|
75
|
+
if (!incomingHooks || typeof incomingHooks !== 'object') return scripts;
|
|
76
|
+
|
|
77
|
+
for (const wrappers of Object.values(incomingHooks)) {
|
|
78
|
+
if (!Array.isArray(wrappers)) continue;
|
|
79
|
+
for (const wrapper of wrappers) {
|
|
80
|
+
const commands = extractHookCommands(wrapper);
|
|
81
|
+
for (const cmd of commands) {
|
|
82
|
+
const script = scriptKey(cmd);
|
|
83
|
+
if (script) scripts.add(script);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return scripts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prune existing hook wrappers that reference scripts NOT in canonical set.
|
|
92
|
+
* Returns { pruned: wrappers[], removed: string[] }
|
|
93
|
+
*/
|
|
94
|
+
function pruneStaleWrappers(existing: any[], canonicalScripts: Set<string>): { pruned: any[]; removed: string[] } {
|
|
95
|
+
if (canonicalScripts.size === 0) {
|
|
96
|
+
return { pruned: existing, removed: [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const removed: string[] = [];
|
|
100
|
+
const pruned: any[] = [];
|
|
101
|
+
|
|
102
|
+
for (const wrapper of existing) {
|
|
103
|
+
if (!Array.isArray(wrapper.hooks)) {
|
|
104
|
+
pruned.push(wrapper);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const keptHooks: any[] = [];
|
|
109
|
+
for (const hook of wrapper.hooks) {
|
|
110
|
+
const cmd = hook?.command;
|
|
111
|
+
if (typeof cmd !== 'string') {
|
|
112
|
+
keptHooks.push(hook);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Only prune hooks that are clearly xtrm-managed (have /hooks/ in their path)
|
|
116
|
+
// User-local hooks from other directories are always preserved
|
|
117
|
+
const isXtrmManaged = /\/hooks\//.test(cmd);
|
|
118
|
+
if (!isXtrmManaged) {
|
|
119
|
+
keptHooks.push(hook);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const script = scriptKey(cmd);
|
|
123
|
+
// Keep if: no script (not a file-based hook) OR script is in canonical set
|
|
124
|
+
if (!script || canonicalScripts.has(script)) {
|
|
125
|
+
keptHooks.push(hook);
|
|
126
|
+
} else {
|
|
127
|
+
removed.push(script);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (keptHooks.length > 0) {
|
|
132
|
+
pruned.push({ ...wrapper, hooks: keptHooks });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { pruned, removed };
|
|
137
|
+
}
|
|
138
|
+
|
|
56
139
|
function mergeMatcher(existingMatcher: string, incomingMatcher: string): string {
|
|
57
140
|
const parts = [
|
|
58
141
|
...existingMatcher.split('|').map((s: string) => s.trim()),
|
|
@@ -130,7 +213,23 @@ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
|
|
|
130
213
|
}
|
|
131
214
|
|
|
132
215
|
function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
|
|
133
|
-
|
|
216
|
+
// Step 1: Collect canonical script filenames from incoming hooks
|
|
217
|
+
const canonicalScripts = collectCanonicalScripts(incomingHooks);
|
|
218
|
+
|
|
219
|
+
// Step 2: Prune existing hooks that reference non-canonical scripts
|
|
220
|
+
const result: any = {};
|
|
221
|
+
for (const [event, existingWrappers] of Object.entries(existingHooks || {})) {
|
|
222
|
+
if (!Array.isArray(existingWrappers)) {
|
|
223
|
+
result[event] = existingWrappers;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const { pruned } = pruneStaleWrappers(existingWrappers, canonicalScripts);
|
|
227
|
+
if (pruned.length > 0) {
|
|
228
|
+
result[event] = pruned;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Step 3: Merge incoming hooks with pruned existing hooks
|
|
134
233
|
for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
|
|
135
234
|
const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
|
|
136
235
|
const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
|
package/test/hooks.test.ts
CHANGED
|
@@ -77,7 +77,7 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
77
77
|
);
|
|
78
78
|
expect(r.status).toBe(2);
|
|
79
79
|
const out = parseHookJson(r.stdout);
|
|
80
|
-
expect(out?.systemMessage).toContain('Bash
|
|
80
|
+
expect(out?.systemMessage).toContain('Bash restricted');
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
it('allows safe Bash commands on protected branch', () => {
|
|
@@ -117,7 +117,7 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
|
117
117
|
);
|
|
118
118
|
expect(r.status, `expected exit 2 for: ${command}`).toBe(2);
|
|
119
119
|
const out = parseHookJson(r.stdout);
|
|
120
|
-
expect(out?.systemMessage).toContain('Bash
|
|
120
|
+
expect(out?.systemMessage).toContain('Bash restricted');
|
|
121
121
|
}
|
|
122
122
|
});
|
|
123
123
|
|
|
@@ -331,7 +331,7 @@ exit 1
|
|
|
331
331
|
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
332
332
|
);
|
|
333
333
|
expect(r.status).toBe(2);
|
|
334
|
-
expect(r.stderr).toContain('
|
|
334
|
+
expect(r.stderr).toContain('active claim');
|
|
335
335
|
} finally {
|
|
336
336
|
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
337
337
|
rmSync(projectDir, { recursive: true, force: true });
|
|
@@ -456,7 +456,7 @@ exit 1
|
|
|
456
456
|
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
457
457
|
);
|
|
458
458
|
expect(r.status).toBe(2);
|
|
459
|
-
expect(r.stderr).toContain('
|
|
459
|
+
expect(r.stderr).toContain('Memory gate');
|
|
460
460
|
} finally {
|
|
461
461
|
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
462
462
|
rmSync(projectDir, { recursive: true, force: true });
|
|
@@ -553,51 +553,6 @@ exit 2
|
|
|
553
553
|
});
|
|
554
554
|
|
|
555
555
|
|
|
556
|
-
// ── gitnexus-impact-reminder.py ──────────────────────────────────────────────
|
|
557
|
-
|
|
558
|
-
function runPythonHook(
|
|
559
|
-
hookFile: string,
|
|
560
|
-
input: Record<string, unknown>,
|
|
561
|
-
) {
|
|
562
|
-
return spawnSync('python3', [path.join(HOOKS_DIR, hookFile)], {
|
|
563
|
-
input: JSON.stringify(input),
|
|
564
|
-
encoding: 'utf8',
|
|
565
|
-
env: { ...process.env },
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
describe('gitnexus-impact-reminder.py', () => {
|
|
570
|
-
it('injects additionalContext when prompt contains an edit-intent keyword', () => {
|
|
571
|
-
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
572
|
-
hook_event_name: 'UserPromptSubmit',
|
|
573
|
-
prompt: 'fix the broken auth logic in login.ts',
|
|
574
|
-
});
|
|
575
|
-
expect(r.status).toBe(0);
|
|
576
|
-
const out = parseHookJson(r.stdout);
|
|
577
|
-
expect(out?.hookSpecificOutput?.additionalContext).toContain('gitnexus impact');
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('does nothing (no output) when prompt has no edit-intent keywords', () => {
|
|
581
|
-
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
582
|
-
hook_event_name: 'UserPromptSubmit',
|
|
583
|
-
prompt: 'explain how the beads gate works',
|
|
584
|
-
});
|
|
585
|
-
expect(r.status).toBe(0);
|
|
586
|
-
expect(r.stdout.trim()).toBe('');
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
it('does nothing for non-UserPromptSubmit events', () => {
|
|
590
|
-
const r = runPythonHook('gitnexus-impact-reminder.py', {
|
|
591
|
-
hook_event_name: 'PreToolUse',
|
|
592
|
-
tool_name: 'Edit',
|
|
593
|
-
tool_input: { file_path: 'foo.ts' },
|
|
594
|
-
prompt: 'fix something',
|
|
595
|
-
});
|
|
596
|
-
expect(r.status).toBe(0);
|
|
597
|
-
expect(r.stdout.trim()).toBe('');
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
556
|
|
|
602
557
|
// ── beads-gate-core.mjs — decision functions ──────────────────────────────────
|
|
603
558
|
|