xtrm-cli 2.1.16 → 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/utils/atomic-config.ts +123 -2
- package/test/atomic-config.test.ts +55 -0
- package/test/hooks.test.ts +156 -52
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
|
+
}
|
|
@@ -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()),
|
|
@@ -72,9 +155,31 @@ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
|
|
|
72
155
|
}
|
|
73
156
|
|
|
74
157
|
const incomingKeys = new Set(incomingCommands.map(commandKey));
|
|
158
|
+
const incomingTokens = new Set(
|
|
159
|
+
typeof incomingWrapper.matcher === 'string'
|
|
160
|
+
? incomingWrapper.matcher.split('|').map((s: string) => s.trim()).filter(Boolean)
|
|
161
|
+
: [],
|
|
162
|
+
);
|
|
163
|
+
|
|
75
164
|
const existingIndex = merged.findIndex((existingWrapper: any) => {
|
|
76
165
|
const existingCommands = extractHookCommands(existingWrapper);
|
|
77
|
-
|
|
166
|
+
if (!existingCommands.some((c: string) => incomingKeys.has(commandKey(c)))) return false;
|
|
167
|
+
|
|
168
|
+
// Only merge with entries whose matchers overlap (share at least one token).
|
|
169
|
+
// Disjoint matchers (e.g. "Write|Edit" vs "Bash") intentionally serve
|
|
170
|
+
// different purposes and must remain as separate entries.
|
|
171
|
+
if (
|
|
172
|
+
typeof existingWrapper.matcher === 'string' &&
|
|
173
|
+
typeof incomingWrapper.matcher === 'string' &&
|
|
174
|
+
incomingTokens.size > 0
|
|
175
|
+
) {
|
|
176
|
+
const existingTokens = existingWrapper.matcher
|
|
177
|
+
.split('|').map((s: string) => s.trim()).filter(Boolean);
|
|
178
|
+
const hasOverlap = existingTokens.some((t: string) => incomingTokens.has(t));
|
|
179
|
+
if (!hasOverlap) return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return true;
|
|
78
183
|
});
|
|
79
184
|
|
|
80
185
|
if (existingIndex === -1) {
|
|
@@ -108,7 +213,23 @@ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
|
|
|
108
213
|
}
|
|
109
214
|
|
|
110
215
|
function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
|
|
111
|
-
|
|
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
|
|
112
233
|
for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
|
|
113
234
|
const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
|
|
114
235
|
const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
|
|
@@ -81,3 +81,58 @@ describe('deepMergeWithProtection (hooks merge behavior)', () => {
|
|
|
81
81
|
expect(merged.hooks.SessionStart).toHaveLength(2);
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
describe('deepMergeWithProtection (hooks merge behavior) — matcher dedup', () => {
|
|
87
|
+
it('keeps two same-script entries separate when their matchers are disjoint', () => {
|
|
88
|
+
// Simulates config/hooks.json having main-guard wired for write-tools
|
|
89
|
+
// AND separately for Bash — they must not be merged into one entry
|
|
90
|
+
const local = {
|
|
91
|
+
hooks: {
|
|
92
|
+
PreToolUse: [] as any[],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const incoming = {
|
|
96
|
+
hooks: {
|
|
97
|
+
PreToolUse: [
|
|
98
|
+
{
|
|
99
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
100
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"', timeout: 5000 }],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
matcher: 'Bash',
|
|
104
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"', timeout: 5000 }],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
110
|
+
const wrappers = merged.hooks.PreToolUse;
|
|
111
|
+
expect(wrappers).toHaveLength(2);
|
|
112
|
+
expect(wrappers[0].matcher).toBe('Write|Edit|MultiEdit');
|
|
113
|
+
expect(wrappers[1].matcher).toBe('Bash');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still upgrades matcher when entries share at least one common token', () => {
|
|
117
|
+
const local = {
|
|
118
|
+
hooks: {
|
|
119
|
+
PreToolUse: [{
|
|
120
|
+
matcher: 'Write|Edit',
|
|
121
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"' }],
|
|
122
|
+
}],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const incoming = {
|
|
126
|
+
hooks: {
|
|
127
|
+
PreToolUse: [{
|
|
128
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
129
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"' }],
|
|
130
|
+
}],
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
134
|
+
const wrappers = merged.hooks.PreToolUse;
|
|
135
|
+
expect(wrappers).toHaveLength(1);
|
|
136
|
+
expect(wrappers[0].matcher).toContain('MultiEdit');
|
|
137
|
+
});
|
|
138
|
+
});
|