ai-code-review-kit 1.1.2

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.
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'child_process'
4
+ import chalk from 'chalk'
5
+ import fs from 'fs'
6
+ import path from 'path'
7
+ import CodeReviewer from './core.js'
8
+ import AIError from './AIError.js'
9
+ import { fileURLToPath } from 'url'
10
+
11
+ const defaultConfig = {
12
+ providerType: 'OPENAI',
13
+ model: '',
14
+ baseURL: '',
15
+ useResponsesApi: false,
16
+
17
+ timeoutMs: 120000,
18
+ concurrency: 2,
19
+ maxRetries: 1,
20
+ retryDelayMs: 500,
21
+
22
+ ignoreDeletions: true,
23
+ stripUnchangedCommentLines: true,
24
+
25
+ diffContextLines: 3,
26
+ maxChunkSize: 12000,
27
+ temperature: 0.2,
28
+ language: 'chinese',
29
+ strict: true,
30
+ skipReview: false,
31
+ showNormal: false,
32
+ correctedResult: true,
33
+ customPrompts: '',
34
+ enabledFileExtensions: '.html, .js, .jsx, .ts, .tsx, .vue',
35
+
36
+ checkSecurity: true,
37
+ checkPerformance: true,
38
+ checkStyle: false,
39
+
40
+ enableRag: false,
41
+ knowledgeBasePaths: '',
42
+ knowledgeBaseIndexDir: '.ai-reviewer-cache/lancedb',
43
+ knowledgeBaseTable: 'project_kb',
44
+ ragTopK: 6,
45
+ ragMaxChars: 8000,
46
+ embeddingsProviderType: '',
47
+ embeddingsBaseURL: '',
48
+ embeddingsApiKey: '',
49
+ embeddingsModel: '',
50
+ embeddingsDimensions: null,
51
+ kbChunkSize: 1500,
52
+ kbChunkOverlap: 200,
53
+ kbMaxFileSizeBytes: 524288
54
+ }
55
+
56
+ function runGit(args, options = {}) {
57
+ return execFileSync('git', args, { encoding: 'utf8', ...options }).trim()
58
+ }
59
+
60
+ function getGitRoot() {
61
+ try {
62
+ return runGit(['rev-parse', '--show-toplevel'], { cwd: process.cwd() })
63
+ } catch {
64
+ handleError('Git error:', 'Not a git repository (or any of the parent directories).')
65
+ }
66
+ }
67
+
68
+ function stripOuterQuotes(value) {
69
+ if (typeof value !== 'string') return value
70
+ const trimmed = value.trim()
71
+ const first = trimmed[0]
72
+ const last = trimmed[trimmed.length - 1]
73
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
74
+ return trimmed.slice(1, -1)
75
+ }
76
+ return trimmed
77
+ }
78
+
79
+ function parseEnvFile(envContent) {
80
+ const config = {}
81
+ envContent.split(/\r?\n/).forEach((line) => {
82
+ const trimmed = line.trim()
83
+ if (!trimmed || trimmed.startsWith('#')) return
84
+
85
+ const normalized = trimmed.startsWith('export ') ? trimmed.slice('export '.length) : trimmed
86
+ const idx = normalized.indexOf('=')
87
+ if (idx === -1) return
88
+
89
+ const key = normalized.slice(0, idx).trim()
90
+ const rawValue = normalized.slice(idx + 1).trim()
91
+ if (!key) return
92
+
93
+ config[key] = stripOuterQuotes(rawValue)
94
+ })
95
+ return config
96
+ }
97
+
98
+ function canonicalizeKey(key) {
99
+ return String(key || '')
100
+ .toLowerCase()
101
+ .replace(/[^a-z0-9]/g, '')
102
+ }
103
+
104
+ function buildCanonicalKeyMap(knownKeys) {
105
+ const map = new Map()
106
+ for (const key of knownKeys) {
107
+ map.set(canonicalizeKey(key), key)
108
+ }
109
+ return map
110
+ }
111
+
112
+ function normalizeConfigKeys(rawConfig, knownKeys, keyMap) {
113
+ const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {}
114
+ const normalized = {}
115
+ for (const [rawKey, rawValue] of Object.entries(config)) {
116
+ if (knownKeys.has(rawKey)) {
117
+ normalized[rawKey] = rawValue
118
+ continue
119
+ }
120
+ const mapped = keyMap.get(canonicalizeKey(rawKey))
121
+ if (mapped) {
122
+ normalized[mapped] = rawValue
123
+ continue
124
+ }
125
+ normalized[rawKey] = rawValue
126
+ }
127
+ return normalized
128
+ }
129
+
130
+ function pickConfigFromProcessEnv(knownKeys, keyMap) {
131
+ const picked = {}
132
+ const prefixes = ['AI_REVIEW_', 'AI_CODE_REVIEW_KIT_']
133
+ for (const [rawKey, rawValue] of Object.entries(process.env || {})) {
134
+ if (rawValue == null || rawValue === '') continue
135
+
136
+ // Backward compatible: allow exact, case-sensitive config keys
137
+ if (knownKeys.has(rawKey)) {
138
+ picked[rawKey] = rawValue
139
+ continue
140
+ }
141
+
142
+ const prefix = prefixes.find((p) => rawKey.startsWith(p))
143
+ if (!prefix) continue
144
+
145
+ const suffix = rawKey.slice(prefix.length)
146
+ const mapped = keyMap.get(canonicalizeKey(suffix))
147
+ if (!mapped) continue
148
+ picked[mapped] = rawValue
149
+ }
150
+ return picked
151
+ }
152
+
153
+ function coerceConfigTypes(config) {
154
+ const next = { ...config }
155
+ const numberKeys = [
156
+ 'maxChunkSize',
157
+ 'temperature',
158
+ 'diffContextLines',
159
+ 'timeoutMs',
160
+ 'concurrency',
161
+ 'maxRetries',
162
+ 'retryDelayMs',
163
+ 'ragTopK',
164
+ 'ragMaxChars',
165
+ 'embeddingsDimensions',
166
+ 'kbChunkSize',
167
+ 'kbChunkOverlap',
168
+ 'kbMaxFileSizeBytes',
169
+ ]
170
+ const booleanKeys = [
171
+ 'strict',
172
+ 'skipReview',
173
+ 'showNormal',
174
+ 'correctedResult',
175
+ 'ignoreDeletions',
176
+ 'stripUnchangedCommentLines',
177
+ 'checkSecurity',
178
+ 'checkPerformance',
179
+ 'checkStyle',
180
+ 'enableRag',
181
+ 'useResponsesApi',
182
+ ]
183
+
184
+ for (const key of booleanKeys) {
185
+ if (typeof next[key] === 'string') {
186
+ const value = next[key].trim().toLowerCase()
187
+ if (value === 'true' || value === '1') next[key] = true
188
+ if (value === 'false' || value === '0') next[key] = false
189
+ }
190
+ }
191
+
192
+ for (const key of numberKeys) {
193
+ if (typeof next[key] === 'string' && next[key].trim() !== '') {
194
+ const num = Number(next[key])
195
+ if (!Number.isNaN(num)) next[key] = num
196
+ }
197
+ }
198
+ return next
199
+ }
200
+
201
+ function loadConfig(gitRoot) {
202
+ try {
203
+ const envPath = path.join(gitRoot, '.env')
204
+ const pkgPath = path.join(gitRoot, 'package.json')
205
+
206
+ let pkgConfig = {}
207
+ if (fs.existsSync(pkgPath)) {
208
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
209
+ if (pkg.aiCheckConfig && typeof pkg.aiCheckConfig === 'object') {
210
+ pkgConfig = pkg.aiCheckConfig
211
+ }
212
+ }
213
+
214
+ let envConfig = {}
215
+ if (fs.existsSync(envPath)) {
216
+ const envContent = fs.readFileSync(envPath, 'utf8')
217
+ envConfig = parseEnvFile(envContent)
218
+ }
219
+
220
+ const knownKeys = new Set([...Object.keys(defaultConfig), 'apiKey'])
221
+ const keyMap = buildCanonicalKeyMap(knownKeys)
222
+
223
+ pkgConfig = normalizeConfigKeys(pkgConfig, knownKeys, keyMap)
224
+ envConfig = normalizeConfigKeys(envConfig, knownKeys, keyMap)
225
+ const processEnvConfig = pickConfigFromProcessEnv(knownKeys, keyMap)
226
+
227
+ // Precedence: package.json < .env < process.env
228
+ const merged = coerceConfigTypes({ ...pkgConfig, ...envConfig, ...processEnvConfig })
229
+ if (Object.keys(merged).length === 0) {
230
+ throw new Error('No ai-code-review-kit configuration found in process.env, .env or package.json')
231
+ }
232
+ return merged
233
+ } catch (err) {
234
+ handleError('Configuration error:', err.message)
235
+ }
236
+ }
237
+
238
+ function getStagedChanges(fileExtensions, gitRoot) {
239
+ try {
240
+ const output = runGit(['diff', '--cached', '--name-only'], { cwd: gitRoot })
241
+ if (!output) {
242
+ console.log(chalk.yellow('No staged changes found.'))
243
+ process.exit(1)
244
+ }
245
+ const changedFiles = output.split('\n').filter(Boolean)
246
+ // 过滤指定扩展名的文件
247
+ const filteredFiles = changedFiles.filter(file => {
248
+ const ext = path.extname(file).toLowerCase()
249
+ return fileExtensions.includes(ext)
250
+ })
251
+
252
+ if (filteredFiles.length === 0) {
253
+ console.log(chalk.yellow(`No changes found in specified file types: ${fileExtensions.join(', ')}`))
254
+ process.exit(0)
255
+ }
256
+ return filteredFiles
257
+ } catch (err) {
258
+ handleError('Error checking staged changes:', err.message)
259
+ }
260
+ }
261
+
262
+ function getDiffContent(filteredFiles, config, gitRoot) {
263
+ try {
264
+ const diffArgs = ['diff', '--cached', '--no-color', `--unified=${config.diffContextLines}`]
265
+ if (filteredFiles.length) {
266
+ diffArgs.push('--', ...filteredFiles)
267
+ }
268
+ const diff = runGit(diffArgs, { cwd: gitRoot })
269
+
270
+ if (!diff) {
271
+ console.log(chalk.yellow('No diff content found for files:'), filteredFiles.join(', '));
272
+ process.exit(0);
273
+ }
274
+ return diff;
275
+ } catch (err) {
276
+ handleError('Error getting diff:', err.message);
277
+ }
278
+ }
279
+
280
+ function handleError (text, message) {
281
+ console.error(chalk.red(text), message)
282
+ process.exit(1)
283
+ }
284
+
285
+ function summarizeIssues(list) {
286
+ const counts = { high: 0, medium: 0, low: 0 }
287
+ const items = Array.isArray(list) ? list : []
288
+ for (const item of items) {
289
+ if (item?.severity === 'high') counts.high += 1
290
+ else if (item?.severity === 'medium') counts.medium += 1
291
+ else if (item?.severity === 'low') counts.low += 1
292
+ }
293
+ return counts
294
+ }
295
+
296
+ function handleReviewResult(result, completeConfig) {
297
+ let exitCode = 0
298
+ if(result.result === 'NO') {
299
+ console.log(chalk.redBright('X Code review was not passed.Please fix the following high-level issues and try again.'))
300
+ exitCode = 1
301
+ } else {
302
+ console.log(chalk.green('√ Code review passed.'))
303
+ exitCode = 0
304
+ }
305
+
306
+ const counts = summarizeIssues(result.list)
307
+ const sessions = result?.meta?.sessions
308
+ const durationMs = result?.meta?.durationMs
309
+ const providerType = result?.meta?.providerType || completeConfig.providerType
310
+ const model = result?.meta?.model || completeConfig.model
311
+ const baseURL = result?.meta?.baseURL || completeConfig.baseURL
312
+ const ragEnabled = Boolean(result?.meta?.ragEnabled)
313
+ const ragUsed = Boolean(result?.meta?.ragUsed)
314
+
315
+ console.log(
316
+ chalk.gray(
317
+ `Summary: sessions=${sessions ?? 'n/a'}, issues(high/medium/low)=${counts.high}/${counts.medium}/${counts.low}, errors=${result.errors?.length || 0}, duration=${Number.isFinite(durationMs) ? `${durationMs}ms` : 'n/a'}`
318
+ )
319
+ )
320
+ console.log(
321
+ chalk.gray(
322
+ `Provider: ${String(providerType || '').toUpperCase()}${model ? ` model=${model}` : ''}${baseURL ? ` baseURL=${baseURL}` : ''} | RAG: ${ragEnabled ? (ragUsed ? 'used' : 'enabled (no hits)') : 'off'}`
323
+ )
324
+ )
325
+
326
+ const printList = result.list.filter(issue => {
327
+ if(issue.severity === 'high') {
328
+ return true
329
+ }
330
+ return completeConfig.showNormal
331
+ })
332
+ if(printList.length) {
333
+ console.log('\n' + chalk.gray('-'.repeat(50)) + '\n');
334
+ printList.forEach(issue => {
335
+ const color = issue.severity === 'high' ? chalk.red : issue.severity === 'medium' ? chalk.yellow : chalk.green
336
+ console.log(color(`- ${issue.location}(${issue.perspective}/${issue.severity}): ${issue.description}\n`) + chalk.blue(`- suggestion: ${issue.suggestion}\n`))
337
+
338
+ })
339
+ if(result.errors.length) {
340
+ console.log(chalk.redBright('some content failed to be reviewed, please check the error message above.'))
341
+ result.errors.forEach(error => {
342
+ console.log(chalk.redBright(error))
343
+ })
344
+ }
345
+ } else if(result.errors.length) {
346
+ console.log(chalk.redBright(result.errors[0]))
347
+ }
348
+ process.exit(exitCode)
349
+ }
350
+
351
+ function getFileExtensions(config) {
352
+ try {
353
+ const extensions = config.enabledFileExtensions.split(',').map(ext => ext.trim().toLowerCase())
354
+ return extensions
355
+ } catch (err) {
356
+ handleError('Error getting file extensions:', err.message)
357
+ }
358
+ }
359
+ export async function main(argv = process.argv.slice(2)) {
360
+ if (argv.includes('--help') || argv.includes('-h')) {
361
+ console.log(`Usage: ai-review run\n\nRuns AI code review for staged git changes.`)
362
+ process.exit(0)
363
+ }
364
+ const gitRoot = getGitRoot()
365
+ const config = loadConfig(gitRoot)
366
+ const completeConfig = Object.assign({}, defaultConfig, config, { repoRoot: gitRoot });
367
+ if (completeConfig.skipReview) {
368
+ console.log(chalk.yellow('AI review skipped (skipReview=true).'))
369
+ process.exit(0)
370
+ }
371
+ const fileExtensions = getFileExtensions(completeConfig)
372
+ const changesFiles = getStagedChanges(fileExtensions, gitRoot)
373
+ console.log(`Find ${chalk.cyan(changesFiles.length)} changed files...`)
374
+ const diffConent = getDiffContent(changesFiles, completeConfig, gitRoot)
375
+ try {
376
+ const reviewer = new CodeReviewer(completeConfig)
377
+ const result = await reviewer.review(diffConent, fileExtensions)
378
+ handleReviewResult(result, completeConfig)
379
+ } catch (err) {
380
+ if(err instanceof AIError && err.options.type === 'API_ERROR' && !completeConfig.strict) {
381
+ console.log(chalk.red('Code review failed.Please check your AI server and try again.'))
382
+ console.log(chalk.red('Error message:'), err.message)
383
+ process.exit(0)
384
+ }
385
+ handleError('Code review error:', err.message)
386
+ }
387
+ }
388
+
389
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
390
+ if (isMain) {
391
+ main()
392
+ }
package/src/cli.js ADDED
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from 'child_process'
4
+ import chalk from 'chalk'
5
+ import fs from 'fs'
6
+ import path from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ function parseArgs(argv) {
10
+ const args = {
11
+ target: 'git', // auto | git | husky
12
+ check: false,
13
+ uninstall: false,
14
+ force: false,
15
+ }
16
+
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const arg = argv[i]
19
+ if (arg === '--check' || arg === '-c') args.check = true
20
+ else if (arg === '--uninstall' || arg === '--remove') args.uninstall = true
21
+ else if (arg === '--force' || arg === '-f') args.force = true
22
+ else if (arg === '--target' || arg === '-t') {
23
+ const value = argv[i + 1]
24
+ if (value) {
25
+ args.target = String(value).toLowerCase()
26
+ i += 1
27
+ }
28
+ } else if (arg.startsWith('--target=')) {
29
+ args.target = String(arg.split('=')[1] || '').toLowerCase()
30
+ }
31
+ }
32
+
33
+ if (!['auto', 'git', 'husky'].includes(args.target)) {
34
+ console.error(chalk.red(`Invalid --target: ${args.target}. Use auto|git|husky.`))
35
+ process.exit(1)
36
+ }
37
+ return args
38
+ }
39
+
40
+ function shEscape(value) {
41
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
42
+ }
43
+
44
+ function runGit(args, options = {}) {
45
+ return execFileSync('git', args, { encoding: 'utf8', ...options }).trim()
46
+ }
47
+
48
+ function getGitRoot() {
49
+ try {
50
+ return runGit(['rev-parse', '--show-toplevel'], { cwd: process.cwd() })
51
+ } catch {
52
+ console.error(chalk.red('Not a git repository (or any of the parent directories).'))
53
+ process.exit(1)
54
+ }
55
+ }
56
+
57
+ function resolveGitPath(gitRoot, gitPath) {
58
+ return path.isAbsolute(gitPath) ? gitPath : path.join(gitRoot, gitPath)
59
+ }
60
+
61
+ function getGitHooksPath(gitRoot) {
62
+ const hooksPath = runGit(['rev-parse', '--git-path', 'hooks'], { cwd: gitRoot })
63
+ return resolveGitPath(gitRoot, hooksPath)
64
+ }
65
+
66
+ function getTargetForInstall(gitRoot, target) {
67
+ if (target === 'git') return 'git'
68
+ if (target === 'husky') return 'husky'
69
+
70
+ // auto
71
+ const huskyDir = path.join(gitRoot, '.husky')
72
+ if (fs.existsSync(huskyDir) && fs.statSync(huskyDir).isDirectory()) {
73
+ return 'husky'
74
+ }
75
+ return 'git'
76
+ }
77
+
78
+ function removeHookBlock(content, markerStart, markerEnd) {
79
+ let output = content
80
+ while (true) {
81
+ const startIdx = output.indexOf(markerStart)
82
+ if (startIdx === -1) return output
83
+
84
+ const endIdx = output.indexOf(markerEnd, startIdx)
85
+ if (endIdx !== -1) {
86
+ const afterEnd = output.indexOf('\n', endIdx)
87
+ output = output.slice(0, startIdx).trimEnd() + '\n' + output.slice(afterEnd === -1 ? output.length : afterEnd + 1)
88
+ } else {
89
+ // Backward compatible uninstall: older versions only had a start marker.
90
+ output = output.slice(0, startIdx).trimEnd() + '\n'
91
+ }
92
+ }
93
+ }
94
+
95
+ function hasMarker(filePath, markerStart) {
96
+ if (!fs.existsSync(filePath)) return false
97
+ const content = fs.readFileSync(filePath, 'utf8')
98
+ return content.includes(markerStart)
99
+ }
100
+
101
+ function writeExecutable(filePath, content) {
102
+ fs.writeFileSync(filePath, content, 'utf8')
103
+ fs.chmodSync(filePath, '755')
104
+ }
105
+
106
+ function getInstallPaths(gitRoot) {
107
+ const huskyPreCommitPath = path.join(gitRoot, '.husky', 'pre-commit')
108
+ const gitHooksPath = getGitHooksPath(gitRoot)
109
+ const gitPreCommitPath = path.join(gitHooksPath, 'pre-commit')
110
+ return { huskyPreCommitPath, gitHooksPath, gitPreCommitPath }
111
+ }
112
+
113
+ function buildHookBlock({ gitRoot, nodePath, aiReviewPath, markerStart, markerEnd }) {
114
+ // Prefer repo-local install for portability; fallback to current absolute path.
115
+ const repoBin = '"$git_root/node_modules/.bin/ai-review"'
116
+ const repoMain = '"$git_root/node_modules/ai-code-review-kit/src/ai-review.js"'
117
+ const pnpCjs = '"$git_root/.pnp.cjs"'
118
+ return `${markerStart}
119
+ run_node() {
120
+ if command -v node >/dev/null 2>&1; then
121
+ node "$@"
122
+ else
123
+ ${shEscape(nodePath)} "$@"
124
+ fi
125
+ }
126
+
127
+ git_root="$(git rev-parse --show-toplevel 2>/dev/null)"
128
+ if [ -n "$git_root" ]; then
129
+ # Yarn PnP (no node_modules). Prefer running via yarn.
130
+ if [ -f ${pnpCjs} ]; then
131
+ if command -v yarn >/dev/null 2>&1; then
132
+ yarn -s ai-review run
133
+ exit $?
134
+ elif command -v corepack >/dev/null 2>&1; then
135
+ corepack yarn -s ai-review run
136
+ exit $?
137
+ fi
138
+ fi
139
+
140
+ # npm/yarn (node_modules)
141
+ if [ -f ${repoMain} ]; then
142
+ run_node ${repoMain}
143
+ exit $?
144
+ fi
145
+ if [ -f ${repoBin} ]; then
146
+ ${repoBin} run
147
+ exit $?
148
+ fi
149
+ fi
150
+
151
+ run_node ${shEscape(aiReviewPath)}
152
+ ${markerEnd}
153
+ `
154
+ }
155
+
156
+ function ensureDir(dirPath) {
157
+ if (!fs.existsSync(dirPath)) {
158
+ fs.mkdirSync(dirPath, { recursive: true })
159
+ }
160
+ }
161
+
162
+ function installToGitHooks({ gitRoot, gitHooksPath, preCommitPath, hookBlock, markerStart, markerEnd, force }) {
163
+ ensureDir(gitHooksPath)
164
+ if (fs.existsSync(preCommitPath)) {
165
+ const hookContent = fs.readFileSync(preCommitPath, 'utf8')
166
+ if (hookContent.includes(markerStart)) {
167
+ if (!force) {
168
+ console.log(chalk.yellow('Hook already exists.'))
169
+ return
170
+ }
171
+ const cleaned = removeHookBlock(hookContent, markerStart, markerEnd)
172
+ const next = `${cleaned.trimEnd()}\n\n${hookBlock}`
173
+ writeExecutable(preCommitPath, next)
174
+ console.log(chalk.green('Hook reinstalled successfully.'))
175
+ return
176
+ }
177
+ const newHookContent = `${hookContent.trimEnd()}\n\n${hookBlock}`
178
+ writeExecutable(preCommitPath, newHookContent)
179
+ } else {
180
+ const hookContent = `#!/bin/sh\n${hookBlock}`
181
+ writeExecutable(preCommitPath, hookContent)
182
+ }
183
+ console.log(chalk.green('Hook installed successfully.'))
184
+ }
185
+
186
+ function installToHusky({ gitRoot, huskyPreCommitPath, hookBlock, markerStart, markerEnd, force }) {
187
+ const huskyDir = path.join(gitRoot, '.husky')
188
+ if (!fs.existsSync(huskyDir) || !fs.statSync(huskyDir).isDirectory()) {
189
+ console.error(chalk.red('Husky directory not found. Please install husky first or use --target git.'))
190
+ process.exit(1)
191
+ }
192
+
193
+ const huskyShim = path.join(huskyDir, '_', 'husky.sh')
194
+ const header = fs.existsSync(huskyShim)
195
+ ? `#!/bin/sh\n. "$(dirname \"$0\")/_/husky.sh"\n`
196
+ : `#!/bin/sh\n`
197
+
198
+ if (fs.existsSync(huskyPreCommitPath)) {
199
+ const hookContent = fs.readFileSync(huskyPreCommitPath, 'utf8')
200
+ if (hookContent.includes(markerStart)) {
201
+ if (!force) {
202
+ console.log(chalk.yellow('Hook already exists.'))
203
+ return
204
+ }
205
+ const cleaned = removeHookBlock(hookContent, markerStart, markerEnd)
206
+ const next = `${cleaned.trimEnd()}\n\n${hookBlock}`
207
+ writeExecutable(huskyPreCommitPath, next)
208
+ console.log(chalk.green('Hook reinstalled successfully.'))
209
+ return
210
+ }
211
+ const next = `${hookContent.trimEnd()}\n\n${hookBlock}`
212
+ writeExecutable(huskyPreCommitPath, next)
213
+ } else {
214
+ const hookContent = `${header}${hookBlock}`
215
+ writeExecutable(huskyPreCommitPath, hookContent)
216
+ }
217
+ console.log(chalk.green('Hook installed successfully.'))
218
+ }
219
+
220
+ function uninstallFromFile(filePath, markerStart, markerEnd) {
221
+ if (!fs.existsSync(filePath)) return false
222
+ const content = fs.readFileSync(filePath, 'utf8')
223
+ if (!content.includes(markerStart)) return false
224
+ const cleaned = removeHookBlock(content, markerStart, markerEnd)
225
+ writeExecutable(filePath, cleaned.trimEnd() + '\n')
226
+ return true
227
+ }
228
+
229
+ export function main(argv = process.argv.slice(2)) {
230
+ const args = parseArgs(argv)
231
+
232
+ const gitRoot = getGitRoot()
233
+ const { huskyPreCommitPath, gitHooksPath, gitPreCommitPath } = getInstallPaths(gitRoot)
234
+
235
+ const aiReviewPath = fileURLToPath(new URL('./ai-review.js', import.meta.url))
236
+ const nodePath = process.execPath
237
+ const markerStart = '# ai-code-review-kit'
238
+ const markerEnd = '# /ai-code-review-kit'
239
+ const hookBlock = buildHookBlock({ gitRoot, nodePath, aiReviewPath, markerStart, markerEnd })
240
+
241
+ if (args.check) {
242
+ const targets = args.target === 'auto' ? ['husky', 'git'] : [args.target]
243
+ const checks = targets.map((t) => {
244
+ const filePath = t === 'husky' ? huskyPreCommitPath : gitPreCommitPath
245
+ return { target: t, installed: hasMarker(filePath, markerStart), filePath }
246
+ })
247
+ const anyInstalled = checks.some((c) => c.installed)
248
+ checks.forEach((c) => {
249
+ const status = c.installed ? chalk.green('installed') : chalk.gray('not installed')
250
+ console.log(`${chalk.cyan(c.target)}: ${status} (${c.filePath})`)
251
+ })
252
+ process.exit(anyInstalled ? 0 : 1)
253
+ }
254
+
255
+ if (args.uninstall) {
256
+ const order = args.target === 'auto' ? ['husky', 'git'] : [args.target]
257
+ let removed = false
258
+ for (const t of order) {
259
+ const filePath = t === 'husky' ? huskyPreCommitPath : gitPreCommitPath
260
+ if (uninstallFromFile(filePath, markerStart, markerEnd)) {
261
+ console.log(chalk.green(`Hook removed from ${t}: ${filePath}`))
262
+ removed = true
263
+ }
264
+ }
265
+ if (!removed) {
266
+ console.log(chalk.yellow('Hook not found.'))
267
+ }
268
+ process.exit(0)
269
+ }
270
+
271
+ const target = getTargetForInstall(gitRoot, args.target)
272
+ if (target === 'husky') {
273
+ installToHusky({ gitRoot, huskyPreCommitPath, hookBlock, markerStart, markerEnd, force: args.force })
274
+ } else {
275
+ installToGitHooks({ gitRoot, gitHooksPath, preCommitPath: gitPreCommitPath, hookBlock, markerStart, markerEnd, force: args.force })
276
+ }
277
+ }
278
+
279
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
280
+ if (isMain) {
281
+ main()
282
+ }