xtrm-cli 2.1.16 → 2.1.18

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.18",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -139,6 +139,92 @@ 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
+
142
228
  export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
143
229
  const result = { ...existing };
144
230
 
@@ -266,7 +352,15 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
266
352
  }
267
353
 
268
354
  const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
269
- const mergedSettings = deepMergeHooks(existingSettings, incomingSettings);
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);
270
364
 
271
365
  await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
272
366
  console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
@@ -72,9 +72,31 @@ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
72
72
  }
73
73
 
74
74
  const incomingKeys = new Set(incomingCommands.map(commandKey));
75
+ const incomingTokens = new Set(
76
+ typeof incomingWrapper.matcher === 'string'
77
+ ? incomingWrapper.matcher.split('|').map((s: string) => s.trim()).filter(Boolean)
78
+ : [],
79
+ );
80
+
75
81
  const existingIndex = merged.findIndex((existingWrapper: any) => {
76
82
  const existingCommands = extractHookCommands(existingWrapper);
77
- return existingCommands.some((c: string) => incomingKeys.has(commandKey(c)));
83
+ if (!existingCommands.some((c: string) => incomingKeys.has(commandKey(c)))) return false;
84
+
85
+ // Only merge with entries whose matchers overlap (share at least one token).
86
+ // Disjoint matchers (e.g. "Write|Edit" vs "Bash") intentionally serve
87
+ // different purposes and must remain as separate entries.
88
+ if (
89
+ typeof existingWrapper.matcher === 'string' &&
90
+ typeof incomingWrapper.matcher === 'string' &&
91
+ incomingTokens.size > 0
92
+ ) {
93
+ const existingTokens = existingWrapper.matcher
94
+ .split('|').map((s: string) => s.trim()).filter(Boolean);
95
+ const hasOverlap = existingTokens.some((t: string) => incomingTokens.has(t));
96
+ if (!hasOverlap) return false;
97
+ }
98
+
99
+ return true;
78
100
  });
79
101
 
80
102
  if (existingIndex === -1) {
@@ -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
+ });
@@ -121,6 +121,15 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
121
121
  }
122
122
  });
123
123
 
124
+ it('allows touch .beads/.memory-gate-done on protected branch', () => {
125
+ const r = runHook(
126
+ 'main-guard.mjs',
127
+ { tool_name: 'Bash', tool_input: { command: 'touch .beads/.memory-gate-done' } },
128
+ { MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
129
+ );
130
+ expect(r.status).toBe(0);
131
+ });
132
+
124
133
  it('allows Bash when MAIN_GUARD_ALLOW_BASH=1 is set', () => {
125
134
  const r = runHook(
126
135
  'main-guard.mjs',
@@ -187,9 +196,11 @@ describe('main-guard-post-push.mjs', () => {
187
196
  repoDir,
188
197
  );
189
198
  expect(r.status).toBe(0);
190
- const out = parseHookJson(r.stdout);
191
- expect(out?.systemMessage).toContain('gh pr create --fill');
192
- expect(out?.systemMessage).toContain('gh pr merge --squash');
199
+ expect(r.stdout).toContain('gh pr create --fill');
200
+ expect(r.stdout).toContain('gh pr merge --squash');
201
+ // output must be plain text (agent-only), not a JSON systemMessage banner
202
+ expect(parseHookJson(r.stdout)).toBeNull();
203
+
193
204
  } finally {
194
205
  rmSync(repoDir, { recursive: true, force: true });
195
206
  }
@@ -704,3 +715,141 @@ process.exit(ok ? 0 : 1);
704
715
  });
705
716
  });
706
717
  });
718
+
719
+
720
+ // ── beads-compact-save.mjs ───────────────────────────────────────────────────
721
+ describe('beads-compact-save.mjs', () => {
722
+ it('exits 0 silently when no .beads directory exists', () => {
723
+ const r = runHook('beads-compact-save.mjs', { hook_event_name: 'PreCompact', cwd: '/tmp' });
724
+ expect(r.status).toBe(0);
725
+ expect(r.stdout).toBe('');
726
+ });
727
+
728
+ it('exits 0 silently and writes no file when no in_progress issues', () => {
729
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-save-'));
730
+ mkdirSync(path.join(projectDir, '.beads'));
731
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
732
+ if [[ "$1" == "list" ]]; then
733
+ echo ""
734
+ echo "--------------------------------------------------------------------------------"
735
+ echo "Total: 2 issues (2 open, 0 in progress)"
736
+ exit 0
737
+ fi
738
+ exit 1
739
+ `);
740
+ try {
741
+ const r = runHook(
742
+ 'beads-compact-save.mjs',
743
+ { hook_event_name: 'PreCompact', cwd: projectDir },
744
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
745
+ );
746
+ expect(r.status).toBe(0);
747
+ const { existsSync } = require('node:fs');
748
+ expect(existsSync(path.join(projectDir, '.beads', '.last_active'))).toBe(false);
749
+ } finally {
750
+ rmSync(fake.tempDir, { recursive: true, force: true });
751
+ rmSync(projectDir, { recursive: true, force: true });
752
+ }
753
+ });
754
+
755
+ it('writes .beads/.last_active with in_progress issue IDs', () => {
756
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-save-'));
757
+ mkdirSync(path.join(projectDir, '.beads'));
758
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
759
+ if [[ "$1" == "list" ]]; then
760
+ cat <<'EOF'
761
+ ◐ proj-abc123 ● P1 First in_progress issue
762
+ ◐ proj-def456 ● P2 Second in_progress issue
763
+
764
+ --------------------------------------------------------------------------------
765
+ Total: 2 issues (0 open, 2 in progress)
766
+ EOF
767
+ exit 0
768
+ fi
769
+ exit 1
770
+ `);
771
+ try {
772
+ const r = runHook(
773
+ 'beads-compact-save.mjs',
774
+ { hook_event_name: 'PreCompact', cwd: projectDir },
775
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
776
+ );
777
+ expect(r.status).toBe(0);
778
+ const { readFileSync: rfs } = require('node:fs');
779
+ const saved = rfs(path.join(projectDir, '.beads', '.last_active'), 'utf8').trim().split('\n');
780
+ expect(saved).toContain('proj-abc123');
781
+ expect(saved).toContain('proj-def456');
782
+ } finally {
783
+ rmSync(fake.tempDir, { recursive: true, force: true });
784
+ rmSync(projectDir, { recursive: true, force: true });
785
+ }
786
+ });
787
+ });
788
+
789
+ // ── beads-compact-restore.mjs ────────────────────────────────────────────────
790
+ describe('beads-compact-restore.mjs', () => {
791
+ it('exits 0 silently when no .beads/.last_active file exists', () => {
792
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-restore-'));
793
+ mkdirSync(path.join(projectDir, '.beads'));
794
+ try {
795
+ const r = runHook(
796
+ 'beads-compact-restore.mjs',
797
+ { hook_event_name: 'SessionStart', cwd: projectDir },
798
+ );
799
+ expect(r.status).toBe(0);
800
+ expect(r.stdout).toBe('');
801
+ } finally {
802
+ rmSync(projectDir, { recursive: true, force: true });
803
+ }
804
+ });
805
+
806
+ it('restores in_progress status, deletes .last_active, and injects additionalSystemPrompt', () => {
807
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-restore-'));
808
+ mkdirSync(path.join(projectDir, '.beads'));
809
+ const { writeFileSync: wfs } = require('node:fs');
810
+ wfs(path.join(projectDir, '.beads', '.last_active'), 'proj-abc123\nproj-def456\n', 'utf8');
811
+
812
+ const callLog = path.join(projectDir, 'bd-calls.log');
813
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
814
+ echo "$@" >> "${callLog}"
815
+ exit 0
816
+ `);
817
+ try {
818
+ const r = runHook(
819
+ 'beads-compact-restore.mjs',
820
+ { hook_event_name: 'SessionStart', cwd: projectDir },
821
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
822
+ );
823
+ expect(r.status).toBe(0);
824
+ // .last_active must be deleted
825
+ const { existsSync: exs, readFileSync: rfs } = require('node:fs');
826
+ expect(exs(path.join(projectDir, '.beads', '.last_active'))).toBe(false);
827
+ // bd update called for each ID
828
+ const calls = rfs(callLog, 'utf8');
829
+ expect(calls).toContain('proj-abc123');
830
+ expect(calls).toContain('proj-def456');
831
+ // additionalSystemPrompt injected for agent
832
+ const out = parseHookJson(r.stdout);
833
+ expect(out?.hookSpecificOutput?.additionalSystemPrompt).toMatch(/Restored 2 in_progress issue/);
834
+ } finally {
835
+ rmSync(fake.tempDir, { recursive: true, force: true });
836
+ rmSync(projectDir, { recursive: true, force: true });
837
+ }
838
+ });
839
+ });
840
+
841
+
842
+ // ── hooks.json wiring ────────────────────────────────────────────────────────
843
+ describe('hooks.json — beads-compact hooks wiring', () => {
844
+ it('wires beads-compact-save.mjs to PreCompact event', () => {
845
+ const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
846
+ const preCompact: Array<{ script: string }> = cfg.hooks.PreCompact ?? [];
847
+ expect(preCompact.some((h) => h.script === 'beads-compact-save.mjs')).toBe(true);
848
+ });
849
+
850
+ it('wires beads-compact-restore.mjs to SessionStart event', () => {
851
+ const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
852
+ const sessionStart: Array<{ script: string }> = cfg.hooks.SessionStart ?? [];
853
+ expect(sessionStart.some((h) => h.script === 'beads-compact-restore.mjs')).toBe(true);
854
+ });
855
+ });