xtrm-cli 2.1.4 → 2.1.11

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.
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import os
4
+
5
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
6
+ from agent_context import AgentContext
7
+
8
+ try:
9
+ ctx = AgentContext()
10
+ ctx.fail_open()
11
+ except Exception as e:
12
+ print(f"Hook error: {e}", file=sys.stderr)
13
+ sys.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.4",
3
+ "version": "2.1.11",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -39,5 +39,8 @@
39
39
  "tsx": "^4.21.0",
40
40
  "typescript": "^5.9.3",
41
41
  "vitest": "^4.0.18"
42
+ },
43
+ "engines": {
44
+ "node": ">=20.0.0"
42
45
  }
43
46
  }
@@ -8,6 +8,7 @@ declare const __dirname: string;
8
8
 
9
9
  const HOOK_CATALOG: Array<{ file: string; event: string; desc: string; beads?: true }> = [
10
10
  { file: 'main-guard.mjs', event: 'PreToolUse', desc: 'Blocks direct edits on protected branches' },
11
+ { file: 'main-guard-post-push.mjs', event: 'PostToolUse', desc: 'After feature-branch push, reminds PR/merge/sync steps' },
11
12
  { file: 'skill-suggestion.py', event: 'UserPromptSubmit', desc: 'Suggests relevant skills based on user prompt' },
12
13
  { file: 'serena-workflow-reminder.py', event: 'SessionStart', desc: 'Injects Serena semantic editing workflow reminder' },
13
14
  { file: 'type-safety-enforcement.py', event: 'PreToolUse', desc: 'Prevents risky Bash and enforces safe edit patterns' },
@@ -0,0 +1,95 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import prompts from 'prompts';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { homedir } from 'node:os';
8
+ import { findRepoRoot } from '../utils/repo-root.js';
9
+ import { t, sym } from '../utils/theme.js';
10
+
11
+ const PI_AGENT_DIR = path.join(homedir(), '.pi', 'agent');
12
+
13
+ interface SchemaField { key: string; label: string; hint: string; secret: boolean; required: boolean; }
14
+ interface OAuthProvider { key: string; instruction: string; }
15
+ interface InstallSchema { fields: SchemaField[]; oauth_providers: OAuthProvider[]; packages: string[]; }
16
+
17
+ export function fillTemplate(template: string, values: Record<string, string>): string {
18
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
19
+ }
20
+
21
+ function isPiInstalled(): boolean {
22
+ return spawnSync('pi', ['--version'], { encoding: 'utf8' }).status === 0;
23
+ }
24
+
25
+ export function createInstallPiCommand(): Command {
26
+ const cmd = new Command('pi');
27
+ cmd
28
+ .description('Install Pi coding agent with providers, extensions, and npm packages')
29
+ .option('-y, --yes', 'Skip confirmation prompts', false)
30
+ .action(async (opts) => {
31
+ const { yes } = opts;
32
+ const repoRoot = await findRepoRoot();
33
+ const piConfigDir = path.join(repoRoot, 'config', 'pi');
34
+
35
+ console.log(t.bold('\n Pi Coding Agent Setup\n'));
36
+
37
+ if (!isPiInstalled()) {
38
+ console.log(kleur.yellow(' pi not found — installing oh-pi globally...\n'));
39
+ const r = spawnSync('npm', ['install', '-g', 'oh-pi'], { stdio: 'inherit' });
40
+ if (r.status !== 0) {
41
+ console.error(kleur.red('\n Failed to install oh-pi. Run: npm install -g oh-pi\n'));
42
+ process.exit(1);
43
+ }
44
+ console.log(t.success(' pi installed\n'));
45
+ } else {
46
+ const v = spawnSync('pi', ['--version'], { encoding: 'utf8' });
47
+ console.log(t.success(` pi ${v.stdout.trim()} already installed\n`));
48
+ }
49
+
50
+ const schema: InstallSchema = await fs.readJson(path.join(piConfigDir, 'install-schema.json'));
51
+ const values: Record<string, string> = {};
52
+
53
+ console.log(t.bold(' API Keys\n'));
54
+ for (const field of schema.fields) {
55
+ if (!field.required && !yes) {
56
+ const { include } = await prompts({ type: 'confirm', name: 'include', message: ` Configure ${field.label}? (optional)`, initial: false });
57
+ if (!include) continue;
58
+ }
59
+ const { value } = await prompts({ type: field.secret ? 'password' : 'text', name: 'value', message: ` ${field.label}`, hint: field.hint, validate: (v) => (field.required && !v) ? 'Required' : true });
60
+ if (value) values[field.key] = value;
61
+ }
62
+
63
+ await fs.ensureDir(PI_AGENT_DIR);
64
+ console.log(t.muted(`\n Writing config to ${PI_AGENT_DIR}`));
65
+
66
+ for (const name of ['models.json', 'auth.json', 'settings.json']) {
67
+ const destPath = path.join(PI_AGENT_DIR, name);
68
+ if (name === 'auth.json' && await fs.pathExists(destPath) && !yes) {
69
+ const { overwrite } = await prompts({ type: 'confirm', name: 'overwrite', message: ` ${name} already exists — overwrite? (OAuth tokens will be lost)`, initial: false });
70
+ if (!overwrite) { console.log(t.muted(` skipped ${name}`)); continue; }
71
+ }
72
+ const raw = await fs.readFile(path.join(piConfigDir, `${name}.template`), 'utf8');
73
+ await fs.writeFile(destPath, fillTemplate(raw, values), 'utf8');
74
+ console.log(t.success(` ${sym.ok} ${name}`));
75
+ }
76
+
77
+ await fs.copy(path.join(piConfigDir, 'extensions'), path.join(PI_AGENT_DIR, 'extensions'), { overwrite: true });
78
+ console.log(t.success(` ${sym.ok} extensions/`));
79
+
80
+ console.log(t.bold('\n npm Packages\n'));
81
+ for (const pkg of schema.packages) {
82
+ const r = spawnSync('pi', ['install', pkg], { stdio: 'inherit' });
83
+ if (r.status === 0) console.log(t.success(` ${sym.ok} ${pkg}`));
84
+ else console.log(kleur.yellow(` ${pkg} — failed, run manually: pi install ${pkg}`));
85
+ }
86
+
87
+ console.log(t.bold('\n OAuth (manual steps)\n'));
88
+ for (const provider of schema.oauth_providers) {
89
+ console.log(t.muted(` ${provider.key}: ${provider.instruction}`));
90
+ }
91
+
92
+ console.log(t.boldGreen('\n Pi setup complete\n'));
93
+ });
94
+ return cmd;
95
+ }
@@ -155,6 +155,11 @@ export function deepMergeHooks(existing: Record<string, any>, incoming: Record<s
155
155
  const incomingEventHooks = Array.isArray(incomingHooks) ? incomingHooks : [incomingHooks];
156
156
 
157
157
  const getCommand = (h: any) => h.command || h.hooks?.[0]?.command;
158
+ const getCommandKey = (cmd?: string): string | null => {
159
+ if (!cmd || typeof cmd !== 'string') return null;
160
+ const m = cmd.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
161
+ return m?.[1] ?? null;
162
+ };
158
163
  const mergeMatcher = (existingMatcher: string, incomingMatcher: string): string => {
159
164
  const existingParts = existingMatcher.split('|').map((s: string) => s.trim()).filter(Boolean);
160
165
  const incomingParts = incomingMatcher.split('|').map((s: string) => s.trim()).filter(Boolean);
@@ -173,7 +178,13 @@ export function deepMergeHooks(existing: Record<string, any>, incoming: Record<s
173
178
  continue;
174
179
  }
175
180
 
176
- const existingIndex = mergedEventHooks.findIndex((h: any) => getCommand(h) === incomingCmd);
181
+ const incomingKey = getCommandKey(incomingCmd);
182
+ const existingIndex = mergedEventHooks.findIndex((h: any) => {
183
+ const existingCmd = getCommand(h);
184
+ if (existingCmd === incomingCmd) return true;
185
+ if (!incomingKey) return false;
186
+ return getCommandKey(existingCmd) === incomingKey;
187
+ });
177
188
  if (existingIndex === -1) {
178
189
  mergedEventHooks.push(incomingHook);
179
190
  continue;
@@ -324,8 +335,30 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
324
335
  console.log(kleur.white(` Please read: ${kleur.cyan('.claude/docs/' + toolName + '-readme.md')}\n`));
325
336
 
326
337
  if (toolName === 'tdd-guard') {
327
- console.log(kleur.white(' Example for Vitest:'));
328
- console.log(kleur.dim(' npm install --save-dev tdd-guard-vitest\n'));
338
+ // Check if tdd-guard CLI is installed globally
339
+ const tddGuardCheck = spawnSync('tdd-guard', ['--version'], { stdio: 'pipe' });
340
+ if (tddGuardCheck.status !== 0) {
341
+ console.log(kleur.red(' ✗ tdd-guard CLI not found globally!\n'));
342
+ console.log(kleur.white(' Install the global CLI:'));
343
+ console.log(kleur.cyan(' npm install -g tdd-guard\n'));
344
+ } else {
345
+ console.log(kleur.green(' ✓ tdd-guard CLI found globally'));
346
+ }
347
+ console.log(kleur.white('\n Install a test reporter (choose one):'));
348
+ console.log(kleur.dim(' npm install --save-dev tdd-guard-vitest # Vitest'));
349
+ console.log(kleur.dim(' npm install --save-dev tdd-guard-jest # Jest'));
350
+ console.log(kleur.dim(' pip install tdd-guard-pytest # pytest\n'));
351
+ }
352
+
353
+ if (toolName === 'quality-gates') {
354
+ console.log(kleur.white(' Install language dependencies:\n'));
355
+ console.log(kleur.white(' TypeScript:'));
356
+ console.log(kleur.dim(' npm install --save-dev typescript eslint prettier'));
357
+ console.log(kleur.white('\n Python:'));
358
+ console.log(kleur.dim(' pip install ruff mypy'));
359
+ console.log(kleur.white('\n For TDD (test-first) enforcement, install separately:'));
360
+ console.log(kleur.dim(' npm install -g tdd-guard'));
361
+ console.log(kleur.dim(' xtrm install project tdd-guard\n'));
329
362
  }
330
363
 
331
364
  console.log(kleur.green(' ✓ Installation complete!\n'));
@@ -354,26 +387,48 @@ export async function installAllProjectSkills(projectRootOverride?: string): Pro
354
387
 
355
388
  export function buildProjectInitGuide(): string {
356
389
  const lines = [
357
- kleur.bold('\nProject Init — Recommended baseline\n'),
358
- `${kleur.cyan('1) Install a quality gate skill (or equivalent checks):')}`,
359
- kleur.dim(' - TypeScript projects: xtrm install project ts-quality-gate'),
360
- kleur.dim(' - Python projects: xtrm install project py-quality-gate'),
361
- kleur.dim(' - TDD workflow: xtrm install project tdd-guard'),
390
+ kleur.bold('\nProject Init — Production baseline\n'),
391
+ kleur.dim('This command prints a complete setup checklist and then bootstraps beads + GitNexus for this repo.\n'),
392
+ `${kleur.cyan('1) Install core project enforcement (quality-gates):')}`,
393
+ kleur.dim(' xtrm install project quality-gates'),
394
+ kleur.dim(' - Installs TypeScript + Python PostToolUse quality hooks'),
395
+ kleur.dim(' - Includes Serena matcher coverage for edit-equivalent tools'),
396
+ kleur.dim(' - Enforces linting/type-checking based on your repo config'),
362
397
  '',
363
- `${kleur.cyan('2) Ensure your checks are actually configured in this repo:')}`,
364
- kleur.dim(' - Testing: commands should run and fail when behavior regresses'),
365
- kleur.dim(' - Linting/formatting: ESLint+Prettier (TS) or ruff (Python)'),
366
- kleur.dim(' - Type checks: tsc (TS) or mypy/pyright (Python)'),
367
- kleur.dim(' - Hooks only enforce what your project config defines'),
398
+ `${kleur.cyan('2) Optional installs depending on workflow:')}`,
399
+ kleur.dim(' xtrm install project tdd-guard'),
400
+ kleur.dim(' - Strong test-first enforcement (PreToolUse + prompt/session checks)'),
401
+ kleur.dim(' xtrm install project service-skills-set'),
402
+ kleur.dim(' - Service-aware skill routing + drift checks + git hook reminders'),
368
403
  '',
369
- `${kleur.cyan('3) Optional: Service Skills Set (service-skills-set)')}`,
370
- kleur.dim(' - For multi-service/Docker repos with repeated operational workflows'),
371
- kleur.dim(' - Adds project hooks + skills that route Claude to service-specific context'),
372
- kleur.dim(' - Helps keep architecture knowledge persistent across sessions'),
404
+ `${kleur.cyan('3) Configure repo checks (hooks only enforce what your repo defines):')}`,
405
+ kleur.dim(' - Tests: should fail on regressions (required for TDD workflows)'),
406
+ kleur.dim(' - TS: eslint + prettier + tsc'),
407
+ kleur.dim(' - PY: ruff + mypy/pyright'),
408
+ '',
409
+ `${kleur.cyan('4) Beads workflow (required for gated edit/commit flow):')}`,
410
+ kleur.dim(' - Claim work: bd ready --json -> bd update <id> --claim --json'),
411
+ kleur.dim(' - During work: keep issue status current; create discovered follow-ups'),
412
+ kleur.dim(' - Finish work: bd close <id> --reason "Done" --json'),
413
+ '',
414
+ `${kleur.cyan('5) Git workflow (main-guard expected path):')}`,
415
+ kleur.dim(' - git checkout -b feature/<name>'),
416
+ kleur.dim(' - commit on feature branch only'),
417
+ kleur.dim(' - git push -u origin feature/<name>'),
418
+ kleur.dim(' - gh pr create --fill && gh pr merge --squash'),
419
+ kleur.dim(' - git checkout main && git pull --ff-only'),
420
+ '',
421
+ `${kleur.cyan('6) Hooks and startup automation:')}`,
422
+ kleur.dim(' - PreToolUse: safety gates (main/beads/TDD/type-safety/etc.)'),
423
+ kleur.dim(' - PostToolUse: quality checks + reminders'),
424
+ kleur.dim(' - Stop: beads stop-gate prevents unresolved session claims'),
425
+ kleur.dim(' - SessionStart: workflow context reminders'),
373
426
  '',
374
427
  kleur.bold('Quick start commands:'),
375
428
  kleur.dim(' xtrm install project list'),
376
- kleur.dim(' xtrm install project ts-quality-gate # or py-quality-gate / tdd-guard'),
429
+ kleur.dim(' xtrm install project quality-gates'),
430
+ kleur.dim(' xtrm install project tdd-guard'),
431
+ kleur.dim(' xtrm install project service-skills-set'),
377
432
  '',
378
433
  ];
379
434
 
@@ -382,7 +437,7 @@ export function buildProjectInitGuide(): string {
382
437
 
383
438
  async function printProjectInitGuide(): Promise<void> {
384
439
  console.log(buildProjectInitGuide());
385
- await runBdInitForProject();
440
+ await bootstrapProjectInit();
386
441
  }
387
442
 
388
443
  async function installProjectByName(toolName: string): Promise<void> {
@@ -393,15 +448,21 @@ async function installProjectByName(toolName: string): Promise<void> {
393
448
  await installProjectSkill(toolName);
394
449
  }
395
450
 
396
- async function runBdInitForProject(): Promise<void> {
451
+ async function bootstrapProjectInit(): Promise<void> {
397
452
  let projectRoot: string;
398
453
  try {
399
454
  projectRoot = getProjectRoot();
400
455
  } catch (err: any) {
401
- console.log(kleur.yellow(`\n ⚠ Skipping bd init: ${err.message}\n`));
456
+ console.log(kleur.yellow(`\n ⚠ Skipping project bootstrap: ${err.message}\n`));
402
457
  return;
403
458
  }
404
459
 
460
+ await runBdInitForProject(projectRoot);
461
+ await runGitNexusInitForProject(projectRoot);
462
+ }
463
+
464
+ async function runBdInitForProject(projectRoot: string): Promise<void> {
465
+
405
466
  console.log(kleur.bold('Running beads initialization (bd init)...'));
406
467
 
407
468
  const result = spawnSync('bd', ['init'], {
@@ -431,6 +492,55 @@ async function runBdInitForProject(): Promise<void> {
431
492
  if (result.stderr) process.stderr.write(result.stderr);
432
493
  }
433
494
 
495
+ async function runGitNexusInitForProject(projectRoot: string): Promise<void> {
496
+ const gitnexusCheck = spawnSync('gitnexus', ['--version'], {
497
+ cwd: projectRoot,
498
+ encoding: 'utf8',
499
+ timeout: 5000,
500
+ });
501
+
502
+ if (gitnexusCheck.status !== 0) {
503
+ console.log(kleur.yellow(' ⚠ gitnexus not found; skipping index bootstrap'));
504
+ console.log(kleur.dim(' Install with: npm install -g gitnexus'));
505
+ return;
506
+ }
507
+
508
+ console.log(kleur.bold('Checking GitNexus index status...'));
509
+
510
+ const status = spawnSync('gitnexus', ['status'], {
511
+ cwd: projectRoot,
512
+ encoding: 'utf8',
513
+ timeout: 10000,
514
+ });
515
+
516
+ const statusText = `${status.stdout || ''}\n${status.stderr || ''}`.toLowerCase();
517
+ const needsAnalyze = status.status !== 0 ||
518
+ statusText.includes('stale') ||
519
+ statusText.includes('not indexed') ||
520
+ statusText.includes('missing');
521
+
522
+ if (!needsAnalyze) {
523
+ console.log(kleur.dim(' ✓ GitNexus index is ready'));
524
+ return;
525
+ }
526
+
527
+ console.log(kleur.bold('Running GitNexus indexing (gitnexus analyze)...'));
528
+ const analyze = spawnSync('gitnexus', ['analyze'], {
529
+ cwd: projectRoot,
530
+ encoding: 'utf8',
531
+ timeout: 120000,
532
+ });
533
+
534
+ if (analyze.status === 0) {
535
+ console.log(kleur.green(' ✓ GitNexus index updated'));
536
+ return;
537
+ }
538
+
539
+ if (analyze.stdout) process.stdout.write(analyze.stdout);
540
+ if (analyze.stderr) process.stderr.write(analyze.stderr);
541
+ console.log(kleur.yellow(` ⚠ gitnexus analyze exited with code ${analyze.status}`));
542
+ }
543
+
434
544
  /**
435
545
  * List available project skills.
436
546
  */
@@ -520,7 +630,7 @@ export function createInstallProjectCommand(): Command {
520
630
  });
521
631
 
522
632
  const initCmd = new Command('init')
523
- .description('Show project onboarding guidance (quality gates + service skills)')
633
+ .description('Show full onboarding guidance and bootstrap beads + GitNexus')
524
634
  .action(async () => {
525
635
  await printProjectInitGuide();
526
636
  });
@@ -537,7 +647,7 @@ export function createProjectCommand(): Command {
537
647
 
538
648
  projectCmd
539
649
  .command('init')
540
- .description('Show project onboarding guidance (quality gates + service skills)')
650
+ .description('Show full onboarding guidance and bootstrap beads + GitNexus')
541
651
  .action(async () => {
542
652
  await printProjectInitGuide();
543
653
  });
@@ -60,6 +60,7 @@ const SETTINGS_HOOKS: Record<string, unknown[]> = {
60
60
 
61
61
  const MARKER_DOC = '# [jaggers] doc-reminder';
62
62
  const MARKER_STALENESS = '# [jaggers] skill-staleness';
63
+ const MARKER_CHAIN = '# [jaggers] chain-githooks';
63
64
 
64
65
  // ─── Pure functions (exported for testing) ───────────────────────────────────
65
66
 
@@ -139,7 +140,6 @@ export async function installGitHooks(projectRoot: string, skillsSrc: string = S
139
140
  ];
140
141
 
141
142
  const hookFiles: { name: string; status: 'added' | 'already-present' }[] = [];
142
- let anyAdded = false;
143
143
 
144
144
  for (const [hookPath, marker, snippet] of snippets) {
145
145
  const content = await fs.readFile(hookPath, 'utf8');
@@ -147,20 +147,47 @@ export async function installGitHooks(projectRoot: string, skillsSrc: string = S
147
147
  if (!content.includes(marker)) {
148
148
  await fs.writeFile(hookPath, content + snippet);
149
149
  hookFiles.push({ name, status: 'added' });
150
- anyAdded = true;
151
150
  } else {
152
151
  hookFiles.push({ name, status: 'already-present' });
153
152
  }
154
153
  }
155
154
 
156
- if (anyAdded) {
157
- const gitHooksDir = path.join(projectRoot, '.git', 'hooks');
158
- await fs.mkdirp(gitHooksDir);
159
- for (const [src, name] of [[preCommit, 'pre-commit'], [prePush, 'pre-push']] as const) {
160
- if (await fs.pathExists(src)) {
161
- const dest = path.join(gitHooksDir, name);
162
- await fs.copy(src, dest, { overwrite: true });
163
- await fs.chmod(dest, 0o755);
155
+ const hooksPathResult = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
156
+ cwd: projectRoot,
157
+ encoding: 'utf8',
158
+ timeout: 5000,
159
+ });
160
+ const configuredHooksPath = hooksPathResult.status === 0 ? hooksPathResult.stdout.trim() : '';
161
+ const activeHooksDir = configuredHooksPath
162
+ ? (path.isAbsolute(configuredHooksPath)
163
+ ? configuredHooksPath
164
+ : path.join(projectRoot, configuredHooksPath))
165
+ : path.join(projectRoot, '.git', 'hooks');
166
+
167
+ const activationTargets = new Set([path.join(projectRoot, '.git', 'hooks'), activeHooksDir]);
168
+ for (const hooksDir of activationTargets) {
169
+ await fs.mkdirp(hooksDir);
170
+
171
+ for (const [name, sourceHook] of [['pre-commit', preCommit], ['pre-push', prePush]] as const) {
172
+ const targetHook = path.join(hooksDir, name);
173
+ if (!await fs.pathExists(targetHook)) {
174
+ await fs.writeFile(targetHook, '#!/usr/bin/env bash\n', { mode: 0o755 });
175
+ } else {
176
+ await fs.chmod(targetHook, 0o755);
177
+ }
178
+
179
+ if (path.resolve(targetHook) === path.resolve(sourceHook)) {
180
+ continue;
181
+ }
182
+
183
+ const chainSnippet =
184
+ `\n${MARKER_CHAIN}\n` +
185
+ `if [ -x "${sourceHook}" ]; then\n` +
186
+ ` "${sourceHook}" "$@"\n` +
187
+ 'fi\n';
188
+ const targetContent = await fs.readFile(targetHook, 'utf8');
189
+ if (!targetContent.includes(MARKER_CHAIN)) {
190
+ await fs.writeFile(targetHook, targetContent + chainSnippet);
164
191
  }
165
192
  }
166
193
  }
@@ -10,6 +10,7 @@ import { findRepoRoot } from '../utils/repo-root.js';
10
10
  import { t, sym } from '../utils/theme.js';
11
11
  import path from 'path';
12
12
  import { createInstallProjectCommand } from './install-project.js';
13
+ import { createInstallPiCommand } from './install-pi.js';
13
14
 
14
15
  interface TargetChanges {
15
16
  target: string;
@@ -529,6 +530,7 @@ export function createInstallCommand(): Command {
529
530
  installCmd.addCommand(createInstallAllCommand());
530
531
  installCmd.addCommand(createInstallBasicCommand());
531
532
  installCmd.addCommand(createInstallProjectCommand());
533
+ installCmd.addCommand(createInstallPiCommand());
532
534
 
533
535
  return installCmd;
534
536
  }
@@ -41,6 +41,82 @@ export function isValueProtected(keyPath: string): boolean {
41
41
  );
42
42
  }
43
43
 
44
+ function extractHookCommands(wrapper: any): string[] {
45
+ if (!wrapper || !Array.isArray(wrapper.hooks)) return [];
46
+ return wrapper.hooks
47
+ .map((h: any) => h?.command)
48
+ .filter((c: any): c is string => typeof c === 'string' && c.trim().length > 0);
49
+ }
50
+
51
+ function commandKey(command: string): string {
52
+ const m = command.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
53
+ return m?.[1] || command.trim();
54
+ }
55
+
56
+ function mergeMatcher(existingMatcher: string, incomingMatcher: string): string {
57
+ const parts = [
58
+ ...existingMatcher.split('|').map((s: string) => s.trim()),
59
+ ...incomingMatcher.split('|').map((s: string) => s.trim()),
60
+ ].filter(Boolean);
61
+ return Array.from(new Set(parts)).join('|');
62
+ }
63
+
64
+ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
65
+ const merged = existing.map((w: any) => ({ ...w }));
66
+
67
+ for (const incomingWrapper of incoming) {
68
+ const incomingCommands = extractHookCommands(incomingWrapper);
69
+ if (incomingCommands.length === 0) {
70
+ merged.push(incomingWrapper);
71
+ continue;
72
+ }
73
+
74
+ const incomingKeys = new Set(incomingCommands.map(commandKey));
75
+ const existingIndex = merged.findIndex((existingWrapper: any) => {
76
+ const existingCommands = extractHookCommands(existingWrapper);
77
+ return existingCommands.some((c: string) => incomingKeys.has(commandKey(c)));
78
+ });
79
+
80
+ if (existingIndex === -1) {
81
+ merged.push(incomingWrapper);
82
+ continue;
83
+ }
84
+
85
+ const existingWrapper = merged[existingIndex];
86
+ if (
87
+ typeof existingWrapper.matcher === 'string' &&
88
+ typeof incomingWrapper.matcher === 'string'
89
+ ) {
90
+ existingWrapper.matcher = mergeMatcher(existingWrapper.matcher, incomingWrapper.matcher);
91
+ }
92
+
93
+ if (Array.isArray(existingWrapper.hooks) && Array.isArray(incomingWrapper.hooks)) {
94
+ const existingByKey = new Set(existingWrapper.hooks
95
+ .map((h: any) => h?.command)
96
+ .filter((c: any): c is string => typeof c === 'string')
97
+ .map(commandKey));
98
+ for (const hook of incomingWrapper.hooks) {
99
+ const cmd = hook?.command;
100
+ if (typeof cmd !== 'string' || !existingByKey.has(commandKey(cmd))) {
101
+ existingWrapper.hooks.push(hook);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ return merged;
108
+ }
109
+
110
+ function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
111
+ const result = { ...(existingHooks || {}) };
112
+ for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
113
+ const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
114
+ const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
115
+ result[event] = mergeHookWrappers(existingWrappers, incomingArray);
116
+ }
117
+ return result;
118
+ }
119
+
44
120
  /**
45
121
  * Deep merge two objects, preserving protected values from the original
46
122
  */
@@ -50,6 +126,19 @@ export function deepMergeWithProtection(original: any, updates: any, currentPath
50
126
  for (const [key, value] of Object.entries(updates)) {
51
127
  const keyPath = currentPath ? `${currentPath}.${key}` : key;
52
128
 
129
+ // Hooks are canonical but should still preserve local custom entries.
130
+ // Merge by command identity and upgrade matchers for overlapping hooks.
131
+ if (
132
+ key === 'hooks' &&
133
+ typeof value === 'object' &&
134
+ value !== null &&
135
+ typeof original[key] === 'object' &&
136
+ original[key] !== null
137
+ ) {
138
+ result[key] = mergeHooksObject(original[key], value);
139
+ continue;
140
+ }
141
+
53
142
  // If this specific value is protected and exists locally, skip it
54
143
  if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
55
144
  continue;
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deepMergeWithProtection } from '../src/utils/atomic-config.js';
3
+
4
+ describe('deepMergeWithProtection (hooks merge behavior)', () => {
5
+ it('upgrades matcher tokens for same hook command without duplicating hook', () => {
6
+ const local = {
7
+ hooks: {
8
+ PostToolUse: [{
9
+ matcher: 'Write|Edit|MultiEdit',
10
+ hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.py"' }],
11
+ }],
12
+ },
13
+ };
14
+
15
+ const incoming = {
16
+ hooks: {
17
+ PostToolUse: [{
18
+ matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
19
+ hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/hooks/quality-check.py"' }],
20
+ }],
21
+ },
22
+ };
23
+
24
+ const merged = deepMergeWithProtection(local, incoming);
25
+ const wrappers = merged.hooks.PostToolUse;
26
+ expect(wrappers).toHaveLength(1);
27
+ expect(wrappers[0].matcher).toContain('mcp__serena__insert_after_symbol');
28
+ expect(wrappers[0].matcher).toContain('mcp__serena__insert_before_symbol');
29
+ expect(wrappers[0].matcher).toContain('mcp__serena__rename_symbol');
30
+ expect(wrappers[0].matcher).toContain('mcp__serena__replace_symbol_body');
31
+ expect(wrappers[0].hooks).toHaveLength(1);
32
+ });
33
+
34
+ it('preserves local custom hooks and still installs incoming wrappers', () => {
35
+ const local = {
36
+ hooks: {
37
+ PreToolUse: [{
38
+ matcher: 'Write',
39
+ hooks: [{ command: 'node /custom/local-hook.mjs' }],
40
+ }],
41
+ },
42
+ };
43
+
44
+ const incoming = {
45
+ hooks: {
46
+ PreToolUse: [{
47
+ matcher: 'Edit',
48
+ hooks: [{ command: 'node /repo/hooks/main-guard.mjs' }],
49
+ }],
50
+ PostToolUse: [{
51
+ matcher: 'Edit',
52
+ hooks: [{ command: 'node /repo/hooks/main-guard-post-push.mjs' }],
53
+ }],
54
+ },
55
+ };
56
+
57
+ const merged = deepMergeWithProtection(local, incoming);
58
+ expect(merged.hooks.PreToolUse).toHaveLength(2);
59
+ expect(merged.hooks.PreToolUse[0].hooks[0].command).toBe('node /custom/local-hook.mjs');
60
+ expect(merged.hooks.PreToolUse[1].hooks[0].command).toBe('node /repo/hooks/main-guard.mjs');
61
+ expect(merged.hooks.PostToolUse).toHaveLength(1);
62
+ });
63
+
64
+ it('keeps protected non-hook keys unchanged', () => {
65
+ const local = {
66
+ model: 'claude-sonnet',
67
+ hooks: {
68
+ SessionStart: [{ hooks: [{ command: 'echo start-local' }] }],
69
+ },
70
+ };
71
+
72
+ const incoming = {
73
+ model: 'claude-opus',
74
+ hooks: {
75
+ SessionStart: [{ hooks: [{ command: 'echo start-repo' }] }],
76
+ },
77
+ };
78
+
79
+ const merged = deepMergeWithProtection(local, incoming);
80
+ expect(merged.model).toBe('claude-sonnet');
81
+ expect(merged.hooks.SessionStart).toHaveLength(2);
82
+ });
83
+ });