claude-git-hooks 2.9.1 → 2.10.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,2324 +1,111 @@
1
1
  #!/usr/bin/env node
2
2
 
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, executeClaudeWithRetry, extractJSON, analyzeCode } from '../lib/utils/claude-client.js';
12
- import { loadPrompt } from '../lib/utils/prompt-builder.js';
13
- import { listPresets } from '../lib/utils/preset-loader.js';
14
- import { getConfig } from '../lib/config.js';
15
- import { getOrPromptTaskId, formatWithTaskId } from '../lib/utils/task-id.js';
16
- import { createPullRequest, getReviewersForFiles, parseGitHubRepo, setupGitHubMcp, getGitHubMcpStatus } from '../lib/utils/github-client.js';
17
- import { showPRPreview, promptConfirmation, promptMenu, showSuccess, showError, showInfo, showWarning, showSpinner, promptEditField } from '../lib/utils/interactive-ui.js';
18
- import { setupGitHubMCP } from '../lib/utils/mcp-setup.js';
19
- import { displayStatistics as showTelemetryStats, clearTelemetry as clearTelemetryData } from '../lib/utils/telemetry.js';
20
- import logger from '../lib/utils/logger.js';
21
-
22
- // Why: ES6 modules don't have __dirname, need to recreate it
23
- const __filename = fileURLToPath(import.meta.url);
24
- const __dirname = dirname(__filename);
25
-
26
- // Helper to read package.json
27
- // Why: ES6 modules can't use require() for JSON files
28
- const getPackageJson = () => {
29
- const packagePath = path.join(__dirname, '..', 'package.json');
30
- return JSON.parse(fs.readFileSync(packagePath, 'utf8'));
31
- };
32
-
33
- // Function to get the latest version from NPM
34
- function getLatestVersion(packageName) {
35
- return new Promise((resolve, reject) => {
36
- // Use the main NPM API, not /latest
37
- https.get(`https://registry.npmjs.org/${packageName}`, (res) => {
38
- let data = '';
39
- res.on('data', chunk => data += chunk);
40
- res.on('end', () => {
41
- try {
42
- const json = JSON.parse(data);
43
- // Get the version from the 'latest' tag
44
- if (json['dist-tags'] && json['dist-tags'].latest) {
45
- resolve(json['dist-tags'].latest);
46
- } else {
47
- reject(new Error('Could not get the version'));
48
- }
49
- } catch (e) {
50
- // If it fails, try with npm view
51
- try {
52
- const version = execSync(`npm view ${packageName} version`, { encoding: 'utf8' }).trim();
53
- resolve(version);
54
- } catch (npmError) {
55
- reject(e);
56
- }
57
- }
58
- });
59
- }).on('error', reject);
60
- });
61
- }
62
-
63
- // Function to check version (used by hooks)
64
- async function checkVersionAndPromptUpdate() {
65
- try {
66
- const currentVersion = getPackageJson().version;
67
- const latestVersion = await getLatestVersion('claude-git-hooks');
68
-
69
- if (currentVersion === latestVersion) {
70
- return true; // Already updated
71
- }
72
-
73
- console.log('');
74
- warning(`New version available: ${latestVersion} (current: ${currentVersion})`);
75
-
76
- // Interactive prompt compatible with all consoles
77
- const rl = readline.createInterface({
78
- input: process.stdin,
79
- output: process.stdout
80
- });
81
-
82
- return new Promise((resolve) => {
83
- rl.question('Do you want to update now? (y/n): ', (answer) => {
84
- rl.close();
85
-
86
- if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
87
- info('Updating claude-git-hooks...');
88
- try {
89
- execSync('npm install -g claude-git-hooks@latest', { stdio: 'inherit' });
90
- success('Update completed. Please run your command again.');
91
- process.exit(0); // Exit so user restarts the process
92
- } catch (e) {
93
- error('Error updating: ' + e.message);
94
- resolve(false);
95
- }
96
- } else {
97
- info('Update postponed. You can update later with: claude-hooks update');
98
- resolve(true); // Continue without updating
99
- }
100
- });
101
- });
102
- } catch (e) {
103
- // If there's an error checking version, continue without blocking
104
- return true;
105
- }
106
- }
107
-
108
- // Colors for output
109
- const colors = {
110
- reset: '\x1b[0m',
111
- red: '\x1b[31m',
112
- green: '\x1b[32m',
113
- yellow: '\x1b[33m',
114
- blue: '\x1b[34m'
115
- };
116
-
117
- function log(message, color = 'reset') {
118
- console.log(`${colors[color]}${message}${colors.reset}`);
119
- }
120
-
121
- function error(message) {
122
- console.error(`${colors.red}❌ ${message}${colors.reset}`);
123
- process.exit(1);
124
- }
125
-
126
- function success(message) {
127
- log(`✅ ${message}`, 'green');
128
- }
129
-
130
- function info(message) {
131
- log(`ℹ️ ${message}`, 'blue');
132
- }
133
-
134
- function warning(message) {
135
- log(`⚠️ ${message}`, 'yellow');
136
- }
137
-
138
- // Entertainment system
139
- class Entertainment {
140
- static jokes = [
141
- "Why do programmers prefer dark mode? Because light attracts bugs!",
142
- "A QA engineer walks into a bar. Orders 1 beer. Orders 0 beers. Orders -1 beers.",
143
- "What's a pirate's favorite programming language? R!",
144
- "There are 10 types of people: those who understand binary and those who don't.",
145
- "Why do programmers confuse Halloween with Christmas? Because Oct 31 = Dec 25",
146
- "What does one bit say to another? See you on the bus!",
147
- "Why don't Java and C++ get along? Because they have different views on pointers.",
148
- "My code doesn't have bugs, just undocumented features."
149
- ];
150
-
151
- static async getJoke() {
152
- return new Promise((resolve) => {
153
- // Try to get joke from API
154
- const req = https.get('https://icanhazdadjoke.com/', {
155
- headers: { 'Accept': 'text/plain' },
156
- timeout: 3000
157
- }, (res) => {
158
- let data = '';
159
- res.on('data', chunk => data += chunk);
160
- res.on('end', () => resolve(data.trim()));
161
- });
162
-
163
- req.on('error', () => {
164
- // If it fails, use local joke
165
- const randomJoke = this.jokes[Math.floor(Math.random() * this.jokes.length)];
166
- resolve(randomJoke);
167
- });
168
-
169
- req.on('timeout', () => {
170
- req.abort();
171
- const randomJoke = this.jokes[Math.floor(Math.random() * this.jokes.length)];
172
- resolve(randomJoke);
173
- });
174
- });
175
- }
176
-
177
- static async showSpinner(promise, message = 'Processing') {
178
- const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
179
- let spinnerIndex = 0;
180
- let jokeCountdown = 10;
181
- let currentJoke = this.jokes[Math.floor(Math.random() * this.jokes.length)];
182
- let isFinished = false;
183
- let isFirstRender = true;
184
-
185
- // Get first joke from API without blocking
186
- this.getJoke().then(joke => {
187
- if (!isFinished) currentJoke = joke;
188
- }).catch(() => { }); // If it fails, keep the local one
189
-
190
- // Hide cursor
191
- process.stdout.write('\x1B[?25l');
192
-
193
- // Reserve space for the 3 lines
194
- process.stdout.write('\n\n\n');
195
-
196
- const interval = setInterval(() => {
197
- if (isFinished) {
198
- clearInterval(interval);
199
- return;
200
- }
201
-
202
- spinnerIndex++;
203
-
204
- // Update countdown every second (10 iterations of 100ms)
205
- if (spinnerIndex % 10 === 0) {
206
- jokeCountdown--;
207
-
208
- // Refresh joke every 10 seconds
209
- if (jokeCountdown <= 0) {
210
- this.getJoke().then(joke => {
211
- if (!isFinished) currentJoke = joke;
212
- }).catch(() => {
213
- if (!isFinished) {
214
- currentJoke = this.jokes[Math.floor(Math.random() * this.jokes.length)];
215
- }
216
- });
217
- jokeCountdown = 10;
218
- }
219
- }
220
-
221
- // Always go back exactly 3 lines up
222
- process.stdout.write('\x1B[3A');
223
-
224
- // Render the 3 lines from the beginning
225
- const spinner = spinners[spinnerIndex % spinners.length];
226
-
227
- // Line 1: Spinner
228
- process.stdout.write('\r\x1B[2K' + `${colors.yellow}${spinner} ${message}${colors.reset}\n`);
229
-
230
- // Line 2: Joke
231
- process.stdout.write('\r\x1B[2K' + `${colors.green}🎭 ${currentJoke}${colors.reset}\n`);
232
-
233
- // Line 3: Countdown
234
- process.stdout.write('\r\x1B[2K' + `${colors.yellow}⏱️ Next joke in: ${jokeCountdown}s${colors.reset}\n`);
235
- }, 100);
236
-
237
- try {
238
- const result = await promise;
239
- isFinished = true;
240
- clearInterval(interval);
241
-
242
- // Clean exactly 3 lines completely
243
- process.stdout.write('\x1B[3A'); // Go up 3 lines
244
- process.stdout.write('\r\x1B[2K'); // Clean line 1
245
- process.stdout.write('\n\r\x1B[2K'); // Go down and clean line 2
246
- process.stdout.write('\n\r\x1B[2K'); // Go down and clean line 3
247
- process.stdout.write('\x1B[2A'); // Go up 2 lines to end up on the first
248
- process.stdout.write('\r'); // Go to beginning of line
249
-
250
- // Show cursor
251
- process.stdout.write('\x1B[?25h');
252
-
253
- return result;
254
- } catch (error) {
255
- isFinished = true;
256
- clearInterval(interval);
257
-
258
- // Clean exactly 3 lines completely
259
- process.stdout.write('\x1B[3A'); // Go up 3 lines
260
- process.stdout.write('\r\x1B[2K'); // Clean line 1
261
- process.stdout.write('\n\r\x1B[2K'); // Go down and clean line 2
262
- process.stdout.write('\n\r\x1B[2K'); // Go down and clean line 3
263
- process.stdout.write('\x1B[2A'); // Go up 2 lines to end up on the first
264
- process.stdout.write('\r'); // Go to beginning of line
265
-
266
- // Show cursor
267
- process.stdout.write('\x1B[?25h');
268
-
269
- throw error;
270
- }
271
- }
272
- }
273
-
274
- // Check if we are in a git repository (including worktrees created in PowerShell)
275
- function checkGitRepo() {
276
- try {
277
- execSync('git rev-parse --git-dir', { stdio: 'ignore' });
278
- return true;
279
- } catch (e) {
280
- // Try to detect worktree created in PowerShell
281
- try {
282
- if (fs.existsSync('.git')) {
283
- const gitContent = fs.readFileSync('.git', 'utf8').trim();
284
- // Check if it's a worktree pointer (gitdir: ...)
285
- if (gitContent.startsWith('gitdir:')) {
286
- let gitdir = gitContent.substring(8).trim();
287
- // Convert Windows path to WSL if needed (C:\ -> /mnt/c/)
288
- if (/^[A-Za-z]:/.test(gitdir)) {
289
- gitdir = gitdir.replace(/^([A-Za-z]):/, (_, drive) => `/mnt/${drive.toLowerCase()}`);
290
- gitdir = gitdir.replace(/\\/g, '/');
291
- }
292
- // Verify the gitdir exists
293
- if (fs.existsSync(gitdir)) {
294
- return true;
295
- }
296
- }
297
- }
298
- } catch (worktreeError) {
299
- // Ignore worktree detection errors
300
- }
301
- return false;
302
- }
303
- }
304
-
305
- // Get the templates path
306
- function getTemplatesPath() {
307
- return path.join(__dirname, '..', 'templates');
308
- }
309
-
310
- // Install command
311
- async function install(args) {
312
- if (!checkGitRepo()) {
313
- error('You are not in a Git repository. Please run this command from the root of a repository.');
314
- }
315
-
316
- const isForce = args.includes('--force');
317
- const skipAuth = args.includes('--skip-auth');
318
-
319
- // Check for updates (unless --skip-auth flag)
320
- if (!skipAuth && !isForce) {
321
- await checkVersionAndPromptUpdate();
322
- }
323
-
324
- if (isForce) {
325
- info('Installing Claude Git Hooks (force mode)...');
326
- } else {
327
- info('Installing Claude Git Hooks...');
328
- }
329
-
330
- // v2.0.0+: No sudo needed (pure Node.js, no system packages required)
331
- // Check dependencies
332
- await checkAndInstallDependencies(null, skipAuth);
333
-
334
- const templatesPath = getTemplatesPath();
335
- const hooksPath = '.git/hooks';
336
-
337
- // Create hooks directory if it doesn't exist
338
- if (!fs.existsSync(hooksPath)) {
339
- fs.mkdirSync(hooksPath, { recursive: true });
340
- }
341
-
342
- // Helper function to copy file with LF line endings
343
- // Why: Bash scripts must have LF (Unix) line endings, not CRLF (Windows)
344
- const copyWithLF = (sourcePath, destPath) => {
345
- let content = fs.readFileSync(sourcePath, 'utf8');
346
- // Convert CRLF to LF
347
- content = content.replace(/\r\n/g, '\n');
348
- fs.writeFileSync(destPath, content, 'utf8');
349
- };
350
-
351
- // Hooks to install
352
- const hooks = ['pre-commit', 'prepare-commit-msg'];
353
-
354
- hooks.forEach(hook => {
355
- const sourcePath = path.join(templatesPath, hook);
356
- const destPath = path.join(hooksPath, hook);
357
-
358
- // Make backup if it exists
359
- if (fs.existsSync(destPath)) {
360
- const backupPath = `${destPath}.backup.${Date.now()}`;
361
- fs.copyFileSync(destPath, backupPath);
362
- info(`Backup created: ${backupPath}`);
363
- }
364
-
365
- // Copy hook with LF line endings (critical for bash)
366
- copyWithLF(sourcePath, destPath);
367
- fs.chmodSync(destPath, '755');
368
- success(`${hook} installed`);
369
- });
370
-
371
- // Copy version verification script with LF line endings
372
- const checkVersionSource = path.join(templatesPath, 'check-version.sh');
373
- const checkVersionDest = path.join(hooksPath, 'check-version.sh');
374
-
375
- if (fs.existsSync(checkVersionSource)) {
376
- copyWithLF(checkVersionSource, checkVersionDest);
377
- fs.chmodSync(checkVersionDest, '755');
378
- success('Version verification script installed');
379
- }
380
-
381
- // Create .claude directory if it doesn't exist
382
- const claudeDir = '.claude';
383
- if (!fs.existsSync(claudeDir)) {
384
- fs.mkdirSync(claudeDir, { recursive: true });
385
- success('.claude directory created');
386
- }
387
-
388
- // Remove old SONAR template files if they exist (migration from v2.6.x to v2.7.0+)
389
- const oldSonarFiles = [
390
- 'CLAUDE_PRE_COMMIT_SONAR.md',
391
- 'CLAUDE_ANALYSIS_PROMPT_SONAR.md'
392
- ];
393
-
394
- oldSonarFiles.forEach(oldFile => {
395
- const oldPath = path.join(claudeDir, oldFile);
396
- if (fs.existsSync(oldPath)) {
397
- fs.unlinkSync(oldPath);
398
- info(`Removed old template: ${oldFile}`);
399
- }
400
- });
401
-
402
- // Create .claude/prompts directory for markdown templates
403
- const promptsDir = path.join(claudeDir, 'prompts');
404
- if (!fs.existsSync(promptsDir)) {
405
- fs.mkdirSync(promptsDir, { recursive: true });
406
- success('.claude/prompts directory created');
407
- }
408
-
409
- // Copy template files (.md and .json) to appropriate locations
410
- const templateFiles = fs.readdirSync(templatesPath)
411
- .filter(file => {
412
- const filePath = path.join(templatesPath, file);
413
- // Exclude example.json files and only include .md and .json files
414
- return fs.statSync(filePath).isFile() &&
415
- (file.endsWith('.md') || file.endsWith('.json')) &&
416
- !file.includes('example.json');
417
- });
418
-
419
- templateFiles.forEach(file => {
420
- const sourcePath = path.join(templatesPath, file);
421
- let destPath;
422
- let destLocation;
423
-
424
- // .md files go to .claude/prompts/, .json files go to .claude/
425
- if (file.endsWith('.md')) {
426
- destPath = path.join(promptsDir, file);
427
- destLocation = '.claude/prompts/';
428
- } else {
429
- destPath = path.join(claudeDir, file);
430
- destLocation = '.claude/';
431
- }
432
-
433
- // In force mode or if it doesn't exist, copy the file
434
- if (isForce || !fs.existsSync(destPath)) {
435
- if (fs.existsSync(sourcePath)) {
436
- fs.copyFileSync(sourcePath, destPath);
437
- success(`${file} installed in ${destLocation}`);
438
- }
439
- } else {
440
- info(`${file} already exists (skipped)`);
441
- }
442
- });
443
-
444
- // Clean up old .md files from .claude/ root (v2.8.0 migration)
445
- // .md files should now be in .claude/prompts/, not .claude/
446
- const oldMdFiles = fs.readdirSync(claudeDir)
447
- .filter(file => {
448
- const filePath = path.join(claudeDir, file);
449
- return fs.statSync(filePath).isFile() && file.endsWith('.md');
450
- });
451
-
452
- if (oldMdFiles.length > 0) {
453
- oldMdFiles.forEach(file => {
454
- const oldPath = path.join(claudeDir, file);
455
- fs.unlinkSync(oldPath);
456
- info(`Removed old template from .claude/: ${file} (now in prompts/)`);
457
- });
458
- }
459
-
460
- // Copy presets directory structure
461
- const presetsSourcePath = path.join(templatesPath, 'presets');
462
- const presetsDestPath = path.join(claudeDir, 'presets');
463
-
464
- if (fs.existsSync(presetsSourcePath)) {
465
- // Create presets directory in .claude
466
- if (!fs.existsSync(presetsDestPath)) {
467
- fs.mkdirSync(presetsDestPath, { recursive: true });
468
- }
469
-
470
- // Copy each preset directory
471
- const presetDirs = fs.readdirSync(presetsSourcePath)
472
- .filter(item => fs.statSync(path.join(presetsSourcePath, item)).isDirectory());
473
-
474
- presetDirs.forEach(presetName => {
475
- const presetSource = path.join(presetsSourcePath, presetName);
476
- const presetDest = path.join(presetsDestPath, presetName);
477
-
478
- // Create preset directory
479
- if (!fs.existsSync(presetDest)) {
480
- fs.mkdirSync(presetDest, { recursive: true });
481
- }
482
-
483
- // Copy all files in preset directory
484
- const presetFiles = fs.readdirSync(presetSource);
485
- presetFiles.forEach(file => {
486
- const sourceFile = path.join(presetSource, file);
487
- const destFile = path.join(presetDest, file);
488
-
489
- if (fs.statSync(sourceFile).isFile()) {
490
- if (isForce || !fs.existsSync(destFile)) {
491
- fs.copyFileSync(sourceFile, destFile);
492
- }
493
- }
494
- });
495
- });
496
-
497
- success(`${presetDirs.length} presets installed in .claude/presets/`);
498
- }
499
-
500
- // Special handling for config.json (v2.8.0+): backup old, create new simplified
501
- const configPath = path.join(claudeDir, 'config.json');
502
- const configOldDir = path.join(claudeDir, 'config_old');
503
- const configExampleDir = path.join(claudeDir, 'config_example');
504
-
505
- // Create config_old directory if needed
506
- if (!fs.existsSync(configOldDir)) {
507
- fs.mkdirSync(configOldDir, { recursive: true });
508
- }
509
-
510
- // Create config_example directory
511
- if (!fs.existsSync(configExampleDir)) {
512
- fs.mkdirSync(configExampleDir, { recursive: true });
513
- }
514
-
515
- // Copy example configs to config_example/ directly from templates/
516
- const exampleConfigs = ['config.example.json', 'config.advanced.example.json'];
517
- exampleConfigs.forEach(exampleFile => {
518
- const sourcePath = path.join(templatesPath, exampleFile);
519
- const destPath = path.join(configExampleDir, exampleFile);
520
- if (fs.existsSync(sourcePath)) {
521
- fs.copyFileSync(sourcePath, destPath);
522
- }
523
- });
524
- success('Example configs installed in .claude/config_example/');
525
-
526
- // Backup existing config if it exists (legacy format migration)
527
- let needsMigration = false;
528
- if (fs.existsSync(configPath)) {
529
- const backupPath = path.join(configOldDir, `config.json.${Date.now()}`);
530
- fs.copyFileSync(configPath, backupPath);
531
- info(`Existing config backed up: ${backupPath}`);
532
-
533
- // Read old config to check if it's legacy format
534
- const oldConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
535
- if (!oldConfig.version || oldConfig.version !== '2.8.0') {
536
- warning('Legacy config detected - will be replaced with v2.8.0 format');
537
- needsMigration = true;
538
-
539
- // Delete old config to force new format
540
- fs.unlinkSync(configPath);
541
- } else {
542
- info('Config already in v2.8.0 format - keeping existing file');
543
- }
544
- }
545
-
546
- // Create new config.json from minimal example if it doesn't exist
547
- if (!fs.existsSync(configPath)) {
548
- // Read example and extract minimal config
549
- const examplePath = path.join(configExampleDir, 'config.example.json');
550
- const exampleContent = fs.readFileSync(examplePath, 'utf8');
551
- const exampleJson = JSON.parse(exampleContent);
552
-
553
- // Create minimal config: just version and preset
554
- const minimalConfig = {
555
- version: exampleJson.version,
556
- preset: exampleJson.preset
557
- };
558
-
559
- fs.writeFileSync(configPath, JSON.stringify(minimalConfig, null, 4));
560
- success('config.json created with minimal v2.8.0 format');
561
- info('📝 Customize: .claude/config.json (see config_example/ for examples)');
562
-
563
- // Auto-run migration if needed to preserve settings
564
- if (needsMigration) {
565
- info('🔄 Auto-migrating settings from backup...');
566
- await autoMigrateConfig(configPath, path.join(configOldDir, fs.readdirSync(configOldDir).sort().pop()));
567
- }
568
- }
569
-
570
- // Create settings.local.json for sensitive data (gitignored)
571
- const settingsLocalPath = path.join(claudeDir, 'settings.local.json');
572
- if (!fs.existsSync(settingsLocalPath)) {
573
- const settingsLocalContent = {
574
- "_comment": "Local settings - DO NOT COMMIT. This file is gitignored.",
575
- "githubToken": ""
576
- };
577
- fs.writeFileSync(settingsLocalPath, JSON.stringify(settingsLocalContent, null, 2));
578
- info('settings.local.json created (add your GitHub token here)');
579
- }
580
-
581
- // Configure Git
582
- configureGit();
583
-
584
- // Update .gitignore
585
- updateGitignore();
586
-
587
- success('Claude Git Hooks installed successfully! 🎉');
588
- console.log('\nUsage:');
589
- console.log(' git commit -m "auto" # Generate message automatically');
590
- console.log(' git commit -m "message" # Analyze code before commit');
591
- console.log(' git commit --no-verify # Skip analysis completely');
592
- console.log('\n💡 Configuration (v2.8.0):');
593
- console.log(' 📁 All templates installed in .claude/');
594
- console.log(' 📝 Edit .claude/config.json (minimal by default)');
595
- console.log(' 📂 Examples: .claude/config_example/');
596
- console.log(' 📦 Backups: .claude/config_old/');
597
- console.log(' 🎯 Presets: backend, frontend, fullstack, database, ai, default');
598
- console.log(' 🚀 Parallel analysis enabled by default (hardcoded)');
599
- console.log(' 🐛 Debug mode: claude-hooks --debug true');
600
- console.log('\n🔗 GitHub PR Creation (v2.5.0+):');
601
- console.log(' claude-hooks setup-github # Configure GitHub token');
602
- console.log(' claude-hooks create-pr main # Create PR with auto-metadata');
603
- console.log('\n📖 Minimal config.json (v2.8.0):');
604
- console.log(' {');
605
- console.log(' "version": "2.8.0",');
606
- console.log(' "preset": "backend"');
607
- console.log(' }');
608
- console.log('\n📖 With GitHub customization:');
609
- console.log(' {');
610
- console.log(' "version": "2.8.0",');
611
- console.log(' "preset": "backend",');
612
- console.log(' "overrides": {');
613
- console.log(' "github": { "pr": { "reviewers": ["your-username"] } }');
614
- console.log(' }');
615
- console.log(' }');
616
- console.log('\n🔧 Advanced: see .claude/config_example/config.advanced.example.json');
617
- console.log('\nFor more options: claude-hooks --help');
618
- }
619
-
620
- // Check complete dependencies (like setup-wsl.sh)
621
- async function checkAndInstallDependencies(sudoPassword = null, skipAuth = false) {
622
- info('Checking system dependencies...');
623
-
624
- // Check Node.js
625
- try {
626
- const nodeVersion = execSync('node --version', { encoding: 'utf8' }).trim();
627
- success(`Node.js ${nodeVersion}`);
628
- } catch (e) {
629
- error('Node.js is not installed. Install Node.js and try again.');
630
- }
631
-
632
- // Check npm
633
- try {
634
- const npmVersion = execSync('npm --version', { encoding: 'utf8' }).trim();
635
- success(`npm ${npmVersion}`);
636
- } catch (e) {
637
- error('npm is not installed.');
638
- }
639
-
640
- // v2.0.0+: jq and curl are no longer needed (pure Node.js implementation)
641
-
642
- // Check Git
643
- try {
644
- const gitVersion = execSync('git --version', { encoding: 'utf8' }).trim();
645
- success(`${gitVersion}`);
646
- } catch (e) {
647
- error('Git is not installed. Install Git and try again.');
648
- }
649
-
650
- // v2.0.0+: Unix tools (sed, awk, grep, etc.) no longer needed (pure Node.js implementation)
651
-
652
- // Check and install Claude CLI (skip if --skip-auth)
653
- if (!skipAuth) {
654
- await checkAndInstallClaude();
655
- await checkClaudeAuth();
656
- } else {
657
- warning('Skipping Claude CLI verification and authentication (--skip-auth)');
658
- }
659
-
660
- // Clear password from memory
661
- sudoPassword = null;
662
- }
663
-
664
- // Detect if running on Windows
665
- // Why: Need to use 'wsl claude' instead of 'claude' on Windows
666
- function isWindows() {
667
- return os.platform() === 'win32' || process.env.OS === 'Windows_NT';
668
- }
669
-
670
- // Get Claude command based on platform
671
- // Why: On Windows, try native Claude first, then WSL as fallback
672
- function getClaudeCommand() {
673
- if (isWindows()) {
674
- // Try native Windows Claude first
675
- try {
676
- execSync('claude --version', { stdio: 'ignore', timeout: 3000 });
677
- return 'claude';
678
- } catch (e) {
679
- // Fallback to WSL
680
- return 'wsl claude';
681
- }
682
- }
683
- return 'claude';
684
- }
685
-
686
- // Check if we need to install dependencies
687
- async function checkIfInstallationNeeded() {
688
- // v2.0.0+: Only check Claude CLI (jq and curl no longer needed)
689
- const claudeCmd = getClaudeCommand();
690
-
691
- try {
692
- execSync(`${claudeCmd} --version`, { stdio: 'ignore' });
693
- } catch (e) {
694
- return true; // Needs Claude installation
695
- }
696
-
697
- return false;
698
- }
699
-
700
- // Check Claude CLI availability
701
- async function checkAndInstallClaude() {
702
- const claudeCmd = getClaudeCommand();
703
- const platform = isWindows() ? 'Windows (via WSL)' : os.platform();
704
-
705
- try {
706
- execSync(`${claudeCmd} --version`, { stdio: 'ignore' });
707
- success(`Claude CLI detected (${platform})`);
708
- } catch (e) {
709
- error(`Claude CLI not detected on ${platform}`);
710
-
711
- if (isWindows()) {
712
- console.log('\n⚠️ On Windows, Claude CLI must be installed in WSL:');
713
- console.log('1. Open WSL terminal (wsl or Ubuntu from Start Menu)');
714
- console.log('2. Follow installation at: https://docs.anthropic.com/claude/docs/claude-cli');
715
- console.log('3. Verify with: wsl claude --version');
716
- } else {
717
- console.log('\nClaude CLI installation: https://docs.anthropic.com/claude/docs/claude-cli');
718
- }
719
-
720
- console.log('\nAfter installation, run: claude-hooks install --force');
721
- process.exit(1);
722
- }
723
- }
724
-
725
- // Check Claude authentication with entertainment
726
- async function checkClaudeAuth() {
727
- info('Checking Claude authentication...');
728
-
729
- // Get correct Claude command for platform
730
- const claudeCmd = getClaudeCommand();
731
- const cmdParts = claudeCmd.split(' ');
732
- const command = cmdParts[0];
733
- const args = [...cmdParts.slice(1), 'auth', 'status'];
734
-
735
- // Use spawn to not block, but with stdio: 'ignore' like the original
736
- const authPromise = new Promise((resolve, reject) => {
737
- const child = spawn(command, args, {
738
- stdio: 'ignore', // Igual que el original
739
- detached: false,
740
- windowsHide: true
741
- });
742
-
743
- // Manual timeout since spawn doesn't have native timeout
744
- const timeout = setTimeout(() => {
745
- child.kill();
746
- reject(new Error('timeout'));
747
- }, 120000); // 2 minutos
748
-
749
- child.on('exit', (code) => {
750
- clearTimeout(timeout);
751
- if (code === 0) {
752
- resolve('success');
753
- } else {
754
- reject(new Error('not_authenticated'));
755
- }
756
- });
757
-
758
- child.on('error', (err) => {
759
- clearTimeout(timeout);
760
- reject(err);
761
- });
762
- });
763
-
764
- try {
765
- await Entertainment.showSpinner(authPromise, 'Checking Claude authentication');
766
- success('Authenticated in Claude');
767
- } catch (e) {
768
- warning('You are not authenticated in Claude');
769
- console.log('Run: claude auth login');
770
- console.log('Then run this command again');
771
- }
772
- }
773
-
774
- // Update .gitignore with Claude entries
775
- function updateGitignore() {
776
- info('Updating .gitignore...');
777
-
778
- const gitignorePath = '.gitignore';
779
- const claudeEntries = [
780
- '# Claude Git Hooks (includes .claude/settings.local.json for tokens)',
781
- '.claude/',
782
- ];
783
-
784
- let gitignoreContent = '';
785
- let fileExists = false;
786
-
787
- // Read existing .gitignore if it exists
788
- if (fs.existsSync(gitignorePath)) {
789
- gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
790
- fileExists = true;
791
- }
792
-
793
- // Check which entries are missing
794
- const missingEntries = [];
795
- claudeEntries.forEach(entry => {
796
- if (entry.startsWith('#')) {
797
- // For comments, check if any Claude comment already exists
798
- if (!gitignoreContent.includes('# Claude')) {
799
- missingEntries.push(entry);
800
- }
801
- } else {
802
- // For normal entries, check if they are already present
803
- const regex = new RegExp(`^${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm');
804
- if (!regex.test(gitignoreContent)) {
805
- missingEntries.push(entry);
806
- }
807
- }
808
- });
809
-
810
- // If there are missing entries, add them
811
- if (missingEntries.length > 0) {
812
- // Ensure there's a newline at the end if the file exists and is not empty
813
- if (fileExists && gitignoreContent.length > 0 && !gitignoreContent.endsWith('\n')) {
814
- gitignoreContent += '\n';
815
- }
816
-
817
- // If the file is not empty, add a blank line before
818
- if (gitignoreContent.length > 0) {
819
- gitignoreContent += '\n';
820
- }
821
-
822
- // Add the missing entries
823
- gitignoreContent += missingEntries.join('\n') + '\n';
824
-
825
- // Write the updated file
826
- fs.writeFileSync(gitignorePath, gitignoreContent);
827
-
828
- if (fileExists) {
829
- success('.gitignore updated with Claude entries');
830
- } else {
831
- success('.gitignore created with Claude entries');
832
- }
833
-
834
- // Show what was added
835
- missingEntries.forEach(entry => {
836
- if (!entry.startsWith('#')) {
837
- info(` + ${entry}`);
838
- }
839
- });
840
- } else {
841
- info('.gitignore already contains all necessary entries');
842
- }
843
- }
844
-
845
- // Configure Git (line endings, etc.)
846
- function configureGit() {
847
- info('Configuring Git...');
848
-
849
- try {
850
- // Configure line endings based on platform
851
- // Why: CRLF/LF handling differs between Windows and Unix
852
- if (isWindows()) {
853
- // On Windows: Keep CRLF in working directory, convert to LF in repo
854
- execSync('git config core.autocrlf true', { stdio: 'ignore' });
855
- success('Line endings configured for Windows (core.autocrlf = true)');
856
- } else {
857
- // On Unix: Keep LF everywhere, convert CRLF to LF on commit
858
- execSync('git config core.autocrlf input', { stdio: 'ignore' });
859
- success('Line endings configured for Unix (core.autocrlf = input)');
860
- }
861
-
862
- } catch (e) {
863
- warning('Error configuring Git: ' + e.message);
864
- }
865
- }
866
-
867
- // Uninstall command
868
- function uninstall() {
869
- if (!checkGitRepo()) {
870
- error('You are not in a Git repository.');
871
- }
872
-
873
- info('Uninstalling Claude Git Hooks...');
874
-
875
- const hooksPath = '.git/hooks';
876
- const hooks = ['pre-commit', 'prepare-commit-msg'];
877
-
878
- hooks.forEach(hook => {
879
- const hookPath = path.join(hooksPath, hook);
880
- if (fs.existsSync(hookPath)) {
881
- fs.unlinkSync(hookPath);
882
- success(`${hook} removed`);
883
- }
884
- });
885
-
886
- success('Claude Git Hooks uninstalled');
887
- }
888
-
889
- // Enable command
890
- function enable(hookName) {
891
- if (!checkGitRepo()) {
892
- error('You are not in a Git repository.');
893
- }
894
-
895
- const hooks = hookName ? [hookName] : ['pre-commit', 'prepare-commit-msg'];
896
-
897
- hooks.forEach(hook => {
898
- const disabledPath = `.git/hooks/${hook}.disabled`;
899
- const enabledPath = `.git/hooks/${hook}`;
900
-
901
- if (fs.existsSync(disabledPath)) {
902
- fs.renameSync(disabledPath, enabledPath);
903
- success(`${hook} enabled`);
904
- } else if (fs.existsSync(enabledPath)) {
905
- info(`${hook} is already enabled`);
906
- } else {
907
- warning(`${hook} not found`);
908
- }
909
- });
910
- }
911
-
912
- // Disable command
913
- function disable(hookName) {
914
- if (!checkGitRepo()) {
915
- error('You are not in a Git repository.');
916
- }
917
-
918
- const hooks = hookName ? [hookName] : ['pre-commit', 'prepare-commit-msg'];
919
-
920
- hooks.forEach(hook => {
921
- const enabledPath = `.git/hooks/${hook}`;
922
- const disabledPath = `.git/hooks/${hook}.disabled`;
923
-
924
- if (fs.existsSync(enabledPath)) {
925
- fs.renameSync(enabledPath, disabledPath);
926
- success(`${hook} disabled`);
927
- } else if (fs.existsSync(disabledPath)) {
928
- info(`${hook} is already disabled`);
929
- } else {
930
- warning(`${hook} not found`);
931
- }
932
- });
933
- }
934
-
935
- // Analyze-diff command
936
- async function analyzeDiff(args) {
937
- if (!checkGitRepo()) {
938
- error('You are not in a Git repository.');
939
- return;
940
- }
941
-
942
- // Load configuration
943
- const config = await getConfig();
944
-
945
- const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
946
-
947
- if (!currentBranch) {
948
- error('You are not in a valid branch.');
949
- return;
950
- }
951
-
952
- // Update remote references
953
- execSync('git fetch', { stdio: 'ignore' });
954
-
955
- let baseBranch, compareWith, contextDescription;
956
-
957
- if (args[0]) {
958
- // Case with argument: compare current branch vs origin/specified-branch
959
- const targetBranch = args[0];
960
- baseBranch = `origin/${targetBranch}`;
961
- compareWith = `${baseBranch}...HEAD`;
962
- contextDescription = `${currentBranch} vs ${baseBranch}`;
963
-
964
- // Check that the origin branch exists
965
- try {
966
- execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
967
- } catch (e) {
968
- error(`Branch ${baseBranch} does not exist.`);
969
- return;
970
- }
971
- } else {
972
- // Case without argument: compare current branch vs origin/current-branch
973
- baseBranch = `origin/${currentBranch}`;
974
- compareWith = `${baseBranch}...HEAD`;
975
- contextDescription = `${currentBranch} vs ${baseBranch}`;
976
-
977
- // Check that the origin branch exists
978
- try {
979
- execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
980
- } catch (e) {
981
- // Try fallback to origin/develop
982
- baseBranch = 'origin/develop';
983
- compareWith = `${baseBranch}...HEAD`;
984
- contextDescription = `${currentBranch} vs ${baseBranch} (fallback)`;
985
-
986
- try {
987
- execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
988
- warning(`Branch origin/${currentBranch} does not exist. Using ${baseBranch} as fallback.`);
989
- } catch (e2) {
990
- // Try fallback to origin/main
991
- baseBranch = 'origin/main';
992
- compareWith = `${baseBranch}...HEAD`;
993
- contextDescription = `${currentBranch} vs ${baseBranch} (fallback)`;
994
-
995
- try {
996
- execSync(`git rev-parse --verify ${baseBranch}`, { stdio: 'ignore' });
997
- warning(`No origin/develop branch. Using ${baseBranch} as fallback.`);
998
- } catch (e3) {
999
- error('Could not find a valid comparison branch (tried origin/current, origin/develop, origin/main).');
1000
- return;
1001
- }
1002
- }
1003
- }
1004
- }
1005
-
1006
- info(`Analyzing: ${contextDescription}...`);
1007
-
1008
- // Get modified files
1009
- let diffFiles;
1010
- try {
1011
- diffFiles = execSync(`git diff ${compareWith} --name-only`, { encoding: 'utf8' }).trim();
1012
-
1013
- if (!diffFiles) {
1014
- // Check if there are staged or unstaged changes
1015
- const stagedFiles = execSync('git diff --cached --name-only', { encoding: 'utf8' }).trim();
1016
- const unstagedFiles = execSync('git diff --name-only', { encoding: 'utf8' }).trim();
1017
-
1018
- if (stagedFiles || unstagedFiles) {
1019
- warning('No differences with remote, but you have uncommitted local changes.');
1020
- console.log('Staged changes:', stagedFiles || 'none');
1021
- console.log('Unstaged changes:', unstagedFiles || 'none');
1022
- } else {
1023
- success('✅ No differences. Your branch is synchronized.');
1024
- }
1025
- return;
1026
- }
1027
- } catch (e) {
1028
- error('Error getting differences: ' + e.message);
1029
- return;
1030
- }
1031
-
1032
- // Get the complete diff
1033
- let fullDiff, commits;
1034
- try {
1035
- if (!args[0] && compareWith.includes('HEAD..')) {
1036
- fullDiff = execSync(`git diff ${compareWith}`, { encoding: 'utf8' });
1037
- commits = execSync(`git log ${baseBranch}..HEAD --oneline`, { encoding: 'utf8' }).trim();
1038
- } else {
1039
- fullDiff = execSync(`git diff ${compareWith}`, { encoding: 'utf8' });
1040
- commits = execSync(`git log ${baseBranch}..HEAD --oneline`, { encoding: 'utf8' }).trim();
1041
- }
1042
- } catch (e) {
1043
- error('Error getting diff or commits: ' + e.message);
1044
- return;
1045
- }
1046
-
1047
- // Check if subagents should be used
1048
- const useSubagents = config.subagents.enabled;
1049
- const subagentModel = config.subagents.model;
1050
- let subagentBatchSize = config.subagents.batchSize;
1051
- // Validate batch size (must be >= 1)
1052
- if (subagentBatchSize < 1) {
1053
- subagentBatchSize = 1;
1054
- }
1055
- const subagentInstruction = useSubagents
1056
- ? `\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`
1057
- : '';
1058
-
1059
- // Truncate full diff if too large
1060
- const truncatedDiff = fullDiff.length > 50000
1061
- ? fullDiff.substring(0, 50000) + '\n... (truncated diff)'
1062
- : fullDiff;
1063
-
1064
- // Load prompt from template
1065
- const prompt = await loadPrompt('ANALYZE_DIFF.md', {
1066
- CONTEXT_DESCRIPTION: contextDescription,
1067
- SUBAGENT_INSTRUCTION: subagentInstruction,
1068
- COMMITS: commits,
1069
- DIFF_FILES: diffFiles,
1070
- FULL_DIFF: truncatedDiff
1071
- });
1072
-
1073
- info('Sending to Claude for analysis...');
1074
- const startTime = Date.now();
1075
-
1076
- // Prepare telemetry context
1077
- const filesChanged = diffFiles.split('\n').length;
1078
- const telemetryContext = {
1079
- fileCount: filesChanged,
1080
- batchSize: filesChanged,
1081
- totalBatches: 1,
1082
- model: subagentModel || 'sonnet',
1083
- hook: 'analyze-diff'
1084
- };
1085
-
1086
- try {
1087
- // Use cross-platform executeClaudeWithRetry from claude-client.js with telemetry
1088
- const response = await executeClaudeWithRetry(prompt, {
1089
- timeout: 180000, // 3 minutes for diff analysis
1090
- telemetryContext
1091
- });
1092
-
1093
- // Extract JSON from response using claude-client utility
1094
- const result = extractJSON(response);
1095
-
1096
- // Show the results
1097
- console.log('');
1098
- console.log('════════════════════════════════════════════════════════════════');
1099
- console.log(' DIFFERENCES ANALYSIS ');
1100
- console.log('════════════════════════════════════════════════════════════════');
1101
- console.log('');
1102
-
1103
- console.log(`🔍 ${colors.blue}Context:${colors.reset} ${contextDescription}`);
1104
- console.log(`📊 ${colors.blue}Changed Files:${colors.reset} ${diffFiles.split('\n').length}`);
1105
- console.log('');
1106
-
1107
- console.log(`📝 ${colors.green}Pull Request Title:${colors.reset}`);
1108
- console.log(` ${result.prTitle}`);
1109
- console.log('');
1110
-
1111
- console.log(`🌿 ${colors.green}Suggested branch name:${colors.reset}`);
1112
- console.log(` ${result.suggestedBranchName}`);
1113
- console.log('');
1114
-
1115
- console.log(`📋 ${colors.green}Type of change:${colors.reset} ${result.changeType}`);
1116
-
1117
- if (result.breakingChanges) {
1118
- console.log(`⚠️ ${colors.yellow}Breaking Changes: SÍ${colors.reset}`);
1119
- }
1120
-
1121
- console.log('');
1122
- console.log(`📄 ${colors.green}Pull Request Description:${colors.reset}`);
1123
- console.log('───────────────────────────────────────────────────────────────');
1124
- console.log(result.prDescription);
1125
- console.log('───────────────────────────────────────────────────────────────');
1126
-
1127
- if (result.testingNotes) {
1128
- console.log('');
1129
- console.log(`🧪 ${colors.green}Testing notes:${colors.reset}`);
1130
- console.log(result.testingNotes);
1131
- }
1132
-
1133
- // Guardar los resultados en un archivo con contexto
1134
- const outputData = {
1135
- ...result,
1136
- context: {
1137
- currentBranch,
1138
- baseBranch,
1139
- contextDescription,
1140
- filesChanged: diffFiles.split('\n').length,
1141
- timestamp: new Date().toISOString()
1142
- }
1143
- };
1144
-
1145
- // Ensure .claude/out directory exists
1146
- const outputDir = '.claude/out';
1147
- if (!fs.existsSync(outputDir)) {
1148
- fs.mkdirSync(outputDir, { recursive: true });
1149
- }
1150
-
1151
- const outputFile = '.claude/out/pr-analysis.json';
1152
- fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2));
1153
-
1154
- const elapsed = Date.now() - startTime;
1155
- const seconds = Math.floor(elapsed / 1000);
1156
- const ms = elapsed % 1000;
1157
- console.log('');
1158
- console.log(`${colors.blue}⏱️ Analysis completed in ${seconds}.${ms}s${colors.reset}`);
1159
- info(`Results saved in ${outputFile}`);
1160
-
1161
- // Sugerencias contextuales
1162
- console.log('');
1163
- if (!args[0] && contextDescription.includes('local changes without push')) {
1164
- // Case of local changes without push
1165
- console.log(`💡 ${colors.yellow}To create new branch with these changes:${colors.reset}`);
1166
- console.log(` git checkout -b ${result.suggestedBranchName}`);
1167
- console.log(` git push -u origin ${result.suggestedBranchName}`);
1168
- } else if (currentBranch !== result.suggestedBranchName) {
1169
- // Caso normal de comparación entre ramas
1170
- console.log(`💡 ${colors.yellow}For renaming your current branch:${colors.reset}`);
1171
- console.log(` git branch -m ${result.suggestedBranchName}`);
1172
- }
1173
-
1174
- console.log(`💡 ${colors.yellow}Tip:${colors.reset} Use this information to create your PR on GitHub.`);
1175
-
1176
- } catch (e) {
1177
- error('Error executing Claude: ' + e.message);
1178
- }
1179
- }
1180
-
1181
- // Create PR command (v2.5.0+ - Octokit-based)
1182
- async function createPr(args) {
1183
- logger.debug('create-pr', 'Starting create-pr command', { args });
1184
-
1185
- if (!checkGitRepo()) {
1186
- error('You are not in a Git repository.');
1187
- logger.debug('create-pr', 'Not in a git repository, exiting');
1188
- return;
1189
- }
1190
-
1191
- try {
1192
- // Load configuration
1193
- logger.debug('create-pr', 'Loading configuration');
1194
- const config = await getConfig();
1195
- logger.debug('create-pr', 'Configuration loaded', {
1196
- preset: config.preset,
1197
- githubEnabled: config.github?.enabled,
1198
- defaultBase: config.github?.pr?.defaultBase
1199
- });
1200
-
1201
- // Import GitHub API module
1202
- logger.debug('create-pr', 'Importing GitHub API modules');
1203
- const { createPullRequest, GitHubAPIError, validateToken, findExistingPR } = await import('../lib/utils/github-api.js');
1204
- const { parseGitHubRepo } = await import('../lib/utils/github-client.js');
1205
-
1206
- showInfo('🚀 Creating Pull Request...');
1207
- console.log('');
1208
-
1209
- // Step 1: Validate GitHub token
1210
- logger.debug('create-pr', 'Step 1: Validating GitHub token');
1211
- const tokenValidation = await validateToken();
1212
- if (!tokenValidation.valid) {
1213
- logger.error('create-pr', 'GitHub authentication failed', { error: tokenValidation.error });
1214
- showError('GitHub authentication failed');
1215
- console.log('');
1216
- console.log('Please configure your GitHub token:');
1217
- console.log(' Option 1: Set GITHUB_TOKEN environment variable');
1218
- console.log(' Option 2: Add token to .claude/settings.local.json:');
1219
- console.log(' { "githubToken": "ghp_your_token_here" }');
1220
- console.log(' Option 3: Run: claude-hooks setup-github');
1221
- console.log('');
1222
- process.exit(1);
1223
- }
1224
-
1225
- logger.debug('create-pr', 'Token validation successful', {
1226
- user: tokenValidation.user,
1227
- hasRepoScope: tokenValidation.hasRepoScope,
1228
- scopes: tokenValidation.scopes
1229
- });
1230
-
1231
- showSuccess(`Authenticated as: ${tokenValidation.user}`);
1232
- if (!tokenValidation.hasRepoScope) {
1233
- showWarning('Token may lack "repo" scope - PR creation might fail');
1234
- }
1235
-
1236
- // Step 2: Get or prompt for task-id (with config for pattern)
1237
- logger.debug('create-pr', 'Step 2: Getting or prompting for task-id');
1238
- const taskId = await getOrPromptTaskId({
1239
- prompt: true, // DO prompt for PRs (unlike commit messages)
1240
- required: false, // Allow skipping
1241
- config: config // Pass config for custom pattern
1242
- });
1243
- logger.debug('create-pr', 'Task ID determined', { taskId });
1244
-
1245
- // Step 3: Parse arguments and determine base branch
1246
- logger.debug('create-pr', 'Step 3: Parsing arguments and determining base branch', { args });
1247
- let baseBranchArg = args[0];
1248
- if (baseBranchArg && /^[A-Z]{2,10}-\d+$/i.test(baseBranchArg)) {
1249
- baseBranchArg = args[1];
1250
- }
1251
- const baseBranch = baseBranchArg || config.github?.pr?.defaultBase || 'develop';
1252
- logger.debug('create-pr', 'Base branch determined', { baseBranch, fromConfig: !baseBranchArg });
1253
-
1254
- // Step 4: Get current branch and repo info
1255
- logger.debug('create-pr', 'Step 4: Getting current branch and repo info');
1256
- const currentBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
1257
- if (!currentBranch) {
1258
- logger.error('create-pr', 'Could not determine current branch');
1259
- error('Could not determine current branch');
1260
- return;
1261
- }
1262
-
1263
- const repoInfo = parseGitHubRepo();
1264
- logger.debug('create-pr', 'Repository and branch info', {
1265
- owner: repoInfo.owner,
1266
- repo: repoInfo.repo,
1267
- currentBranch,
1268
- baseBranch
1269
- });
1270
-
1271
- showInfo(`Repository: ${repoInfo.fullName}`);
1272
- showInfo(`Branch: ${currentBranch} → ${baseBranch}`);
1273
-
1274
- // Step 5: Check for existing PR
1275
- logger.debug('create-pr', 'Step 5: Checking for existing PR');
1276
- const existingPR = await findExistingPR({
1277
- owner: repoInfo.owner,
1278
- repo: repoInfo.repo,
1279
- head: currentBranch,
1280
- base: baseBranch
1281
- });
1282
-
1283
- if (existingPR) {
1284
- logger.debug('create-pr', 'Existing PR found, exiting', {
1285
- prNumber: existingPR.number,
1286
- prUrl: existingPR.html_url
1287
- });
1288
- showWarning(`A PR already exists for this branch: #${existingPR.number}`);
1289
- console.log(` ${existingPR.html_url}`);
1290
- console.log('');
1291
- return;
1292
- }
1293
-
1294
- logger.debug('create-pr', 'No existing PR found, continuing');
1295
-
1296
- // Step 6: Update remote and check for differences
1297
- logger.debug('create-pr', 'Step 6: Fetching latest changes from remote');
1298
- execSync('git fetch', { stdio: 'ignore' });
1299
- const compareWith = `origin/${baseBranch}...HEAD`;
1300
-
1301
- try {
1302
- execSync(`git rev-parse --verify origin/${baseBranch}`, { stdio: 'ignore' });
1303
- } catch (e) {
1304
- error(`Base branch origin/${baseBranch} does not exist`);
1305
- return;
1306
- }
1307
-
1308
- let diffFiles;
1309
- try {
1310
- diffFiles = execSync(`git diff ${compareWith} --name-only`, { encoding: 'utf8' }).trim();
1311
- if (!diffFiles) {
1312
- showWarning('No differences with remote branch. Nothing to create a PR for.');
1313
- return;
1314
- }
1315
- } catch (e) {
1316
- error('Error getting differences: ' + e.message);
1317
- return;
1318
- }
1319
-
1320
- const filesArray = diffFiles.split('\n').filter(f => f.trim());
1321
- logger.debug('create-pr', 'Modified files detected', {
1322
- fileCount: filesArray.length,
1323
- files: filesArray
1324
- });
1325
- showInfo(`Found ${filesArray.length} modified file(s)`);
1326
-
1327
- // Step 7: Generate PR metadata with Claude (reuse analyze-diff logic)
1328
- logger.debug('create-pr', 'Step 7: Generating PR metadata with Claude');
1329
- let fullDiff, commits;
1330
- try {
1331
- fullDiff = execSync(`git diff ${compareWith}`, { encoding: 'utf8' });
1332
- commits = execSync(`git log origin/${baseBranch}..HEAD --oneline`, { encoding: 'utf8' }).trim();
1333
- } catch (e) {
1334
- error('Error getting diff or commits: ' + e.message);
1335
- return;
1336
- }
1337
-
1338
- const truncatedDiff = fullDiff.length > 50000
1339
- ? fullDiff.substring(0, 50000) + '\n... (truncated)'
1340
- : fullDiff;
1341
-
1342
- const contextDescription = `${currentBranch} vs origin/${baseBranch}`;
1343
- const prompt = await loadPrompt('ANALYZE_DIFF.md', {
1344
- CONTEXT_DESCRIPTION: contextDescription,
1345
- SUBAGENT_INSTRUCTION: '',
1346
- COMMITS: commits,
1347
- DIFF_FILES: diffFiles,
1348
- FULL_DIFF: truncatedDiff
1349
- });
1350
-
1351
- showInfo('Generating PR metadata with Claude...');
1352
- logger.debug('create-pr', 'Calling Claude with prompt', { promptLength: prompt.length });
1353
-
1354
- // Prepare telemetry context for create-pr
1355
- const telemetryContext = {
1356
- fileCount: filesArray.length,
1357
- batchSize: filesArray.length,
1358
- totalBatches: 1,
1359
- model: 'sonnet', // create-pr always uses main model
1360
- hook: 'create-pr'
1361
- };
1362
-
1363
- const response = await executeClaudeWithRetry(prompt, {
1364
- timeout: 180000,
1365
- telemetryContext
1366
- });
1367
- logger.debug('create-pr', 'Claude response received', { responseLength: response.length });
1368
-
1369
- const analysisResult = extractJSON(response);
1370
- logger.debug('create-pr', 'Analysis result extracted', {
1371
- hasResult: !!analysisResult,
1372
- hasPrTitle: !!analysisResult?.prTitle
1373
- });
1374
-
1375
- if (!analysisResult || !analysisResult.prTitle) {
1376
- logger.error('create-pr', 'Failed to generate PR metadata from analysis', { analysisResult });
1377
- error('Failed to generate PR metadata from analysis');
1378
- return;
1379
- }
1380
-
1381
- // Step 8: Prepare PR data
1382
- logger.debug('create-pr', 'Step 8: Preparing PR data');
1383
- let prTitle = analysisResult.prTitle;
1384
- if (taskId) {
1385
- prTitle = formatWithTaskId(prTitle, taskId);
1386
- logger.debug('create-pr', 'Task ID added to title', { prTitle });
1387
- }
1388
-
1389
- const prBody = analysisResult.prDescription || analysisResult.description || '';
1390
- logger.debug('create-pr', 'PR title and body prepared', {
1391
- titleLength: prTitle.length,
1392
- bodyLength: prBody.length
1393
- });
1394
-
1395
- // Step 9: Get labels from preset
1396
- logger.debug('create-pr', 'Step 9: Getting labels from preset');
1397
- let labels = [];
1398
- if (config.preset && config.github?.pr?.labelRules) {
1399
- labels = config.github.pr.labelRules[config.preset] || [];
1400
- }
1401
- if (analysisResult.breakingChanges) {
1402
- labels.push('breaking-change');
1403
- }
1404
- logger.debug('create-pr', 'Labels determined', { labels, preset: config.preset });
1405
-
1406
- // Step 10: Get reviewers from CODEOWNERS and config
1407
- logger.debug('create-pr', 'Step 10: Getting reviewers from CODEOWNERS and config');
1408
- const reviewers = await getReviewersForFiles(filesArray, config.github?.pr);
1409
- logger.debug('create-pr', 'Reviewers determined', { reviewers, sources: 'CODEOWNERS + config' });
1410
-
1411
- // Step 11: Show PR preview
1412
- const prData = {
1413
- title: prTitle,
1414
- body: prBody,
1415
- head: currentBranch,
1416
- base: baseBranch,
1417
- labels,
1418
- reviewers
1419
- };
1420
-
1421
- showPRPreview(prData);
1422
-
1423
- // Step 12: Prompt for confirmation
1424
- const action = await promptMenu(
1425
- 'What would you like to do?',
1426
- [
1427
- { key: 'c', label: 'Create PR' },
1428
- { key: 'x', label: 'Cancel' }
1429
- ],
1430
- 'c'
1431
- );
1432
-
1433
- if (action === 'x') {
1434
- showInfo('PR creation cancelled');
1435
-
1436
- // Save metadata for later use
1437
- const outputDir = '.claude/out';
1438
- if (!fs.existsSync(outputDir)) {
1439
- fs.mkdirSync(outputDir, { recursive: true });
1440
- }
1441
- const outputFile = path.join(outputDir, 'pr-metadata.json');
1442
- fs.writeFileSync(outputFile, JSON.stringify(prData, null, 2));
1443
- showInfo(`PR metadata saved to ${outputFile}`);
1444
- return;
1445
- }
1446
-
1447
- // Step 13: Create PR via Octokit
1448
- logger.debug('create-pr', 'Step 13: Creating PR via Octokit');
1449
- showInfo('Creating pull request on GitHub...');
1450
-
1451
- try {
1452
- logger.debug('create-pr', 'Calling createPullRequest API', {
1453
- owner: repoInfo.owner,
1454
- repo: repoInfo.repo,
1455
- head: prData.head,
1456
- base: prData.base
1457
- });
1458
-
1459
- const result = await createPullRequest({
1460
- owner: repoInfo.owner,
1461
- repo: repoInfo.repo,
1462
- title: prData.title,
1463
- body: prData.body,
1464
- head: prData.head,
1465
- base: prData.base,
1466
- draft: false,
1467
- labels: prData.labels,
1468
- reviewers: prData.reviewers
1469
- });
1470
-
1471
- logger.debug('create-pr', 'PR created successfully', {
1472
- prNumber: result.number,
1473
- prUrl: result.html_url
1474
- });
1475
-
1476
- console.log('');
1477
- showSuccess('Pull request created successfully!');
1478
- console.log('');
1479
- console.log(` PR #${result.number}: ${result.html_url}`);
1480
- console.log('');
1481
-
1482
- if (result.reviewers.length > 0) {
1483
- showInfo(`Reviewers requested: ${result.reviewers.join(', ')}`);
1484
- }
1485
- if (result.labels.length > 0) {
1486
- showInfo(`Labels added: ${result.labels.join(', ')}`);
1487
- }
1488
-
1489
- } catch (apiError) {
1490
- logger.error('create-pr', 'Failed to create pull request', apiError);
1491
- showError('Failed to create pull request');
1492
- console.error('');
1493
- console.error(` ${apiError.message}`);
1494
-
1495
- if (apiError.context?.suggestion) {
1496
- console.error('');
1497
- console.error(` 💡 ${apiError.context.suggestion}`);
1498
- }
1499
- console.error('');
1500
-
1501
- // Save PR metadata for manual creation or retry
1502
- const outputDir = '.claude/out';
1503
- if (!fs.existsSync(outputDir)) {
1504
- fs.mkdirSync(outputDir, { recursive: true });
1505
- }
1506
- const outputFile = path.join(outputDir, 'pr-metadata.json');
1507
- fs.writeFileSync(outputFile, JSON.stringify({
1508
- ...prData,
1509
- error: apiError.message,
1510
- timestamp: new Date().toISOString()
1511
- }, null, 2));
1512
-
1513
- logger.debug('create-pr', 'PR metadata saved', { outputFile });
1514
- showInfo(`PR metadata saved to ${outputFile}`);
1515
- showInfo('You can create the PR manually using this data');
1516
-
1517
- process.exit(1);
1518
- }
1519
-
1520
- } catch (err) {
1521
- logger.error('create-pr', 'Error creating PR', err);
1522
- showError('Error creating PR: ' + err.message);
1523
-
1524
- if (err.context) {
1525
- logger.debug('create-pr', 'Error context', err.context);
1526
- console.error('Context:', JSON.stringify(err.context, null, 2));
1527
- }
1528
-
1529
- process.exit(1);
1530
- }
1531
- }
1532
-
1533
- // Setup GitHub authentication
1534
- async function setupGitHub() {
1535
- const { validateToken } = await import('../lib/utils/github-api.js');
1536
-
1537
- console.log('');
1538
- info('GitHub Authentication Setup');
1539
- console.log('');
1540
-
1541
- // Check existing token
1542
- try {
1543
- const validation = await validateToken();
1544
- if (validation.valid) {
1545
- success(`Already authenticated as: ${validation.user}`);
1546
- console.log(` Scopes: ${validation.scopes.join(', ')}`);
1547
-
1548
- if (!validation.hasRepoScope) {
1549
- warning('Token lacks "repo" scope - PR creation may fail');
1550
- }
1551
-
1552
- console.log('');
1553
- info('To use a different token, edit .claude/settings.local.json');
1554
- return;
1555
- }
1556
- } catch (e) {
1557
- // No token configured, continue with setup
1558
- }
1559
-
1560
- console.log('No GitHub token found. You have several options:');
1561
- console.log('');
1562
- console.log('Option 1: Create .claude/settings.local.json');
1563
- console.log(' {');
1564
- console.log(' "githubToken": "ghp_your_token_here"');
1565
- console.log(' }');
1566
- console.log('');
1567
- console.log('Option 2: Set environment variable');
1568
- console.log(' export GITHUB_TOKEN="ghp_your_token_here"');
1569
- console.log('');
1570
- console.log('Option 3: Run setup-mcp (if you also want MCP features)');
1571
- console.log(' claude-hooks setup-mcp');
1572
- console.log('');
1573
- console.log('To create a token:');
1574
- console.log(' 1. Go to https://github.com/settings/tokens/new');
1575
- console.log(' 2. Select scopes: repo, read:org');
1576
- console.log(' 3. Generate and copy the token');
1577
- console.log('');
1578
- }
1579
-
1580
- // Comando status
1581
- function status() {
1582
- if (!checkGitRepo()) {
1583
- error('You are not in a Git repository.');
1584
- }
1585
-
1586
- info('Claude Git Hooks status:\n');
1587
-
1588
- const hooks = ['pre-commit', 'prepare-commit-msg'];
1589
- hooks.forEach(hook => {
1590
- const enabledPath = `.git/hooks/${hook}`;
1591
- const disabledPath = `.git/hooks/${hook}.disabled`;
1592
-
1593
- if (fs.existsSync(enabledPath)) {
1594
- success(`${hook}: enabled`);
1595
- } else if (fs.existsSync(disabledPath)) {
1596
- warning(`${hook}: disabled`);
1597
- } else {
1598
- error(`${hook}: not installed`);
1599
- }
1600
- });
1601
-
1602
- // Check guidelines files
1603
- console.log('\nGuidelines files:');
1604
- const guidelines = ['CLAUDE_PRE_COMMIT.md'];
1605
- guidelines.forEach(guideline => {
1606
- const claudePath = path.join('.claude', guideline);
1607
- if (fs.existsSync(claudePath)) {
1608
- success(`${guideline}: present in .claude/`);
1609
- } else if (fs.existsSync(guideline)) {
1610
- warning(`${guideline}: present in root (should be in .claude/)`);
1611
- } else {
1612
- warning(`${guideline}: missing`);
1613
- }
1614
- });
1615
-
1616
- // Verificar entradas en .gitignore
1617
- console.log('\n.gitignore:');
1618
- const gitignorePath = '.gitignore';
1619
- if (fs.existsSync(gitignorePath)) {
1620
- const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
1621
- const claudeIgnore = '.claude/';
1622
-
1623
- const regex = new RegExp(`^${claudeIgnore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm');
1624
- if (regex.test(gitignoreContent)) {
1625
- success(`${claudeIgnore}: included (protects all Claude files)`);
1626
- } else {
1627
- warning(`${claudeIgnore}: missing`);
1628
- info('\nRun "claude-hooks install" to update .gitignore');
1629
- }
1630
- } else {
1631
- warning('.gitignore doesn´t exist');
1632
- }
1633
- }
1634
-
1635
- // Cross-platform version comparison (semver)
1636
- // Why: Pure JavaScript, no bash dependency
1637
- // Returns: 0 if equal, 1 if v1 > v2, -1 if v1 < v2
1638
- function compareVersions(v1, v2) {
1639
- if (v1 === v2) return 0;
1640
-
1641
- const v1Parts = v1.split('.').map(Number);
1642
- const v2Parts = v2.split('.').map(Number);
1643
-
1644
- for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
1645
- const v1Part = v1Parts[i] || 0;
1646
- const v2Part = v2Parts[i] || 0;
1647
-
1648
- if (v1Part > v2Part) return 1;
1649
- if (v1Part < v2Part) return -1;
1650
- }
1651
-
1652
- return 0;
1653
- }
1654
-
1655
- // Update command - update to the latest version
1656
- async function update() {
1657
- info('Checking latest available version...');
1658
-
1659
- try {
1660
- const currentVersion = getPackageJson().version;
1661
- const latestVersion = await getLatestVersion('claude-git-hooks');
1662
-
1663
- const comparison = compareVersions(currentVersion, latestVersion);
1664
-
1665
- if (comparison === 0) {
1666
- success(`You already have the latest version installed (${currentVersion})`);
1667
- return;
1668
- } else if (comparison > 0) {
1669
- info(`You are using a development version (${currentVersion})`);
1670
- info(`Latest published version: ${latestVersion}`);
1671
- success(`You already have the latest version installed (${currentVersion})`);
1672
- return;
1673
- }
1674
-
1675
- info(`Current version: ${currentVersion}`);
1676
- info(`Available version: ${latestVersion}`);
1677
-
1678
- // Actualizar el paquete
1679
- info('Updating claude-git-hooks...');
1680
- try {
1681
- execSync('npm install -g claude-git-hooks@latest', { stdio: 'inherit' });
1682
- success(`Successfully updated to version ${latestVersion}`);
1683
-
1684
- // Reinstall hooks with the new version
1685
- info('Reinstalling hooks with the new version...');
1686
- await install(['--force']);
1687
-
1688
- } catch (updateError) {
1689
- error('Error updating. Try running: npm install -g claude-git-hooks@latest');
1690
- }
1691
- } catch (e) {
1692
- warning('Could not check the latest available version');
1693
- warning('Trying to update anyway...');
1694
- try {
1695
- execSync('npm install -g claude-git-hooks@latest', { stdio: 'inherit' });
1696
- success('Update completed');
1697
- await install(['--force']);
1698
- } catch (updateError) {
1699
- error('Error updating: ' + updateError.message);
1700
- }
1701
- }
1702
- }
1703
-
1704
- // Show version command
1705
- // Why: Reusable function to display current version from package.json
1706
- function showVersion() {
1707
- const pkg = getPackageJson();
1708
- console.log(`${pkg.name} v${pkg.version}`);
1709
- }
1710
-
1711
- // Comando help
1712
- function showHelp() {
1713
- console.log(`
1714
- Claude Git Hooks - Code analysis and automatic messages with Claude CLI
1715
-
1716
- Usage: claude-hooks <command> [options]
1717
-
1718
- Commands:
1719
- install [options] Install hooks in the current repository
1720
- --force Reinstall even if they already exist
1721
- --skip-auth Skip Claude authentication verification
1722
- update Update to the latest available version
1723
- uninstall Uninstall hooks from the repository
1724
- enable [hook] Enable hooks (all or one specific)
1725
- disable [hook] Disable hooks (all or one specific)
1726
- status Show the status of hooks
1727
- analyze-diff [base] Analyze differences between branches and generate PR info
1728
- create-pr [base] Create pull request with auto-generated metadata and reviewers
1729
- setup-github Setup GitHub login (required for create-pr)
1730
- presets List all available presets
1731
- --set-preset <name> Set the active preset
1732
- preset current Show the current active preset
1733
- telemetry [action] Telemetry management (show or clear)
1734
- --debug <value> Set debug mode (true, false, or status)
1735
- --version, -v Show the current version
1736
- help Show this help
1737
-
1738
- Available hooks:
1739
- pre-commit Code analysis before commit
1740
- prepare-commit-msg Automatic message generation
1741
-
1742
- Examples:
1743
- claude-hooks install # Install all hooks
1744
- claude-hooks install --skip-auth # Install without verifying authentication
1745
- claude-hooks update # Update to the latest version
1746
- claude-hooks disable pre-commit # Disable only pre-commit
1747
- claude-hooks enable # Enable all hooks
1748
- claude-hooks status # View current status
1749
- claude-hooks analyze-diff main # Analyze differences with main
1750
- claude-hooks setup-github # Configure GitHub authentication for PR creation
1751
- claude-hooks setup-mcp # Setup GitHub MCP (one-time setup)
1752
- claude-hooks create-pr develop # Create PR targeting develop branch
1753
- claude-hooks presets # List available presets
1754
- claude-hooks --set-preset backend # Set backend preset
1755
- claude-hooks preset current # Show current preset
1756
- claude-hooks telemetry show # Show telemetry statistics
1757
- claude-hooks telemetry clear # Clear telemetry data
1758
- claude-hooks --debug true # Enable debug mode
1759
- claude-hooks --debug status # Check debug status
1760
-
1761
- Commit use cases:
1762
- git commit -m "message" # Manual message + blocking analysis
1763
- git commit -m "auto" # Automatic message + blocking analysis
1764
- git commit --no-verify -m "auto" # Automatic message without analysis
1765
- git commit --no-verify -m "msg" # Manual message without analysis
1766
-
1767
- Analyze-diff use case:
1768
- claude-hooks analyze-diff main # Analyze changes vs main and generate:
1769
- → PR Title: "feat: add user authentication module"
1770
- → PR Description: "## Summary\n- Added JWT authentication..."
1771
- → Suggested branch: "feature/user-authentication"
1772
-
1773
- Create-pr use case (v2.5.0+):
1774
- claude-hooks create-pr develop # Create PR targeting develop:
1775
- → Validates GitHub token
1776
- → Extracts task-id from branch (IX-123, #456, LIN-123)
1777
- → Analyzes diff and generates PR metadata with Claude
1778
- → Creates PR directly via GitHub API (Octokit)
1779
- → Adds labels based on preset
1780
- → Returns PR URL
1781
-
1782
- Token configuration:
1783
- → .claude/settings.local.json (recommended, gitignored)
1784
- → GITHUB_TOKEN environment variable
1785
- → Claude Desktop config (auto-detected)
1786
-
1787
- Presets (v2.3.0+):
1788
- Built-in tech-stack specific configurations:
1789
- - backend: Spring Boot + SQL Server (.java, .xml, .yml)
1790
- - frontend: React + Material-UI (.js, .jsx, .ts, .tsx, .css)
1791
- - fullstack: Backend + Frontend with API consistency checks
1792
- - database: SQL Server migrations and procedures (.sql)
1793
- - ai: Node.js + Claude CLI integration (.js, .json, .md)
1794
- - default: General-purpose mixed languages
1795
-
1796
- Configuration (v2.2.0+):
1797
- Create .claude/config.json in your project to customize:
1798
- - Preset selection
1799
- - Analysis settings (maxFileSize, maxFiles, timeout)
1800
- - Commit message generation (autoKeyword, timeout)
1801
- - Parallel execution (enabled, model, batchSize)
1802
- - Template paths and output files
1803
- - Debug mode
1804
-
1805
- Example: .claude/config.json
1806
- {
1807
- "preset": "backend",
1808
- "analysis": { "maxFiles": 30, "timeout": 180000 },
1809
- "subagents": {
1810
- "enabled": true, # Enable parallel execution
1811
- "model": "haiku", # haiku (fast) | sonnet | opus
1812
- "batchSize": 2 # Files per batch (1=fastest)
1813
- },
1814
- "system": { "debug": true }
1815
- }
1816
-
1817
- Parallel Analysis (v2.2.0+):
1818
- When analyzing 3+ files, parallel execution runs multiple Claude CLI
1819
- processes simultaneously for faster analysis:
1820
- - batchSize: 1 → Maximum speed (1 file per process)
1821
- - batchSize: 2 → Balanced (2 files per process)
1822
- - batchSize: 4+ → Fewer API calls but slower
1823
- - Speed improvement: up to 4x faster with batchSize: 1
1824
-
1825
- Debug Mode:
1826
- Enable detailed logging for troubleshooting:
1827
- - CLI: claude-hooks --debug true
1828
- - Config: "system": { "debug": true } in .claude/config.json
1829
- - Check status: claude-hooks --debug status
1830
-
1831
- Customization:
1832
- Override prompts by copying to .claude/:
1833
- cp templates/COMMIT_MESSAGE.md .claude/
1834
- cp templates/ANALYZE_DIFF.md .claude/
1835
- cp templates/CLAUDE_PRE_COMMIT.md .claude/
1836
- # Edit as needed - system uses .claude/ version if exists
1837
-
1838
- More information: https://github.com/pablorovito/claude-git-hooks
1839
- `);
1840
- }
1841
-
1842
- // Configuration Management Functions
1843
-
1844
- /**
1845
- * Updates a configuration value in .claude/config.json
1846
- * Why: Centralized config update logic for all CLI commands
1847
- *
1848
- * @param {string} propertyPath - Dot notation path (e.g., 'preset', 'system.debug')
1849
- * @param {any} value - Value to set
1850
- * @param {Object} options - Optional settings
1851
- * @param {Function} options.validator - Custom validation function, receives value, throws on invalid
1852
- * @param {Function} options.successMessage - Function that receives value and returns success message
1853
- */
1854
- async function updateConfig(propertyPath, value, options = {}) {
1855
- const { validator, successMessage } = options;
1856
-
1857
- try {
1858
- // Validate value if validator provided
1859
- if (validator) {
1860
- await validator(value);
1861
- }
1862
-
1863
- // Get repo root
1864
- const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
1865
- const configDir = path.join(repoRoot, '.claude');
1866
- const configPath = path.join(configDir, 'config.json');
1867
-
1868
- // Ensure .claude directory exists
1869
- if (!fs.existsSync(configDir)) {
1870
- fs.mkdirSync(configDir, { recursive: true });
1871
- }
1872
-
1873
- // Load existing config or create new
1874
- let config = {};
1875
- if (fs.existsSync(configPath)) {
1876
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1877
- }
1878
-
1879
- // Set value at propertyPath (support dot notation like 'system.debug')
1880
- const pathParts = propertyPath.split('.');
1881
- let current = config;
1882
- for (let i = 0; i < pathParts.length - 1; i++) {
1883
- const part = pathParts[i];
1884
- if (!current[part] || typeof current[part] !== 'object') {
1885
- current[part] = {};
1886
- }
1887
- current = current[part];
1888
- }
1889
- current[pathParts[pathParts.length - 1]] = value;
1890
-
1891
- // Save config
1892
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1893
-
1894
- // Show success message
1895
- const message = successMessage ? await successMessage(value) : 'Configuration updated';
1896
- success(message);
1897
- info(`Configuration saved to ${configPath}`);
1898
- } catch (err) {
1899
- error(`Failed to update configuration: ${err.message}`);
1900
- process.exit(1);
1901
- }
1902
- }
1903
-
1904
- // Preset Management Functions
1905
-
1906
- /**
1907
- * Shows all available presets
1908
- */
1909
- async function showPresets() {
1910
- try {
1911
- const presets = await listPresets();
1912
-
1913
- if (presets.length === 0) {
1914
- warning('No presets found');
1915
- return;
1916
- }
1917
-
1918
- console.log('');
1919
- info('Available presets:');
1920
- console.log('');
1921
-
1922
- presets.forEach(preset => {
1923
- console.log(` ${colors.green}${preset.name}${colors.reset}`);
1924
- console.log(` ${preset.displayName}`);
1925
- console.log(` ${colors.blue}${preset.description}${colors.reset}`);
1926
- console.log('');
1927
- });
1928
-
1929
- info('To set a preset: claude-hooks --set-preset <name>');
1930
- info('To see current preset: claude-hooks preset current');
1931
- console.log('');
1932
- } catch (err) {
1933
- error(`Failed to list presets: ${err.message}`);
1934
- }
1935
- }
1936
-
1937
- /**
1938
- * Sets the active preset
1939
- * Why: Configures tech-stack specific analysis settings
1940
- */
1941
- async function setPreset(presetName) {
1942
- if (!presetName) {
1943
- error('Please specify a preset name: claude-hooks --set-preset <name>');
1944
- return;
1945
- }
1946
-
1947
- await updateConfig('preset', presetName, {
1948
- validator: async (name) => {
1949
- const presets = await listPresets();
1950
- const preset = presets.find(p => p.name === name);
1951
- if (!preset) {
1952
- error(`Preset "${name}" not found`);
1953
- info('Available presets:');
1954
- presets.forEach(p => console.log(` - ${p.name}`));
1955
- throw new Error(`Invalid preset: ${name}`);
1956
- }
1957
- return preset;
1958
- },
1959
- successMessage: async (name) => {
1960
- const presets = await listPresets();
1961
- const preset = presets.find(p => p.name === name);
1962
- return `Preset '${preset.displayName}' activated`;
1963
- }
1964
- });
1965
- }
1966
-
1967
- /**
1968
- * Shows the current active preset
1969
- */
1970
- async function currentPreset() {
1971
- try {
1972
- const config = await getConfig();
1973
- const presetName = config.preset || 'default';
1974
-
1975
- const presets = await listPresets();
1976
- const preset = presets.find(p => p.name === presetName);
1977
-
1978
- if (preset) {
1979
- console.log('');
1980
- success(`Current preset: ${preset.displayName} (${preset.name})`);
1981
- console.log(` ${colors.blue}${preset.description}${colors.reset}`);
1982
- console.log('');
1983
- } else {
1984
- warning(`Current preset "${presetName}" not found`);
1985
- }
1986
- } catch (err) {
1987
- error(`Failed to get current preset: ${err.message}`);
1988
- }
1989
- }
1990
-
1991
- // ============================================================================
1992
- // DEPRECATED CODE SECTION - Will be removed in v3.0.0
1993
- // ============================================================================
1994
- // This section contains migration code for legacy configs (pre-v2.8.0)
1995
- // Auto-executed during install when legacy config detected
1996
- // Manual command: claude-hooks migrate-config
1997
- // ============================================================================
1998
-
1999
- /**
2000
- * Extracts allowed settings from legacy config format
2001
- * Shared by both autoMigrateConfig and migrateConfig
2002
- *
2003
- * @param {Object} rawConfig - Legacy format config
2004
- * @returns {Object} Allowed overrides only
2005
- */
2006
- function extractLegacySettings(rawConfig) {
2007
- const allowedOverrides = {};
2008
-
2009
- // GitHub PR config (fully allowed)
2010
- if (rawConfig.github?.pr) {
2011
- allowedOverrides.github = { pr: {} };
2012
- if (rawConfig.github.pr.defaultBase !== undefined) {
2013
- allowedOverrides.github.pr.defaultBase = rawConfig.github.pr.defaultBase;
2014
- }
2015
- if (rawConfig.github.pr.reviewers !== undefined) {
2016
- allowedOverrides.github.pr.reviewers = rawConfig.github.pr.reviewers;
2017
- }
2018
- if (rawConfig.github.pr.labelRules !== undefined) {
2019
- allowedOverrides.github.pr.labelRules = rawConfig.github.pr.labelRules;
2020
- }
2021
- }
2022
-
2023
- // Subagent batchSize (allowed)
2024
- if (rawConfig.subagents?.batchSize !== undefined) {
2025
- allowedOverrides.subagents = { batchSize: rawConfig.subagents.batchSize };
2026
- }
2027
-
2028
- // Advanced params (preserved with warning in manual migration)
2029
- if (rawConfig.analysis?.ignoreExtensions !== undefined) {
2030
- if (!allowedOverrides.analysis) allowedOverrides.analysis = {};
2031
- allowedOverrides.analysis.ignoreExtensions = rawConfig.analysis.ignoreExtensions;
2032
- }
2033
-
2034
- if (rawConfig.commitMessage?.taskIdPattern !== undefined) {
2035
- if (!allowedOverrides.commitMessage) allowedOverrides.commitMessage = {};
2036
- allowedOverrides.commitMessage.taskIdPattern = rawConfig.commitMessage.taskIdPattern;
2037
- }
2038
-
2039
- if (rawConfig.subagents?.model !== undefined) {
2040
- if (!allowedOverrides.subagents) allowedOverrides.subagents = {};
2041
- allowedOverrides.subagents.model = rawConfig.subagents.model;
2042
- }
2043
-
2044
- return allowedOverrides;
2045
- }
2046
-
2047
3
  /**
2048
- * Auto-migrates legacy config during installation
2049
- * Called automatically by installer when legacy format detected
4
+ * File: claude-hooks
5
+ * Purpose: Main CLI entry point - thin router to command modules
2050
6
  *
2051
- * @param {string} newConfigPath - Path to new config.json
2052
- * @param {string} backupConfigPath - Path to backup of old config
7
+ * All command implementations are in lib/commands/
8
+ * This file only handles argument parsing and routing.
2053
9
  */
2054
- async function autoMigrateConfig(newConfigPath, backupConfigPath) {
2055
- try {
2056
- const rawConfig = JSON.parse(fs.readFileSync(backupConfigPath, 'utf8'));
2057
- const allowedOverrides = extractLegacySettings(rawConfig);
2058
10
 
2059
- // Read the newly created minimal config
2060
- const newConfig = JSON.parse(fs.readFileSync(newConfigPath, 'utf8'));
2061
-
2062
- // Add overrides if any
2063
- if (Object.keys(allowedOverrides).length > 0) {
2064
- newConfig.overrides = allowedOverrides;
2065
- fs.writeFileSync(newConfigPath, JSON.stringify(newConfig, null, 4));
2066
- success('✅ Settings migrated from legacy config');
2067
- info(`📋 Preserved ${Object.keys(allowedOverrides).length} custom settings`);
2068
- }
2069
- } catch (err) {
2070
- warning(`⚠️ Could not auto-migrate settings: ${err.message}`);
2071
- info('💡 Run "claude-hooks migrate-config" manually if needed');
2072
- }
2073
- }
11
+ import { error } from '../lib/commands/helpers.js';
12
+
13
+ // Import commands
14
+ import { runInstall } from '../lib/commands/install.js';
15
+ import { runEnable, runDisable, runStatus, runUninstall } from '../lib/commands/hooks.js';
16
+ import { runAnalyzeDiff } from '../lib/commands/analyze-diff.js';
17
+ import { runCreatePr } from '../lib/commands/create-pr.js';
18
+ import { runSetupGitHub } from '../lib/commands/setup-github.js';
19
+ import { runShowPresets, runSetPreset, runCurrentPreset } from '../lib/commands/presets.js';
20
+ import { runUpdate } from '../lib/commands/update.js';
21
+ import { runMigrateConfig } from '../lib/commands/migrate-config.js';
22
+ import { runSetDebug } from '../lib/commands/debug.js';
23
+ import { runShowTelemetry, runClearTelemetry } from '../lib/commands/telemetry-cmd.js';
24
+ import { runShowHelp, runShowVersion } from '../lib/commands/help.js';
2074
25
 
2075
26
  /**
2076
- * Migrates legacy config.json to v2.8.0 format (Manual command)
2077
- * Why: Simplifies configuration, reduces redundancy
2078
- *
2079
- * DEPRECATED: Will be removed in v3.0.0 (most users will have migrated by then)
27
+ * Main CLI router
2080
28
  */
2081
- async function migrateConfig() {
2082
- const claudeDir = '.claude';
2083
- const configPath = path.join(claudeDir, 'config.json');
2084
-
2085
- if (!fs.existsSync(configPath)) {
2086
- info('ℹ️ No config file found. Nothing to migrate.');
2087
- console.log('\n💡 To create a new config:');
2088
- console.log(' 1. Run: claude-hooks install --force');
2089
- console.log(' 2. Or copy from: .claude/config_example/config.example.json');
2090
- return;
2091
- }
2092
-
2093
- try {
2094
- const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2095
-
2096
- // Check if already in v2.8.0 format
2097
- if (rawConfig.version === '2.8.0') {
2098
- success('✅ Config is already in v2.8.0 format.');
2099
- return;
2100
- }
2101
-
2102
- info('📦 Starting config migration to v2.8.0...');
2103
-
2104
- // Create backup in config_old/
2105
- const configOldDir = path.join(claudeDir, 'config_old');
2106
- if (!fs.existsSync(configOldDir)) {
2107
- fs.mkdirSync(configOldDir, { recursive: true });
2108
- }
2109
- const backupPath = path.join(configOldDir, `config.json.${Date.now()}`);
2110
- fs.copyFileSync(configPath, backupPath);
2111
- success(`Backup created: ${backupPath}`);
2112
-
2113
- // Extract only allowed parameters
2114
- const allowedOverrides = extractLegacySettings(rawConfig);
2115
-
2116
- // Check for advanced params
2117
- const hasAdvancedParams = allowedOverrides.analysis?.ignoreExtensions ||
2118
- allowedOverrides.commitMessage?.taskIdPattern ||
2119
- allowedOverrides.subagents?.model;
2120
-
2121
- // Build new config
2122
- const newConfig = {
2123
- version: '2.8.0',
2124
- preset: rawConfig.preset || 'default'
2125
- };
2126
-
2127
- // Only add overrides if there are any
2128
- if (Object.keys(allowedOverrides).length > 0) {
2129
- newConfig.overrides = allowedOverrides;
2130
- }
2131
-
2132
- // Show diff
2133
- console.log('\n📝 Migration preview:');
2134
- console.log(` Old format: ${Object.keys(rawConfig).length} top-level keys`);
2135
- console.log(` New format: ${Object.keys(newConfig).length} top-level keys`);
2136
- if (Object.keys(allowedOverrides).length > 0) {
2137
- console.log(` Preserved: ${Object.keys(allowedOverrides).length} override sections`);
2138
- }
2139
-
2140
- // Write new config
2141
- fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 4));
2142
- success('✅ Config migrated to v2.8.0 successfully!');
2143
-
2144
- if (hasAdvancedParams) {
2145
- warning('⚠️ Advanced parameters detected and preserved');
2146
- info('📖 See .claude/config.advanced.example.json for documentation');
2147
- }
2148
-
2149
- console.log(`\n✨ New config:`);
2150
- console.log(JSON.stringify(newConfig, null, 2));
2151
- console.log(`\n💾 Old config backed up to: ${backupPath}`);
2152
- console.log('\n💡 Many parameters are now hardcoded with sensible defaults');
2153
- console.log(' See CHANGELOG.md for full list of changes');
2154
-
2155
- } catch (error) {
2156
- error(`Failed to migrate config: ${error.message}`);
2157
- console.log('\n💡 Manual migration:');
2158
- console.log(' 1. Backup your current config');
2159
- console.log(' 2. See .claude/config.example.json for new format');
2160
- console.log(' 3. Copy minimal example and customize');
2161
- }
2162
- }
2163
-
2164
- /**
2165
- * Sets debug mode
2166
- * Why: Enables detailed logging for troubleshooting
2167
- */
2168
- async function setDebug(value) {
2169
- if (!value) {
2170
- error('Please specify a value: claude-hooks --debug <true|false|status>');
2171
- return;
2172
- }
2173
-
2174
- const normalizedValue = value.toLowerCase();
2175
-
2176
- // Handle status check
2177
- if (normalizedValue === 'status') {
2178
- try {
2179
- const config = await getConfig();
2180
- const isEnabled = config.system.debug || false;
2181
- console.log('');
2182
- info(`Debug mode: ${isEnabled ? colors.green + 'enabled' + colors.reset : colors.red + 'disabled' + colors.reset}`);
2183
- console.log('');
2184
- } catch (err) {
2185
- error(`Failed to check debug status: ${err.message}`);
2186
- }
2187
- return;
2188
- }
2189
-
2190
- // Validate and convert to boolean
2191
- if (normalizedValue !== 'true' && normalizedValue !== 'false') {
2192
- error('Invalid value. Use: true, false, or status');
2193
- return;
2194
- }
2195
-
2196
- const debugValue = normalizedValue === 'true';
2197
-
2198
- await updateConfig('system.debug', debugValue, {
2199
- successMessage: (val) => `Debug mode ${val ? 'enabled' : 'disabled'}`
2200
- });
2201
- }
2202
-
2203
- // Main
2204
- /**
2205
- * Show telemetry statistics
2206
- * Why: Help users understand JSON parsing patterns and batch performance
2207
- */
2208
- async function showTelemetry() {
2209
- await showTelemetryStats();
2210
- }
2211
-
2212
- /**
2213
- * Clear telemetry data
2214
- * Why: Allow users to reset telemetry
2215
- */
2216
- async function clearTelemetry() {
2217
- const config = await getConfig();
2218
-
2219
- if (!config.system?.telemetry && config.system?.telemetry !== undefined) {
2220
- console.log('\n⚠️ Telemetry is currently disabled.\n');
2221
- console.log('To re-enable (default), remove or set to true in .claude/config.json:');
2222
- console.log('{');
2223
- console.log(' "system": {');
2224
- console.log(' "telemetry": true');
2225
- console.log(' }');
2226
- console.log('}\n');
2227
- return;
2228
- }
2229
-
2230
- const confirmed = await promptConfirmation('Are you sure you want to clear all telemetry data?');
2231
- if (confirmed) {
2232
- await clearTelemetryData();
2233
- success('Telemetry data cleared successfully');
2234
- } else {
2235
- info('Telemetry data was not cleared');
2236
- }
2237
- }
2238
-
2239
29
  async function main() {
2240
- const args = process.argv.slice(2);
2241
- const command = args[0];
2242
-
2243
- switch (command) {
2244
- case 'install':
2245
- await install(args.slice(1));
2246
- break;
2247
- case 'update':
2248
- await update();
2249
- break;
2250
- case 'uninstall':
2251
- uninstall();
2252
- break;
2253
- case 'enable':
2254
- enable(args[1]);
2255
- break;
2256
- case 'disable':
2257
- disable(args[1]);
2258
- break;
2259
- case 'status':
2260
- status();
2261
- break;
2262
- case 'analyze-diff':
2263
- await analyzeDiff(args.slice(1));
2264
- break;
2265
- case 'create-pr':
2266
- await createPr(args.slice(1));
2267
- break;
2268
- case 'setup-mcp':
2269
- await setupGitHubMCP();
2270
- break;
2271
- case 'setup-github':
2272
- await setupGitHub();
2273
- break;
2274
- case 'presets':
2275
- await showPresets();
2276
- break;
2277
- case '--set-preset':
2278
- await setPreset(args[1]);
2279
- break;
2280
- case 'preset':
2281
- // Handle subcommands: preset current
2282
- if (args[1] === 'current') {
2283
- await currentPreset();
2284
- } else {
2285
- error(`Unknown preset subcommand: ${args[1]}`);
2286
- }
2287
- break;
2288
- case 'migrate-config':
2289
- await migrateConfig();
2290
- break;
2291
- case 'telemetry':
2292
- // Handle subcommands: telemetry show, telemetry clear
2293
- if (args[1] === 'show' || args[1] === undefined) {
2294
- await showTelemetry();
2295
- } else if (args[1] === 'clear') {
2296
- await clearTelemetry();
2297
- } else {
2298
- error(`Unknown telemetry subcommand: ${args[1]}`);
2299
- }
2300
- break;
2301
- case '--debug':
2302
- await setDebug(args[1]);
2303
- break;
2304
- case '--version':
2305
- case '-v':
2306
- case 'version':
2307
- showVersion();
2308
- break;
2309
- case 'help':
2310
- case '--help':
2311
- case '-h':
2312
- case undefined:
2313
- showHelp();
2314
- break;
2315
- default:
2316
- error(`Unknown command: ${command}`);
2317
- showHelp();
2318
- }
30
+ const args = process.argv.slice(2);
31
+ const command = args[0];
32
+
33
+ switch (command) {
34
+ case 'install':
35
+ await runInstall(args.slice(1));
36
+ break;
37
+ case 'update':
38
+ await runUpdate();
39
+ break;
40
+ case 'uninstall':
41
+ runUninstall();
42
+ break;
43
+ case 'enable':
44
+ runEnable(args[1]);
45
+ break;
46
+ case 'disable':
47
+ runDisable(args[1]);
48
+ break;
49
+ case 'status':
50
+ runStatus();
51
+ break;
52
+ case 'analyze-diff':
53
+ await runAnalyzeDiff(args.slice(1));
54
+ break;
55
+ case 'create-pr':
56
+ await runCreatePr(args.slice(1));
57
+ break;
58
+ case 'setup-github':
59
+ await runSetupGitHub();
60
+ break;
61
+ case 'presets':
62
+ await runShowPresets();
63
+ break;
64
+ case '--set-preset':
65
+ await runSetPreset(args[1]);
66
+ break;
67
+ case 'preset':
68
+ // Handle subcommands: preset current
69
+ if (args[1] === 'current') {
70
+ await runCurrentPreset();
71
+ } else {
72
+ error(`Unknown preset subcommand: ${args[1]}`);
73
+ }
74
+ break;
75
+ case 'migrate-config':
76
+ await runMigrateConfig();
77
+ break;
78
+ case 'telemetry':
79
+ // Handle subcommands: telemetry show, telemetry clear
80
+ if (args[1] === 'show' || args[1] === undefined) {
81
+ await runShowTelemetry();
82
+ } else if (args[1] === 'clear') {
83
+ await runClearTelemetry();
84
+ } else {
85
+ error(`Unknown telemetry subcommand: ${args[1]}`);
86
+ }
87
+ break;
88
+ case '--debug':
89
+ await runSetDebug(args[1]);
90
+ break;
91
+ case '--version':
92
+ case '-v':
93
+ case 'version':
94
+ runShowVersion();
95
+ break;
96
+ case 'help':
97
+ case '--help':
98
+ case '-h':
99
+ case undefined:
100
+ runShowHelp();
101
+ break;
102
+ default:
103
+ error(`Unknown command: ${command}`);
104
+ runShowHelp();
105
+ }
2319
106
  }
2320
107
 
2321
108
  // Execute main
2322
109
  main().catch(err => {
2323
- error(`Unexpected error: ${err.message}`);
2324
- });
110
+ error(`Unexpected error: ${err.message}`);
111
+ });