claude-git-hooks 1.5.4 → 2.0.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.
package/bin/claude-hooks CHANGED
@@ -1,11 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { execSync, spawn } = require('child_process');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
7
- const readline = require('readline');
8
- const https = require('https');
3
+ import { execSync, spawn } from 'child_process';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import readline from 'readline';
8
+ import https from 'https';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname } from 'path';
11
+ import { executeClaude, extractJSON } from '../lib/utils/claude-client.js';
12
+
13
+ // Why: ES6 modules don't have __dirname, need to recreate it
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ // Helper to read package.json
18
+ // Why: ES6 modules can't use require() for JSON files
19
+ const getPackageJson = () => {
20
+ const packagePath = path.join(__dirname, '..', 'package.json');
21
+ return JSON.parse(fs.readFileSync(packagePath, 'utf8'));
22
+ };
9
23
 
10
24
  // Function to get the latest version from NPM
11
25
  function getLatestVersion(packageName) {
@@ -40,26 +54,26 @@ function getLatestVersion(packageName) {
40
54
  // Function to check version (used by hooks)
41
55
  async function checkVersionAndPromptUpdate() {
42
56
  try {
43
- const currentVersion = require('../package.json').version;
57
+ const currentVersion = getPackageJson().version;
44
58
  const latestVersion = await getLatestVersion('claude-git-hooks');
45
-
59
+
46
60
  if (currentVersion === latestVersion) {
47
61
  return true; // Already updated
48
62
  }
49
-
63
+
50
64
  console.log('');
51
65
  warning(`New version available: ${latestVersion} (current: ${currentVersion})`);
52
-
66
+
53
67
  // Interactive prompt compatible with all consoles
54
68
  const rl = readline.createInterface({
55
69
  input: process.stdin,
56
70
  output: process.stdout
57
71
  });
58
-
72
+
59
73
  return new Promise((resolve) => {
60
74
  rl.question('Do you want to update now? (y/n): ', (answer) => {
61
75
  rl.close();
62
-
76
+
63
77
  if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
64
78
  info('Updating claude-git-hooks...');
65
79
  try {
@@ -82,11 +96,6 @@ async function checkVersionAndPromptUpdate() {
82
96
  }
83
97
  }
84
98
 
85
- // Export for use in hooks
86
- if (typeof module !== 'undefined' && module.exports) {
87
- module.exports = { checkVersionAndPromptUpdate };
88
- }
89
-
90
99
  // Colors for output
91
100
  const colors = {
92
101
  reset: '\x1b[0m',
@@ -124,7 +133,7 @@ function readPassword(prompt) {
124
133
  input: process.stdin,
125
134
  output: process.stdout
126
135
  });
127
-
136
+
128
137
  // Disable echo
129
138
  rl.stdoutMuted = true;
130
139
  rl._writeToOutput = function _writeToOutput(stringToWrite) {
@@ -133,7 +142,7 @@ function readPassword(prompt) {
133
142
  else
134
143
  rl.output.write(stringToWrite);
135
144
  };
136
-
145
+
137
146
  rl.question(prompt, (password) => {
138
147
  rl.close();
139
148
  console.log(); // New line
@@ -142,45 +151,6 @@ function readPassword(prompt) {
142
151
  });
143
152
  }
144
153
 
145
- // Check if sudo password is correct
146
- function testSudoPassword(password) {
147
- try {
148
- execSync('echo "' + password + '" | sudo -S true', {
149
- stdio: 'ignore',
150
- timeout: 5000
151
- });
152
- return true;
153
- } catch (e) {
154
- return false;
155
- }
156
- }
157
-
158
- // Install package with automatic sudo
159
- function installPackage(packageName, sudoPassword = null) {
160
- try {
161
- if (sudoPassword) {
162
- if (os.platform() === 'linux') {
163
- execSync(`echo "${sudoPassword}" | sudo -S apt-get update && echo "${sudoPassword}" | sudo -S apt-get install -y ${packageName}`, {
164
- stdio: 'inherit'
165
- });
166
- }
167
- } else {
168
- if (os.platform() === 'linux') {
169
- execSync(`sudo apt-get update && sudo apt-get install -y ${packageName}`, {
170
- stdio: 'inherit'
171
- });
172
- } else if (os.platform() === 'darwin') {
173
- execSync(`brew install ${packageName}`, {
174
- stdio: 'inherit'
175
- });
176
- }
177
- }
178
- return true;
179
- } catch (e) {
180
- return false;
181
- }
182
- }
183
-
184
154
  // Entertainment system
185
155
  class Entertainment {
186
156
  static jokes = [
@@ -231,26 +201,26 @@ class Entertainment {
231
201
  // Get first joke from API without blocking
232
202
  this.getJoke().then(joke => {
233
203
  if (!isFinished) currentJoke = joke;
234
- }).catch(() => {}); // If it fails, keep the local one
204
+ }).catch(() => { }); // If it fails, keep the local one
235
205
 
236
206
  // Hide cursor
237
207
  process.stdout.write('\x1B[?25l');
238
-
208
+
239
209
  // Reserve space for the 3 lines
240
210
  process.stdout.write('\n\n\n');
241
-
211
+
242
212
  const interval = setInterval(() => {
243
213
  if (isFinished) {
244
214
  clearInterval(interval);
245
215
  return;
246
216
  }
247
-
217
+
248
218
  spinnerIndex++;
249
-
219
+
250
220
  // Update countdown every second (10 iterations of 100ms)
251
221
  if (spinnerIndex % 10 === 0) {
252
222
  jokeCountdown--;
253
-
223
+
254
224
  // Refresh joke every 10 seconds
255
225
  if (jokeCountdown <= 0) {
256
226
  this.getJoke().then(joke => {
@@ -263,19 +233,19 @@ class Entertainment {
263
233
  jokeCountdown = 10;
264
234
  }
265
235
  }
266
-
236
+
267
237
  // Always go back exactly 3 lines up
268
238
  process.stdout.write('\x1B[3A');
269
-
239
+
270
240
  // Render the 3 lines from the beginning
271
241
  const spinner = spinners[spinnerIndex % spinners.length];
272
-
242
+
273
243
  // Line 1: Spinner
274
244
  process.stdout.write('\r\x1B[2K' + `${colors.yellow}${spinner} ${message}${colors.reset}\n`);
275
-
245
+
276
246
  // Line 2: Joke
277
247
  process.stdout.write('\r\x1B[2K' + `${colors.green}🎭 ${currentJoke}${colors.reset}\n`);
278
-
248
+
279
249
  // Line 3: Countdown
280
250
  process.stdout.write('\r\x1B[2K' + `${colors.yellow}⏱️ Next joke in: ${jokeCountdown}s${colors.reset}\n`);
281
251
  }, 100);
@@ -284,7 +254,7 @@ class Entertainment {
284
254
  const result = await promise;
285
255
  isFinished = true;
286
256
  clearInterval(interval);
287
-
257
+
288
258
  // Clean exactly 3 lines completely
289
259
  process.stdout.write('\x1B[3A'); // Go up 3 lines
290
260
  process.stdout.write('\r\x1B[2K'); // Clean line 1
@@ -292,15 +262,15 @@ class Entertainment {
292
262
  process.stdout.write('\n\r\x1B[2K'); // Go down and clean line 3
293
263
  process.stdout.write('\x1B[2A'); // Go up 2 lines to end up on the first
294
264
  process.stdout.write('\r'); // Go to beginning of line
295
-
265
+
296
266
  // Show cursor
297
267
  process.stdout.write('\x1B[?25h');
298
-
268
+
299
269
  return result;
300
270
  } catch (error) {
301
271
  isFinished = true;
302
272
  clearInterval(interval);
303
-
273
+
304
274
  // Clean exactly 3 lines completely
305
275
  process.stdout.write('\x1B[3A'); // Go up 3 lines
306
276
  process.stdout.write('\r\x1B[2K'); // Clean line 1
@@ -308,21 +278,42 @@ class Entertainment {
308
278
  process.stdout.write('\n\r\x1B[2K'); // Go down and clean line 3
309
279
  process.stdout.write('\x1B[2A'); // Go up 2 lines to end up on the first
310
280
  process.stdout.write('\r'); // Go to beginning of line
311
-
281
+
312
282
  // Show cursor
313
283
  process.stdout.write('\x1B[?25h');
314
-
284
+
315
285
  throw error;
316
286
  }
317
287
  }
318
288
  }
319
289
 
320
- // Check if we are in a git repository
290
+ // Check if we are in a git repository (including worktrees created in PowerShell)
321
291
  function checkGitRepo() {
322
292
  try {
323
293
  execSync('git rev-parse --git-dir', { stdio: 'ignore' });
324
294
  return true;
325
295
  } catch (e) {
296
+ // Try to detect worktree created in PowerShell
297
+ try {
298
+ if (fs.existsSync('.git')) {
299
+ const gitContent = fs.readFileSync('.git', 'utf8').trim();
300
+ // Check if it's a worktree pointer (gitdir: ...)
301
+ if (gitContent.startsWith('gitdir:')) {
302
+ let gitdir = gitContent.substring(8).trim();
303
+ // Convert Windows path to WSL if needed (C:\ -> /mnt/c/)
304
+ if (/^[A-Za-z]:/.test(gitdir)) {
305
+ gitdir = gitdir.replace(/^([A-Za-z]):/, (_, drive) => `/mnt/${drive.toLowerCase()}`);
306
+ gitdir = gitdir.replace(/\\/g, '/');
307
+ }
308
+ // Verify the gitdir exists
309
+ if (fs.existsSync(gitdir)) {
310
+ return true;
311
+ }
312
+ }
313
+ }
314
+ } catch (worktreeError) {
315
+ // Ignore worktree detection errors
316
+ }
326
317
  return false;
327
318
  }
328
319
  }
@@ -340,32 +331,16 @@ async function install(args) {
340
331
 
341
332
  const isForce = args.includes('--force');
342
333
  const skipAuth = args.includes('--skip-auth');
343
-
334
+
344
335
  if (isForce) {
345
336
  info('Installing Claude Git Hooks (force mode)...');
346
337
  } else {
347
338
  info('Installing Claude Git Hooks...');
348
339
  }
349
340
 
350
- // Request sudo password at the beginning if necessary
351
- let sudoPassword = null;
352
- if (os.platform() === 'linux') {
353
- const needsInstall = await checkIfInstallationNeeded();
354
- if (needsInstall) {
355
- info('Sudo access is needed for automatic dependency installation, please enter password');
356
- sudoPassword = await readPassword('Enter your Ubuntu password for sudo: ');
357
-
358
- if (sudoPassword && !testSudoPassword(sudoPassword)) {
359
- warning('Incorrect password. Continuing without automatic installation.');
360
- sudoPassword = null;
361
- } else if (sudoPassword) {
362
- success('Password verified. Proceeding with automatic installation.');
363
- }
364
- }
365
- }
366
-
367
- // Check dependencies with automatic installation
368
- await checkAndInstallDependencies(sudoPassword, skipAuth);
341
+ // v2.0.0+: No sudo needed (pure Node.js, no system packages required)
342
+ // Check dependencies
343
+ await checkAndInstallDependencies(null, skipAuth);
369
344
 
370
345
  const templatesPath = getTemplatesPath();
371
346
  const hooksPath = '.git/hooks';
@@ -375,32 +350,41 @@ async function install(args) {
375
350
  fs.mkdirSync(hooksPath, { recursive: true });
376
351
  }
377
352
 
353
+ // Helper function to copy file with LF line endings
354
+ // Why: Bash scripts must have LF (Unix) line endings, not CRLF (Windows)
355
+ const copyWithLF = (sourcePath, destPath) => {
356
+ let content = fs.readFileSync(sourcePath, 'utf8');
357
+ // Convert CRLF to LF
358
+ content = content.replace(/\r\n/g, '\n');
359
+ fs.writeFileSync(destPath, content, 'utf8');
360
+ };
361
+
378
362
  // Hooks to install
379
363
  const hooks = ['pre-commit', 'prepare-commit-msg'];
380
-
364
+
381
365
  hooks.forEach(hook => {
382
366
  const sourcePath = path.join(templatesPath, hook);
383
367
  const destPath = path.join(hooksPath, hook);
384
-
368
+
385
369
  // Make backup if it exists
386
370
  if (fs.existsSync(destPath)) {
387
371
  const backupPath = `${destPath}.backup.${Date.now()}`;
388
372
  fs.copyFileSync(destPath, backupPath);
389
373
  info(`Backup created: ${backupPath}`);
390
374
  }
391
-
392
- // Copy hook
393
- fs.copyFileSync(sourcePath, destPath);
375
+
376
+ // Copy hook with LF line endings (critical for bash)
377
+ copyWithLF(sourcePath, destPath);
394
378
  fs.chmodSync(destPath, '755');
395
379
  success(`${hook} installed`);
396
380
  });
397
381
 
398
- // Copy version verification script
382
+ // Copy version verification script with LF line endings
399
383
  const checkVersionSource = path.join(templatesPath, 'check-version.sh');
400
384
  const checkVersionDest = path.join(hooksPath, 'check-version.sh');
401
-
385
+
402
386
  if (fs.existsSync(checkVersionSource)) {
403
- fs.copyFileSync(checkVersionSource, checkVersionDest);
387
+ copyWithLF(checkVersionSource, checkVersionDest);
404
388
  fs.chmodSync(checkVersionDest, '755');
405
389
  success('Version verification script installed');
406
390
  }
@@ -418,11 +402,11 @@ async function install(args) {
418
402
  'CLAUDE_ANALYSIS_PROMPT_SONAR.md',
419
403
  'CLAUDE_RESOLUTION_PROMPT.md'
420
404
  ];
421
-
405
+
422
406
  claudeFiles.forEach(file => {
423
407
  const destPath = path.join(claudeDir, file);
424
408
  const sourcePath = path.join(templatesPath, file);
425
-
409
+
426
410
  // In force mode or if it doesn't exist, copy the file
427
411
  if (isForce || !fs.existsSync(destPath)) {
428
412
  if (fs.existsSync(sourcePath)) {
@@ -450,13 +434,19 @@ async function install(args) {
450
434
  console.log(' // SKIP_ANALYSIS_BLOCK # Exclude block until finding another equal one');
451
435
  console.log(' ...excluded code...');
452
436
  console.log(' // SKIP_ANALYSIS_BLOCK');
437
+ console.log('\nNEW: Parallel analysis for faster multi-file commits:');
438
+ console.log(' export CLAUDE_USE_SUBAGENTS=true # Enable subagents');
439
+ console.log(' export CLAUDE_SUBAGENT_MODEL=haiku # haiku/sonnet/opus');
440
+ console.log(' export CLAUDE_SUBAGENT_BATCH_SIZE=3 # Parallel per batch (default: 3)');
441
+ console.log(' # Example: 4 files, BATCH_SIZE=1 → 4 sequential batches');
442
+ console.log(' # Example: 4 files, BATCH_SIZE=3 → 2 batches (3 parallel + 1)');
453
443
  console.log('\nFor more options: claude-hooks --help');
454
444
  }
455
445
 
456
446
  // Check complete dependencies (like setup-wsl.sh)
457
447
  async function checkAndInstallDependencies(sudoPassword = null, skipAuth = false) {
458
448
  info('Checking system dependencies...');
459
-
449
+
460
450
  // Check Node.js
461
451
  try {
462
452
  const nodeVersion = execSync('node --version', { encoding: 'utf8' }).trim();
@@ -464,7 +454,7 @@ async function checkAndInstallDependencies(sudoPassword = null, skipAuth = false
464
454
  } catch (e) {
465
455
  error('Node.js is not installed. Install Node.js and try again.');
466
456
  }
467
-
457
+
468
458
  // Check npm
469
459
  try {
470
460
  const npmVersion = execSync('npm --version', { encoding: 'utf8' }).trim();
@@ -472,43 +462,9 @@ async function checkAndInstallDependencies(sudoPassword = null, skipAuth = false
472
462
  } catch (e) {
473
463
  error('npm is not installed.');
474
464
  }
475
-
476
- // Check and install jq
477
- try {
478
- const jqVersion = execSync('jq --version', { encoding: 'utf8' }).trim();
479
- success(`jq ${jqVersion}`);
480
- } catch (e) {
481
- warning('jq is not installed. Installing...');
482
- if (installPackage('jq', sudoPassword)) {
483
- success('jq installed successfully');
484
- } else {
485
- warning('Could not install jq automatically');
486
- if (os.platform() === 'linux') {
487
- console.log('Install it manually with: sudo apt install jq');
488
- } else if (os.platform() === 'darwin') {
489
- console.log('Install it manually with: brew install jq');
490
- }
491
- }
492
- }
493
-
494
- // Check and install curl
495
- try {
496
- const curlVersion = execSync('curl --version', { encoding: 'utf8' }).split('\n')[0];
497
- success(`curl ${curlVersion.split(' ')[1]}`);
498
- } catch (e) {
499
- warning('curl is not installed. Installing...');
500
- if (installPackage('curl', sudoPassword)) {
501
- success('curl installed successfully');
502
- } else {
503
- warning('Could not install curl automatically');
504
- if (os.platform() === 'linux') {
505
- console.log('Install it manually with: sudo apt install curl');
506
- } else if (os.platform() === 'darwin') {
507
- console.log('Install it manually with: brew install curl');
508
- }
509
- }
510
- }
511
-
465
+
466
+ // v2.0.0+: jq and curl are no longer needed (pure Node.js implementation)
467
+
512
468
  // Check Git
513
469
  try {
514
470
  const gitVersion = execSync('git --version', { encoding: 'utf8' }).trim();
@@ -516,95 +472,98 @@ async function checkAndInstallDependencies(sudoPassword = null, skipAuth = false
516
472
  } catch (e) {
517
473
  error('Git is not installed. Install Git and try again.');
518
474
  }
519
-
520
- // Check standard Unix tools
521
- const unixTools = ['sed', 'awk', 'grep', 'head', 'tail', 'stat', 'tput'];
522
- const missingTools = [];
523
-
524
- unixTools.forEach(tool => {
525
- try {
526
- execSync(`which ${tool}`, { stdio: 'ignore' });
527
- } catch (e) {
528
- missingTools.push(tool);
529
- }
530
- });
531
-
532
- if (missingTools.length === 0) {
533
- success('Standard Unix tools verified');
534
- } else {
535
- error(`Missing standard Unix tools: ${missingTools.join(', ')}. Retry installation in an Ubuntu console`);
536
- }
537
-
475
+
476
+ // v2.0.0+: Unix tools (sed, awk, grep, etc.) no longer needed (pure Node.js implementation)
477
+
538
478
  // Check and install Claude CLI
539
479
  await checkAndInstallClaude();
540
-
480
+
541
481
  // Check Claude authentication (if not skipped)
542
482
  if (!skipAuth) {
543
483
  await checkClaudeAuth();
544
484
  } else {
545
485
  warning('Skipping Claude authentication verification (--skip-auth)');
546
486
  }
547
-
487
+
548
488
  // Clear password from memory
549
489
  sudoPassword = null;
550
490
  }
551
491
 
492
+ // Detect if running on Windows
493
+ // Why: Need to use 'wsl claude' instead of 'claude' on Windows
494
+ function isWindows() {
495
+ return os.platform() === 'win32' || process.env.OS === 'Windows_NT';
496
+ }
497
+
498
+ // Get Claude command based on platform
499
+ // Why: On Windows, Claude CLI runs in WSL, so we need 'wsl claude'
500
+ function getClaudeCommand() {
501
+ return isWindows() ? 'wsl claude' : 'claude';
502
+ }
503
+
552
504
  // Check if we need to install dependencies
553
505
  async function checkIfInstallationNeeded() {
554
- const dependencies = ['jq', 'curl'];
555
-
556
- for (const dep of dependencies) {
557
- try {
558
- execSync(`which ${dep}`, { stdio: 'ignore' });
559
- } catch (e) {
560
- return true; // Needs installation
561
- }
562
- }
563
-
564
- // Verificar Claude CLI
506
+ // v2.0.0+: Only check Claude CLI (jq and curl no longer needed)
507
+ const claudeCmd = getClaudeCommand();
508
+
565
509
  try {
566
- execSync('claude --version', { stdio: 'ignore' });
510
+ execSync(`${claudeCmd} --version`, { stdio: 'ignore' });
567
511
  } catch (e) {
568
512
  return true; // Needs Claude installation
569
513
  }
570
-
514
+
571
515
  return false;
572
516
  }
573
517
 
574
- // Check and install Claude CLI
518
+ // Check Claude CLI availability
575
519
  async function checkAndInstallClaude() {
520
+ const claudeCmd = getClaudeCommand();
521
+ const platform = isWindows() ? 'Windows (via WSL)' : os.platform();
522
+
576
523
  try {
577
- execSync('claude --version', { stdio: 'ignore' });
578
- success('Claude CLI detected');
524
+ execSync(`${claudeCmd} --version`, { stdio: 'ignore' });
525
+ success(`Claude CLI detected (${platform})`);
579
526
  } catch (e) {
580
- info('Claude CLI not detected. Installing...');
581
- try {
582
- execSync('npm install -g @anthropic-ai/claude-cli', { stdio: 'inherit' });
583
- success('Claude CLI installed successfully');
584
- } catch (installError) {
585
- error('Error installing Claude CLI. Install manually: npm install -g @anthropic-ai/claude-cli');
527
+ error(`Claude CLI not detected on ${platform}`);
528
+
529
+ if (isWindows()) {
530
+ console.log('\n⚠️ On Windows, Claude CLI must be installed in WSL:');
531
+ console.log('1. Open WSL terminal (wsl or Ubuntu from Start Menu)');
532
+ console.log('2. Follow installation at: https://docs.anthropic.com/claude/docs/claude-cli');
533
+ console.log('3. Verify with: wsl claude --version');
534
+ } else {
535
+ console.log('\nClaude CLI installation: https://docs.anthropic.com/claude/docs/claude-cli');
586
536
  }
537
+
538
+ console.log('\nAfter installation, run: claude-hooks install --force');
539
+ process.exit(1);
587
540
  }
588
541
  }
589
542
 
590
543
  // Check Claude authentication with entertainment
591
544
  async function checkClaudeAuth() {
592
545
  info('Checking Claude authentication...');
593
-
546
+
547
+ // Get correct Claude command for platform
548
+ const claudeCmd = getClaudeCommand();
549
+ const cmdParts = claudeCmd.split(' ');
550
+ const command = cmdParts[0];
551
+ const args = [...cmdParts.slice(1), 'auth', 'status'];
552
+
594
553
  // Use spawn to not block, but with stdio: 'ignore' like the original
595
554
  const authPromise = new Promise((resolve, reject) => {
596
- const child = spawn('claude', ['auth', 'status'], {
555
+ const child = spawn(command, args, {
597
556
  stdio: 'ignore', // Igual que el original
598
557
  detached: false,
599
558
  windowsHide: true
600
559
  });
601
-
560
+
602
561
  // Manual timeout since spawn doesn't have native timeout
603
562
  const timeout = setTimeout(() => {
604
563
  child.kill();
605
564
  reject(new Error('timeout'));
606
565
  }, 120000); // 2 minutos
607
-
566
+
608
567
  child.on('exit', (code) => {
609
568
  clearTimeout(timeout);
610
569
  if (code === 0) {
@@ -613,7 +572,7 @@ async function checkClaudeAuth() {
613
572
  reject(new Error('not_authenticated'));
614
573
  }
615
574
  });
616
-
575
+
617
576
  child.on('error', (err) => {
618
577
  clearTimeout(timeout);
619
578
  reject(err);
@@ -633,7 +592,7 @@ async function checkClaudeAuth() {
633
592
  // Update .gitignore with Claude entries
634
593
  function updateGitignore() {
635
594
  info('Updating .gitignore...');
636
-
595
+
637
596
  const gitignorePath = '.gitignore';
638
597
  const claudeEntries = [
639
598
  '# Claude Git Hooks',
@@ -642,16 +601,16 @@ function updateGitignore() {
642
601
  'claude_resolution_prompt.md',
643
602
  '.claude-pr-analysis.json',
644
603
  ];
645
-
604
+
646
605
  let gitignoreContent = '';
647
606
  let fileExists = false;
648
-
607
+
649
608
  // Read existing .gitignore if it exists
650
609
  if (fs.existsSync(gitignorePath)) {
651
610
  gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
652
611
  fileExists = true;
653
612
  }
654
-
613
+
655
614
  // Check which entries are missing
656
615
  const missingEntries = [];
657
616
  claudeEntries.forEach(entry => {
@@ -668,31 +627,31 @@ function updateGitignore() {
668
627
  }
669
628
  }
670
629
  });
671
-
630
+
672
631
  // If there are missing entries, add them
673
632
  if (missingEntries.length > 0) {
674
633
  // Ensure there's a newline at the end if the file exists and is not empty
675
634
  if (fileExists && gitignoreContent.length > 0 && !gitignoreContent.endsWith('\n')) {
676
635
  gitignoreContent += '\n';
677
636
  }
678
-
637
+
679
638
  // If the file is not empty, add a blank line before
680
639
  if (gitignoreContent.length > 0) {
681
640
  gitignoreContent += '\n';
682
641
  }
683
-
642
+
684
643
  // Add the missing entries
685
644
  gitignoreContent += missingEntries.join('\n') + '\n';
686
-
645
+
687
646
  // Write the updated file
688
647
  fs.writeFileSync(gitignorePath, gitignoreContent);
689
-
648
+
690
649
  if (fileExists) {
691
650
  success('.gitignore updated with Claude entries');
692
651
  } else {
693
652
  success('.gitignore created with Claude entries');
694
653
  }
695
-
654
+
696
655
  // Show what was added
697
656
  missingEntries.forEach(entry => {
698
657
  if (!entry.startsWith('#')) {
@@ -707,22 +666,22 @@ function updateGitignore() {
707
666
  // Configure Git (line endings, etc.)
708
667
  function configureGit() {
709
668
  info('Configuring Git...');
710
-
669
+
711
670
  try {
712
- // Configure line endings for WSL
713
- execSync('git config core.autocrlf input', { stdio: 'ignore' });
714
- success('Line endings configured for WSL (core.autocrlf = input)');
715
-
716
- // Try to configure on Windows through PowerShell
717
- try {
718
- execSync('powershell.exe -Command "git config core.autocrlf true"', { stdio: 'ignore' });
671
+ // Configure line endings based on platform
672
+ // Why: CRLF/LF handling differs between Windows and Unix
673
+ if (isWindows()) {
674
+ // On Windows: Keep CRLF in working directory, convert to LF in repo
675
+ execSync('git config core.autocrlf true', { stdio: 'ignore' });
719
676
  success('Line endings configured for Windows (core.autocrlf = true)');
720
- } catch (psError) {
721
- info('Could not configure automatically on Windows');
677
+ } else {
678
+ // On Unix: Keep LF everywhere, convert CRLF to LF on commit
679
+ execSync('git config core.autocrlf input', { stdio: 'ignore' });
680
+ success('Line endings configured for Unix (core.autocrlf = input)');
722
681
  }
723
-
682
+
724
683
  } catch (e) {
725
- warning('Error configuring Git');
684
+ warning('Error configuring Git: ' + e.message);
726
685
  }
727
686
  }
728
687
 
@@ -736,7 +695,7 @@ function uninstall() {
736
695
 
737
696
  const hooksPath = '.git/hooks';
738
697
  const hooks = ['pre-commit', 'prepare-commit-msg'];
739
-
698
+
740
699
  hooks.forEach(hook => {
741
700
  const hookPath = path.join(hooksPath, hook);
742
701
  if (fs.existsSync(hookPath)) {
@@ -755,11 +714,11 @@ function enable(hookName) {
755
714
  }
756
715
 
757
716
  const hooks = hookName ? [hookName] : ['pre-commit', 'prepare-commit-msg'];
758
-
717
+
759
718
  hooks.forEach(hook => {
760
719
  const disabledPath = `.git/hooks/${hook}.disabled`;
761
720
  const enabledPath = `.git/hooks/${hook}`;
762
-
721
+
763
722
  if (fs.existsSync(disabledPath)) {
764
723
  fs.renameSync(disabledPath, enabledPath);
765
724
  success(`${hook} enabled`);
@@ -778,11 +737,11 @@ function disable(hookName) {
778
737
  }
779
738
 
780
739
  const hooks = hookName ? [hookName] : ['pre-commit', 'prepare-commit-msg'];
781
-
740
+
782
741
  hooks.forEach(hook => {
783
742
  const enabledPath = `.git/hooks/${hook}`;
784
743
  const disabledPath = `.git/hooks/${hook}.disabled`;
785
-
744
+
786
745
  if (fs.existsSync(enabledPath)) {
787
746
  fs.renameSync(enabledPath, disabledPath);
788
747
  success(`${hook} disabled`);
@@ -795,14 +754,14 @@ function disable(hookName) {
795
754
  }
796
755
 
797
756
  // Analyze-diff command
798
- function analyzeDiff(args) {
757
+ async function analyzeDiff(args) {
799
758
  if (!checkGitRepo()) {
800
759
  error('You are not in a Git repository.');
801
760
  return;
802
761
  }
803
762
 
804
763
  const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
805
-
764
+
806
765
  if (!currentBranch) {
807
766
  error('You are not in a valid branch.');
808
767
  return;
@@ -819,7 +778,7 @@ function analyzeDiff(args) {
819
778
  baseBranch = `origin/${targetBranch}`;
820
779
  compareWith = `${baseBranch}...HEAD`;
821
780
  contextDescription = `${currentBranch} vs ${baseBranch}`;
822
-
781
+
823
782
  // Check that the origin branch exists
824
783
  try {
825
784
  execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
@@ -832,7 +791,7 @@ function analyzeDiff(args) {
832
791
  baseBranch = `origin/${currentBranch}`;
833
792
  compareWith = `${baseBranch}...HEAD`;
834
793
  contextDescription = `${currentBranch} vs ${baseBranch}`;
835
-
794
+
836
795
  // Check that the origin branch exists
837
796
  try {
838
797
  execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
@@ -841,7 +800,7 @@ function analyzeDiff(args) {
841
800
  baseBranch = 'origin/develop';
842
801
  compareWith = `${baseBranch}...HEAD`;
843
802
  contextDescription = `${currentBranch} vs ${baseBranch} (fallback)`;
844
-
803
+
845
804
  try {
846
805
  execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
847
806
  warning(`Branch origin/${currentBranch} does not exist. Using ${baseBranch} as fallback.`);
@@ -850,7 +809,7 @@ function analyzeDiff(args) {
850
809
  baseBranch = 'origin/main';
851
810
  compareWith = `${baseBranch}...HEAD`;
852
811
  contextDescription = `${currentBranch} vs ${baseBranch} (fallback)`;
853
-
812
+
854
813
  try {
855
814
  execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
856
815
  warning(`No origin/develop branch. Using ${baseBranch} as fallback.`);
@@ -868,12 +827,12 @@ function analyzeDiff(args) {
868
827
  let diffFiles;
869
828
  try {
870
829
  diffFiles = execSync(`git diff ${compareWith} --name-only`, { encoding: 'utf8' }).trim();
871
-
830
+
872
831
  if (!diffFiles) {
873
832
  // Check if there are staged or unstaged changes
874
833
  const stagedFiles = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim();
875
834
  const unstagedFiles = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
876
-
835
+
877
836
  if (stagedFiles || unstagedFiles) {
878
837
  warning('No differences with remote, but you have uncommitted local changes.');
879
838
  console.log('Staged changes:', stagedFiles || 'none');
@@ -902,19 +861,26 @@ function analyzeDiff(args) {
902
861
  error('Error getting diff or commits: ' + e.message);
903
862
  return;
904
863
  }
905
-
906
- // Create the prompt for Claude
907
- const tempDir = `/tmp/claude-analyze-${Date.now()}`;
908
- fs.mkdirSync(tempDir, { recursive: true });
909
-
910
- const promptFile = path.join(tempDir, 'prompt.txt');
911
- const prompt = `Analyze the following changes. CONTEXT: ${contextDescription}
912
864
 
865
+ // Check if subagents should be used
866
+ const useSubagents = process.env.CLAUDE_USE_SUBAGENTS === 'true';
867
+ const subagentModel = process.env.CLAUDE_SUBAGENT_MODEL || 'haiku';
868
+ let subagentBatchSize = parseInt(process.env.CLAUDE_SUBAGENT_BATCH_SIZE || '3');
869
+ // Validate batch size (must be >= 1)
870
+ if (subagentBatchSize < 1) {
871
+ subagentBatchSize = 1;
872
+ }
873
+ const subagentInstruction = useSubagents
874
+ ? `\n\nIMPORTANT PARALLEL PROCESSING: If analyzing 3+ files, process them in batches of ${subagentBatchSize}. For EACH batch, create that many subagents in parallel using Task tool (send single message with multiple Task calls). Each subagent analyzes one file and provides insights. After ALL batches complete, consolidate into SINGLE JSON with ONE cohesive PR title/description. Model: ${subagentModel}. Example: 4 files with BATCH_SIZE=1 → 4 sequential batches of 1 subagent each. Example: 4 files with BATCH_SIZE=3 → batch 1 has 3 parallel subagents (files 1-3), batch 2 has 1 subagent (file 4).\n`
875
+ : '';
876
+
877
+ const prompt = `Analyze the following changes. CONTEXT: ${contextDescription}
878
+ ${subagentInstruction}
913
879
  Please generate:
914
880
  1. A concise and descriptive PR title (maximum 72 characters)
915
881
  2. A detailed PR description that includes:
916
882
  - Summary of changes
917
- - Motivation/context
883
+ - Motivation/context
918
884
  - Type of change (feature/fix/refactor/docs/etc)
919
885
  - Recommended testing
920
886
  3. A suggested branch name following the format: type/short-description (example: feature/add-user-auth, fix/memory-leak)
@@ -940,67 +906,53 @@ ${diffFiles}
940
906
  === FULL DIFF ===
941
907
  ${fullDiff.substring(0, 50000)} ${fullDiff.length > 50000 ? '\n... (truncated diff)' : ''}`;
942
908
 
943
- fs.writeFileSync(promptFile, prompt);
944
-
945
909
  info('Sending to Claude for analysis...');
946
-
910
+ const startTime = Date.now();
911
+
947
912
  try {
948
- const response = execSync(`claude < "${promptFile}"`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 10 });
949
-
950
- // Extraer el JSON de la respuesta
951
- const jsonMatch = response.match(/\{[\s\S]*\}/);
952
- if (!jsonMatch) {
953
- error('Did not receive a valid JSON response from Claude.');
954
- console.log('Complete response:', response);
955
- return;
956
- }
957
-
958
- let result;
959
- try {
960
- result = JSON.parse(jsonMatch[0]);
961
- } catch (e) {
962
- error('Error parsing JSON response: ' + e.message);
963
- console.log('JSON received:', jsonMatch[0]);
964
- return;
965
- }
966
-
913
+ // Use cross-platform executeClaude from claude-client.js
914
+ const response = await executeClaude(prompt, { timeout: 180000 }); // 3 minutes for diff analysis
915
+
916
+ // Extract JSON from response using claude-client utility
917
+ const result = extractJSON(response);
918
+
967
919
  // Show the results
968
920
  console.log('');
969
921
  console.log('════════════════════════════════════════════════════════════════');
970
922
  console.log(' DIFFERENCES ANALYSIS ');
971
923
  console.log('════════════════════════════════════════════════════════════════');
972
924
  console.log('');
973
-
925
+
974
926
  console.log(`🔍 ${colors.blue}Context:${colors.reset} ${contextDescription}`);
975
927
  console.log(`📊 ${colors.blue}Changed Files:${colors.reset} ${diffFiles.split('\n').length}`);
976
928
  console.log('');
977
-
929
+
978
930
  console.log(`📝 ${colors.green}Pull Request Title:${colors.reset}`);
979
931
  console.log(` ${result.prTitle}`);
980
932
  console.log('');
981
-
933
+
982
934
  console.log(`🌿 ${colors.green}Suggested branch name:${colors.reset}`);
983
935
  console.log(` ${result.suggestedBranchName}`);
984
936
  console.log('');
985
-
937
+
986
938
  console.log(`📋 ${colors.green}Type of change:${colors.reset} ${result.changeType}`);
987
-
939
+
988
940
  if (result.breakingChanges) {
989
941
  console.log(`⚠️ ${colors.yellow}Breaking Changes: SÍ${colors.reset}`);
990
942
  }
991
-
943
+
992
944
  console.log('');
993
945
  console.log(`📄 ${colors.green}Pull Request Description:${colors.reset}`);
994
946
  console.log('───────────────────────────────────────────────────────────────');
995
947
  console.log(result.prDescription);
996
948
  console.log('───────────────────────────────────────────────────────────────');
997
-
949
+
998
950
  if (result.testingNotes) {
999
951
  console.log('');
1000
952
  console.log(`🧪 ${colors.green}Testing notes:${colors.reset}`);
1001
953
  console.log(result.testingNotes);
1002
954
  }
1003
-
955
+
1004
956
  // Guardar los resultados en un archivo con contexto
1005
957
  const outputData = {
1006
958
  ...result,
@@ -1012,12 +964,17 @@ ${fullDiff.substring(0, 50000)} ${fullDiff.length > 50000 ? '\n... (truncated di
1012
964
  timestamp: new Date().toISOString()
1013
965
  }
1014
966
  };
1015
-
967
+
1016
968
  const outputFile = '.claude-pr-analysis.json';
1017
969
  fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2));
970
+
971
+ const elapsed = Date.now() - startTime;
972
+ const seconds = Math.floor(elapsed / 1000);
973
+ const ms = elapsed % 1000;
1018
974
  console.log('');
975
+ console.log(`${colors.blue}⏱️ Analysis completed in ${seconds}.${ms}s${colors.reset}`);
1019
976
  info(`Results saved in ${outputFile}`);
1020
-
977
+
1021
978
  // Sugerencias contextuales
1022
979
  console.log('');
1023
980
  if (!args[0] && contextDescription.includes('local changes without push')) {
@@ -1030,14 +987,11 @@ ${fullDiff.substring(0, 50000)} ${fullDiff.length > 50000 ? '\n... (truncated di
1030
987
  console.log(`💡 ${colors.yellow}For renaming your current branch:${colors.reset}`);
1031
988
  console.log(` git branch -m ${result.suggestedBranchName}`);
1032
989
  }
1033
-
990
+
1034
991
  console.log(`💡 ${colors.yellow}Tip:${colors.reset} Use this information to create your PR on GitHub.`);
1035
-
992
+
1036
993
  } catch (e) {
1037
994
  error('Error executing Claude: ' + e.message);
1038
- } finally {
1039
- // Clean temporary files
1040
- fs.rmSync(tempDir, { recursive: true, force: true });
1041
995
  }
1042
996
  }
1043
997
 
@@ -1053,7 +1007,7 @@ function status() {
1053
1007
  hooks.forEach(hook => {
1054
1008
  const enabledPath = `.git/hooks/${hook}`;
1055
1009
  const disabledPath = `.git/hooks/${hook}.disabled`;
1056
-
1010
+
1057
1011
  if (fs.existsSync(enabledPath)) {
1058
1012
  success(`${hook}: enabled`);
1059
1013
  } else if (fs.existsSync(disabledPath)) {
@@ -1084,7 +1038,7 @@ function status() {
1084
1038
  const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
1085
1039
  const claudeIgnores = ['.claude/', 'debug-claude-response.json', '.claude-pr-analysis.json'];
1086
1040
  let allPresent = true;
1087
-
1041
+
1088
1042
  claudeIgnores.forEach(entry => {
1089
1043
  const regex = new RegExp(`^${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm');
1090
1044
  if (regex.test(gitignoreContent)) {
@@ -1094,7 +1048,7 @@ function status() {
1094
1048
  allPresent = false;
1095
1049
  }
1096
1050
  });
1097
-
1051
+
1098
1052
  if (!allPresent) {
1099
1053
  info('\nRun "claude-hooks install" to update .gitignore');
1100
1054
  }
@@ -1116,12 +1070,12 @@ function compareVersions(v1, v2) {
1116
1070
  // Usar el script compartido para mantener consistencia
1117
1071
  const result = execSync(`bash -c 'source "${getTemplatesPath()}/check-version.sh" && compare_versions "${v1}" "${v2}"; echo $?'`, { encoding: 'utf8' }).trim();
1118
1072
  const exitCode = parseInt(result);
1119
-
1073
+
1120
1074
  // Convertir los códigos de retorno del script bash a valores JS
1121
1075
  if (exitCode === 0) return 0; // iguales
1122
1076
  if (exitCode === 1) return 1; // v1 > v2
1123
1077
  if (exitCode === 2) return -1; // v1 < v2
1124
-
1078
+
1125
1079
  // Fallback: comparación simple si el script falla
1126
1080
  if (v1 === v2) return 0;
1127
1081
  const sorted = [v1, v2].sort((a, b) => {
@@ -1145,13 +1099,13 @@ function compareVersions(v1, v2) {
1145
1099
  // Update command - update to the latest version
1146
1100
  async function update() {
1147
1101
  info('Checking latest available version...');
1148
-
1102
+
1149
1103
  try {
1150
- const currentVersion = require('../package.json').version;
1104
+ const currentVersion = getPackageJson().version;
1151
1105
  const latestVersion = await getLatestVersion('claude-git-hooks');
1152
-
1106
+
1153
1107
  const comparison = compareVersions(currentVersion, latestVersion);
1154
-
1108
+
1155
1109
  if (comparison === 0) {
1156
1110
  success(`You already have the latest version installed (${currentVersion})`);
1157
1111
  return;
@@ -1161,20 +1115,20 @@ async function update() {
1161
1115
  success(`You already have the latest version installed (${currentVersion})`);
1162
1116
  return;
1163
1117
  }
1164
-
1118
+
1165
1119
  info(`Current version: ${currentVersion}`);
1166
1120
  info(`Available version: ${latestVersion}`);
1167
-
1121
+
1168
1122
  // Actualizar el paquete
1169
1123
  info('Updating claude-git-hooks...');
1170
1124
  try {
1171
1125
  execSync('npm install -g claude-git-hooks@latest', { stdio: 'inherit' });
1172
1126
  success(`Successfully updated to version ${latestVersion}`);
1173
-
1127
+
1174
1128
  // Reinstall hooks with the new version
1175
1129
  info('Reinstalling hooks with the new version...');
1176
1130
  await install(['--force']);
1177
-
1131
+
1178
1132
  } catch (updateError) {
1179
1133
  error('Error updating. Try running: npm install -g claude-git-hooks@latest');
1180
1134
  }
@@ -1241,6 +1195,18 @@ Exclude code from analysis:
1241
1195
  ...excluded code...
1242
1196
  // SKIP_ANALYSIS_BLOCK
1243
1197
 
1198
+ Performance optimization (NEW in v1.5.5):
1199
+ export CLAUDE_USE_SUBAGENTS=true # Enable parallel analysis for 3+ files
1200
+ export CLAUDE_SUBAGENT_MODEL=haiku # Model: haiku (fast), sonnet, opus
1201
+ export CLAUDE_SUBAGENT_BATCH_SIZE=3 # Parallel subagents per batch (default: 3)
1202
+
1203
+ # Batching examples:
1204
+ # BATCH_SIZE=1 with 4 files → 4 sequential batches (1 subagent each)
1205
+ # BATCH_SIZE=3 with 4 files → 2 batches (3 parallel, then 1)
1206
+ # BATCH_SIZE=4 with 4 files → 1 batch (4 parallel subagents)
1207
+
1208
+ # Benefits: Faster for multi-file commits, shows execution time
1209
+
1244
1210
  More information: https://github.com/pablorovito/claude-git-hooks
1245
1211
  `);
1246
1212
  }
@@ -1270,7 +1236,7 @@ async function main() {
1270
1236
  status();
1271
1237
  break;
1272
1238
  case 'analyze-diff':
1273
- analyzeDiff(args.slice(1));
1239
+ await analyzeDiff(args.slice(1));
1274
1240
  break;
1275
1241
  case 'help':
1276
1242
  case '--help':