@codihaus/claude-skills 1.5.1 → 1.6.1

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.
@@ -15,7 +15,9 @@ import {
15
15
  checkProjectDeps,
16
16
  printDepsReport,
17
17
  installProjectDeps,
18
- installPythonDeps
18
+ installProjectDepsWithPM,
19
+ installPythonDeps,
20
+ installOptionalTools
19
21
  } from '../utils/deps.js';
20
22
  import {
21
23
  getAvailableSkills,
@@ -33,11 +35,13 @@ import {
33
35
  import {
34
36
  runProjectSetup,
35
37
  detectEnvExample,
36
- hasEnvFile
38
+ hasEnvFile,
39
+ detectPackageManager
37
40
  } from '../utils/project-setup.js';
38
41
 
39
42
  export async function init(options) {
40
43
  const projectPath = process.cwd();
44
+ let globalDeps = null; // Declare at function level for later use
41
45
 
42
46
  console.log(chalk.bold('\nšŸš€ Claude Skills Initialization\n'));
43
47
  console.log(chalk.gray(`Project: ${projectPath}\n`));
@@ -45,7 +49,7 @@ export async function init(options) {
45
49
  // Step 1: Check global dependencies
46
50
  if (!options.noDeps) {
47
51
  const spinner = ora('Checking system dependencies...').start();
48
- const globalDeps = await checkGlobalDeps();
52
+ globalDeps = await checkGlobalDeps();
49
53
  spinner.stop();
50
54
 
51
55
  printDepsReport(globalDeps);
@@ -69,19 +73,26 @@ export async function init(options) {
69
73
  }
70
74
  }
71
75
 
72
- // Offer to install missing Python packages
76
+ // Check if Python is available before offering to install packages
77
+ const hasPython = globalDeps.recommended.find(d => d.name === 'python3')?.found;
78
+ const hasPip = globalDeps.recommended.find(d => d.name === 'pip3')?.found;
79
+
80
+ // Offer to install missing Python packages (only if Python is installed)
73
81
  const missingPython = globalDeps.python?.filter(p => !p.installed) || [];
74
- if (missingPython.length > 0 && !options.yes) {
82
+ if (hasPython && hasPip && missingPython.length > 0 && !options.yes) {
75
83
  const { installPython } = await inquirer.prompt([{
76
84
  type: 'confirm',
77
85
  name: 'installPython',
78
- message: `Install missing Python packages (${missingPython.map(p => p.name).join(', ')})?`,
86
+ message: `Install missing Python packages (${missingPython.map(p => p.name).join(', ')})? (will try --user first, then sudo if needed)`,
79
87
  default: true
80
88
  }]);
81
89
 
82
90
  if (installPython) {
83
91
  await installPythonDeps(missingPython);
84
92
  }
93
+ } else if (missingPython.length > 0 && (!hasPython || !hasPip)) {
94
+ console.log(chalk.yellow('\nāš ļø Python packages are needed but Python/pip is not available.'));
95
+ console.log(chalk.gray('Install Python first, then run: pip3 install --user ' + missingPython.map(p => p.name).join(' ')));
85
96
  }
86
97
  }
87
98
 
@@ -216,12 +227,15 @@ export async function init(options) {
216
227
  try {
217
228
  const result = await setupHooks(projectPath);
218
229
  if (result) {
219
- hooksSpinner.succeed('Hooks configured');
230
+ hooksSpinner.succeed('Hooks configured (with retry limits & graceful failure)');
231
+ console.log(chalk.gray(' → Auto-updates docs graph when editing plans/'));
232
+ console.log(chalk.gray(' → Max 3 retries, 5s timeout, non-blocking failures'));
220
233
  } else {
221
234
  hooksSpinner.info('Hooks skipped');
222
235
  }
223
236
  } catch (e) {
224
237
  hooksSpinner.warn('Failed to set up hooks');
238
+ console.log(chalk.yellow(' → You can configure hooks manually later'));
225
239
  }
226
240
  }
227
241
 
@@ -245,13 +259,42 @@ export async function init(options) {
245
259
  gitSpinner.warn('Failed to update .gitignore');
246
260
  }
247
261
 
248
- // Step 11: Project setup (package manager, .env, node version)
262
+ // Step 11: Offer to install optional tools
263
+ if (!options.noDeps && globalDeps.optional) {
264
+ const missingOptional = globalDeps.optional.filter(d => !d.found);
265
+
266
+ if (missingOptional.length > 0 && !options.yes) {
267
+ console.log(chalk.cyan('\nšŸ“¦ Optional Tools\n'));
268
+ console.log(chalk.gray('These tools enhance skills but are not required. Select which ones to install:\n'));
269
+
270
+ const { selectedTools } = await inquirer.prompt([{
271
+ type: 'checkbox',
272
+ name: 'selectedTools',
273
+ message: 'Select optional tools to install:',
274
+ choices: missingOptional.map(tool => ({
275
+ name: `${tool.name.padEnd(8)} - ${tool.purpose} ${chalk.gray(`(${tool.usedBy.join(', ')})`)}`,
276
+ value: tool.name,
277
+ // Check all by default except 'gh' (GitHub CLI)
278
+ checked: tool.name !== 'gh'
279
+ }))
280
+ }]);
281
+
282
+ if (selectedTools.length > 0) {
283
+ const toolsToInstall = missingOptional.filter(t => selectedTools.includes(t.name));
284
+ await installOptionalTools(toolsToInstall, globalDeps.os);
285
+ } else {
286
+ console.log(chalk.gray('No optional tools selected. Skipping...\n'));
287
+ }
288
+ }
289
+ }
290
+
291
+ // Step 12: Project setup (package manager, .env, node version)
249
292
  console.log('\n' + chalk.cyan('─'.repeat(40)));
250
293
  console.log(chalk.bold('šŸ“¦ Project Setup\n'));
251
294
 
252
295
  const setupResult = await runProjectSetup(projectPath, { yes: options.yes });
253
296
 
254
- // Step 12: Check skill-specific project dependencies
297
+ // Step 13: Check skill-specific project dependencies
255
298
  if (!options.noDeps) {
256
299
  console.log('');
257
300
  const projectSpinner = ora('Checking skill dependencies...').start();
@@ -262,9 +305,25 @@ export async function init(options) {
262
305
  console.log(chalk.yellow('\nāš ļø Some optional dependencies for skills are missing:\n'));
263
306
 
264
307
  for (const dep of projectDeps.missing) {
265
- const cmd = dep.type === 'pip'
266
- ? chalk.cyan(`pip install ${dep.name}`)
267
- : chalk.cyan(`npm install -D ${dep.name}`);
308
+ const pm = setupResult.packageManager?.name || 'npm';
309
+ let cmd;
310
+ if (dep.type === 'pip') {
311
+ cmd = chalk.cyan(`pip install ${dep.name}`);
312
+ } else {
313
+ switch (pm) {
314
+ case 'pnpm':
315
+ cmd = chalk.cyan(`pnpm add -D ${dep.name}`);
316
+ break;
317
+ case 'yarn':
318
+ cmd = chalk.cyan(`yarn add -D ${dep.name}`);
319
+ break;
320
+ case 'bun':
321
+ cmd = chalk.cyan(`bun add -D ${dep.name}`);
322
+ break;
323
+ default:
324
+ cmd = chalk.cyan(`npm install -D ${dep.name}`);
325
+ }
326
+ }
268
327
  console.log(` ${dep.name} - ${dep.purpose}`);
269
328
  console.log(` ${cmd}\n`);
270
329
  }
@@ -278,7 +337,12 @@ export async function init(options) {
278
337
  }]);
279
338
 
280
339
  if (install) {
281
- await installProjectDeps(projectPath, projectDeps.missing);
340
+ // Use package manager-aware installation if we have one
341
+ if (setupResult.packageManager) {
342
+ await installProjectDepsWithPM(projectPath, projectDeps.missing, setupResult.packageManager);
343
+ } else {
344
+ await installProjectDeps(projectPath, projectDeps.missing);
345
+ }
282
346
  }
283
347
  }
284
348
  }
@@ -323,6 +387,14 @@ export async function init(options) {
323
387
  stepNum++;
324
388
  console.log(` ${stepNum}. See all skills: ${chalk.cyan('/help')}`);
325
389
  console.log('');
326
- console.log(chalk.gray('Docs: .claude/skills/_registry.md'));
390
+ console.log(chalk.gray('Docs:'));
391
+ console.log(chalk.gray(' • Skills: .claude/skills/_registry.md'));
392
+ console.log(chalk.gray(' • Hooks: .claude/hooks-guide.md (troubleshooting hook failures)'));
327
393
  console.log('');
394
+
395
+ // Add note about hooks if they were set up
396
+ if (!options.noHooks) {
397
+ console.log(chalk.dim('šŸ’” Tip: If hooks fail repeatedly, see .claude/hooks-guide.md for troubleshooting'));
398
+ console.log('');
399
+ }
328
400
  }
@@ -46,15 +46,27 @@ const DEFAULT_SETTINGS = {
46
46
 
47
47
  /**
48
48
  * Hooks for automatic graph updates
49
+ *
50
+ * IMPORTANT: Hooks should be resilient and fail gracefully.
51
+ * - Use `|| true` to prevent hook failures from blocking Claude
52
+ * - Add retry limits to prevent infinite loops
53
+ * - Keep hooks fast (< 2 seconds) to avoid slowing down workflow
49
54
  */
50
55
  const DEFAULT_HOOKS = {
51
56
  postToolUse: [
52
57
  {
53
58
  matcher: {
54
59
  toolName: "Write|Edit",
55
- path: "plans/**/*"
60
+ path: "plans/**/*.md" // Only trigger on .md files in plans/
61
+ },
62
+ command: "bash .claude/scripts/safe-graph-update.sh $PATH",
63
+ // Retry configuration (Claude Code built-in support)
64
+ retries: {
65
+ maxAttempts: 3, // Max 3 attempts total
66
+ backoff: "exponential", // Wait longer between retries
67
+ failureAction: "warn" // Show warning but don't block
56
68
  },
57
- command: "python3 scripts/graph.py --check-path $PATH || true"
69
+ timeout: 5000 // 5 second timeout per attempt
58
70
  }
59
71
  ]
60
72
  };
@@ -93,8 +105,24 @@ export async function setupHooks(projectPath, options = {}) {
93
105
 
94
106
  const claudePath = path.join(projectPath, '.claude');
95
107
  const hooksPath = path.join(claudePath, 'hooks.json');
108
+ const claudeScriptsPath = path.join(claudePath, 'scripts');
96
109
 
97
110
  await fs.ensureDir(claudePath);
111
+ await fs.ensureDir(claudeScriptsPath);
112
+
113
+ // Copy safe-graph-update.sh wrapper script to .claude/scripts/
114
+ const wrapperSource = path.join(TEMPLATES_PATH, 'scripts', 'safe-graph-update.sh');
115
+ const wrapperDest = path.join(claudeScriptsPath, 'safe-graph-update.sh');
116
+
117
+ if (await fs.pathExists(wrapperSource)) {
118
+ await fs.copy(wrapperSource, wrapperDest);
119
+ // Make executable
120
+ try {
121
+ await fs.chmod(wrapperDest, 0o755);
122
+ } catch (e) {
123
+ // chmod might fail on Windows, that's ok
124
+ }
125
+ }
98
126
 
99
127
  let hooks = DEFAULT_HOOKS;
100
128
 
@@ -108,8 +136,23 @@ export async function setupHooks(projectPath, options = {}) {
108
136
  }
109
137
  }
110
138
 
139
+ // Update command to use safe wrapper if it exists
140
+ if (hooks.postToolUse && hooks.postToolUse[0]) {
141
+ if (await fs.pathExists(wrapperDest)) {
142
+ hooks.postToolUse[0].command = "bash .claude/scripts/safe-graph-update.sh $PATH";
143
+ }
144
+ }
145
+
111
146
  await fs.writeJson(hooksPath, hooks, { spaces: 2 });
112
147
 
148
+ // Copy hooks guide documentation
149
+ const hooksGuideSource = path.join(TEMPLATES_PATH, 'hooks-guide.md');
150
+ const hooksGuideDest = path.join(claudePath, 'hooks-guide.md');
151
+
152
+ if (await fs.pathExists(hooksGuideSource)) {
153
+ await fs.copy(hooksGuideSource, hooksGuideDest);
154
+ }
155
+
113
156
  return { path: hooksPath, hooks };
114
157
  }
115
158
 
@@ -218,8 +261,16 @@ export async function updateGitignore(projectPath) {
218
261
 
219
262
  const entriesToAdd = [
220
263
  '',
221
- '# Claude Code',
264
+ '# Claude Code - Settings and local configuration',
222
265
  '.claude/settings.local.json',
266
+ '',
267
+ '# Claude Code - Installed skills, knowledge, and scripts',
268
+ '# These are managed by @codihaus/claude-skills package',
269
+ '.claude/skills/',
270
+ '.claude/knowledge/',
271
+ '.claude/scripts/',
272
+ '',
273
+ '# Environment files',
223
274
  '.env',
224
275
  '*.env.local',
225
276
  ''
package/src/utils/deps.js CHANGED
@@ -39,7 +39,7 @@ function getInstallHint(tool, osType) {
39
39
  const hints = {
40
40
  node: {
41
41
  macos: 'brew install node OR https://nodejs.org/',
42
- debian: 'sudo apt install nodejs npm OR https://nodejs.org/',
42
+ debian: 'sudo apt update && sudo apt install nodejs npm OR https://nodejs.org/',
43
43
  redhat: 'sudo dnf install nodejs npm OR https://nodejs.org/',
44
44
  arch: 'sudo pacman -S nodejs npm',
45
45
  linux: 'https://nodejs.org/ (use official installer)',
@@ -47,7 +47,7 @@ function getInstallHint(tool, osType) {
47
47
  },
48
48
  git: {
49
49
  macos: 'xcode-select --install OR brew install git',
50
- debian: 'sudo apt install git',
50
+ debian: 'sudo apt update && sudo apt install git',
51
51
  redhat: 'sudo dnf install git',
52
52
  arch: 'sudo pacman -S git',
53
53
  linux: 'sudo apt install git OR sudo dnf install git',
@@ -55,19 +55,19 @@ function getInstallHint(tool, osType) {
55
55
  },
56
56
  python3: {
57
57
  macos: 'brew install python3 OR https://python.org/',
58
- debian: 'sudo apt install python3 python3-pip',
58
+ debian: 'sudo apt update && sudo apt install python3 python3-pip',
59
59
  redhat: 'sudo dnf install python3 python3-pip',
60
60
  arch: 'sudo pacman -S python python-pip',
61
- linux: 'sudo apt install python3 python3-pip',
61
+ linux: 'sudo apt install python3 python3-pip OR sudo dnf install python3 python3-pip',
62
62
  unknown: 'https://python.org/'
63
63
  },
64
64
  pip3: {
65
- macos: 'python3 -m ensurepip OR brew install python3',
66
- debian: 'sudo apt install python3-pip',
65
+ macos: 'python3 -m ensurepip --upgrade OR brew install python3',
66
+ debian: 'sudo apt update && sudo apt install python3-pip',
67
67
  redhat: 'sudo dnf install python3-pip',
68
68
  arch: 'sudo pacman -S python-pip',
69
- linux: 'sudo apt install python3-pip',
70
- unknown: 'python3 -m ensurepip'
69
+ linux: 'sudo apt install python3-pip OR python3 -m ensurepip --upgrade',
70
+ unknown: 'python3 -m ensurepip --upgrade'
71
71
  },
72
72
  jq: {
73
73
  macos: 'brew install jq',
@@ -236,6 +236,7 @@ export const PROJECT_DEPS = {
236
236
  forTesting: [
237
237
  {
238
238
  name: '@playwright/test',
239
+ type: 'npm',
239
240
  purpose: 'UI testing with /dev-test',
240
241
  usedBy: ['/dev-test', '/dev-coding-frontend']
241
242
  }
@@ -243,6 +244,7 @@ export const PROJECT_DEPS = {
243
244
  optional: [
244
245
  {
245
246
  name: '@mermaid-js/mermaid-cli',
247
+ type: 'npm',
246
248
  purpose: 'Diagram rendering (optional)',
247
249
  usedBy: ['/utils/diagram'],
248
250
  global: true
@@ -539,9 +541,18 @@ export async function installPythonDeps(packages) {
539
541
  for (const pkg of packages) {
540
542
  try {
541
543
  console.log(` Installing ${pkg.name}...`);
542
- execSync(pkg.installCmd, { stdio: 'inherit' });
544
+
545
+ // Try without sudo first (user install)
546
+ try {
547
+ execSync(`pip3 install --user ${pkg.name}`, { stdio: 'inherit' });
548
+ } catch (userError) {
549
+ // If user install fails, try with sudo (system-wide)
550
+ console.log(chalk.yellow(` User install failed, trying with sudo...`));
551
+ execSync(`sudo pip3 install ${pkg.name}`, { stdio: 'inherit' });
552
+ }
543
553
  } catch (e) {
544
554
  console.log(chalk.red(` Failed to install ${pkg.name}`));
555
+ console.log(chalk.gray(` Try manually: pip3 install --user ${pkg.name}`));
545
556
  return false;
546
557
  }
547
558
  }
@@ -549,6 +560,150 @@ export async function installPythonDeps(packages) {
549
560
  return true;
550
561
  }
551
562
 
563
+ /**
564
+ * Install missing optional tools
565
+ */
566
+ export async function installOptionalTools(tools, osType) {
567
+ if (tools.length === 0) return true;
568
+
569
+ console.log(chalk.cyan('\nInstalling optional tools...\n'));
570
+
571
+ const installCommands = {
572
+ gh: {
573
+ macos: 'brew install gh',
574
+ debian: 'curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && sudo apt update && sudo apt install gh -y',
575
+ redhat: 'sudo dnf install gh -y',
576
+ arch: 'sudo pacman -S github-cli --noconfirm',
577
+ fallback: 'npm install -g gh'
578
+ },
579
+ mmdc: {
580
+ all: 'npm install -g @mermaid-js/mermaid-cli'
581
+ },
582
+ tree: {
583
+ macos: 'brew install tree',
584
+ debian: 'sudo apt install tree -y',
585
+ redhat: 'sudo dnf install tree -y',
586
+ arch: 'sudo pacman -S tree --noconfirm'
587
+ },
588
+ scc: {
589
+ macos: 'brew install scc',
590
+ debian: 'go install github.com/boyter/scc/v3@latest',
591
+ redhat: 'go install github.com/boyter/scc/v3@latest',
592
+ arch: 'yay -S scc --noconfirm || go install github.com/boyter/scc/v3@latest',
593
+ fallback: 'go install github.com/boyter/scc/v3@latest'
594
+ },
595
+ rg: {
596
+ macos: 'brew install ripgrep',
597
+ debian: 'sudo apt install ripgrep -y',
598
+ redhat: 'sudo dnf install ripgrep -y',
599
+ arch: 'sudo pacman -S ripgrep --noconfirm'
600
+ }
601
+ };
602
+
603
+ let successCount = 0;
604
+ let failCount = 0;
605
+
606
+ for (const tool of tools) {
607
+ const commands = installCommands[tool.name];
608
+ if (!commands) {
609
+ console.log(chalk.gray(` ā—‹ ${tool.name} - no auto-install available`));
610
+ console.log(chalk.gray(` → ${tool.installHint}\n`));
611
+ continue;
612
+ }
613
+
614
+ let command = commands.all || commands[osType] || commands.fallback;
615
+
616
+ if (!command) {
617
+ console.log(chalk.gray(` ā—‹ ${tool.name} - no auto-install available for ${osType}`));
618
+ console.log(chalk.gray(` → ${tool.installHint}\n`));
619
+ continue;
620
+ }
621
+
622
+ console.log(` Installing ${chalk.cyan(tool.name)}...`);
623
+
624
+ try {
625
+ execSync(command, {
626
+ stdio: ['pipe', 'pipe', 'pipe'],
627
+ encoding: 'utf-8'
628
+ });
629
+ console.log(chalk.green(` āœ“ ${tool.name} installed successfully\n`));
630
+ successCount++;
631
+ } catch (e) {
632
+ // Try fallback if available
633
+ if (commands.fallback && command !== commands.fallback) {
634
+ console.log(chalk.yellow(` Primary method failed, trying fallback...`));
635
+ try {
636
+ execSync(commands.fallback, {
637
+ stdio: ['pipe', 'pipe', 'pipe'],
638
+ encoding: 'utf-8'
639
+ });
640
+ console.log(chalk.green(` āœ“ ${tool.name} installed successfully\n`));
641
+ successCount++;
642
+ continue;
643
+ } catch (fallbackError) {
644
+ // Fallback also failed
645
+ }
646
+ }
647
+
648
+ console.log(chalk.red(` āœ— Failed to install ${tool.name}`));
649
+ console.log(chalk.gray(` Try manually: ${tool.installHint}\n`));
650
+ failCount++;
651
+ }
652
+ }
653
+
654
+ if (successCount > 0) {
655
+ console.log(chalk.green(`\nāœ“ Installed ${successCount} optional tools`));
656
+ }
657
+ if (failCount > 0) {
658
+ console.log(chalk.yellow(`⚠ Failed to install ${failCount} optional tools`));
659
+ }
660
+
661
+ return failCount === 0;
662
+ }
663
+
664
+ /**
665
+ * Install project dependencies with package manager detection
666
+ */
667
+ export async function installProjectDepsWithPM(projectPath, deps, packageManager) {
668
+ const npmDeps = deps.filter(d => !d.global && d.type !== 'pip');
669
+
670
+ if (npmDeps.length === 0) {
671
+ return true;
672
+ }
673
+
674
+ console.log(chalk.cyan(`\nInstalling npm dependencies with ${packageManager.name}...`));
675
+ const names = npmDeps.map(d => d.name).join(' ');
676
+
677
+ // Build install command based on package manager
678
+ let installCmd;
679
+ switch (packageManager.name) {
680
+ case 'pnpm':
681
+ installCmd = `pnpm add -D ${names}`;
682
+ break;
683
+ case 'yarn':
684
+ installCmd = `yarn add -D ${names}`;
685
+ break;
686
+ case 'bun':
687
+ installCmd = `bun add -D ${names}`;
688
+ break;
689
+ case 'npm':
690
+ default:
691
+ installCmd = `npm install -D ${names}`;
692
+ break;
693
+ }
694
+
695
+ try {
696
+ execSync(installCmd, {
697
+ cwd: projectPath,
698
+ stdio: 'inherit'
699
+ });
700
+ return true;
701
+ } catch (e) {
702
+ console.log(chalk.red(`Failed to install npm dependencies with ${packageManager.name}`));
703
+ return false;
704
+ }
705
+ }
706
+
552
707
  /**
553
708
  * Get summary of what's missing
554
709
  */
@@ -198,6 +198,22 @@ export async function copySkillsToProject(projectPath, skillNames = null) {
198
198
  } else {
199
199
  // Copy directory
200
200
  await fs.copy(skill.path, targetSkillPath);
201
+
202
+ // Make scripts within skill folder executable
203
+ const skillScriptsPath = path.join(targetSkillPath, 'scripts');
204
+ if (await fs.pathExists(skillScriptsPath)) {
205
+ const scriptFiles = await fs.readdir(skillScriptsPath);
206
+ for (const scriptFile of scriptFiles) {
207
+ const scriptPath = path.join(skillScriptsPath, scriptFile);
208
+ const stat = await fs.stat(scriptPath);
209
+ if (stat.isFile()) {
210
+ const isScript = scriptFile.endsWith('.py') || scriptFile.endsWith('.sh') || scriptFile.endsWith('.js');
211
+ if (isScript) {
212
+ await fs.chmod(scriptPath, 0o755);
213
+ }
214
+ }
215
+ }
216
+ }
201
217
  }
202
218
 
203
219
  copied.push(skill.name);
@@ -326,6 +342,17 @@ export async function copyScriptsToProject(projectPath) {
326
342
 
327
343
  try {
328
344
  await fs.copy(sourcePath, targetItemPath);
345
+
346
+ // Make scripts executable (chmod +x)
347
+ const stat = await fs.stat(targetItemPath);
348
+ if (stat.isFile()) {
349
+ // Check if it's a script file
350
+ const isScript = item.endsWith('.py') || item.endsWith('.sh') || item.endsWith('.js');
351
+ if (isScript) {
352
+ await fs.chmod(targetItemPath, 0o755);
353
+ }
354
+ }
355
+
329
356
  copied.push(item);
330
357
  } catch (e) {
331
358
  errors.push({ name: item, error: e.message });
@@ -0,0 +1,143 @@
1
+ # Hook Trigger Test Cases
2
+
3
+ Test cases for `path: "plans/**/*.md"` matcher.
4
+
5
+ ## āœ… WILL Trigger (Correct)
6
+
7
+ These files SHOULD trigger the docs-graph update hook:
8
+
9
+ ```
10
+ plans/brd/README.md āœ“
11
+ plans/brd/context.md āœ“
12
+ plans/brd/use-cases/auth/UC-AUTH-001-login.md āœ“
13
+ plans/brd/changes/CR-001-add-sso.md āœ“
14
+ plans/features/auth/README.md āœ“
15
+ plans/features/auth/scout.md āœ“
16
+ plans/features/auth/specs/README.md āœ“
17
+ plans/features/auth/specs/UC-AUTH-001/README.md āœ“
18
+ plans/scout/README.md āœ“
19
+ plans/scout/structure.md āœ“
20
+ plans/scout/stack.md āœ“
21
+ plans/docs-graph.md āœ“
22
+ ```
23
+
24
+ ## āŒ Will NOT Trigger (Correct - Performance Optimization)
25
+
26
+ These files should NOT trigger the hook (saves resources):
27
+
28
+ ```
29
+ plans/features/auth/questionnaire-2024-01-20.xlsx āœ— (Excel file)
30
+ plans/scout/screenshots/home.png āœ— (Image file)
31
+ plans/scout/screenshots/dashboard.png āœ— (Image file)
32
+ plans/docs-graph.json āœ— (JSON file, processed separately)
33
+ plans/features/billing/architecture.json āœ— (JSON file)
34
+ plans/.gitkeep āœ— (No extension)
35
+ plans/brd/references.txt āœ— (Text file)
36
+ ```
37
+
38
+ ## Pattern Explanation
39
+
40
+ **Pattern:** `plans/**/*.md`
41
+
42
+ - `plans/` - Must be in plans directory
43
+ - `**/` - Can be at any depth (0 or more subdirectories)
44
+ - `*.md` - Must end with .md extension
45
+
46
+ **Double Protection:**
47
+
48
+ 1. **Hook matcher** (Claude Code level):
49
+ ```json
50
+ {
51
+ "matcher": {
52
+ "toolName": "Write|Edit",
53
+ "path": "plans/**/*.md"
54
+ }
55
+ }
56
+ ```
57
+
58
+ 2. **Script validation** (Bash level):
59
+ ```bash
60
+ if [[ ! "$FILE_PATH" =~ ^plans/.*\.md$ ]]; then
61
+ exit 0
62
+ fi
63
+ ```
64
+
65
+ ## Testing
66
+
67
+ To test if a file will trigger the hook:
68
+
69
+ ```bash
70
+ # Test pattern matching
71
+ echo "plans/brd/README.md" | grep -E '^plans/.*\.md$'
72
+ # Output: plans/brd/README.md ← Matches!
73
+
74
+ echo "plans/scout/screenshots/home.png" | grep -E '^plans/.*\.md$'
75
+ # No output ← Doesn't match
76
+ ```
77
+
78
+ ## Performance Impact
79
+
80
+ **Before** (with `plans/**/*`):
81
+ - 100 files changed in plans/
82
+ - Hook runs 100 times
83
+ - Includes: 70 .md, 20 .png, 5 .xlsx, 5 .json
84
+ - Wastes 30% of hook runs
85
+
86
+ **After** (with `plans/**/*.md`):
87
+ - 100 files changed in plans/
88
+ - Hook runs 70 times
89
+ - Includes: 70 .md only
90
+ - Saves 30% of hook runs
91
+
92
+ ## Common Gotchas
93
+
94
+ āŒ **Don't match root .md files:**
95
+ ```json
96
+ {
97
+ "matcher": {
98
+ "path": "*.md" // ← Bad: matches README.md in root
99
+ }
100
+ }
101
+ ```
102
+
103
+ āŒ **Don't match all .md everywhere:**
104
+ ```json
105
+ {
106
+ "matcher": {
107
+ "path": "**/*.md" // ← Bad: matches node_modules/**/*.md
108
+ }
109
+ }
110
+ ```
111
+
112
+ āœ… **Do scope to specific directories:**
113
+ ```json
114
+ {
115
+ "matcher": {
116
+ "path": "plans/**/*.md" // ← Good: only plans/
117
+ }
118
+ }
119
+ ```
120
+
121
+ ## Verification After Init
122
+
123
+ After running `npx @codihaus/claude-skills init`, verify:
124
+
125
+ ```bash
126
+ # Check hook configuration
127
+ cat .claude/hooks.json | jq '.postToolUse[0].matcher.path'
128
+ # Should output: "plans/**/*.md"
129
+
130
+ cat .claude/hooks.json | jq '.postToolUse[0].command'
131
+ # Should output: "bash .claude/scripts/safe-graph-update.sh $PATH"
132
+
133
+ # Test the safe wrapper script
134
+ bash .claude/scripts/safe-graph-update.sh plans/brd/README.md
135
+ # Should run graph.py
136
+
137
+ bash .claude/scripts/safe-graph-update.sh plans/screenshot.png
138
+ # Should exit early (not run graph.py)
139
+
140
+ # Check that script exists in .claude/scripts/
141
+ ls -la .claude/scripts/safe-graph-update.sh
142
+ # Should show the file with executable permissions
143
+ ```