claude-git-hooks 2.35.3 → 2.44.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.
@@ -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) => !a.startsWith('--'));
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
- // Exit with error code if linting failed
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
@@ -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
 
@@ -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
- console.error('\nError executing Claude CLI');
360
- console.error('Check that Claude CLI is configured correctly');
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 { getStagedFiles, getStagedStats, getFileDiff } from '../utils/git-operations.js';
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
- logger.warning('Could not generate message automatically with Claude');
296
- logger.warning('Commit canceled. Run again without "auto" to write manual message');
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
  }
@@ -278,7 +278,8 @@ export const createEmptyResult = () => ({
278
278
  *
279
279
  * @param {Object} result - Analysis result
280
280
  */
281
- export const displayIssueSummary = (result) => {
281
+ export const displayIssueSummary = (result, { silent = false } = {}) => {
282
+ if (silent) return;
282
283
  const { blocker = 0, critical = 0, major = 0, minor = 0, info = 0 } = result.issues || {};
283
284
  const total = blocker + critical + major + minor + info;
284
285
 
@@ -296,7 +297,8 @@ export const displayIssueSummary = (result) => {
296
297
  *
297
298
  * @param {Object} result - Analysis result from Claude
298
299
  */
299
- export const displayResults = (result) => {
300
+ export const displayResults = (result, { silent = false } = {}) => {
301
+ if (silent) return;
300
302
  console.log();
301
303
  console.log('╔════════════════════════════════════════════════════════════════════╗');
302
304
  console.log('║ CODE QUALITY ANALYSIS ║');
@@ -376,7 +378,7 @@ const ORCHESTRATOR_THRESHOLD = 3;
376
378
  * @returns {Promise<Object>} Analysis result
377
379
  */
378
380
  export const runAnalysis = async (filesData, config, options = {}) => {
379
- const { saveDebug = config.system?.debug, hook = 'analysis' } = options;
381
+ const { saveDebug = config.system?.debug, hook = 'analysis', headless = false, costTracker = null } = options;
380
382
 
381
383
  if (filesData.length === 0) {
382
384
  logger.debug('analysis-engine - runAnalysis', 'No files to analyze');
@@ -396,7 +398,7 @@ export const runAnalysis = async (filesData, config, options = {}) => {
396
398
  if (useOrchestrator) {
397
399
  // Orchestrated parallel execution: Opus groups files semantically
398
400
  const orchestrationStart = Date.now();
399
- const orchestration = await orchestrateBatches(filesData);
401
+ const orchestration = await orchestrateBatches(filesData, { headless });
400
402
  const orchestrationTime = Date.now() - orchestrationStart;
401
403
 
402
404
  const { batches, commonContext } = orchestration;
@@ -442,7 +444,9 @@ export const runAnalysis = async (filesData, config, options = {}) => {
442
444
  timeout: config.analysis?.timeout,
443
445
  model: batch.model,
444
446
  saveDebug: false,
445
- telemetryContext
447
+ telemetryContext,
448
+ headless,
449
+ costTracker
446
450
  });
447
451
  })
448
452
  );
@@ -489,7 +493,9 @@ export const runAnalysis = async (filesData, config, options = {}) => {
489
493
  result = await analyzeCode(prompt, {
490
494
  timeout: config.analysis?.timeout,
491
495
  saveDebug,
492
- telemetryContext
496
+ telemetryContext,
497
+ headless,
498
+ costTracker
493
499
  });
494
500
  }
495
501