claude-git-hooks 2.20.0 → 2.30.1
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 +155 -5
- package/CLAUDE.md +495 -63
- package/README.md +53 -5
- package/bin/claude-hooks +90 -0
- package/lib/cli-metadata.js +89 -2
- package/lib/commands/analyze-pr.js +678 -0
- package/lib/commands/back-merge.js +740 -0
- package/lib/commands/check-coupling.js +209 -0
- package/lib/commands/close-release.js +485 -0
- package/lib/commands/create-pr.js +62 -3
- package/lib/commands/create-release.js +600 -0
- package/lib/commands/diff-batch-info.js +7 -13
- package/lib/commands/help.js +72 -42
- package/lib/commands/install.js +1 -5
- package/lib/commands/revert-feature.js +436 -0
- package/lib/commands/setup-linear.js +96 -0
- package/lib/commands/shadow.js +654 -0
- package/lib/config.js +16 -2
- package/lib/hooks/pre-commit.js +8 -6
- package/lib/utils/authorization.js +429 -0
- package/lib/utils/claude-client.js +16 -5
- package/lib/utils/coupling-detector.js +133 -0
- package/lib/utils/diff-analysis-orchestrator.js +7 -14
- package/lib/utils/git-operations.js +480 -1
- package/lib/utils/github-api.js +358 -112
- package/lib/utils/judge.js +67 -8
- package/lib/utils/linear-connector.js +284 -0
- package/lib/utils/package-info.js +0 -1
- package/lib/utils/pr-statistics.js +85 -0
- package/lib/utils/token-store.js +161 -0
- package/package.json +69 -69
- package/templates/ANALYZE_PR.md +79 -0
- package/templates/config.advanced.example.json +44 -3
- package/templates/settings.local.example.json +2 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: linear-connector.js
|
|
3
|
+
* Purpose: Linear API connector for fetching ticket context
|
|
4
|
+
*
|
|
5
|
+
* Connection flow (resilient):
|
|
6
|
+
* 1. testConnection() → verify token and API reachability
|
|
7
|
+
* 2. fetchTicket(identifier) → get ticket details
|
|
8
|
+
* 3. On failure: retry → reconnect → graceful degradation
|
|
9
|
+
*
|
|
10
|
+
* Token priority (same pattern as github-api.js):
|
|
11
|
+
* 1. LINEAR_API_TOKEN env var
|
|
12
|
+
* 2. .claude/settings.local.json → linearToken
|
|
13
|
+
*
|
|
14
|
+
* Dependencies:
|
|
15
|
+
* - https: Node.js built-in HTTP client
|
|
16
|
+
* - logger: Debug and error logging
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import https from 'https';
|
|
20
|
+
import logger from './logger.js';
|
|
21
|
+
import { loadToken } from './token-store.js';
|
|
22
|
+
|
|
23
|
+
const LINEAR_HOSTNAME = 'api.linear.app';
|
|
24
|
+
const LINEAR_PATH = '/graphql';
|
|
25
|
+
const LINEAR_TIMEOUT = 10000;
|
|
26
|
+
const MAX_RETRIES = 2;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Custom error for Linear connector failures
|
|
30
|
+
*/
|
|
31
|
+
export class LinearConnectorError extends Error {
|
|
32
|
+
constructor(message, { cause, context } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'LinearConnectorError';
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
this.context = context;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load Linear API token from settings
|
|
42
|
+
* Delegates to token-store.js for centralized token management.
|
|
43
|
+
* @returns {string|null} Token or null if not found
|
|
44
|
+
*/
|
|
45
|
+
export const loadLinearToken = () => loadToken('linearToken', ['LINEAR_API_TOKEN']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Execute a GraphQL query against Linear API
|
|
49
|
+
* @param {string} token - Linear API token
|
|
50
|
+
* @param {string} query - GraphQL query
|
|
51
|
+
* @param {Object} variables - Query variables
|
|
52
|
+
* @returns {Promise<Object>} Response data
|
|
53
|
+
*/
|
|
54
|
+
const graphqlRequest = (token, query, variables = {}) =>
|
|
55
|
+
new Promise((resolve, reject) => {
|
|
56
|
+
const body = JSON.stringify({ query, variables });
|
|
57
|
+
|
|
58
|
+
const req = https.request(
|
|
59
|
+
{
|
|
60
|
+
hostname: LINEAR_HOSTNAME,
|
|
61
|
+
path: LINEAR_PATH,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
Authorization: token,
|
|
66
|
+
'Content-Length': Buffer.byteLength(body)
|
|
67
|
+
},
|
|
68
|
+
timeout: LINEAR_TIMEOUT
|
|
69
|
+
},
|
|
70
|
+
(res) => {
|
|
71
|
+
let data = '';
|
|
72
|
+
res.on('data', (chunk) => (data += chunk));
|
|
73
|
+
res.on('end', () => {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(data);
|
|
76
|
+
|
|
77
|
+
if (parsed.errors) {
|
|
78
|
+
reject(
|
|
79
|
+
new LinearConnectorError(
|
|
80
|
+
`Linear API error: ${parsed.errors[0].message}`,
|
|
81
|
+
{
|
|
82
|
+
context: { errors: parsed.errors }
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
resolve(parsed.data);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
reject(
|
|
92
|
+
new LinearConnectorError('Failed to parse Linear API response', {
|
|
93
|
+
cause: e
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
req.on('error', (error) => {
|
|
102
|
+
reject(
|
|
103
|
+
new LinearConnectorError('Linear API request failed', {
|
|
104
|
+
cause: error,
|
|
105
|
+
context: { hostname: LINEAR_HOSTNAME }
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
req.on('timeout', () => {
|
|
111
|
+
req.destroy();
|
|
112
|
+
reject(
|
|
113
|
+
new LinearConnectorError('Linear API request timed out', {
|
|
114
|
+
context: { timeout: LINEAR_TIMEOUT }
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
req.write(body);
|
|
120
|
+
req.end();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Test Linear API connection health
|
|
125
|
+
* @param {string} token - Linear API token
|
|
126
|
+
* @returns {Promise<boolean>} True if connection is healthy
|
|
127
|
+
*/
|
|
128
|
+
export const testConnection = async (token) => {
|
|
129
|
+
logger.debug('linear-connector - testConnection', 'Testing Linear API connection');
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const data = await graphqlRequest(token, '{ viewer { id name } }');
|
|
133
|
+
const isHealthy = !!data?.viewer?.id;
|
|
134
|
+
|
|
135
|
+
logger.debug('linear-connector - testConnection', 'Connection test result', {
|
|
136
|
+
healthy: isHealthy,
|
|
137
|
+
user: data?.viewer?.name
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return isHealthy;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
logger.debug('linear-connector - testConnection', 'Connection test failed', {
|
|
143
|
+
error: error.message
|
|
144
|
+
});
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract team key and issue number from Linear identifier
|
|
151
|
+
* @param {string} identifier - e.g., "AUT-1234"
|
|
152
|
+
* @returns {{ teamKey: string, number: number }|null}
|
|
153
|
+
*/
|
|
154
|
+
export const parseLinearIdentifier = (identifier) => {
|
|
155
|
+
const match = identifier.match(/^([A-Z]+)-(\d+)$/i);
|
|
156
|
+
if (!match) return null;
|
|
157
|
+
return { teamKey: match[1].toUpperCase(), number: parseInt(match[2], 10) };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract Linear ticket identifier from PR title
|
|
162
|
+
* @param {string} title - PR title (e.g., "[AUT-1234] feat: add feature")
|
|
163
|
+
* @returns {string|null} Linear identifier or null
|
|
164
|
+
*/
|
|
165
|
+
export const extractLinearTicketFromTitle = (title) => {
|
|
166
|
+
if (!title) return null;
|
|
167
|
+
|
|
168
|
+
// Match [AUT-1234] pattern at start of title
|
|
169
|
+
const match = title.match(/^\[([A-Z]+-\d+)\]/i);
|
|
170
|
+
if (match) {
|
|
171
|
+
return match[1].toUpperCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Fetch a Linear ticket by identifier with retry logic
|
|
179
|
+
*
|
|
180
|
+
* Flow: test connection → fetch → retry → reconnect → graceful degradation
|
|
181
|
+
*
|
|
182
|
+
* @param {string} identifier - Linear identifier (e.g., "AUT-1234")
|
|
183
|
+
* @returns {Promise<Object|null>} Ticket data or null on failure
|
|
184
|
+
*/
|
|
185
|
+
export const fetchTicket = async (identifier) => {
|
|
186
|
+
logger.debug('linear-connector - fetchTicket', 'Fetching Linear ticket', { identifier });
|
|
187
|
+
|
|
188
|
+
const token = loadLinearToken();
|
|
189
|
+
if (!token) {
|
|
190
|
+
logger.debug('linear-connector - fetchTicket', 'No Linear token configured, skipping');
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const parsed = parseLinearIdentifier(identifier);
|
|
195
|
+
if (!parsed) {
|
|
196
|
+
logger.debug('linear-connector - fetchTicket', 'Invalid identifier format', { identifier });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const query = `
|
|
201
|
+
query FetchTicket($teamKey: String!, $number: Float!) {
|
|
202
|
+
issues(filter: {
|
|
203
|
+
team: { key: { eq: $teamKey } }
|
|
204
|
+
number: { eq: $number }
|
|
205
|
+
}, first: 1) {
|
|
206
|
+
nodes {
|
|
207
|
+
id
|
|
208
|
+
identifier
|
|
209
|
+
title
|
|
210
|
+
description
|
|
211
|
+
priority
|
|
212
|
+
state { name }
|
|
213
|
+
labels { nodes { name } }
|
|
214
|
+
assignee { name }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
const variables = { teamKey: parsed.teamKey, number: parsed.number };
|
|
221
|
+
|
|
222
|
+
// Step a: Test connection health
|
|
223
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
// Test connection on first attempt
|
|
226
|
+
if (attempt === 0) {
|
|
227
|
+
const healthy = await testConnection(token);
|
|
228
|
+
if (!healthy) {
|
|
229
|
+
logger.debug(
|
|
230
|
+
'linear-connector - fetchTicket',
|
|
231
|
+
'Connection unhealthy, retrying',
|
|
232
|
+
{ attempt }
|
|
233
|
+
);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Step b: Fetch ticket
|
|
239
|
+
const data = await graphqlRequest(token, query, variables);
|
|
240
|
+
const ticket = data?.issues?.nodes?.[0];
|
|
241
|
+
|
|
242
|
+
if (!ticket) {
|
|
243
|
+
logger.debug('linear-connector - fetchTicket', 'Ticket not found', { identifier });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
logger.debug('linear-connector - fetchTicket', 'Ticket fetched successfully', {
|
|
248
|
+
identifier: ticket.identifier,
|
|
249
|
+
title: ticket.title,
|
|
250
|
+
state: ticket.state?.name
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
id: ticket.id,
|
|
255
|
+
identifier: ticket.identifier,
|
|
256
|
+
title: ticket.title,
|
|
257
|
+
description: ticket.description || '',
|
|
258
|
+
priority: ticket.priority,
|
|
259
|
+
state: ticket.state?.name || 'unknown',
|
|
260
|
+
labels: (ticket.labels?.nodes || []).map((l) => l.name),
|
|
261
|
+
assignee: ticket.assignee?.name || null
|
|
262
|
+
};
|
|
263
|
+
} catch (error) {
|
|
264
|
+
logger.debug('linear-connector - fetchTicket', `Attempt ${attempt + 1} failed`, {
|
|
265
|
+
error: error.message,
|
|
266
|
+
attempt
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (attempt < MAX_RETRIES) {
|
|
270
|
+
// Steps c/d: Retry with reconnection
|
|
271
|
+
logger.debug('linear-connector - fetchTicket', 'Retrying after connection failure');
|
|
272
|
+
// Small delay before retry
|
|
273
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Graceful degradation
|
|
279
|
+
logger.warning(
|
|
280
|
+
'linear-connector - fetchTicket',
|
|
281
|
+
`Could not fetch Linear ticket ${identifier} after ${MAX_RETRIES + 1} attempts`
|
|
282
|
+
);
|
|
283
|
+
return null;
|
|
284
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: pr-statistics.js
|
|
3
|
+
* Purpose: Write-only JSONL statistics for PR analyses
|
|
4
|
+
*
|
|
5
|
+
* Design: Append-only JSONL file at .claude/statistics/pr/stats.jsonl
|
|
6
|
+
* No read/query functions (YAGNI).
|
|
7
|
+
*
|
|
8
|
+
* Dependencies:
|
|
9
|
+
* - fs: File system operations
|
|
10
|
+
* - path: Cross-platform path handling
|
|
11
|
+
* - logger: Debug logging
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import logger from './logger.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get repository root directory
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
const getRepoRoot = () => {
|
|
24
|
+
try {
|
|
25
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return process.cwd();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record a PR analysis result as a JSONL entry
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} data - Analysis data
|
|
35
|
+
* @param {string} data.url - PR URL
|
|
36
|
+
* @param {number} data.number - PR number
|
|
37
|
+
* @param {string} data.repo - Repository full name (owner/repo)
|
|
38
|
+
* @param {string} data.preset - Preset used
|
|
39
|
+
* @param {string} data.verdict - Analysis verdict
|
|
40
|
+
* @param {number} data.totalIssues - Total issues found
|
|
41
|
+
* @param {Object} data.issuesByCategory - Issues grouped by category
|
|
42
|
+
* @param {number} data.inlineCount - Number of inline comments
|
|
43
|
+
* @param {number} data.generalCount - Number of general comments
|
|
44
|
+
* @param {number} data.commentsPosted - Comments actually posted
|
|
45
|
+
* @param {number} data.commentsSkipped - Comments skipped by user
|
|
46
|
+
*/
|
|
47
|
+
export const recordPRAnalysis = (data) => {
|
|
48
|
+
try {
|
|
49
|
+
const repoRoot = getRepoRoot();
|
|
50
|
+
const statsDir = path.join(repoRoot, '.claude', 'statistics', 'pr');
|
|
51
|
+
const statsFile = path.join(statsDir, 'stats.jsonl');
|
|
52
|
+
|
|
53
|
+
// Ensure directory exists
|
|
54
|
+
if (!fs.existsSync(statsDir)) {
|
|
55
|
+
fs.mkdirSync(statsDir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const record = {
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
url: data.url,
|
|
61
|
+
number: data.number,
|
|
62
|
+
repo: data.repo,
|
|
63
|
+
preset: data.preset,
|
|
64
|
+
verdict: data.verdict,
|
|
65
|
+
totalIssues: data.totalIssues,
|
|
66
|
+
issuesByCategory: data.issuesByCategory,
|
|
67
|
+
inlineCount: data.inlineCount,
|
|
68
|
+
generalCount: data.generalCount,
|
|
69
|
+
commentsPosted: data.commentsPosted,
|
|
70
|
+
commentsSkipped: data.commentsSkipped
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
fs.appendFileSync(statsFile, `${JSON.stringify(record)}\n`, 'utf8');
|
|
74
|
+
|
|
75
|
+
logger.debug('pr-statistics - recordPRAnalysis', 'Statistics recorded', {
|
|
76
|
+
statsFile,
|
|
77
|
+
totalIssues: data.totalIssues
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Non-fatal: statistics failure should never block the command
|
|
81
|
+
logger.debug('pr-statistics - recordPRAnalysis', 'Failed to record statistics', {
|
|
82
|
+
error: error.message
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: token-store.js
|
|
3
|
+
* Purpose: Abstract utility for storing and loading tokens from .claude/settings.local.json
|
|
4
|
+
*
|
|
5
|
+
* Centralizes token persistence for all integrations (GitHub, Linear, etc.)
|
|
6
|
+
* Single source of truth for settings.local.json read/write operations.
|
|
7
|
+
*
|
|
8
|
+
* Token storage: .claude/settings.local.json (gitignored)
|
|
9
|
+
*
|
|
10
|
+
* Dependencies:
|
|
11
|
+
* - fs: File system operations
|
|
12
|
+
* - path: Cross-platform path handling
|
|
13
|
+
* - child_process: Git repo root detection
|
|
14
|
+
* - logger: Debug logging
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { execSync } from 'child_process';
|
|
20
|
+
import logger from './logger.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get repository root directory
|
|
24
|
+
* @returns {string} Absolute path to repo root
|
|
25
|
+
*/
|
|
26
|
+
const getRepoRoot = () => {
|
|
27
|
+
try {
|
|
28
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return process.cwd();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the path to settings.local.json
|
|
36
|
+
* @returns {string} Absolute path
|
|
37
|
+
*/
|
|
38
|
+
const getSettingsPath = () => {
|
|
39
|
+
const repoRoot = getRepoRoot();
|
|
40
|
+
return path.join(repoRoot, '.claude', 'settings.local.json');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load all local settings from .claude/settings.local.json
|
|
45
|
+
* Why: Gitignored file for sensitive data like tokens
|
|
46
|
+
*
|
|
47
|
+
* @returns {Object} Settings object or empty object
|
|
48
|
+
*/
|
|
49
|
+
export const loadLocalSettings = () => {
|
|
50
|
+
try {
|
|
51
|
+
const settingsPath = getSettingsPath();
|
|
52
|
+
|
|
53
|
+
logger.debug('token-store - loadLocalSettings', 'Checking for settings file', {
|
|
54
|
+
settingsPath
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (fs.existsSync(settingsPath)) {
|
|
58
|
+
const content = fs.readFileSync(settingsPath, 'utf8');
|
|
59
|
+
const settings = JSON.parse(content);
|
|
60
|
+
logger.debug('token-store - loadLocalSettings', 'Settings loaded successfully');
|
|
61
|
+
return settings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.debug('token-store - loadLocalSettings', 'Settings file not found');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.debug('token-store - loadLocalSettings', 'Could not load local settings', {
|
|
67
|
+
error: error.message,
|
|
68
|
+
stack: error.stack
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load a specific token from settings or environment variable
|
|
76
|
+
*
|
|
77
|
+
* @param {string} settingsKey - Key in settings.local.json (e.g., 'githubToken', 'linearToken')
|
|
78
|
+
* @param {string[]} envVars - Environment variable names to check, in priority order
|
|
79
|
+
* @returns {string|null} Token value or null if not found
|
|
80
|
+
*/
|
|
81
|
+
export const loadToken = (settingsKey, envVars = []) => {
|
|
82
|
+
// Priority 1: Environment variables (in order)
|
|
83
|
+
for (const envVar of envVars) {
|
|
84
|
+
if (process.env[envVar]) {
|
|
85
|
+
logger.debug('token-store - loadToken', `Using ${envVar} env var`);
|
|
86
|
+
return process.env[envVar];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Priority 2: Local settings file
|
|
91
|
+
const settings = loadLocalSettings();
|
|
92
|
+
if (settings[settingsKey]) {
|
|
93
|
+
logger.debug('token-store - loadToken', `Using ${settingsKey} from settings.local.json`);
|
|
94
|
+
return settings[settingsKey];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Save a token to .claude/settings.local.json
|
|
102
|
+
*
|
|
103
|
+
* @param {string} settingsKey - Key in settings.local.json (e.g., 'githubToken', 'linearToken')
|
|
104
|
+
* @param {string} token - Token value to save
|
|
105
|
+
* @returns {{ success: boolean, path: string, error?: string }}
|
|
106
|
+
*/
|
|
107
|
+
export const saveToken = (settingsKey, token) => {
|
|
108
|
+
try {
|
|
109
|
+
const repoRoot = getRepoRoot();
|
|
110
|
+
const claudeDir = path.join(repoRoot, '.claude');
|
|
111
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
112
|
+
|
|
113
|
+
logger.debug('token-store - saveToken', 'Saving token', { settingsKey, settingsPath });
|
|
114
|
+
|
|
115
|
+
// Ensure .claude directory exists
|
|
116
|
+
if (!fs.existsSync(claudeDir)) {
|
|
117
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Load existing settings or create new
|
|
121
|
+
let settings = {};
|
|
122
|
+
if (fs.existsSync(settingsPath)) {
|
|
123
|
+
try {
|
|
124
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
logger.debug(
|
|
127
|
+
'token-store - saveToken',
|
|
128
|
+
'Invalid existing settings, starting fresh'
|
|
129
|
+
);
|
|
130
|
+
settings = {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Update token
|
|
135
|
+
settings[settingsKey] = token;
|
|
136
|
+
|
|
137
|
+
// Add comment if new file
|
|
138
|
+
if (!settings._comment) {
|
|
139
|
+
settings._comment = 'Local settings - DO NOT COMMIT. This file is gitignored.';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Save settings with restricted permissions (owner read/write only)
|
|
143
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
144
|
+
|
|
145
|
+
logger.debug('token-store - saveToken', 'Token saved successfully');
|
|
146
|
+
|
|
147
|
+
return { success: true, path: settingsPath };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logger.error('token-store - saveToken', 'Failed to save token', error);
|
|
150
|
+
return { success: false, path: null, error: error.message };
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a token exists (in env vars or settings)
|
|
156
|
+
*
|
|
157
|
+
* @param {string} settingsKey - Key in settings.local.json
|
|
158
|
+
* @param {string[]} envVars - Environment variable names to check
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export const hasToken = (settingsKey, envVars = []) => loadToken(settingsKey, envVars) !== null;
|
package/package.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-git-hooks",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"claude-hooks": "./bin/claude-hooks"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "npm run test:all",
|
|
11
|
-
"test:all": "npm run lint && npm run test:smoke && npm run test:unit && npm run test:integration",
|
|
12
|
-
"test:smoke": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/smoke --maxWorkers=1",
|
|
13
|
-
"test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --forceExit",
|
|
14
|
-
"test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration --maxWorkers=1 --testTimeout=30000 --forceExit",
|
|
15
|
-
"test:integration:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration/ci-safe.test.js --maxWorkers=1 --testTimeout=30000 --forceExit",
|
|
16
|
-
"test:changed": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --changedSince=main --forceExit",
|
|
17
|
-
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
|
|
18
|
-
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
19
|
-
"lint": "eslint lib/ bin/claude-hooks",
|
|
20
|
-
"lint:fix": "eslint lib/ bin/claude-hooks --fix",
|
|
21
|
-
"format": "prettier --write \"lib/**/*.js\" \"bin/**\" \"test/**/*.js\"",
|
|
22
|
-
"precommit": "npm run lint && npm run test:smoke",
|
|
23
|
-
"prepublishOnly": "npm run test:all"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"git",
|
|
27
|
-
"hooks",
|
|
28
|
-
"claude",
|
|
29
|
-
"ai",
|
|
30
|
-
"code-review",
|
|
31
|
-
"commit-messages",
|
|
32
|
-
"pre-commit",
|
|
33
|
-
"automation"
|
|
34
|
-
],
|
|
35
|
-
"author": "Pablo Rovito",
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"repository": {
|
|
38
|
-
"type": "git",
|
|
39
|
-
"url": "https://github.com/mscope-S-L/git-hooks.git"
|
|
40
|
-
},
|
|
41
|
-
"engines": {
|
|
42
|
-
"node": ">=16.9.0"
|
|
43
|
-
},
|
|
44
|
-
"engineStrict": false,
|
|
45
|
-
"os": [
|
|
46
|
-
"darwin",
|
|
47
|
-
"linux",
|
|
48
|
-
"win32"
|
|
49
|
-
],
|
|
50
|
-
"preferGlobal": true,
|
|
51
|
-
"files": [
|
|
52
|
-
"bin/",
|
|
53
|
-
"lib/",
|
|
54
|
-
"templates/",
|
|
55
|
-
"README.md",
|
|
56
|
-
"CHANGELOG.md",
|
|
57
|
-
"CLAUDE.md",
|
|
58
|
-
"LICENSE"
|
|
59
|
-
],
|
|
60
|
-
"dependencies": {
|
|
61
|
-
"@octokit/rest": "^21.0.0"
|
|
62
|
-
},
|
|
63
|
-
"devDependencies": {
|
|
64
|
-
"@types/jest": "^29.5.0",
|
|
65
|
-
"eslint": "^8.57.0",
|
|
66
|
-
"jest": "^29.7.0",
|
|
67
|
-
"prettier": "^3.2.0"
|
|
68
|
-
}
|
|
69
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-git-hooks",
|
|
3
|
+
"version": "2.30.1",
|
|
4
|
+
"description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-hooks": "./bin/claude-hooks"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "npm run test:all",
|
|
11
|
+
"test:all": "npm run lint && npm run test:smoke && npm run test:unit && npm run test:integration",
|
|
12
|
+
"test:smoke": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/smoke --maxWorkers=1",
|
|
13
|
+
"test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --forceExit",
|
|
14
|
+
"test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration --maxWorkers=1 --testTimeout=30000 --forceExit",
|
|
15
|
+
"test:integration:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration/ci-safe.test.js --maxWorkers=1 --testTimeout=30000 --forceExit",
|
|
16
|
+
"test:changed": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit --changedSince=main --forceExit",
|
|
17
|
+
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
|
|
18
|
+
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
19
|
+
"lint": "eslint lib/ bin/claude-hooks",
|
|
20
|
+
"lint:fix": "eslint lib/ bin/claude-hooks --fix",
|
|
21
|
+
"format": "prettier --write \"lib/**/*.js\" \"bin/**\" \"test/**/*.js\"",
|
|
22
|
+
"precommit": "npm run lint && npm run test:smoke",
|
|
23
|
+
"prepublishOnly": "npm run test:all"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"git",
|
|
27
|
+
"hooks",
|
|
28
|
+
"claude",
|
|
29
|
+
"ai",
|
|
30
|
+
"code-review",
|
|
31
|
+
"commit-messages",
|
|
32
|
+
"pre-commit",
|
|
33
|
+
"automation"
|
|
34
|
+
],
|
|
35
|
+
"author": "Pablo Rovito",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/mscope-S-L/git-hooks.git"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=16.9.0"
|
|
43
|
+
},
|
|
44
|
+
"engineStrict": false,
|
|
45
|
+
"os": [
|
|
46
|
+
"darwin",
|
|
47
|
+
"linux",
|
|
48
|
+
"win32"
|
|
49
|
+
],
|
|
50
|
+
"preferGlobal": true,
|
|
51
|
+
"files": [
|
|
52
|
+
"bin/",
|
|
53
|
+
"lib/",
|
|
54
|
+
"templates/",
|
|
55
|
+
"README.md",
|
|
56
|
+
"CHANGELOG.md",
|
|
57
|
+
"CLAUDE.md",
|
|
58
|
+
"LICENSE"
|
|
59
|
+
],
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@octokit/rest": "^21.0.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/jest": "^29.5.0",
|
|
65
|
+
"eslint": "^8.57.0",
|
|
66
|
+
"jest": "^29.7.0",
|
|
67
|
+
"prettier": "^3.2.0"
|
|
68
|
+
}
|
|
69
|
+
}
|