claude-git-hooks 2.35.2 → 2.43.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/CHANGELOG.md +150 -0
- package/CLAUDE.md +24 -1384
- package/README.md +113 -0
- package/bin/claude-hooks +11 -7
- package/lib/cli-metadata.js +17 -3
- package/lib/commands/analyze-pr.js +270 -145
- package/lib/commands/analyze.js +151 -3
- package/lib/commands/create-pr.js +345 -134
- package/lib/commands/helpers.js +9 -4
- package/lib/commands/hooks.js +5 -5
- package/lib/commands/install.js +77 -28
- package/lib/commands/lint.js +120 -4
- package/lib/config.js +4 -1
- package/lib/hooks/pre-commit.js +26 -5
- package/lib/hooks/prepare-commit-msg.js +78 -4
- package/lib/utils/analysis-engine.js +12 -6
- package/lib/utils/claude-client.js +222 -12
- package/lib/utils/claude-diagnostics.js +5 -4
- package/lib/utils/cost-tracker.js +128 -0
- package/lib/utils/diff-analysis-orchestrator.js +2 -1
- package/lib/utils/git-operations.js +105 -2
- package/lib/utils/hooks-verified-marker.js +121 -0
- package/lib/utils/interactive-ui.js +4 -4
- package/lib/utils/judge.js +4 -3
- package/lib/utils/langfuse-tracer.js +156 -0
- package/lib/utils/linter-runner.js +29 -4
- package/lib/utils/logger.js +30 -5
- package/lib/utils/pr-metadata-engine.js +4 -2
- package/lib/utils/tool-runner.js +4 -3
- package/package.json +4 -2
- package/templates/CUSTOMIZATION_GUIDE.md +2 -2
package/lib/commands/helpers.js
CHANGED
|
@@ -19,6 +19,7 @@ import https from 'https';
|
|
|
19
19
|
import { fileURLToPath } from 'url';
|
|
20
20
|
import { dirname } from 'path';
|
|
21
21
|
import { getRunningWSLDistros } from '../utils/claude-client.js';
|
|
22
|
+
import logger from '../utils/logger.js';
|
|
22
23
|
|
|
23
24
|
// Why: ES6 modules don't have __dirname, need to recreate it
|
|
24
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -38,20 +39,24 @@ export function log(message, color = 'reset') {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export function error(message) {
|
|
41
|
-
|
|
42
|
+
logger.error('helpers', message);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function fatal(message) {
|
|
46
|
+
logger.error('helpers', message);
|
|
42
47
|
process.exit(1);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export function success(message) {
|
|
46
|
-
|
|
51
|
+
logger.success(message);
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
export function info(message) {
|
|
50
|
-
|
|
55
|
+
logger.info(message);
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export function warning(message) {
|
|
54
|
-
|
|
59
|
+
logger.warning(message);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
package/lib/commands/hooks.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
|
-
import { error, success, info, warning, checkGitRepo, getGitHooksPath } from './helpers.js';
|
|
8
|
+
import { error, fatal, success, info, warning, checkGitRepo, getGitHooksPath } from './helpers.js';
|
|
9
9
|
import { removeCompletions } from './install.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -14,7 +14,7 @@ import { removeCompletions } from './install.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export function runEnable(hookName) {
|
|
16
16
|
if (!checkGitRepo()) {
|
|
17
|
-
|
|
17
|
+
fatal('You are not in a Git repository.');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const hooksDir = getGitHooksPath();
|
|
@@ -41,7 +41,7 @@ export function runEnable(hookName) {
|
|
|
41
41
|
*/
|
|
42
42
|
export function runDisable(hookName) {
|
|
43
43
|
if (!checkGitRepo()) {
|
|
44
|
-
|
|
44
|
+
fatal('You are not in a Git repository.');
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const hooksDir = getGitHooksPath();
|
|
@@ -67,7 +67,7 @@ export function runDisable(hookName) {
|
|
|
67
67
|
*/
|
|
68
68
|
export function runStatus() {
|
|
69
69
|
if (!checkGitRepo()) {
|
|
70
|
-
|
|
70
|
+
fatal('You are not in a Git repository.');
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
info('Claude Git Hooks status:\n');
|
|
@@ -128,7 +128,7 @@ export function runStatus() {
|
|
|
128
128
|
*/
|
|
129
129
|
export function runUninstall() {
|
|
130
130
|
if (!checkGitRepo()) {
|
|
131
|
-
|
|
131
|
+
fatal('You are not in a Git repository.');
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
info('Uninstalling Claude Git Hooks...');
|
package/lib/commands/install.js
CHANGED
|
@@ -18,6 +18,7 @@ import os from 'os';
|
|
|
18
18
|
import readline from 'readline';
|
|
19
19
|
import {
|
|
20
20
|
error,
|
|
21
|
+
fatal,
|
|
21
22
|
success,
|
|
22
23
|
info,
|
|
23
24
|
warning,
|
|
@@ -176,7 +177,7 @@ async function checkAndInstallDependencies(skipAuth = false) {
|
|
|
176
177
|
const nodeVersion = execSync('node --version', { encoding: 'utf8' }).trim();
|
|
177
178
|
success(`Node.js ${nodeVersion}`);
|
|
178
179
|
} catch (e) {
|
|
179
|
-
|
|
180
|
+
fatal('Node.js is not installed. Install Node.js and try again.');
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
// Check npm
|
|
@@ -184,7 +185,7 @@ async function checkAndInstallDependencies(skipAuth = false) {
|
|
|
184
185
|
const npmVersion = execSync('npm --version', { encoding: 'utf8' }).trim();
|
|
185
186
|
success(`npm ${npmVersion}`);
|
|
186
187
|
} catch (e) {
|
|
187
|
-
|
|
188
|
+
fatal('npm is not installed.');
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
// Check Maven (optional — needed for backend/fullstack presets with Spotless)
|
|
@@ -205,7 +206,7 @@ async function checkAndInstallDependencies(skipAuth = false) {
|
|
|
205
206
|
const gitVersion = execSync('git --version', { encoding: 'utf8' }).trim();
|
|
206
207
|
success(`${gitVersion}`);
|
|
207
208
|
} catch (e) {
|
|
208
|
-
|
|
209
|
+
fatal('Git is not installed. Install Git and try again.');
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
// v2.0.0+: Unix tools (sed, awk, grep, etc.) no longer needed (pure Node.js implementation)
|
|
@@ -387,20 +388,30 @@ async function autoMigrateConfig(newConfigPath, backupConfigPath) {
|
|
|
387
388
|
*/
|
|
388
389
|
export async function runInstall(args) {
|
|
389
390
|
if (!checkGitRepo()) {
|
|
390
|
-
|
|
391
|
+
fatal(
|
|
391
392
|
'You are not in a Git repository. Please run this command from the root of a repository.'
|
|
392
393
|
);
|
|
393
394
|
}
|
|
394
395
|
|
|
395
396
|
const isForce = args.includes('--force');
|
|
396
397
|
const skipAuth = args.includes('--skip-auth');
|
|
398
|
+
const isHeadless = args.includes('--headless');
|
|
399
|
+
const verifySdk = args.includes('--verify-sdk');
|
|
397
400
|
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
if (skipAuth) {
|
|
402
|
+
warning('--skip-auth is deprecated. Use --headless for full CI/container mode.');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const skipVersionCheck = skipAuth || isHeadless;
|
|
406
|
+
|
|
407
|
+
// Check for updates (unless --skip-auth, --headless, or --force flag)
|
|
408
|
+
if (!skipVersionCheck && !isForce) {
|
|
400
409
|
await checkVersionAndPromptUpdate();
|
|
401
410
|
}
|
|
402
411
|
|
|
403
|
-
if (
|
|
412
|
+
if (isHeadless) {
|
|
413
|
+
info('Installing Claude Git Hooks (headless mode)...');
|
|
414
|
+
} else if (isForce) {
|
|
404
415
|
info('Installing Claude Git Hooks (force mode)...');
|
|
405
416
|
} else {
|
|
406
417
|
info('Installing Claude Git Hooks...');
|
|
@@ -408,7 +419,12 @@ export async function runInstall(args) {
|
|
|
408
419
|
|
|
409
420
|
// v2.0.0+: No sudo needed (pure Node.js, no system packages required)
|
|
410
421
|
// Check dependencies
|
|
411
|
-
|
|
422
|
+
if (!isHeadless) {
|
|
423
|
+
await checkAndInstallDependencies(skipAuth);
|
|
424
|
+
} else {
|
|
425
|
+
// Headless: skip Claude CLI/auth checks and Maven — CI manages its own deps
|
|
426
|
+
info('Skipping dependency checks (headless mode)');
|
|
427
|
+
}
|
|
412
428
|
|
|
413
429
|
const templatesPath = getTemplatesPath();
|
|
414
430
|
const hooksPath = getGitHooksPath();
|
|
@@ -658,33 +674,66 @@ export async function runInstall(args) {
|
|
|
658
674
|
info('settings.local.json created (add your GitHub token here)');
|
|
659
675
|
}
|
|
660
676
|
|
|
661
|
-
// Configure Git
|
|
662
|
-
|
|
677
|
+
// Configure Git — skipped in headless to avoid mutating CI git config
|
|
678
|
+
if (!isHeadless) {
|
|
679
|
+
configureGit();
|
|
680
|
+
}
|
|
663
681
|
|
|
664
|
-
// Update .gitignore
|
|
665
|
-
|
|
682
|
+
// Update .gitignore — skipped in headless: CI repos manage .gitignore explicitly
|
|
683
|
+
if (!isHeadless) {
|
|
684
|
+
updateGitignore();
|
|
685
|
+
}
|
|
666
686
|
|
|
667
|
-
// Install shell completions
|
|
668
|
-
|
|
687
|
+
// Install shell completions — skipped in headless: shell completions are useless in CI
|
|
688
|
+
if (!isHeadless) {
|
|
689
|
+
installCompletions();
|
|
690
|
+
}
|
|
669
691
|
|
|
670
|
-
// Check linter toolchain availability
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
692
|
+
// Check linter toolchain availability — skipped in headless: linter availability is a CI concern
|
|
693
|
+
if (!isHeadless) {
|
|
694
|
+
try {
|
|
695
|
+
const config = await getConfig();
|
|
696
|
+
const presetName = config.preset || 'default';
|
|
697
|
+
if (config.linting?.enabled !== false) {
|
|
698
|
+
const { checkLinterAvailability } = await import('../utils/linter-runner.js');
|
|
699
|
+
await checkLinterAvailability(presetName);
|
|
700
|
+
}
|
|
701
|
+
} catch {
|
|
702
|
+
// Non-fatal — linter check failure should not block installation
|
|
677
703
|
}
|
|
678
|
-
} catch {
|
|
679
|
-
// Non-fatal — linter check failure should not block installation
|
|
680
704
|
}
|
|
681
705
|
|
|
682
|
-
|
|
683
|
-
|
|
706
|
+
if (isHeadless) {
|
|
707
|
+
success('claude-hooks installed (headless mode).');
|
|
708
|
+
} else {
|
|
709
|
+
success('Claude Git Hooks installed successfully! 🎉');
|
|
710
|
+
console.log('\nRun claude-hooks --help to see all available commands.');
|
|
711
|
+
|
|
712
|
+
// Run GitHub token setup
|
|
713
|
+
console.log('');
|
|
714
|
+
await runSetupGitHub();
|
|
715
|
+
}
|
|
684
716
|
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
717
|
+
// Optional SDK verification — opt-in, never automatic
|
|
718
|
+
if (verifySdk) {
|
|
719
|
+
info('Verifying SDK connectivity (1-token ping)...');
|
|
720
|
+
try {
|
|
721
|
+
const { verifySDKConnection } = await import('../utils/claude-client.js');
|
|
722
|
+
const result = await verifySDKConnection();
|
|
723
|
+
if (result.ok) {
|
|
724
|
+
success(`SDK verified: ${result.usage.input_tokens} in / ${result.usage.output_tokens} out tokens.`);
|
|
725
|
+
} else {
|
|
726
|
+
throw new Error(result.error);
|
|
727
|
+
}
|
|
728
|
+
} catch (sdkErr) {
|
|
729
|
+
if (isHeadless) {
|
|
730
|
+
error(`SDK verification failed: ${sdkErr.message}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
} else {
|
|
733
|
+
warning(`SDK verification failed: ${sdkErr.message}. Install completed but SDK is not reachable.`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
688
737
|
}
|
|
689
738
|
|
|
690
739
|
// ── Shell completion generation ──────────────────────────────────────────────
|
package/lib/commands/lint.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* claude-hooks lint src/ lib/utils/ # multiple directories
|
|
9
9
|
* claude-hooks lint file1.js file2.java # specific files
|
|
10
10
|
* claude-hooks lint src/ file3.js lib/ # mix of dirs and files
|
|
11
|
+
* claude-hooks lint --headless --format json # CI mode with JSON output
|
|
11
12
|
*
|
|
12
13
|
* Path resolution (like git add):
|
|
13
14
|
* - Directories → walk and collect files matching preset extensions
|
|
@@ -32,6 +33,28 @@ import { getStagedFiles, getRepoRoot } from '../utils/git-operations.js';
|
|
|
32
33
|
import { runLinters, displayLintResults } from '../utils/linter-runner.js';
|
|
33
34
|
import logger from '../utils/logger.js';
|
|
34
35
|
|
|
36
|
+
// ─── JSON Error Helper ────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit error JSON to stdout and set exit code
|
|
40
|
+
* @param {string} message - Error message
|
|
41
|
+
* @param {number} exitCode - Process exit code (default: 1)
|
|
42
|
+
* @private
|
|
43
|
+
*/
|
|
44
|
+
function _emitErrorJSON(message, exitCode = 1) {
|
|
45
|
+
process.stdout.write(`${JSON.stringify({ status: 'error', error: message })}\n`);
|
|
46
|
+
process.exitCode = exitCode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Emit JSON result to stdout (does not exit — caller handles exit)
|
|
51
|
+
* @param {Object} payload - JSON payload
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
function _emitJSON(payload) {
|
|
55
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
/**
|
|
36
59
|
* Resolve user-provided paths into a flat list of files
|
|
37
60
|
* Handles directories (walked recursively), individual files, and mixtures.
|
|
@@ -116,13 +139,34 @@ function _walkForFiles(dir, extSet, files, depth = 0) {
|
|
|
116
139
|
}
|
|
117
140
|
}
|
|
118
141
|
|
|
142
|
+
// Known flags that should not be treated as paths
|
|
143
|
+
const KNOWN_FLAGS = new Set(['--headless', '--format']);
|
|
144
|
+
|
|
119
145
|
/**
|
|
120
146
|
* Main lint command handler
|
|
121
147
|
*
|
|
122
148
|
* @param {string[]} args - CLI arguments (paths and flags)
|
|
123
149
|
*/
|
|
124
150
|
export async function runLint(args = []) {
|
|
151
|
+
// Parse flags
|
|
152
|
+
const headless = args.includes('--headless');
|
|
153
|
+
const fmtIdx = args.indexOf('--format');
|
|
154
|
+
const format = fmtIdx >= 0 ? args[fmtIdx + 1] : null;
|
|
155
|
+
const isJSON = format === 'json';
|
|
156
|
+
|
|
157
|
+
// Disallowed combo: --format json without --headless
|
|
158
|
+
if (isJSON && !headless) {
|
|
159
|
+
_emitErrorJSON('--format json requires --headless', 2);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Activate JSON mode before any output so info/warning route to stderr
|
|
164
|
+
if (isJSON) logger.setJSONMode(true);
|
|
165
|
+
|
|
166
|
+
const startTime = Date.now();
|
|
167
|
+
|
|
125
168
|
if (!checkGitRepo()) {
|
|
169
|
+
if (isJSON) { _emitErrorJSON('Not a git repository'); return; }
|
|
126
170
|
error('Not a git repository');
|
|
127
171
|
process.exit(1);
|
|
128
172
|
}
|
|
@@ -134,6 +178,19 @@ export async function runLint(args = []) {
|
|
|
134
178
|
}
|
|
135
179
|
|
|
136
180
|
if (config.linting?.enabled === false) {
|
|
181
|
+
if (isJSON) {
|
|
182
|
+
_emitJSON({
|
|
183
|
+
status: 'disabled',
|
|
184
|
+
preset: config.preset || 'default',
|
|
185
|
+
fileCount: 0,
|
|
186
|
+
totalErrors: 0,
|
|
187
|
+
totalWarnings: 0,
|
|
188
|
+
durationMs: Date.now() - startTime,
|
|
189
|
+
tools: []
|
|
190
|
+
});
|
|
191
|
+
process.exit(0);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
137
194
|
info('Linting is disabled in configuration');
|
|
138
195
|
return;
|
|
139
196
|
}
|
|
@@ -142,8 +199,14 @@ export async function runLint(args = []) {
|
|
|
142
199
|
const presetName = config.preset || 'default';
|
|
143
200
|
const { metadata } = await loadPreset(presetName);
|
|
144
201
|
|
|
145
|
-
// Separate flags from paths
|
|
146
|
-
const paths = args.filter((a) =>
|
|
202
|
+
// Separate flags from paths — filter out known flags and their values
|
|
203
|
+
const paths = args.filter((a, i) => {
|
|
204
|
+
if (KNOWN_FLAGS.has(a)) return false;
|
|
205
|
+
// Skip value immediately after a flag that takes a value
|
|
206
|
+
if (i > 0 && args[i - 1] === '--format') return false;
|
|
207
|
+
if (a.startsWith('--')) return false;
|
|
208
|
+
return true;
|
|
209
|
+
});
|
|
147
210
|
|
|
148
211
|
let filesToLint;
|
|
149
212
|
|
|
@@ -153,6 +216,19 @@ export async function runLint(args = []) {
|
|
|
153
216
|
const stagedFiles = getStagedFiles({ extensions: metadata.fileExtensions });
|
|
154
217
|
|
|
155
218
|
if (stagedFiles.length === 0) {
|
|
219
|
+
if (isJSON) {
|
|
220
|
+
_emitJSON({
|
|
221
|
+
status: 'no-files',
|
|
222
|
+
preset: presetName,
|
|
223
|
+
fileCount: 0,
|
|
224
|
+
totalErrors: 0,
|
|
225
|
+
totalWarnings: 0,
|
|
226
|
+
durationMs: Date.now() - startTime,
|
|
227
|
+
tools: []
|
|
228
|
+
});
|
|
229
|
+
process.exit(0);
|
|
230
|
+
return; // guard test-mode fallthrough
|
|
231
|
+
}
|
|
156
232
|
info('No staged files to lint');
|
|
157
233
|
return;
|
|
158
234
|
}
|
|
@@ -163,6 +239,18 @@ export async function runLint(args = []) {
|
|
|
163
239
|
filesToLint = resolvePaths(paths, metadata.fileExtensions, repoRoot);
|
|
164
240
|
|
|
165
241
|
if (filesToLint.length === 0) {
|
|
242
|
+
if (isJSON) {
|
|
243
|
+
_emitJSON({
|
|
244
|
+
status: 'no-files',
|
|
245
|
+
preset: presetName,
|
|
246
|
+
fileCount: 0,
|
|
247
|
+
totalErrors: 0,
|
|
248
|
+
totalWarnings: 0,
|
|
249
|
+
durationMs: Date.now() - startTime,
|
|
250
|
+
tools: []
|
|
251
|
+
});
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
166
254
|
info('No matching files found in specified paths');
|
|
167
255
|
return;
|
|
168
256
|
}
|
|
@@ -171,12 +259,40 @@ export async function runLint(args = []) {
|
|
|
171
259
|
info(`🎯 Linting ${filesToLint.length} file(s) with '${metadata.displayName}' preset`);
|
|
172
260
|
|
|
173
261
|
const lintResult = await runLinters(filesToLint, config, presetName);
|
|
174
|
-
displayLintResults(lintResult);
|
|
175
262
|
|
|
176
|
-
|
|
263
|
+
if (!isJSON) {
|
|
264
|
+
displayLintResults(lintResult);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Determine failure status
|
|
177
268
|
const failOnError = config.linting?.failOnError !== false;
|
|
178
269
|
const failOnWarning = config.linting?.failOnWarning === true;
|
|
270
|
+
const shouldFail =
|
|
271
|
+
(failOnError && lintResult.totalErrors > 0) ||
|
|
272
|
+
(failOnWarning && lintResult.totalWarnings > 0);
|
|
179
273
|
|
|
274
|
+
if (isJSON) {
|
|
275
|
+
_emitJSON({
|
|
276
|
+
status: shouldFail ? 'fail' : 'pass',
|
|
277
|
+
preset: presetName,
|
|
278
|
+
fileCount: filesToLint.length,
|
|
279
|
+
totalErrors: lintResult.totalErrors,
|
|
280
|
+
totalWarnings: lintResult.totalWarnings,
|
|
281
|
+
durationMs: Date.now() - startTime,
|
|
282
|
+
tools: lintResult.results.map((r) => ({
|
|
283
|
+
name: r.tool,
|
|
284
|
+
files: r.files || 0,
|
|
285
|
+
errors: r.errors.length,
|
|
286
|
+
warnings: r.warnings.length,
|
|
287
|
+
fixed: r.fixedCount || 0,
|
|
288
|
+
issues: [...r.errors, ...r.warnings]
|
|
289
|
+
}))
|
|
290
|
+
});
|
|
291
|
+
process.exit(shouldFail ? 1 : 0);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Exit with error code if linting failed
|
|
180
296
|
if (failOnError && lintResult.totalErrors > 0) {
|
|
181
297
|
process.exit(1);
|
|
182
298
|
}
|
package/lib/config.js
CHANGED
|
@@ -33,7 +33,7 @@ const HARDCODED = {
|
|
|
33
33
|
analysis: {
|
|
34
34
|
maxFileSize: 1000000, // 1MB - sufficient for most files
|
|
35
35
|
maxFiles: 30, // Reasonable limit per commit
|
|
36
|
-
timeout:
|
|
36
|
+
timeout: 360000, // 6 minutes - adequate for Claude API
|
|
37
37
|
contextLines: 3, // Git default
|
|
38
38
|
ignoreExtensions: [] // Can be set in advanced config only
|
|
39
39
|
},
|
|
@@ -91,6 +91,9 @@ const HARDCODED = {
|
|
|
91
91
|
failOnError: true, // Block commit on linting errors
|
|
92
92
|
failOnWarning: false, // Do not block on warnings
|
|
93
93
|
timeout: 30000 // 30s per linter
|
|
94
|
+
},
|
|
95
|
+
claude: {
|
|
96
|
+
defaultModel: 'sonnet' // Fallback model for SDK headless mode
|
|
94
97
|
}
|
|
95
98
|
};
|
|
96
99
|
|
package/lib/hooks/pre-commit.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - resolution-prompt: Issue resolution generation
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { getStagedFiles, getRepoRoot } from '../utils/git-operations.js';
|
|
22
|
+
import { getStagedFiles, getRepoRoot, getStagedTreeSha } from '../utils/git-operations.js';
|
|
23
|
+
import { writeMarker } from '../utils/hooks-verified-marker.js';
|
|
23
24
|
import { filterFiles } from '../utils/file-operations.js';
|
|
24
25
|
import {
|
|
25
26
|
buildFilesData,
|
|
@@ -53,6 +54,10 @@ import { runLinters, displayLintResults, lintIssuesToAnalysisDetails } from '../
|
|
|
53
54
|
const main = async () => {
|
|
54
55
|
const startTime = Date.now();
|
|
55
56
|
|
|
57
|
+
// Headless mode: activated by env var for CI/ECS environments (CT-805)
|
|
58
|
+
// Declared before try/catch so the catch block can show appropriate error messages
|
|
59
|
+
const isHeadless = process.env.CLAUDE_HOOKS_HEADLESS === '1';
|
|
60
|
+
|
|
56
61
|
try {
|
|
57
62
|
// Load configuration
|
|
58
63
|
const config = await getConfig();
|
|
@@ -194,7 +199,8 @@ const main = async () => {
|
|
|
194
199
|
// Step 6: Run analysis using shared engine
|
|
195
200
|
const result = await runAnalysis(filesData, config, {
|
|
196
201
|
hook: 'pre-commit',
|
|
197
|
-
saveDebug: config.system.debug
|
|
202
|
+
saveDebug: config.system.debug,
|
|
203
|
+
headless: isHeadless
|
|
198
204
|
});
|
|
199
205
|
|
|
200
206
|
// Step 7: Display results using shared function
|
|
@@ -234,7 +240,7 @@ const main = async () => {
|
|
|
234
240
|
if (judgeModule) {
|
|
235
241
|
try {
|
|
236
242
|
const { judgeAndFix } = judgeModule;
|
|
237
|
-
const judgeResult = await judgeAndFix(result, filesData, config);
|
|
243
|
+
const judgeResult = await judgeAndFix(result, filesData, config, { headless: isHeadless });
|
|
238
244
|
|
|
239
245
|
// Update result with remaining issues (fixed + false positives removed)
|
|
240
246
|
result.blockingIssues = judgeResult.remainingIssues.filter((i) =>
|
|
@@ -341,6 +347,16 @@ const main = async () => {
|
|
|
341
347
|
console.log(`\n⏱️ Analysis time: ${duration}s`);
|
|
342
348
|
logger.success('Code analysis completed. Quality gate passed.');
|
|
343
349
|
|
|
350
|
+
// Write hooks-verified marker for prepare-commit-msg trailer
|
|
351
|
+
try {
|
|
352
|
+
const treeSha = getStagedTreeSha();
|
|
353
|
+
writeMarker(repoRoot, treeSha, version);
|
|
354
|
+
logger.debug('pre-commit - main', 'Hooks-verified marker written', { treeSha });
|
|
355
|
+
} catch (markerErr) {
|
|
356
|
+
// Non-fatal: trailer won't be added but commit still succeeds
|
|
357
|
+
logger.warning(`Could not write hooks-verified marker: ${markerErr.message}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
344
360
|
process.exit(0);
|
|
345
361
|
} catch (error) {
|
|
346
362
|
// Record analysis failure metric
|
|
@@ -356,8 +372,13 @@ const main = async () => {
|
|
|
356
372
|
|
|
357
373
|
logger.error('pre-commit - main', 'Pre-commit hook failed', error);
|
|
358
374
|
|
|
359
|
-
|
|
360
|
-
|
|
375
|
+
if (isHeadless) {
|
|
376
|
+
console.error('\nError executing Claude SDK');
|
|
377
|
+
console.error('Check that ANTHROPIC_API_KEY is set correctly');
|
|
378
|
+
} else {
|
|
379
|
+
console.error('\nError executing Claude CLI');
|
|
380
|
+
console.error('Check that Claude CLI is configured correctly');
|
|
381
|
+
}
|
|
361
382
|
|
|
362
383
|
if (error.output) {
|
|
363
384
|
console.error('\nClaude CLI output:');
|
|
@@ -18,7 +18,15 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import fs from 'fs/promises';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
getStagedFiles,
|
|
23
|
+
getStagedStats,
|
|
24
|
+
getFileDiff,
|
|
25
|
+
getRepoRoot,
|
|
26
|
+
getStagedTreeSha,
|
|
27
|
+
appendTrailer
|
|
28
|
+
} from '../utils/git-operations.js';
|
|
29
|
+
import { readMarker, consumeMarker, validateMarker } from '../utils/hooks-verified-marker.js';
|
|
22
30
|
import { analyzeCode } from '../utils/claude-client.js';
|
|
23
31
|
import { loadPrompt } from '../utils/prompt-builder.js';
|
|
24
32
|
import { getVersion } from '../utils/package-info.js';
|
|
@@ -119,6 +127,9 @@ const main = async () => {
|
|
|
119
127
|
// Load configuration (includes preset + user overrides)
|
|
120
128
|
const config = await getConfig();
|
|
121
129
|
|
|
130
|
+
// Headless mode: activated by env var for CI/ECS environments (CT-805)
|
|
131
|
+
const isHeadless = process.env.CLAUDE_HOOKS_HEADLESS === '1';
|
|
132
|
+
|
|
122
133
|
// Enable debug mode from config
|
|
123
134
|
if (config.system.debug) {
|
|
124
135
|
logger.setDebugMode(true);
|
|
@@ -134,11 +145,44 @@ const main = async () => {
|
|
|
134
145
|
|
|
135
146
|
// Only process normal commits
|
|
136
147
|
// Why: Don't interfere with merge commits, amend, squash, etc.
|
|
148
|
+
// Marker is NOT consumed here — stays for the next eligible commit
|
|
137
149
|
if (commitSource && commitSource !== 'message') {
|
|
138
150
|
logger.debug('prepare-commit-msg - main', `Skipping: commit source is ${commitSource}`);
|
|
139
151
|
process.exit(0);
|
|
140
152
|
}
|
|
141
153
|
|
|
154
|
+
// Hooks-Verified trailer: validate marker now, append trailer at the very end.
|
|
155
|
+
// Why: The auto-message flow overwrites the commit file, so we can't append
|
|
156
|
+
// the trailer until the final message is written. We validate+consume the marker
|
|
157
|
+
// early (before the auto check) and remember the result for later.
|
|
158
|
+
const repoRoot = getRepoRoot();
|
|
159
|
+
const marker = readMarker(repoRoot);
|
|
160
|
+
let shouldAppendTrailer = false;
|
|
161
|
+
|
|
162
|
+
if (marker) {
|
|
163
|
+
let currentTreeSha;
|
|
164
|
+
try {
|
|
165
|
+
currentTreeSha = getStagedTreeSha();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
logger.debug('prepare-commit-msg - main', 'Could not compute staged tree SHA', {
|
|
168
|
+
err: err.message
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (currentTreeSha && validateMarker(marker, currentTreeSha)) {
|
|
173
|
+
shouldAppendTrailer = true;
|
|
174
|
+
logger.debug('prepare-commit-msg - main', 'Marker valid; trailer will be appended after message is finalized');
|
|
175
|
+
} else {
|
|
176
|
+
logger.debug(
|
|
177
|
+
'prepare-commit-msg - main',
|
|
178
|
+
'Marker stale (tree mismatch); ignoring'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Always consume marker (whether matched or not) — no stale carryover
|
|
183
|
+
consumeMarker(repoRoot);
|
|
184
|
+
}
|
|
185
|
+
|
|
142
186
|
// Read current message
|
|
143
187
|
const currentMsg = await fs.readFile(commitMsgFile, 'utf8');
|
|
144
188
|
const firstLine = currentMsg.split('\n')[0].trim();
|
|
@@ -147,6 +191,16 @@ const main = async () => {
|
|
|
147
191
|
|
|
148
192
|
// Check if message is "auto"
|
|
149
193
|
if (firstLine !== config.commitMessage.autoKeyword) {
|
|
194
|
+
// Not auto — append trailer to the manual message and exit
|
|
195
|
+
if (shouldAppendTrailer) {
|
|
196
|
+
try {
|
|
197
|
+
const withTrailer = appendTrailer(currentMsg, 'Hooks-Verified', 'true');
|
|
198
|
+
await fs.writeFile(commitMsgFile, withTrailer, 'utf8');
|
|
199
|
+
logger.debug('prepare-commit-msg - main', 'Hooks-Verified trailer appended to manual message');
|
|
200
|
+
} catch (trailerErr) {
|
|
201
|
+
logger.warning(`Could not append Hooks-Verified trailer: ${trailerErr.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
150
204
|
logger.debug('prepare-commit-msg - main', 'Not generating: message is not "auto"');
|
|
151
205
|
process.exit(0);
|
|
152
206
|
}
|
|
@@ -243,7 +297,8 @@ const main = async () => {
|
|
|
243
297
|
const response = await analyzeCode(prompt, {
|
|
244
298
|
timeout: config.commitMessage.timeout,
|
|
245
299
|
saveDebug: config.system.debug,
|
|
246
|
-
telemetryContext
|
|
300
|
+
telemetryContext,
|
|
301
|
+
headless: isHeadless
|
|
247
302
|
});
|
|
248
303
|
|
|
249
304
|
logger.debug('prepare-commit-msg - main', 'Response received', {
|
|
@@ -266,6 +321,20 @@ const main = async () => {
|
|
|
266
321
|
// Write to commit message file
|
|
267
322
|
await fs.writeFile(commitMsgFile, `${message}\n`, 'utf8');
|
|
268
323
|
|
|
324
|
+
// Append trailer to the auto-generated message
|
|
325
|
+
if (shouldAppendTrailer) {
|
|
326
|
+
try {
|
|
327
|
+
const generated = await fs.readFile(commitMsgFile, 'utf8');
|
|
328
|
+
const withTrailer = appendTrailer(generated, 'Hooks-Verified', 'true');
|
|
329
|
+
await fs.writeFile(commitMsgFile, withTrailer, 'utf8');
|
|
330
|
+
logger.debug('prepare-commit-msg - main', 'Hooks-Verified trailer appended to auto message');
|
|
331
|
+
} catch (trailerErr) {
|
|
332
|
+
logger.warning(
|
|
333
|
+
`Could not append Hooks-Verified trailer: ${trailerErr.message}`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
269
338
|
// Record commit generation metric
|
|
270
339
|
recordMetric('commit.generated', {
|
|
271
340
|
fileCount: filesData.length,
|
|
@@ -292,8 +361,13 @@ const main = async () => {
|
|
|
292
361
|
|
|
293
362
|
logger.error('prepare-commit-msg - main', 'Failed to generate commit message', error);
|
|
294
363
|
|
|
295
|
-
|
|
296
|
-
|
|
364
|
+
if (isHeadless) {
|
|
365
|
+
logger.warning('Could not generate message automatically via SDK');
|
|
366
|
+
logger.warning('Check that ANTHROPIC_API_KEY is set correctly');
|
|
367
|
+
} else {
|
|
368
|
+
logger.warning('Could not generate message automatically with Claude');
|
|
369
|
+
logger.warning('Commit canceled. Run again without "auto" to write manual message');
|
|
370
|
+
}
|
|
297
371
|
|
|
298
372
|
process.exit(1);
|
|
299
373
|
}
|