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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.16",
3
+ "version": "2.1.19",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -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
- return existingCommands.some((c: string) => incomingKeys.has(commandKey(c)));
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
- const result = { ...(existingHooks || {}) };
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
+ });