claude-git-hooks 2.8.0 → 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/CHANGELOG.md +157 -0
- package/README.md +209 -749
- package/bin/claude-hooks +97 -2235
- package/lib/commands/analyze-diff.js +262 -0
- package/lib/commands/create-pr.js +374 -0
- package/lib/commands/debug.js +52 -0
- package/lib/commands/help.js +147 -0
- package/lib/commands/helpers.js +389 -0
- package/lib/commands/hooks.js +150 -0
- package/lib/commands/install.js +688 -0
- package/lib/commands/migrate-config.js +103 -0
- package/lib/commands/presets.js +101 -0
- package/lib/commands/setup-github.js +93 -0
- package/lib/commands/telemetry-cmd.js +48 -0
- package/lib/commands/update.js +67 -0
- package/lib/config.js +1 -0
- package/lib/hooks/pre-commit.js +21 -2
- package/lib/hooks/prepare-commit-msg.js +13 -1
- package/lib/utils/claude-client.js +103 -20
- package/lib/utils/github-api.js +87 -17
- package/lib/utils/github-client.js +9 -550
- package/lib/utils/prompt-builder.js +10 -9
- package/lib/utils/resolution-prompt.js +2 -2
- package/lib/utils/telemetry.js +507 -0
- package/package.json +1 -1
- package/lib/utils/mcp-setup.js +0 -342
|
@@ -23,6 +23,7 @@ import logger from './logger.js';
|
|
|
23
23
|
import config from '../config.js';
|
|
24
24
|
import { detectClaudeError, formatClaudeError, ClaudeErrorType, isRecoverableError } from './claude-diagnostics.js';
|
|
25
25
|
import { which } from './which-command.js';
|
|
26
|
+
import { recordJsonParseFailure, recordBatchSuccess, rotateTelemetry } from './telemetry.js';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Custom error for Claude client failures
|
|
@@ -526,10 +527,11 @@ const executeClaudeInteractive = (prompt, { timeout = 300000 } = {}) => new Prom
|
|
|
526
527
|
* Why: Claude may include markdown formatting or explanatory text around JSON
|
|
527
528
|
*
|
|
528
529
|
* @param {string} response - Raw response from Claude
|
|
530
|
+
* @param {Object} telemetryContext - Context for telemetry recording (optional)
|
|
529
531
|
* @returns {Object} Parsed JSON object
|
|
530
532
|
* @throws {ClaudeClientError} If no valid JSON found
|
|
531
533
|
*/
|
|
532
|
-
const extractJSON = (response) => {
|
|
534
|
+
const extractJSON = (response, telemetryContext = {}) => {
|
|
533
535
|
logger.debug(
|
|
534
536
|
'claude-client - extractJSON',
|
|
535
537
|
'Extracting JSON from response',
|
|
@@ -594,16 +596,24 @@ const extractJSON = (response) => {
|
|
|
594
596
|
}
|
|
595
597
|
|
|
596
598
|
// No valid JSON found
|
|
599
|
+
const responsePreview = response.substring(0, 500);
|
|
600
|
+
|
|
597
601
|
logger.error(
|
|
598
602
|
'claude-client - extractJSON',
|
|
599
603
|
'No valid JSON found in response',
|
|
600
604
|
new ClaudeClientError('No valid JSON in response', {
|
|
601
|
-
output:
|
|
605
|
+
output: responsePreview
|
|
602
606
|
})
|
|
603
607
|
);
|
|
604
608
|
|
|
609
|
+
// Telemetry is now recorded by withRetry wrapper
|
|
605
610
|
throw new ClaudeClientError('No valid JSON found in Claude response', {
|
|
606
|
-
|
|
611
|
+
context: {
|
|
612
|
+
response: response,
|
|
613
|
+
errorInfo: {
|
|
614
|
+
type: 'JSON_PARSE_ERROR'
|
|
615
|
+
}
|
|
616
|
+
}
|
|
607
617
|
});
|
|
608
618
|
};
|
|
609
619
|
|
|
@@ -661,8 +671,8 @@ const saveDebugResponse = async (prompt, response, filename = config.output.debu
|
|
|
661
671
|
};
|
|
662
672
|
|
|
663
673
|
/**
|
|
664
|
-
* Wraps an async function with retry logic for recoverable errors
|
|
665
|
-
* Why: Reusable retry logic for Claude CLI operations
|
|
674
|
+
* Wraps an async function with retry logic for recoverable errors and telemetry tracking
|
|
675
|
+
* Why: Reusable retry logic for Claude CLI operations with per-attempt telemetry
|
|
666
676
|
*
|
|
667
677
|
* @param {Function} fn - Async function to execute with retry
|
|
668
678
|
* @param {Object} options - Retry options
|
|
@@ -670,15 +680,50 @@ const saveDebugResponse = async (prompt, response, filename = config.output.debu
|
|
|
670
680
|
* @param {number} options.baseRetryDelay - Base delay in ms (default: 2000)
|
|
671
681
|
* @param {number} options.retryCount - Current retry attempt (internal, default: 0)
|
|
672
682
|
* @param {string} options.operationName - Name for logging (default: 'operation')
|
|
683
|
+
* @param {Object} options.telemetryContext - Context for telemetry recording (optional)
|
|
673
684
|
* @returns {Promise<any>} Result from fn
|
|
674
685
|
* @throws {Error} If fn fails after all retries
|
|
675
686
|
*/
|
|
676
|
-
const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation' } = {}) => {
|
|
687
|
+
const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation', telemetryContext = null } = {}) => {
|
|
677
688
|
const retryDelay = baseRetryDelay * Math.pow(2, retryCount);
|
|
689
|
+
const startTime = Date.now();
|
|
678
690
|
|
|
679
691
|
try {
|
|
680
|
-
|
|
692
|
+
const result = await fn();
|
|
693
|
+
|
|
694
|
+
// Record success telemetry if context provided
|
|
695
|
+
if (telemetryContext) {
|
|
696
|
+
const duration = Date.now() - startTime;
|
|
697
|
+
await recordBatchSuccess({
|
|
698
|
+
...telemetryContext,
|
|
699
|
+
duration,
|
|
700
|
+
retryAttempt: retryCount,
|
|
701
|
+
totalRetries: maxRetries
|
|
702
|
+
}).catch(err => {
|
|
703
|
+
logger.debug('claude-client - withRetry', 'Failed to record success telemetry', err);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return result;
|
|
681
708
|
} catch (error) {
|
|
709
|
+
// Record failure telemetry if context provided (before retry decision)
|
|
710
|
+
if (telemetryContext) {
|
|
711
|
+
const errorType = error.context?.errorInfo?.type || error.name || 'UNKNOWN_ERROR';
|
|
712
|
+
const errorMessage = error.message || 'Unknown error';
|
|
713
|
+
|
|
714
|
+
await recordJsonParseFailure({
|
|
715
|
+
...telemetryContext,
|
|
716
|
+
errorType,
|
|
717
|
+
errorMessage,
|
|
718
|
+
retryAttempt: retryCount,
|
|
719
|
+
totalRetries: maxRetries,
|
|
720
|
+
responseLength: error.context?.response?.length || 0,
|
|
721
|
+
responsePreview: error.context?.response?.substring(0, 100) || ''
|
|
722
|
+
}).catch(err => {
|
|
723
|
+
logger.debug('claude-client - withRetry', 'Failed to record failure telemetry', err);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
682
727
|
// Check if error is recoverable and we haven't exceeded retry limit
|
|
683
728
|
const hasContext = !!error.context;
|
|
684
729
|
const hasErrorInfo = !!error.context?.errorInfo;
|
|
@@ -714,8 +759,8 @@ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount
|
|
|
714
759
|
// Wait before retry
|
|
715
760
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
716
761
|
|
|
717
|
-
// Retry with incremented count
|
|
718
|
-
return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName });
|
|
762
|
+
// Retry with incremented count and same telemetry context
|
|
763
|
+
return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName, telemetryContext });
|
|
719
764
|
}
|
|
720
765
|
|
|
721
766
|
// Add retry attempt to error context if not already present
|
|
@@ -730,17 +775,25 @@ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount
|
|
|
730
775
|
};
|
|
731
776
|
|
|
732
777
|
/**
|
|
733
|
-
* Executes Claude CLI with retry logic
|
|
734
|
-
* Why: Provides retry capability for executeClaude calls
|
|
778
|
+
* Executes Claude CLI with retry logic and telemetry tracking
|
|
779
|
+
* Why: Provides retry capability for executeClaude calls with per-attempt telemetry
|
|
735
780
|
*
|
|
736
781
|
* @param {string} prompt - Prompt text
|
|
737
|
-
* @param {Object} options - Execution options
|
|
782
|
+
* @param {Object} options - Execution options
|
|
783
|
+
* @param {number} options.timeout - Timeout in milliseconds
|
|
784
|
+
* @param {Array<string>} options.allowedTools - Allowed tools for Claude
|
|
785
|
+
* @param {Object} options.telemetryContext - Context for telemetry (fileCount, hook, etc.)
|
|
738
786
|
* @returns {Promise<string>} Claude's response
|
|
739
787
|
*/
|
|
740
788
|
const executeClaudeWithRetry = async (prompt, options = {}) => {
|
|
789
|
+
const { telemetryContext = {}, ...executeOptions } = options;
|
|
790
|
+
|
|
741
791
|
return withRetry(
|
|
742
|
-
() => executeClaude(prompt,
|
|
743
|
-
{
|
|
792
|
+
() => executeClaude(prompt, executeOptions),
|
|
793
|
+
{
|
|
794
|
+
operationName: 'executeClaude',
|
|
795
|
+
telemetryContext: telemetryContext
|
|
796
|
+
}
|
|
744
797
|
);
|
|
745
798
|
};
|
|
746
799
|
|
|
@@ -752,17 +805,25 @@ const executeClaudeWithRetry = async (prompt, options = {}) => {
|
|
|
752
805
|
* @param {Object} options - Analysis options
|
|
753
806
|
* @param {number} options.timeout - Timeout in milliseconds
|
|
754
807
|
* @param {boolean} options.saveDebug - Save response to debug file (default: from config)
|
|
808
|
+
* @param {Object} options.telemetryContext - Context for telemetry (fileCount, batchSize, etc.)
|
|
755
809
|
* @returns {Promise<Object>} Parsed analysis result
|
|
756
810
|
* @throws {ClaudeClientError} If analysis fails
|
|
757
811
|
*/
|
|
758
|
-
const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug } = {}) => {
|
|
812
|
+
const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug, telemetryContext = {} } = {}) => {
|
|
813
|
+
const startTime = Date.now();
|
|
814
|
+
|
|
759
815
|
logger.debug(
|
|
760
816
|
'claude-client - analyzeCode',
|
|
761
817
|
'Starting code analysis',
|
|
762
818
|
{ promptLength: prompt.length, timeout, saveDebug }
|
|
763
819
|
);
|
|
764
820
|
|
|
765
|
-
//
|
|
821
|
+
// Rotate telemetry files periodically
|
|
822
|
+
rotateTelemetry().catch(err => {
|
|
823
|
+
logger.debug('claude-client - analyzeCode', 'Failed to rotate telemetry', err);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// Use withRetry to wrap the entire analysis flow (with telemetry tracking)
|
|
766
827
|
return withRetry(
|
|
767
828
|
async () => {
|
|
768
829
|
// Execute Claude CLI
|
|
@@ -774,7 +835,9 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system
|
|
|
774
835
|
}
|
|
775
836
|
|
|
776
837
|
// Extract and parse JSON
|
|
777
|
-
const result = extractJSON(response);
|
|
838
|
+
const result = extractJSON(response, telemetryContext);
|
|
839
|
+
|
|
840
|
+
const duration = Date.now() - startTime;
|
|
778
841
|
|
|
779
842
|
logger.debug(
|
|
780
843
|
'claude-client - analyzeCode',
|
|
@@ -782,13 +845,21 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system
|
|
|
782
845
|
{
|
|
783
846
|
hasApproved: 'approved' in result,
|
|
784
847
|
hasQualityGate: 'QUALITY_GATE' in result,
|
|
785
|
-
blockingIssuesCount: result.blockingIssues?.length ?? 0
|
|
848
|
+
blockingIssuesCount: result.blockingIssues?.length ?? 0,
|
|
849
|
+
duration
|
|
786
850
|
}
|
|
787
851
|
);
|
|
788
852
|
|
|
853
|
+
// Telemetry is now recorded by withRetry wrapper
|
|
789
854
|
return result;
|
|
790
855
|
},
|
|
791
|
-
{
|
|
856
|
+
{
|
|
857
|
+
operationName: 'analyzeCode',
|
|
858
|
+
telemetryContext: {
|
|
859
|
+
responseLength: 0, // Will be updated in withRetry
|
|
860
|
+
...telemetryContext
|
|
861
|
+
}
|
|
862
|
+
}
|
|
792
863
|
);
|
|
793
864
|
};
|
|
794
865
|
|
|
@@ -810,6 +881,7 @@ const chunkArray = (array, size) => {
|
|
|
810
881
|
* Runs multiple analyzeCode calls in parallel
|
|
811
882
|
* @param {Array<string>} prompts - Array of prompts to analyze
|
|
812
883
|
* @param {Object} options - Same options as analyzeCode
|
|
884
|
+
* @param {Object} options.telemetryContext - Base telemetry context (will be augmented per batch)
|
|
813
885
|
* @returns {Promise<Array<Object>>} Array of results
|
|
814
886
|
*/
|
|
815
887
|
const analyzeCodeParallel = async (prompts, options = {}) => {
|
|
@@ -824,7 +896,18 @@ const analyzeCodeParallel = async (prompts, options = {}) => {
|
|
|
824
896
|
const promises = prompts.map((prompt, index) => {
|
|
825
897
|
console.log(` ⚡ Launching batch ${index + 1}/${prompts.length}...`);
|
|
826
898
|
logger.debug('claude-client - analyzeCodeParallel', `Starting batch ${index + 1}`);
|
|
827
|
-
|
|
899
|
+
|
|
900
|
+
// Augment telemetry context with batch info
|
|
901
|
+
const batchTelemetryContext = {
|
|
902
|
+
...(options.telemetryContext || {}),
|
|
903
|
+
batchIndex: index,
|
|
904
|
+
totalBatches: prompts.length
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
return analyzeCode(prompt, {
|
|
908
|
+
...options,
|
|
909
|
+
telemetryContext: batchTelemetryContext
|
|
910
|
+
});
|
|
828
911
|
});
|
|
829
912
|
|
|
830
913
|
console.log(' ⏳ Waiting for all batches to complete...\n');
|
package/lib/utils/github-api.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* File: github-api.js
|
|
3
3
|
* Purpose: Direct GitHub API integration via Octokit
|
|
4
4
|
*
|
|
5
|
-
* Why Octokit
|
|
5
|
+
* Why Octokit:
|
|
6
6
|
* - PR creation is deterministic (no AI judgment needed)
|
|
7
7
|
* - Reliable error handling
|
|
8
8
|
* - No external process dependencies
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
* Token priority:
|
|
12
12
|
* 1. GITHUB_TOKEN env var (CI/CD friendly)
|
|
13
13
|
* 2. GITHUB_PERSONAL_ACCESS_TOKEN env var
|
|
14
|
-
* 3. .claude/settings.local.json → githubToken
|
|
15
|
-
* 4. Claude Desktop config (cross-platform)
|
|
14
|
+
* 3. .claude/settings.local.json → githubToken (local dev, gitignored)
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
import { Octokit } from '@octokit/rest';
|
|
@@ -21,7 +20,6 @@ import fs from 'fs';
|
|
|
21
20
|
import path from 'path';
|
|
22
21
|
import { fileURLToPath } from 'url';
|
|
23
22
|
import logger from './logger.js';
|
|
24
|
-
import { findGitHubTokenInDesktopConfig } from './mcp-setup.js';
|
|
25
23
|
|
|
26
24
|
// Get package info for user agent
|
|
27
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -89,6 +87,89 @@ const loadLocalSettings = () => {
|
|
|
89
87
|
return {};
|
|
90
88
|
};
|
|
91
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Save GitHub token to .claude/settings.local.json
|
|
92
|
+
* Why: Persist token in gitignored location for local development
|
|
93
|
+
*
|
|
94
|
+
* @param {string} token - GitHub Personal Access Token
|
|
95
|
+
* @returns {Object} { success: boolean, path: string, error?: string }
|
|
96
|
+
*/
|
|
97
|
+
export const saveGitHubToken = (token) => {
|
|
98
|
+
try {
|
|
99
|
+
const repoRoot = getRepoRoot();
|
|
100
|
+
const claudeDir = path.join(repoRoot, '.claude');
|
|
101
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
102
|
+
|
|
103
|
+
logger.debug('github-api - saveGitHubToken', 'Saving token', { settingsPath });
|
|
104
|
+
|
|
105
|
+
// Ensure .claude directory exists
|
|
106
|
+
if (!fs.existsSync(claudeDir)) {
|
|
107
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
108
|
+
logger.debug('github-api - saveGitHubToken', 'Created .claude directory');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Load existing settings or create new
|
|
112
|
+
let settings = {};
|
|
113
|
+
if (fs.existsSync(settingsPath)) {
|
|
114
|
+
try {
|
|
115
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
116
|
+
} catch (e) {
|
|
117
|
+
logger.debug('github-api - saveGitHubToken', 'Invalid existing settings, starting fresh');
|
|
118
|
+
settings = {};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Update token
|
|
123
|
+
settings.githubToken = token;
|
|
124
|
+
|
|
125
|
+
// Add comment if new file
|
|
126
|
+
if (!settings._comment) {
|
|
127
|
+
settings._comment = 'Local settings - DO NOT COMMIT. This file is gitignored.';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Save settings
|
|
131
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
132
|
+
|
|
133
|
+
logger.debug('github-api - saveGitHubToken', 'Token saved successfully');
|
|
134
|
+
|
|
135
|
+
return { success: true, path: settingsPath };
|
|
136
|
+
|
|
137
|
+
} catch (error) {
|
|
138
|
+
logger.error('github-api - saveGitHubToken', 'Failed to save token', error);
|
|
139
|
+
return { success: false, path: null, error: error.message };
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate GitHub token format
|
|
145
|
+
* Why: Catch obvious format errors before API call
|
|
146
|
+
*
|
|
147
|
+
* @param {string} token - Token to validate
|
|
148
|
+
* @returns {Object} { valid: boolean, warning?: string }
|
|
149
|
+
*/
|
|
150
|
+
export const validateTokenFormat = (token) => {
|
|
151
|
+
if (!token || token.trim() === '') {
|
|
152
|
+
return { valid: false, warning: 'Token is empty' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const trimmed = token.trim();
|
|
156
|
+
|
|
157
|
+
// Valid prefixes for GitHub tokens
|
|
158
|
+
if (trimmed.startsWith('ghp_') || trimmed.startsWith('github_pat_')) {
|
|
159
|
+
return { valid: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Older token format (40 hex chars)
|
|
163
|
+
if (/^[a-f0-9]{40}$/i.test(trimmed)) {
|
|
164
|
+
return { valid: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
valid: true, // Still allow, just warn
|
|
169
|
+
warning: 'Token format looks unusual (expected ghp_... or github_pat_...)'
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
92
173
|
/**
|
|
93
174
|
* Get GitHub authentication token
|
|
94
175
|
* Why: Centralized token resolution with multiple fallback sources
|
|
@@ -97,7 +178,6 @@ const loadLocalSettings = () => {
|
|
|
97
178
|
* 1. GITHUB_TOKEN env var (standard for CI/CD)
|
|
98
179
|
* 2. GITHUB_PERSONAL_ACCESS_TOKEN env var (legacy support)
|
|
99
180
|
* 3. .claude/settings.local.json → githubToken (local dev, gitignored)
|
|
100
|
-
* 4. Claude Desktop config (cross-platform GUI users)
|
|
101
181
|
*
|
|
102
182
|
* @returns {string} GitHub token
|
|
103
183
|
* @throws {GitHubAPIError} If no token found
|
|
@@ -122,15 +202,6 @@ export const getGitHubToken = () => {
|
|
|
122
202
|
return localSettings.githubToken;
|
|
123
203
|
}
|
|
124
204
|
|
|
125
|
-
// Priority 4: Claude Desktop config
|
|
126
|
-
const desktopToken = findGitHubTokenInDesktopConfig();
|
|
127
|
-
if (desktopToken?.token) {
|
|
128
|
-
logger.debug('github-api - getGitHubToken', 'Using token from Claude Desktop config', {
|
|
129
|
-
configPath: desktopToken.configPath
|
|
130
|
-
});
|
|
131
|
-
return desktopToken.token;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
205
|
// No token found
|
|
135
206
|
throw new GitHubAPIError(
|
|
136
207
|
'GitHub token not found. Please configure authentication.',
|
|
@@ -139,10 +210,9 @@ export const getGitHubToken = () => {
|
|
|
139
210
|
searchedLocations: [
|
|
140
211
|
'GITHUB_TOKEN env var',
|
|
141
212
|
'GITHUB_PERSONAL_ACCESS_TOKEN env var',
|
|
142
|
-
'.claude/settings.local.json → githubToken'
|
|
143
|
-
'Claude Desktop config'
|
|
213
|
+
'.claude/settings.local.json → githubToken'
|
|
144
214
|
],
|
|
145
|
-
suggestion: 'Run: claude-hooks setup-github
|
|
215
|
+
suggestion: 'Run: claude-hooks setup-github'
|
|
146
216
|
}
|
|
147
217
|
}
|
|
148
218
|
);
|