smart-review 1.0.3 → 1.2.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/README.en-US.md CHANGED
@@ -7,7 +7,8 @@
7
7
  ## ✨ Features
8
8
 
9
9
  - Static rule checks for Security, Performance, Best Practices
10
- - AI analysis (OpenAI-compatible) with incremental Git Diff review
10
+ - AI analysis with unified OpenAI / Anthropic / Gemini integration
11
+ - Skill-driven review orchestration to enforce detailed risk and fix outputs
11
12
  - Smart batching and chunked processing for large files
12
13
  - Git Hook integration (pre-commit)
13
14
  - Highly configurable and multilingual output
@@ -124,6 +125,11 @@ Add to `package.json` if you use Husky:
124
125
  }
125
126
  ```
126
127
 
128
+ #### Interrupt & Terminal Compatibility
129
+ - Works in Git Bash, CMD, and PowerShell
130
+ - Press `q` or `Esc` during review to interrupt and print completed results
131
+ - Interruptions do not fail the review; only blocking risks stop the commit
132
+
127
133
  ## ⚙️ Config
128
134
 
129
135
  Main config `.smart-review/smart-review.json` example:
@@ -132,6 +138,7 @@ Main config `.smart-review/smart-review.json` example:
132
138
  {
133
139
  "ai": {
134
140
  "enabled": true,
141
+ "provider": "openai",
135
142
  "model": "deepseek-chat",
136
143
  "baseURL": "https://api.deepseek.com/v1",
137
144
  "reviewOnlyChanges": true,
@@ -141,6 +148,37 @@ Main config `.smart-review/smart-review.json` example:
141
148
  "useStaticHints": true,
142
149
  "maxRequestTokens": 8000,
143
150
  "temperature": 0,
151
+ "skills": {
152
+ "enabled": true,
153
+ "strict": false,
154
+ "maxSkillsPerRequest": 4,
155
+ "required": ["DIFF_RISK_GUARD", "EVIDENCE_ENFORCER"],
156
+ "optional": ["SECURITY_DEEP", "LOGIC_CORRECTNESS", "API_CONTRACT"],
157
+ "routes": [
158
+ {
159
+ "match": ["**/auth/**", "**/security/**"],
160
+ "modes": ["diff", "batch", "segment"],
161
+ "add": ["SECURITY_DEEP", "API_CONTRACT"]
162
+ }
163
+ ]
164
+ },
165
+ "tools": {
166
+ "enabled": false,
167
+ "maxCalls": 2,
168
+ "maxReadLines": 400,
169
+ "maxSearchMatches": 50,
170
+ "maxSearchFiles": 120,
171
+ "maxListFiles": 200,
172
+ "allow": [
173
+ "read_file",
174
+ "get_staged_diff",
175
+ "list_files",
176
+ "search_in_file",
177
+ "get_file_outline",
178
+ "search_in_repo",
179
+ "list_changed_files"
180
+ ]
181
+ },
144
182
  "concurrency": 3
145
183
  },
146
184
  "riskLevels": {
@@ -158,8 +196,9 @@ Main config `.smart-review/smart-review.json` example:
158
196
 
159
197
  #### AI (`ai`)
160
198
  - `enabled`: Enable AI analysis
161
- - `model`: OpenAI model name
162
- - `apiKey`: OpenAI API key
199
+ - `provider`: Model provider, supports `openai`, `anthropic`, `gemini`
200
+ - `model`: Model name for the selected provider
201
+ - `apiKey`: Unified API key field (or use environment variables)
163
202
  - `baseURL`: API base URL
164
203
  - `reviewOnlyChanges`: Enable Git Diff incremental review; true analyzes only changed lines
165
204
  - `maxResponseTokens`: Max tokens in AI response
@@ -173,7 +212,28 @@ Main config `.smart-review/smart-review.json` example:
173
212
  - `includeStaticHints`: Include rule hints in AI analysis
174
213
  - `temperature`: Model creativity; 0 favors deterministic outputs
175
214
  - `concurrency`: Number of concurrent AI requests
176
-
215
+ - `skills.enabled`: Enable review skill orchestration
216
+ - `skills.strict`: Enforce output constraints (path/snippet/reason/suggestion)
217
+ - `skills.maxSkillsPerRequest`: Max skills applied in one request
218
+ - `skills.required`: Required skill list
219
+ - `skills.optional`: Optional skill list (mode-based supplement)
220
+ - `skills.routes`: Dynamically append skills by file path and mode (`match/modes/add`)
221
+ - `tools.enabled`: Enable local read-only tool calling (see tool list below)
222
+ - `tools.maxCalls`: Max tool-call rounds per request
223
+ - `tools.maxReadLines`: Max lines for single `read_file` call
224
+ - `tools.maxSearchMatches`: Max returned matches for `search_in_file` / `search_in_repo`
225
+ - `tools.maxSearchFiles`: Max scanned files for one `search_in_repo` call
226
+ - `tools.maxListFiles`: Max returned files for one `list_files` call
227
+ - `tools.allow`: Tool allowlist (only listed tools can be called by the model)
228
+
229
+ #### AI Read-Only Tool List (`ai.tools.allow`)
230
+ - `read_file`: Read specific line ranges from one file
231
+ - `get_staged_diff`: Get staged Git diff (optionally filtered by path)
232
+ - `list_files`: Recursively list repository files (supports subdir and keyword/wildcard filtering)
233
+ - `search_in_file`: Search text or regex in a single file
234
+ - `get_file_outline`: Extract lightweight file outline (class/function/method signatures)
235
+ - `search_in_repo`: Search text or regex across repository files (with scan and result caps)
236
+ - `list_changed_files`: List changed Git files (supports staged/unstaged and status filtering)
177
237
  #### Risk Levels (`riskLevels`)
178
238
  - `critical` / `high` / `medium` / `low` / `suggestion`
179
239
  - Each level supports `block` to decide whether to block commits
@@ -530,7 +590,11 @@ To add a new language (e.g., `ja-JP`), create `templates/rules/ja-JP/` with the
530
590
  ## 🌍 Environment Variables
531
591
 
532
592
  ```bash
593
+ export AI_API_KEY="your-api-key"
533
594
  export OPENAI_API_KEY="your-api-key"
595
+ export ANTHROPIC_API_KEY="your-api-key"
596
+ export GEMINI_API_KEY="your-api-key"
597
+ export GOOGLE_API_KEY="your-api-key"
534
598
  export DEBUG_SMART_REVIEW=true
535
599
  export SMART_REVIEW_LOCALE=en-US
536
600
  ```
@@ -539,7 +603,7 @@ To use a custom OpenAI-compatible endpoint, set `ai.baseURL` in `.smart-review/s
539
603
 
540
604
  ```json
541
605
  {
542
- "ai": { "baseURL": "https://api.openai.com/v1" }
606
+ "ai": { "provider": "openai", "baseURL": "https://api.openai.com/v1" }
543
607
  }
544
608
  ```
545
609
 
@@ -577,4 +641,4 @@ smart-review --staged --debug
577
641
  4. Push the branch (`git push origin feature/amazing-feature`)
578
642
  5. Open a Pull Request
579
643
 
580
- ⭐ If this project helps you, please star the repo!
644
+ ⭐ If this project helps you, please star the repo!
package/README.md CHANGED
@@ -7,7 +7,8 @@
7
7
  ## ✨ 特性
8
8
 
9
9
  - 🔍 **静态规则检测** - 内置安全、性能、最佳实践规则
10
- - 🧠 **AI智能分析** - 基于 OpenAI GPT 的深度代码分析
10
+ - 🧠 **AI智能分析** - 支持 OpenAI / Anthropic / Gemini 的统一对接
11
+ - 🧩 **审查技能编排** - 支持 Skills 约束输出,强制细化风险分析与修复建议
11
12
  - ⚡ **Git Diff增量审查** - 智能识别变更内容,只审查修改的代码行,大幅提升审查效率
12
13
  - 🚀 **智能分批处理** - 自动优化大文件处理,支持分段分析
13
14
  - 📊 **大文件支持** - 智能分段处理超大文件,突破token限制
@@ -131,6 +132,11 @@ node bin/review.js --files test/src/large-test-file.js
131
132
  }
132
133
  ```
133
134
 
135
+ #### 中断与终端兼容
136
+ - 支持在 Git Bash、CMD、PowerShell 中进行交互中断
137
+ - 审查过程中输入 `q` 或按 `Esc` 可中断审查并输出已完成结果
138
+ - 中断不会被视为审查失败,只有存在阻断风险才会阻止提交
139
+
134
140
  ## ⚙️ 配置文件
135
141
 
136
142
  ### 主配置文件 `.smart-review/smart-review.json`
@@ -139,6 +145,7 @@ node bin/review.js --files test/src/large-test-file.js
139
145
  {
140
146
  "ai": {
141
147
  "enabled": true,
148
+ "provider": "openai",
142
149
  "model": "deepseek-chat",
143
150
  "apiKey": "your-api-key",
144
151
  "baseURL": "https://api.deepseek.com/v1",
@@ -153,6 +160,37 @@ node bin/review.js --files test/src/large-test-file.js
153
160
  "tokenRatio": 4,
154
161
  "chunkOverlapLines": 5,
155
162
  "includeStaticHints": true,
163
+ "skills": {
164
+ "enabled": true,
165
+ "strict": false,
166
+ "maxSkillsPerRequest": 4,
167
+ "required": ["DIFF_RISK_GUARD", "EVIDENCE_ENFORCER"],
168
+ "optional": ["SECURITY_DEEP", "LOGIC_CORRECTNESS", "API_CONTRACT"],
169
+ "routes": [
170
+ {
171
+ "match": ["**/auth/**", "**/security/**"],
172
+ "modes": ["diff", "batch", "segment"],
173
+ "add": ["SECURITY_DEEP", "API_CONTRACT"]
174
+ }
175
+ ]
176
+ },
177
+ "tools": {
178
+ "enabled": false,
179
+ "maxCalls": 2,
180
+ "maxReadLines": 400,
181
+ "maxSearchMatches": 50,
182
+ "maxSearchFiles": 120,
183
+ "maxListFiles": 200,
184
+ "allow": [
185
+ "read_file",
186
+ "get_staged_diff",
187
+ "list_files",
188
+ "search_in_file",
189
+ "get_file_outline",
190
+ "search_in_repo",
191
+ "list_changed_files"
192
+ ]
193
+ },
156
194
  "temperature": 0,
157
195
  "concurrency": 3
158
196
  },
@@ -183,8 +221,9 @@ node bin/review.js --files test/src/large-test-file.js
183
221
 
184
222
  #### AI 配置 (`ai`)
185
223
  - `enabled`: 是否启用AI分析
186
- - `model`: OpenAI模型名称
187
- - `apiKey`: OpenAI API密钥
224
+ - `provider`: 模型提供方,支持 `openai`、`anthropic`、`gemini`
225
+ - `model`: 对应提供方的模型名称
226
+ - `apiKey`: 统一 API 密钥字段(也可通过环境变量注入)
188
227
  - `baseURL`: API基础URL
189
228
  - `reviewOnlyChanges`: 是否启用Git Diff增量审查模式。`true`时只审查变更的代码行,`false`时审查整个文件内容。默认为`true`,大幅提升审查效率
190
229
  - `maxResponseTokens`: AI响应最大token数
@@ -199,6 +238,28 @@ node bin/review.js --files test/src/large-test-file.js
199
238
  - `includeStaticHints`: 是否在AI分析中包含静态规则提示
200
239
  - `temperature`: AI模型的创造性参数,0表示最确定性的输出
201
240
  - `concurrency`: 并发AI请求数量,默认3个。设置为1或更小时使用串行处理,大于1时启用并发处理以提升性能
241
+ - `skills.enabled`: 是否启用审查技能编排
242
+ - `skills.strict`: 是否强制检查输出是否满足“路径/片段/原因/建议”约束
243
+ - `skills.maxSkillsPerRequest`: 单次请求最多启用的技能数量
244
+ - `skills.required`: 必选技能列表
245
+ - `skills.optional`: 可选技能列表(按模式补充)
246
+ - `skills.routes`: 按文件路径和模式动态追加技能(`match/modes/add`)
247
+ - `tools.enabled`: 启用本地只读工具调用(见下方工具清单)
248
+ - `tools.maxCalls`: 单次请求最多工具调用轮次
249
+ - `tools.maxReadLines`: `read_file` 单次读取最大行数
250
+ - `tools.maxSearchMatches`: `search_in_file` / `search_in_repo` 单次最多返回匹配条数
251
+ - `tools.maxSearchFiles`: `search_in_repo` 单次最多扫描文件数
252
+ - `tools.maxListFiles`: `list_files` 单次最多返回文件数
253
+ - `tools.allow`: 工具白名单(仅允许模型调用白名单中的工具)
254
+
255
+ #### AI 只读工具清单 (`ai.tools.allow`)
256
+ - `read_file`: 读取指定文件的行区间
257
+ - `get_staged_diff`: 获取暂存区 diff(可按文件路径过滤)
258
+ - `list_files`: 递归列出仓库内文件(支持子目录和关键字/通配符过滤)
259
+ - `search_in_file`: 在单个文件内按文本或正则搜索
260
+ - `get_file_outline`: 提取文件结构轮廓(类/函数/方法等)
261
+ - `search_in_repo`: 在仓库范围内按文本或正则搜索(支持扫描文件数和结果数上限)
262
+ - `list_changed_files`: 获取 Git 变更文件列表(支持 staged/unstaged 和状态过滤)
202
263
 
203
264
  #### 风险等级配置 (`riskLevels`)
204
265
  - `critical`: 致命风险
@@ -560,8 +621,15 @@ const reviewer = new CodeReviewer(customConfig, defaultRules);
560
621
  可通过环境变量配置:
561
622
 
562
623
  ```bash
563
- # OpenAI API配置
624
+ # 统一 API 配置(最高优先级)
625
+ export AI_API_KEY="your-api-key"
626
+
627
+ # 按 Provider 配置
564
628
  export OPENAI_API_KEY="your-api-key"
629
+ export ANTHROPIC_API_KEY="your-api-key"
630
+ export GEMINI_API_KEY="your-api-key"
631
+ # 或 Google 生态变量
632
+ export GOOGLE_API_KEY="your-api-key"
565
633
 
566
634
  # 调试模式
567
635
  export DEBUG_SMART_REVIEW=true
@@ -578,7 +646,7 @@ export SMART_REVIEW_LOCALE=zh-CN # 或 en-US
578
646
 
579
647
  ```json
580
648
  {
581
- "ai": { "baseURL": "https://api.openai.com/v1" }
649
+ "ai": { "provider": "openai", "baseURL": "https://api.openai.com/v1" }
582
650
  }
583
651
  ```
584
652
 
@@ -740,13 +808,4 @@ smart-review --staged --debug
740
808
  4. 推送到分支 (`git push origin feature/amazing-feature`)
741
809
  5. 开启 Pull Request
742
810
 
743
- ## 📞 支持
744
-
745
- - 📧 邮箱: zlife@vip.qq.com
746
- - 🐛 问题反馈: [GitHub Issues](https://github.com/vlinr/smart-review/issues)
747
-
748
- ---
749
-
750
811
  ⭐ 如果这个项目对你有帮助,请给个 Star!
751
-
752
-
package/bin/install.js CHANGED
@@ -264,6 +264,8 @@ class Installer {
264
264
 
265
265
  echo "${t(loc, 'hook_start_review')}"
266
266
 
267
+ trap 'echo "${t(loc, 'interrupt_cancelled')}"; exit 0' INT TERM
268
+
267
269
  # 获取暂存区文件
268
270
  STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
269
271
 
@@ -283,9 +285,14 @@ ROOT_BIN="$REPO_ROOT/node_modules/.bin/smart-review"
283
285
 
284
286
  FOUND_CMD=""
285
287
  FOUND_IS_ENTRY=0
288
+ FOUND_ENTRY_CMD=""
286
289
 
287
290
  if [ -f "$ROOT_BIN" ]; then
288
291
  FOUND_CMD="$ROOT_BIN"
292
+ ROOT_ENTRY="$REPO_ROOT/node_modules/smart-review/bin/review.js"
293
+ if [ -f "$ROOT_ENTRY" ]; then
294
+ FOUND_ENTRY_CMD="$ROOT_ENTRY"
295
+ fi
289
296
  else
290
297
  MAX_ASCEND=6
291
298
  while IFS= read -r file; do
@@ -296,9 +303,13 @@ else
296
303
  candidate_bin="$REPO_ROOT/$dir/node_modules/.bin/smart-review"
297
304
  candidate_entry="$REPO_ROOT/$dir/node_modules/smart-review/bin/review.js"
298
305
  if [ -f "$candidate_bin" ]; then
299
- FOUND_CMD="$candidate_bin"; FOUND_IS_ENTRY=0; break 2
306
+ FOUND_CMD="$candidate_bin"; FOUND_IS_ENTRY=0;
307
+ if [ -f "$candidate_entry" ]; then
308
+ FOUND_ENTRY_CMD="$candidate_entry"
309
+ fi
310
+ break 2
300
311
  elif [ -f "$candidate_entry" ]; then
301
- FOUND_CMD="$candidate_entry"; FOUND_IS_ENTRY=1; break 2
312
+ FOUND_CMD="$candidate_entry"; FOUND_IS_ENTRY=1; FOUND_ENTRY_CMD="$candidate_entry"; break 2
302
313
  fi
303
314
  dir=$(dirname "$dir")
304
315
  depth=$((depth + 1))
@@ -319,13 +330,89 @@ if [ -z "$FOUND_CMD" ]; then
319
330
  fi
320
331
 
321
332
  echo "${t(loc, 'hook_use_command_prefix')} $FOUND_CMD --staged"
322
- if [ $FOUND_IS_ENTRY -eq 1 ]; then
323
- node "$FOUND_CMD" --staged
333
+ USE_WINPTY=0
334
+ if command -v uname >/dev/null 2>&1; then
335
+ KERNEL=$(uname -s)
324
336
  else
325
- "$FOUND_CMD" --staged
337
+ KERNEL=""
326
338
  fi
327
-
339
+ IS_MSYS=0
340
+ if [[ "$KERNEL" == MINGW* || "$KERNEL" == MSYS* || -n "$MSYSTEM" ]]; then
341
+ IS_MSYS=1
342
+ fi
343
+ HAS_TTY=0
344
+ if [ -t 0 ] || [ -t 1 ]; then
345
+ HAS_TTY=1
346
+ fi
347
+ TTY_DEVICE=""
348
+ if [ $HAS_TTY -eq 1 ] && [ -r /dev/tty ]; then
349
+ TTY_DEVICE="/dev/tty"
350
+ elif [ $HAS_TTY -eq 1 ] && [ -r "CONIN$" ]; then
351
+ TTY_DEVICE="CONIN$"
352
+ elif [ -r /dev/tty ]; then
353
+ TTY_DEVICE="/dev/tty"
354
+ elif [ -r "CONIN$" ]; then
355
+ TTY_DEVICE="CONIN$"
356
+ fi
357
+ if [ -n "$TTY_DEVICE" ]; then
358
+ export SMART_REVIEW_TTY="$TTY_DEVICE"
359
+ fi
360
+ if [ $IS_MSYS -eq 1 ] && [ $HAS_TTY -eq 1 ]; then
361
+ if command -v winpty >/dev/null 2>&1; then
362
+ USE_WINPTY=1
363
+ fi
364
+ fi
365
+ run_direct() {
366
+ if [ $USE_WINPTY -eq 1 ]; then
367
+ if [ -n "$FOUND_ENTRY_CMD" ]; then
368
+ winpty node "$FOUND_ENTRY_CMD" --staged
369
+ else
370
+ winpty "$FOUND_CMD" --staged
371
+ fi
372
+ else
373
+ if [ -n "$FOUND_ENTRY_CMD" ]; then
374
+ node "$FOUND_ENTRY_CMD" --staged
375
+ else
376
+ "$FOUND_CMD" --staged
377
+ fi
378
+ fi
379
+ }
380
+ run_with_device() {
381
+ if [ -n "$TTY_DEVICE" ]; then
382
+ "$@" < "$TTY_DEVICE"
383
+ else
384
+ "$@"
385
+ fi
386
+ }
387
+ TMP_ERR=""
388
+ if command -v mktemp >/dev/null 2>&1; then
389
+ TMP_ERR=$(mktemp -t smart-review-err.XXXXXX)
390
+ else
391
+ TMP_ERR="/tmp/smart-review-err-$$"
392
+ fi
393
+ run_direct 2> "$TMP_ERR"
328
394
  EXIT_CODE=$?
395
+ if [ $EXIT_CODE -ne 0 ] && [ -s "$TMP_ERR" ] && grep -qi "stdin is not a tty" "$TMP_ERR"; then
396
+ if [ $USE_WINPTY -eq 1 ]; then
397
+ if [ -n "$FOUND_ENTRY_CMD" ]; then
398
+ run_with_device winpty node "$FOUND_ENTRY_CMD" --staged
399
+ else
400
+ run_with_device winpty "$FOUND_CMD" --staged
401
+ fi
402
+ else
403
+ if [ -n "$FOUND_ENTRY_CMD" ]; then
404
+ run_with_device node "$FOUND_ENTRY_CMD" --staged
405
+ else
406
+ run_with_device "$FOUND_CMD" --staged
407
+ fi
408
+ fi
409
+ EXIT_CODE=$?
410
+ fi
411
+ rm -f "$TMP_ERR"
412
+ if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ]; then
413
+ echo "${t(loc, 'interrupt_cancelled')}"
414
+ exit 0
415
+ fi
329
416
  if [ $EXIT_CODE -ne 0 ]; then
330
417
  echo "${t(loc, 'hook_review_fail')}"
331
418
  exit 1
@@ -336,18 +423,98 @@ fi
336
423
  `;
337
424
 
338
425
  fs.writeFileSync(preCommitHook, hookContent);
339
- // Windows 兼容:提供 CMD 包装器,调用 bash 执行同名脚本
426
+ const escapeCmdText = (value) => String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
427
+ const cmdMessages = {
428
+ start: escapeCmdText(t(loc, 'hook_start_review')),
429
+ noStaged: escapeCmdText(t(loc, 'hook_no_staged')),
430
+ foundHeader: escapeCmdText(t(loc, 'hook_found_staged_header')),
431
+ cdFail: escapeCmdText(t(loc, 'hook_cd_repo_fail')),
432
+ cmdNotFound1: escapeCmdText(t(loc, 'hook_cmd_not_found1')),
433
+ cmdNotFound2: escapeCmdText(t(loc, 'hook_cmd_not_found2')),
434
+ cmdMissingContinue: escapeCmdText(t(loc, 'hook_cmd_missing_continue')),
435
+ useCmdPrefix: escapeCmdText(t(loc, 'hook_use_command_prefix')),
436
+ reviewFail: escapeCmdText(t(loc, 'hook_review_fail')),
437
+ reviewPass: escapeCmdText(t(loc, 'hook_review_pass')),
438
+ interruptCancelled: escapeCmdText(t(loc, 'interrupt_cancelled'))
439
+ };
440
+ const cmdNodeScript = [
441
+ "const { execSync, spawnSync } = require('child_process');",
442
+ "const fs = require('fs');",
443
+ "const path = require('path');",
444
+ `const MSG = { start: '${cmdMessages.start}', noStaged: '${cmdMessages.noStaged}', foundHeader: '${cmdMessages.foundHeader}', cdFail: '${cmdMessages.cdFail}', cmdNotFound1: '${cmdMessages.cmdNotFound1}', cmdNotFound2: '${cmdMessages.cmdNotFound2}', cmdMissingContinue: '${cmdMessages.cmdMissingContinue}', useCmdPrefix: '${cmdMessages.useCmdPrefix}', reviewFail: '${cmdMessages.reviewFail}', reviewPass: '${cmdMessages.reviewPass}', interruptCancelled: '${cmdMessages.interruptCancelled}' };`,
445
+ "const log = (value) => { if (value) console.log(value); };",
446
+ "log(MSG.start);",
447
+ "let staged = '';",
448
+ "try { staged = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }); } catch (e) {}",
449
+ "const stagedFiles = staged.split(/\\r?\\n/).filter(Boolean);",
450
+ "if (!stagedFiles.length) { log(MSG.noStaged); process.exit(0); }",
451
+ "log(MSG.foundHeader);",
452
+ "log(stagedFiles.join('\\n'));",
453
+ "let repoRoot = '';",
454
+ "try { repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim(); } catch (e) {}",
455
+ "if (!repoRoot) { log(MSG.cdFail); process.exit(1); }",
456
+ "const rootBin = path.join(repoRoot, 'node_modules', '.bin', 'smart-review');",
457
+ "const rootEntry = path.join(repoRoot, 'node_modules', 'smart-review', 'bin', 'review.js');",
458
+ "let foundCmd = '';",
459
+ "let foundEntry = '';",
460
+ "if (fs.existsSync(rootBin)) { foundCmd = rootBin; if (fs.existsSync(rootEntry)) { foundEntry = rootEntry; } }",
461
+ "if (!foundCmd) {",
462
+ " const maxAscend = 6;",
463
+ " outer: for (const file of stagedFiles) {",
464
+ " let dir = path.dirname(file);",
465
+ " let depth = 0;",
466
+ " while (dir && dir !== '.' && depth < maxAscend) {",
467
+ " const candidateBin = path.join(repoRoot, dir, 'node_modules', '.bin', 'smart-review');",
468
+ " const candidateEntry = path.join(repoRoot, dir, 'node_modules', 'smart-review', 'bin', 'review.js');",
469
+ " if (fs.existsSync(candidateBin)) { foundCmd = candidateBin; if (fs.existsSync(candidateEntry)) { foundEntry = candidateEntry; } break outer; }",
470
+ " if (fs.existsSync(candidateEntry)) { foundCmd = candidateEntry; foundEntry = candidateEntry; break outer; }",
471
+ " dir = path.dirname(dir);",
472
+ " depth++;",
473
+ " }",
474
+ " }",
475
+ "}",
476
+ "if (!foundCmd) {",
477
+ " try { execSync('where smart-review', { stdio: 'ignore' }); foundCmd = 'smart-review'; } catch (e) {}",
478
+ "}",
479
+ "if (!foundCmd) { log(MSG.cmdNotFound1); log(MSG.cmdNotFound2); log(MSG.cmdMissingContinue); process.exit(0); }",
480
+ "log(MSG.useCmdPrefix + ' ' + foundCmd + ' --staged');",
481
+ "const hasTty = !!(process.stdin && process.stdin.isTTY) || !!(process.stdout && process.stdout.isTTY);",
482
+ "const tryTty = (p) => { try { const fd = fs.openSync(p, 'r'); fs.closeSync(fd); return p; } catch (e) { return ''; } };",
483
+ "const ensureTty = () => {",
484
+ " if (process.env.SMART_REVIEW_TTY) return;",
485
+ " let tty = '';",
486
+ " if (hasTty) { tty = tryTty('\\\\\\\\.\\\\CONIN$') || tryTty('CONIN$'); }",
487
+ " if (!tty) { tty = tryTty('\\\\\\\\.\\\\CONIN$') || tryTty('CONIN$'); }",
488
+ " if (tty) { process.env.SMART_REVIEW_TTY = tty; process.env.SMART_REVIEW_FORCE_TTY = '1'; }",
489
+ "};",
490
+ "const args = ['--staged'];",
491
+ "const runOnce = (capture) => {",
492
+ " if (foundEntry) {",
493
+ " return spawnSync(process.execPath, [foundEntry, ...args], { stdio: capture ? 'pipe' : 'inherit' });",
494
+ " }",
495
+ " return spawnSync(foundCmd, args, { stdio: capture ? 'pipe' : 'inherit', shell: true });",
496
+ "};",
497
+ "let result = runOnce(true);",
498
+ "if (result.stdout) process.stdout.write(result.stdout);",
499
+ "if (result.stderr) process.stderr.write(result.stderr);",
500
+ "let code = Number.isInteger(result.status) ? result.status : (Number.isInteger(result.code) ? result.code : 0);",
501
+ "const errText = result.stderr ? String(result.stderr) : '';",
502
+ "if (code !== 0 && /stdin is not a tty/i.test(errText)) {",
503
+ " ensureTty();",
504
+ " result = runOnce(false);",
505
+ " code = Number.isInteger(result.status) ? result.status : (Number.isInteger(result.code) ? result.code : 0);",
506
+ "}",
507
+ "if (code === 130 || code === 143) { log(MSG.interruptCancelled); process.exit(0); }",
508
+ "if (code !== 0) { log(MSG.reviewFail); process.exit(1); }",
509
+ "log(MSG.reviewPass); process.exit(0);"
510
+ ].join('');
511
+ // Windows 兼容:提供 CMD 包装器,避免 bash 在 CMD 下的 TTY 问题
340
512
  try {
341
513
  const preCommitCmd = path.join(gitHooksDir, 'pre-commit.cmd');
342
514
  const cmdContent = [
343
515
  '@echo off',
344
516
  'SETLOCAL',
345
- 'set HOOK=%~dp0pre-commit',
346
- 'if not exist "%HOOK%" (',
347
- ' echo [smart-review] pre-commit hook missing.',
348
- ' exit /b 1',
349
- ')',
350
- 'bash "%HOOK%"',
517
+ `node -e "${cmdNodeScript}"`,
351
518
  'exit /b %ERRORLEVEL%\r\n'
352
519
  ].join('\r\n');
353
520
  fs.writeFileSync(preCommitCmd, cmdContent);
@@ -416,4 +583,4 @@ fi
416
583
 
417
584
  // 运行安装
418
585
  const installer = new Installer();
419
- (async () => { await installer.install(); })();
586
+ (async () => { await installer.install(); })();