xtrm-cli 2.1.19 → 2.1.20

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.19",
3
+ "version": "2.1.20",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -139,6 +139,94 @@ 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 including subdirectory (e.g., "gitnexus/gitnexus-hook.cjs")
149
+ const m = cmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
150
+ if (m) return m[1];
151
+ const m2 = cmd.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
152
+ return m2?.[1] ?? null;
153
+ }
154
+
155
+ /**
156
+ * Prune hooks from settings.json that are NOT in the canonical config.
157
+ * This removes stale entries from old versions before merging new ones.
158
+ *
159
+ * @param existing Current settings.json hooks
160
+ * @param canonical Canonical hooks config from hooks.json
161
+ * @returns Pruned settings with stale hooks removed
162
+ */
163
+ export function pruneStaleHooks(
164
+ existing: Record<string, any>,
165
+ canonical: Record<string, any>,
166
+ ): { result: Record<string, any>; removed: string[] } {
167
+ const result = { ...existing };
168
+ const removed: string[] = [];
169
+
170
+ if (!result.hooks || typeof result.hooks !== 'object') {
171
+ return { result, removed };
172
+ }
173
+ if (!canonical.hooks || typeof canonical.hooks !== 'object') {
174
+ return { result, removed };
175
+ }
176
+
177
+ // Collect all canonical script filenames
178
+ const canonicalScripts = new Set<string>();
179
+ for (const [event, hooks] of Object.entries(canonical.hooks)) {
180
+ const hookList = Array.isArray(hooks) ? hooks : [hooks];
181
+ for (const wrapper of hookList) {
182
+ const innerHooks = wrapper.hooks || [wrapper];
183
+ for (const hook of innerHooks) {
184
+ const script = getScriptFilename(hook);
185
+ if (script) canonicalScripts.add(script);
186
+ }
187
+ }
188
+ }
189
+
190
+ // Prune existing hooks not in canonical
191
+ for (const [event, hooks] of Object.entries(result.hooks)) {
192
+ if (!Array.isArray(hooks)) continue;
193
+
194
+ const prunedWrappers: any[] = [];
195
+ for (const wrapper of hooks) {
196
+ const innerHooks = wrapper.hooks || [wrapper];
197
+ const keptInner: any[] = [];
198
+
199
+ for (const hook of innerHooks) {
200
+ const script = getScriptFilename(hook);
201
+ // Keep if: no script (not a file-based hook) OR script is canonical
202
+ if (!script || canonicalScripts.has(script)) {
203
+ keptInner.push(hook);
204
+ } else {
205
+ removed.push(`${event}:${script}`);
206
+ }
207
+ }
208
+
209
+ if (keptInner.length > 0) {
210
+ if (wrapper.hooks) {
211
+ prunedWrappers.push({ ...wrapper, hooks: keptInner });
212
+ } else if (keptInner.length === 1) {
213
+ prunedWrappers.push(keptInner[0]);
214
+ } else {
215
+ prunedWrappers.push({ ...wrapper, hooks: keptInner });
216
+ }
217
+ }
218
+ }
219
+
220
+ if (prunedWrappers.length > 0) {
221
+ result.hooks[event] = prunedWrappers;
222
+ } else {
223
+ delete result.hooks[event];
224
+ }
225
+ }
226
+
227
+ return { result, removed };
228
+ }
229
+
142
230
  export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
143
231
  const result = { ...existing };
144
232
 
@@ -266,7 +354,15 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
266
354
  }
267
355
 
268
356
  const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
269
- const mergedSettings = deepMergeHooks(existingSettings, incomingSettings);
357
+
358
+ // First prune stale hooks not in canonical config
359
+ const { result: prunedSettings, removed } = pruneStaleHooks(existingSettings, incomingSettings);
360
+ if (removed.length > 0) {
361
+ console.log(kleur.yellow(` ↳ Pruned ${removed.length} stale hook(s): ${removed.join(', ')}`));
362
+ }
363
+
364
+ // Then merge canonical hooks
365
+ const mergedSettings = deepMergeHooks(prunedSettings, incomingSettings);
270
366
 
271
367
  await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
272
368
  console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { createProjectCommand } from './commands/install-project.js';
13
13
  import { createStatusCommand } from './commands/status.js';
14
14
  import { createResetCommand } from './commands/reset.js';
15
15
  import { createHelpCommand } from './commands/help.js';
16
+ import { createCleanCommand } from './commands/clean.js';
16
17
  import { printBanner } from './utils/banner.js';
17
18
 
18
19
  const program = new Command();
@@ -36,6 +37,7 @@ program.addCommand(createInstallCommand());
36
37
  program.addCommand(createProjectCommand());
37
38
  program.addCommand(createStatusCommand());
38
39
  program.addCommand(createResetCommand());
40
+ program.addCommand(createCleanCommand());
39
41
  program.addCommand(createHelpCommand());
40
42
 
41
43
  // Default action: show help