claude-git-hooks 2.35.3 → 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.
@@ -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
- console.error(`${colors.red}❌ ${message}${colors.reset}`);
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
- log(`✅ ${message}`, 'green');
51
+ logger.success(message);
47
52
  }
48
53
 
49
54
  export function info(message) {
50
- log(`ℹ️ ${message}`, 'blue');
55
+ logger.info(message);
51
56
  }
52
57
 
53
58
  export function warning(message) {
54
- log(`⚠️ ${message}`, 'yellow');
59
+ logger.warning(message);
55
60
  }
56
61
 
57
62
  /**
@@ -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
- error('You are not in a Git repository.');
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
- error('You are not in a Git repository.');
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
- error('You are not in a Git repository.');
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
- error('You are not in a Git repository.');
131
+ fatal('You are not in a Git repository.');
132
132
  }
133
133
 
134
134
  info('Uninstalling Claude Git Hooks...');
@@ -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
- error('Node.js is not installed. Install Node.js and try again.');
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
- error('npm is not installed.');
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
- error('Git is not installed. Install Git and try again.');
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
- error(
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
- // Check for updates (unless --skip-auth flag)
399
- if (!skipAuth && !isForce) {
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 (isForce) {
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
- await checkAndInstallDependencies(skipAuth);
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
- configureGit();
677
+ // Configure Git — skipped in headless to avoid mutating CI git config
678
+ if (!isHeadless) {
679
+ configureGit();
680
+ }
663
681
 
664
- // Update .gitignore
665
- updateGitignore();
682
+ // Update .gitignore — skipped in headless: CI repos manage .gitignore explicitly
683
+ if (!isHeadless) {
684
+ updateGitignore();
685
+ }
666
686
 
667
- // Install shell completions
668
- installCompletions();
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 for the selected preset
671
- try {
672
- const config = await getConfig();
673
- const presetName = config.preset || 'default';
674
- if (config.linting?.enabled !== false) {
675
- const { checkLinterAvailability } = await import('../utils/linter-runner.js');
676
- await checkLinterAvailability(presetName);
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
- success('Claude Git Hooks installed successfully! 🎉');
683
- console.log('\nRun claude-hooks --help to see all available commands.');
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
- // Run GitHub token setup
686
- console.log('');
687
- await runSetupGitHub();
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 ──────────────────────────────────────────────
@@ -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
  }