claude-git-hooks 2.18.0 → 2.19.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CLAUDE.md +12 -8
  3. package/README.md +2 -1
  4. package/bin/claude-hooks +75 -89
  5. package/lib/cli-metadata.js +301 -0
  6. package/lib/commands/analyze-diff.js +12 -10
  7. package/lib/commands/analyze.js +9 -5
  8. package/lib/commands/bump-version.js +66 -43
  9. package/lib/commands/create-pr.js +71 -34
  10. package/lib/commands/debug.js +4 -7
  11. package/lib/commands/generate-changelog.js +11 -4
  12. package/lib/commands/help.js +47 -27
  13. package/lib/commands/helpers.js +66 -43
  14. package/lib/commands/hooks.js +15 -13
  15. package/lib/commands/install.js +546 -39
  16. package/lib/commands/migrate-config.js +8 -11
  17. package/lib/commands/presets.js +6 -13
  18. package/lib/commands/setup-github.js +12 -3
  19. package/lib/commands/telemetry-cmd.js +8 -6
  20. package/lib/commands/update.js +1 -2
  21. package/lib/config.js +36 -31
  22. package/lib/hooks/pre-commit.js +34 -54
  23. package/lib/hooks/prepare-commit-msg.js +39 -58
  24. package/lib/utils/analysis-engine.js +28 -21
  25. package/lib/utils/changelog-generator.js +162 -34
  26. package/lib/utils/claude-client.js +438 -377
  27. package/lib/utils/claude-diagnostics.js +20 -10
  28. package/lib/utils/file-operations.js +51 -79
  29. package/lib/utils/file-utils.js +46 -9
  30. package/lib/utils/git-operations.js +140 -123
  31. package/lib/utils/git-tag-manager.js +24 -23
  32. package/lib/utils/github-api.js +85 -61
  33. package/lib/utils/github-client.js +12 -14
  34. package/lib/utils/installation-diagnostics.js +4 -4
  35. package/lib/utils/interactive-ui.js +29 -17
  36. package/lib/utils/logger.js +4 -1
  37. package/lib/utils/pr-metadata-engine.js +67 -33
  38. package/lib/utils/preset-loader.js +20 -62
  39. package/lib/utils/prompt-builder.js +50 -55
  40. package/lib/utils/resolution-prompt.js +33 -44
  41. package/lib/utils/sanitize.js +20 -19
  42. package/lib/utils/task-id.js +27 -40
  43. package/lib/utils/telemetry.js +29 -17
  44. package/lib/utils/version-manager.js +173 -126
  45. package/lib/utils/which-command.js +23 -12
  46. package/package.json +69 -69
@@ -31,6 +31,7 @@ import {
31
31
  Entertainment
32
32
  } from './helpers.js';
33
33
  import { runSetupGitHub } from './setup-github.js';
34
+ import { generateCompletionData } from '../cli-metadata.js';
34
35
 
35
36
  /**
36
37
  * Function to check version (used by hooks)
@@ -65,7 +66,7 @@ async function checkVersionAndPromptUpdate() {
65
66
  success('Update completed. Please run your command again.');
66
67
  process.exit(0); // Exit so user restarts the process
67
68
  } catch (e) {
68
- error(`Error updating: ${ e.message}`);
69
+ error(`Error updating: ${e.message}`);
69
70
  resolve(false);
70
71
  }
71
72
  } else {
@@ -96,10 +97,14 @@ async function checkAndInstallClaude() {
96
97
  if (isWindows()) {
97
98
  console.log('\n⚠️ On Windows, Claude CLI must be installed in WSL:');
98
99
  console.log('1. Open WSL terminal (wsl or Ubuntu from Start Menu)');
99
- console.log('2. Follow installation at: https://docs.anthropic.com/claude/docs/claude-cli');
100
+ console.log(
101
+ '2. Follow installation at: https://docs.anthropic.com/claude/docs/claude-cli'
102
+ );
100
103
  console.log('3. Verify with: wsl claude --version');
101
104
  } else {
102
- console.log('\nClaude CLI installation: https://docs.anthropic.com/claude/docs/claude-cli');
105
+ console.log(
106
+ '\nClaude CLI installation: https://docs.anthropic.com/claude/docs/claude-cli'
107
+ );
103
108
  }
104
109
 
105
110
  console.log('\nAfter installation, run: claude-hooks install --force');
@@ -211,7 +216,7 @@ function updateGitignore() {
211
216
  const gitignorePath = '.gitignore';
212
217
  const claudeEntries = [
213
218
  '# Claude Git Hooks (includes .claude/settings.local.json for tokens)',
214
- '.claude/',
219
+ '.claude/'
215
220
  ];
216
221
 
217
222
  let gitignoreContent = '';
@@ -225,7 +230,7 @@ function updateGitignore() {
225
230
 
226
231
  // Check which entries are missing
227
232
  const missingEntries = [];
228
- claudeEntries.forEach(entry => {
233
+ claudeEntries.forEach((entry) => {
229
234
  if (entry.startsWith('#')) {
230
235
  // For comments, check if any Claude comment already exists
231
236
  if (!gitignoreContent.includes('# Claude')) {
@@ -253,7 +258,7 @@ function updateGitignore() {
253
258
  }
254
259
 
255
260
  // Add the missing entries
256
- gitignoreContent += `${missingEntries.join('\n') }\n`;
261
+ gitignoreContent += `${missingEntries.join('\n')}\n`;
257
262
 
258
263
  // Write the updated file
259
264
  fs.writeFileSync(gitignorePath, gitignoreContent);
@@ -265,7 +270,7 @@ function updateGitignore() {
265
270
  }
266
271
 
267
272
  // Show what was added
268
- missingEntries.forEach(entry => {
273
+ missingEntries.forEach((entry) => {
269
274
  if (!entry.startsWith('#')) {
270
275
  info(` + ${entry}`);
271
276
  }
@@ -293,9 +298,8 @@ function configureGit() {
293
298
  execSync('git config core.autocrlf input', { stdio: 'ignore' });
294
299
  success('Line endings configured for Unix (core.autocrlf = input)');
295
300
  }
296
-
297
301
  } catch (e) {
298
- warning(`Error configuring Git: ${ e.message}`);
302
+ warning(`Error configuring Git: ${e.message}`);
299
303
  }
300
304
  }
301
305
 
@@ -381,7 +385,9 @@ async function autoMigrateConfig(newConfigPath, backupConfigPath) {
381
385
  */
382
386
  export async function runInstall(args) {
383
387
  if (!checkGitRepo()) {
384
- error('You are not in a Git repository. Please run this command from the root of a repository.');
388
+ error(
389
+ 'You are not in a Git repository. Please run this command from the root of a repository.'
390
+ );
385
391
  }
386
392
 
387
393
  const isForce = args.includes('--force');
@@ -422,7 +428,7 @@ export async function runInstall(args) {
422
428
  // Hooks to install
423
429
  const hooks = ['pre-commit', 'prepare-commit-msg'];
424
430
 
425
- hooks.forEach(hook => {
431
+ hooks.forEach((hook) => {
426
432
  const sourcePath = path.join(templatesPath, hook);
427
433
  const destPath = path.join(hooksPath, hook);
428
434
 
@@ -457,12 +463,9 @@ export async function runInstall(args) {
457
463
  }
458
464
 
459
465
  // Remove old SONAR template files if they exist (migration from v2.6.x to v2.7.0+)
460
- const oldSonarFiles = [
461
- 'CLAUDE_PRE_COMMIT_SONAR.md',
462
- 'CLAUDE_ANALYSIS_PROMPT_SONAR.md'
463
- ];
466
+ const oldSonarFiles = ['CLAUDE_PRE_COMMIT_SONAR.md', 'CLAUDE_ANALYSIS_PROMPT_SONAR.md'];
464
467
 
465
- oldSonarFiles.forEach(oldFile => {
468
+ oldSonarFiles.forEach((oldFile) => {
466
469
  const oldPath = path.join(claudeDir, oldFile);
467
470
  if (fs.existsSync(oldPath)) {
468
471
  fs.unlinkSync(oldPath);
@@ -478,16 +481,17 @@ export async function runInstall(args) {
478
481
  }
479
482
 
480
483
  // Copy template files (.md and .json) to appropriate locations
481
- const templateFiles = fs.readdirSync(templatesPath)
482
- .filter(file => {
483
- const filePath = path.join(templatesPath, file);
484
- // Exclude example.json files and only include .md and .json files
485
- return fs.statSync(filePath).isFile() &&
486
- (file.endsWith('.md') || file.endsWith('.json')) &&
487
- !file.includes('example.json');
488
- });
484
+ const templateFiles = fs.readdirSync(templatesPath).filter((file) => {
485
+ const filePath = path.join(templatesPath, file);
486
+ // Exclude example.json files and only include .md and .json files
487
+ return (
488
+ fs.statSync(filePath).isFile() &&
489
+ (file.endsWith('.md') || file.endsWith('.json')) &&
490
+ !file.includes('example.json')
491
+ );
492
+ });
489
493
 
490
- templateFiles.forEach(file => {
494
+ templateFiles.forEach((file) => {
491
495
  const sourcePath = path.join(templatesPath, file);
492
496
  let destPath;
493
497
  let destLocation;
@@ -514,14 +518,13 @@ export async function runInstall(args) {
514
518
 
515
519
  // Clean up old .md files from .claude/ root (v2.8.0 migration)
516
520
  // .md files should now be in .claude/prompts/, not .claude/
517
- const oldMdFiles = fs.readdirSync(claudeDir)
518
- .filter(file => {
519
- const filePath = path.join(claudeDir, file);
520
- return fs.statSync(filePath).isFile() && file.endsWith('.md');
521
- });
521
+ const oldMdFiles = fs.readdirSync(claudeDir).filter((file) => {
522
+ const filePath = path.join(claudeDir, file);
523
+ return fs.statSync(filePath).isFile() && file.endsWith('.md');
524
+ });
522
525
 
523
526
  if (oldMdFiles.length > 0) {
524
- oldMdFiles.forEach(file => {
527
+ oldMdFiles.forEach((file) => {
525
528
  const oldPath = path.join(claudeDir, file);
526
529
  fs.unlinkSync(oldPath);
527
530
  info(`Removed old template from .claude/: ${file} (now in prompts/)`);
@@ -539,10 +542,11 @@ export async function runInstall(args) {
539
542
  }
540
543
 
541
544
  // Copy each preset directory
542
- const presetDirs = fs.readdirSync(presetsSourcePath)
543
- .filter(item => fs.statSync(path.join(presetsSourcePath, item)).isDirectory());
545
+ const presetDirs = fs
546
+ .readdirSync(presetsSourcePath)
547
+ .filter((item) => fs.statSync(path.join(presetsSourcePath, item)).isDirectory());
544
548
 
545
- presetDirs.forEach(presetName => {
549
+ presetDirs.forEach((presetName) => {
546
550
  const presetSource = path.join(presetsSourcePath, presetName);
547
551
  const presetDest = path.join(presetsDestPath, presetName);
548
552
 
@@ -553,7 +557,7 @@ export async function runInstall(args) {
553
557
 
554
558
  // Copy all files in preset directory
555
559
  const presetFiles = fs.readdirSync(presetSource);
556
- presetFiles.forEach(file => {
560
+ presetFiles.forEach((file) => {
557
561
  const sourceFile = path.join(presetSource, file);
558
562
  const destFile = path.join(presetDest, file);
559
563
 
@@ -585,7 +589,7 @@ export async function runInstall(args) {
585
589
 
586
590
  // Copy example configs to config_example/ directly from templates/
587
591
  const exampleConfigs = ['config.example.json', 'config.advanced.example.json'];
588
- exampleConfigs.forEach(exampleFile => {
592
+ exampleConfigs.forEach((exampleFile) => {
589
593
  const sourcePath = path.join(templatesPath, exampleFile);
590
594
  const destPath = path.join(configExampleDir, exampleFile);
591
595
  if (fs.existsSync(sourcePath)) {
@@ -634,7 +638,10 @@ export async function runInstall(args) {
634
638
  // Auto-run migration if needed to preserve settings
635
639
  if (needsMigration) {
636
640
  info('🔄 Auto-migrating settings from backup...');
637
- await autoMigrateConfig(configPath, path.join(configOldDir, fs.readdirSync(configOldDir).sort().pop()));
641
+ await autoMigrateConfig(
642
+ configPath,
643
+ path.join(configOldDir, fs.readdirSync(configOldDir).sort().pop())
644
+ );
638
645
  }
639
646
  }
640
647
 
@@ -642,8 +649,8 @@ export async function runInstall(args) {
642
649
  const settingsLocalPath = path.join(claudeDir, 'settings.local.json');
643
650
  if (!fs.existsSync(settingsLocalPath)) {
644
651
  const settingsLocalContent = {
645
- '_comment': 'Local settings - DO NOT COMMIT. This file is gitignored.',
646
- 'githubToken': ''
652
+ _comment: 'Local settings - DO NOT COMMIT. This file is gitignored.',
653
+ githubToken: ''
647
654
  };
648
655
  fs.writeFileSync(settingsLocalPath, JSON.stringify(settingsLocalContent, null, 2));
649
656
  info('settings.local.json created (add your GitHub token here)');
@@ -655,6 +662,9 @@ export async function runInstall(args) {
655
662
  // Update .gitignore
656
663
  updateGitignore();
657
664
 
665
+ // Install shell completions
666
+ installCompletions();
667
+
658
668
  success('Claude Git Hooks installed successfully! 🎉');
659
669
  console.log('\nRun claude-hooks --help to see all available commands.');
660
670
 
@@ -662,3 +672,500 @@ export async function runInstall(args) {
662
672
  console.log('');
663
673
  await runSetupGitHub();
664
674
  }
675
+
676
+ // ── Shell completion generation ──────────────────────────────────────────────
677
+
678
+ /**
679
+ * User-level paths for completion scripts
680
+ * @returns {{ bash: string, zsh: string, fish: string, powershell: string }}
681
+ */
682
+ export function getCompletionPaths() {
683
+ const home = os.homedir();
684
+ return {
685
+ bash: path.join(home, '.local', 'share', 'bash-completion', 'completions', 'claude-hooks'),
686
+ zsh: path.join(home, '.zfunc', '_claude-hooks'),
687
+ fish: path.join(home, '.config', 'fish', 'completions', 'claude-hooks.fish'),
688
+ powershell: path.join(home, '.config', 'powershell', 'completions', 'claude-hooks.ps1')
689
+ };
690
+ }
691
+
692
+ /**
693
+ * Get PowerShell profile path by querying PowerShell's $PROFILE variable.
694
+ * Why: Hardcoding ~/Documents/PowerShell/ breaks on OneDrive-redirected or locale-specific
695
+ * Documents folders (e.g., "OneDrive - Corp/Documentos/WindowsPowerShell/").
696
+ * Falls back to conventional paths if the query fails.
697
+ * @returns {string}
698
+ */
699
+ export function getPowerShellProfilePath() {
700
+ const home = os.homedir();
701
+ const isWindows = os.platform() === 'win32' || process.env.OS === 'Windows_NT';
702
+
703
+ if (isWindows) {
704
+ // Ask PowerShell itself for the correct $PROFILE path
705
+ // This handles OneDrive redirection, localized folder names, and pwsh vs PS 5.1
706
+ for (const shell of ['pwsh', 'powershell.exe']) {
707
+ try {
708
+ const profilePath = execSync(`${shell} -NoProfile -Command "$PROFILE"`, {
709
+ encoding: 'utf8',
710
+ timeout: 5000,
711
+ stdio: ['pipe', 'pipe', 'pipe']
712
+ }).trim();
713
+ if (profilePath && profilePath.endsWith('.ps1')) {
714
+ return profilePath;
715
+ }
716
+ } catch {
717
+ // Shell not available or timed out, try next
718
+ }
719
+ }
720
+ // Fallback: conventional paths if PowerShell query fails
721
+ const psCorePath = path.join(
722
+ home,
723
+ 'Documents',
724
+ 'PowerShell',
725
+ 'Microsoft.PowerShell_profile.ps1'
726
+ );
727
+ if (fs.existsSync(path.dirname(psCorePath))) {
728
+ return psCorePath;
729
+ }
730
+ return path.join(
731
+ home,
732
+ 'Documents',
733
+ 'WindowsPowerShell',
734
+ 'Microsoft.PowerShell_profile.ps1'
735
+ );
736
+ }
737
+ return path.join(home, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1');
738
+ }
739
+
740
+ /**
741
+ * Generate Bash completion script from completion data
742
+ * @param {Object} data - Flat completion data from generateCompletionData()
743
+ * @returns {string}
744
+ */
745
+ export function generateBashCompletion(data) {
746
+ const allCommands = data.commands.join(' ');
747
+
748
+ // Build per-command flag/arg cases
749
+ const cases = [];
750
+ for (const cmd of data.commands) {
751
+ const parts = [];
752
+ if (data.flags[cmd]) {
753
+ parts.push(...data.flags[cmd]);
754
+ }
755
+ if (data.subcommands[cmd]) {
756
+ parts.push(...data.subcommands[cmd]);
757
+ }
758
+ if (data.argValues[cmd]) {
759
+ parts.push(...data.argValues[cmd]);
760
+ }
761
+ if (parts.length > 0) {
762
+ cases.push(
763
+ ` ${cmd})\n COMPREPLY=( $(compgen -W "${parts.join(' ')}" -- "$cur") )\n return 0\n ;;`
764
+ );
765
+ }
766
+ // Dynamic branch completion
767
+ if (data.argCompletions[cmd]) {
768
+ cases.push(
769
+ ` ${cmd})\n local branches\n branches=$(${data.argCompletions[cmd]} 2>/dev/null)\n COMPREPLY=( $(compgen -W "$branches" -- "$cur") )\n return 0\n ;;`
770
+ );
771
+ }
772
+ }
773
+
774
+ return `# Bash completion for claude-hooks
775
+ # Generated by claude-hooks install — do not edit manually
776
+ _claude_hooks_completions() {
777
+ local cur prev commands
778
+ COMPREPLY=()
779
+ cur="\${COMP_WORDS[COMP_CWORD]}"
780
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
781
+ commands="${allCommands}"
782
+
783
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
784
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
785
+ return 0
786
+ fi
787
+
788
+ case "$prev" in
789
+ ${cases.join('\n')}
790
+ esac
791
+
792
+ return 0
793
+ }
794
+ complete -F _claude_hooks_completions claude-hooks
795
+ `;
796
+ }
797
+
798
+ /**
799
+ * Generate Zsh completion script from completion data
800
+ * @param {Object} data - Flat completion data from generateCompletionData()
801
+ * @returns {string}
802
+ */
803
+ export function generateZshCompletion(data) {
804
+ // Build command descriptions for _describe
805
+ const cmdDescs = data.commands
806
+ .filter((c) => !c.startsWith('-'))
807
+ .map((c) => `'${c}:${(data.descriptions[c] || '').replace(/'/g, "\\'")}'`)
808
+ .join('\n ');
809
+
810
+ // Build per-command completions
811
+ const subcases = [];
812
+ for (const cmd of data.commands) {
813
+ if (cmd.startsWith('-')) continue;
814
+ const parts = [];
815
+ if (data.flags[cmd]) {
816
+ for (const flag of data.flags[cmd]) {
817
+ const desc = '';
818
+ parts.push(`'${flag}[${desc}]'`);
819
+ }
820
+ }
821
+ if (data.subcommands[cmd]) {
822
+ parts.push(`'(${data.subcommands[cmd].join(' ')})'`);
823
+ }
824
+ if (data.argValues[cmd]) {
825
+ parts.push(`'(${data.argValues[cmd].join(' ')})'`);
826
+ }
827
+ if (data.argCompletions[cmd]) {
828
+ subcases.push(
829
+ ` ${cmd})\n local branches\n branches=(\${(f)"$(${data.argCompletions[cmd]} 2>/dev/null)"})\n _describe 'branch' branches\n ;;`
830
+ );
831
+ continue;
832
+ }
833
+ if (parts.length > 0) {
834
+ subcases.push(
835
+ ` ${cmd})\n _arguments ${parts.join(' ')}\n ;;`
836
+ );
837
+ }
838
+ }
839
+
840
+ return `#compdef claude-hooks
841
+ # Zsh completion for claude-hooks
842
+ # Generated by claude-hooks install — do not edit manually
843
+
844
+ _claude-hooks() {
845
+ local -a commands
846
+ commands=(
847
+ ${cmdDescs}
848
+ )
849
+
850
+ _arguments '1:command:->cmds' '*:arg:->args'
851
+
852
+ case $state in
853
+ cmds)
854
+ _describe 'command' commands
855
+ ;;
856
+ args)
857
+ case $words[2] in
858
+ ${subcases.join('\n')}
859
+ esac
860
+ ;;
861
+ esac
862
+ }
863
+
864
+ _claude-hooks "$@"
865
+ `;
866
+ }
867
+
868
+ /**
869
+ * Generate Fish completion script from completion data
870
+ * @param {Object} data - Flat completion data from generateCompletionData()
871
+ * @returns {string}
872
+ */
873
+ export function generateFishCompletion(data) {
874
+ const lines = [
875
+ '# Fish completion for claude-hooks',
876
+ '# Generated by claude-hooks install — do not edit manually',
877
+ '',
878
+ '# Disable file completions by default',
879
+ 'complete -c claude-hooks -f',
880
+ ''
881
+ ];
882
+
883
+ // Top-level commands
884
+ for (const cmd of data.commands) {
885
+ if (cmd.startsWith('-')) continue;
886
+ const desc = data.descriptions[cmd] || '';
887
+ lines.push(
888
+ `complete -c claude-hooks -n '__fish_use_subcommand' -a '${cmd}' -d '${desc.replace(/'/g, "\\'")}'`
889
+ );
890
+ }
891
+
892
+ lines.push('');
893
+
894
+ // Per-command flags and subcommands
895
+ for (const cmd of data.commands) {
896
+ if (cmd.startsWith('-')) continue;
897
+
898
+ if (data.flags[cmd]) {
899
+ for (const flag of data.flags[cmd]) {
900
+ const flagName = flag.replace(/^--/, '');
901
+ lines.push(
902
+ `complete -c claude-hooks -n '__fish_seen_subcommand_from ${cmd}' -l '${flagName}' -d '${(data.descriptions[cmd] || '').replace(/'/g, "\\'")}'`
903
+ );
904
+ }
905
+ }
906
+
907
+ if (data.subcommands[cmd]) {
908
+ for (const sub of data.subcommands[cmd]) {
909
+ lines.push(
910
+ `complete -c claude-hooks -n '__fish_seen_subcommand_from ${cmd}' -a '${sub}'`
911
+ );
912
+ }
913
+ }
914
+
915
+ if (data.argValues[cmd]) {
916
+ for (const val of data.argValues[cmd]) {
917
+ lines.push(
918
+ `complete -c claude-hooks -n '__fish_seen_subcommand_from ${cmd}' -a '${val}'`
919
+ );
920
+ }
921
+ }
922
+
923
+ if (data.argCompletions[cmd]) {
924
+ lines.push(
925
+ `complete -c claude-hooks -n '__fish_seen_subcommand_from ${cmd}' -a '(${data.argCompletions[cmd]} 2>/dev/null)'`
926
+ );
927
+ }
928
+ }
929
+
930
+ lines.push('');
931
+ return lines.join('\n');
932
+ }
933
+
934
+ /**
935
+ * Generate PowerShell completion script from completion data
936
+ * @param {Object} data - Flat completion data from generateCompletionData()
937
+ * @returns {string}
938
+ */
939
+ export function generatePowerShellCompletion(data) {
940
+ const allCommands = data.commands.map((c) => `'${c}'`).join(', ');
941
+
942
+ // Build per-command switch cases
943
+ const cases = [];
944
+ for (const cmd of data.commands) {
945
+ const completions = [];
946
+ if (data.flags[cmd]) {
947
+ for (const flag of data.flags[cmd]) {
948
+ completions.push(
949
+ `[System.Management.Automation.CompletionResult]::new('${flag}', '${flag}', [System.Management.Automation.CompletionResultType]::ParameterName, '${(data.descriptions[cmd] || '').replace(/'/g, "''")}')`
950
+ );
951
+ }
952
+ }
953
+ if (data.subcommands[cmd]) {
954
+ for (const sub of data.subcommands[cmd]) {
955
+ completions.push(
956
+ `[System.Management.Automation.CompletionResult]::new('${sub}', '${sub}', [System.Management.Automation.CompletionResultType]::ParameterValue, '${sub}')`
957
+ );
958
+ }
959
+ }
960
+ if (data.argValues[cmd]) {
961
+ for (const val of data.argValues[cmd]) {
962
+ completions.push(
963
+ `[System.Management.Automation.CompletionResult]::new('${val}', '${val}', [System.Management.Automation.CompletionResultType]::ParameterValue, '${val}')`
964
+ );
965
+ }
966
+ }
967
+ if (completions.length > 0) {
968
+ cases.push(
969
+ ` '${cmd}' {\n ${completions.join('\n ')}\n }`
970
+ );
971
+ }
972
+ }
973
+
974
+ return `# PowerShell completion for claude-hooks
975
+ # Generated by claude-hooks install — do not edit manually
976
+
977
+ # PS 5.1 workaround: -Native completers don't fire for ExternalScript commands (.ps1 npm shims).
978
+ # Wrapping in a function makes PowerShell resolve it as Function type, which -Native completers support.
979
+ if ((Get-Command claude-hooks -ErrorAction SilentlyContinue).CommandType -eq 'ExternalScript') {
980
+ $script:_claudeHooksOriginalPath = (Get-Command claude-hooks -CommandType ExternalScript).Source
981
+ function global:claude-hooks { & $script:_claudeHooksOriginalPath @args }
982
+ }
983
+
984
+ Register-ArgumentCompleter -CommandName claude-hooks -Native -ScriptBlock {
985
+ param($wordToComplete, $commandAst, $cursorPosition)
986
+ $commands = @(${allCommands})
987
+ $tokens = $commandAst.ToString().Split()
988
+ # .Split() drops trailing empty entries — detect cursor past last token
989
+ $position = $tokens.Count
990
+ if ($commandAst.ToString() -match '\\s$') { $position++ }
991
+
992
+ if ($position -le 2) {
993
+ $commands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
994
+ [System.Management.Automation.CompletionResult]::new($_, $_, [System.Management.Automation.CompletionResultType]::ParameterValue, $_)
995
+ }
996
+ return
997
+ }
998
+
999
+ $cmd = $tokens[1]
1000
+ switch ($cmd) {
1001
+ ${cases.join('\n')}
1002
+ }
1003
+ }
1004
+ `;
1005
+ }
1006
+
1007
+ /**
1008
+ * Ensure a marker+line pair exists in an rc file, replacing stale content if needed.
1009
+ * Idempotent: skips write if marker and line already match exactly.
1010
+ * Self-healing: replaces the line after the marker if content has changed (e.g., backslash → $HOME paths).
1011
+ * @param {string} filePath - Absolute path to rc file
1012
+ * @param {string} marker - Unique comment marker to identify our block
1013
+ * @param {string} line - The line to place after the marker
1014
+ */
1015
+ function appendLineIfMissing(filePath, marker, line) {
1016
+ let content = '';
1017
+ if (fs.existsSync(filePath)) {
1018
+ content = fs.readFileSync(filePath, 'utf8');
1019
+ }
1020
+ if (content.includes(marker)) {
1021
+ // Marker exists — check if the line after it matches the desired content
1022
+ const markerLineRegex = new RegExp(
1023
+ `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n[^\\n]*`
1024
+ );
1025
+ const match = content.match(markerLineRegex);
1026
+ if (match && match[0] === `${marker}\n${line}`) {
1027
+ return; // Already correct
1028
+ }
1029
+ // Replace stale line after marker with the correct one
1030
+ content = content.replace(markerLineRegex, `${marker}\n${line}`);
1031
+ fs.writeFileSync(filePath, content);
1032
+ return;
1033
+ }
1034
+ const newline = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
1035
+ fs.appendFileSync(filePath, `${newline}\n${marker}\n${line}\n`);
1036
+ }
1037
+
1038
+ /**
1039
+ * Install shell completion scripts for all supported shells
1040
+ * Reads command metadata and generates completion scripts to user-level paths.
1041
+ * Uses $HOME-relative paths in source lines to avoid backslash issues on Windows/MINGW64.
1042
+ * Wrapped in try/catch — warns on failure, never blocks install.
1043
+ */
1044
+ export function installCompletions() {
1045
+ try {
1046
+ const data = generateCompletionData();
1047
+ const paths = getCompletionPaths();
1048
+ let installed = 0;
1049
+
1050
+ // Bash
1051
+ try {
1052
+ fs.mkdirSync(path.dirname(paths.bash), { recursive: true });
1053
+ fs.writeFileSync(paths.bash, generateBashCompletion(data));
1054
+ // Use $HOME-relative path in source line (forward slashes)
1055
+ // Why: path.join() produces backslash paths on Windows, which MINGW64 bash can't interpret
1056
+ const bashRelPath = '.local/share/bash-completion/completions/claude-hooks';
1057
+ const bashSourceLine = `[ -f "$HOME/${bashRelPath}" ] && source "$HOME/${bashRelPath}"`;
1058
+ // Write to both .bashrc and .bash_profile for maximum compatibility
1059
+ // Why: MINGW64/Git Bash reads .bash_profile first; some setups don't source .bashrc from it
1060
+ const home = os.homedir();
1061
+ const bashrc = path.join(home, '.bashrc');
1062
+ appendLineIfMissing(bashrc, '# claude-hooks completions', bashSourceLine);
1063
+ const bashProfile = path.join(home, '.bash_profile');
1064
+ if (fs.existsSync(bashProfile)) {
1065
+ appendLineIfMissing(
1066
+ bashProfile,
1067
+ '# claude-hooks completions',
1068
+ bashSourceLine
1069
+ );
1070
+ }
1071
+ installed++;
1072
+ } catch (e) {
1073
+ warning(`Bash completions: ${e.message}`);
1074
+ }
1075
+
1076
+ // Zsh
1077
+ try {
1078
+ fs.mkdirSync(path.dirname(paths.zsh), { recursive: true });
1079
+ fs.writeFileSync(paths.zsh, generateZshCompletion(data));
1080
+ const zshrc = path.join(os.homedir(), '.zshrc');
1081
+ appendLineIfMissing(
1082
+ zshrc,
1083
+ '# claude-hooks completions',
1084
+ 'fpath=(~/.zfunc $fpath); autoload -Uz compinit && compinit'
1085
+ );
1086
+ installed++;
1087
+ } catch (e) {
1088
+ warning(`Zsh completions: ${e.message}`);
1089
+ }
1090
+
1091
+ // Fish
1092
+ try {
1093
+ fs.mkdirSync(path.dirname(paths.fish), { recursive: true });
1094
+ fs.writeFileSync(paths.fish, generateFishCompletion(data));
1095
+ installed++;
1096
+ } catch (e) {
1097
+ warning(`Fish completions: ${e.message}`);
1098
+ }
1099
+
1100
+ // PowerShell
1101
+ try {
1102
+ fs.mkdirSync(path.dirname(paths.powershell), { recursive: true });
1103
+ fs.writeFileSync(paths.powershell, generatePowerShellCompletion(data));
1104
+ // Use $HOME-relative path with forward slashes (PowerShell supports both separators)
1105
+ const psRelPath = '.config/powershell/completions/claude-hooks.ps1';
1106
+ const psProfile = getPowerShellProfilePath();
1107
+ try {
1108
+ fs.mkdirSync(path.dirname(psProfile), { recursive: true });
1109
+ appendLineIfMissing(
1110
+ psProfile,
1111
+ '# claude-hooks completions',
1112
+ `. "$HOME/${psRelPath}"`
1113
+ );
1114
+ } catch (e) {
1115
+ warning(`PowerShell profile: ${e.message}`);
1116
+ }
1117
+ installed++;
1118
+ } catch (e) {
1119
+ warning(`PowerShell completions: ${e.message}`);
1120
+ }
1121
+
1122
+ if (installed > 0) {
1123
+ success(`Shell completions installed (${installed} shells)`);
1124
+ info('Restart your shell or open a new terminal for completions to take effect.');
1125
+ }
1126
+ } catch (e) {
1127
+ warning(`Shell completions: ${e.message}`);
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * Remove shell completion scripts and rc file modifications
1133
+ * Called by runUninstall() for cleanup.
1134
+ */
1135
+ export function removeCompletions() {
1136
+ const paths = getCompletionPaths();
1137
+
1138
+ // Remove completion files
1139
+ for (const [shell, filePath] of Object.entries(paths)) {
1140
+ try {
1141
+ if (fs.existsSync(filePath)) {
1142
+ fs.unlinkSync(filePath);
1143
+ }
1144
+ } catch (e) {
1145
+ warning(`Could not remove ${shell} completion: ${e.message}`);
1146
+ }
1147
+ }
1148
+
1149
+ // Clean rc files (bashrc, bash_profile, zshrc, PowerShell profile)
1150
+ const rcFiles = [
1151
+ path.join(os.homedir(), '.bashrc'),
1152
+ path.join(os.homedir(), '.bash_profile'),
1153
+ path.join(os.homedir(), '.zshrc'),
1154
+ getPowerShellProfilePath()
1155
+ ];
1156
+
1157
+ for (const rcFile of rcFiles) {
1158
+ try {
1159
+ if (fs.existsSync(rcFile)) {
1160
+ let content = fs.readFileSync(rcFile, 'utf8');
1161
+ const marker = '# claude-hooks completions';
1162
+ if (content.includes(marker)) {
1163
+ content = content.replace(/\n?# claude-hooks completions\n[^\n]*\n?/g, '\n');
1164
+ fs.writeFileSync(rcFile, content);
1165
+ }
1166
+ }
1167
+ } catch (e) {
1168
+ warning(`Could not clean ${path.basename(rcFile)}: ${e.message}`);
1169
+ }
1170
+ }
1171
+ }