@raolin2025/claude-code-node 2.0.0 → 2.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.md CHANGED
@@ -111,6 +111,7 @@ cc-node --resume session-1747000000000-abc123
111
111
  | **Grep** | 内容搜索(rg/grep) | `always-allow` | — |
112
112
  | **WebFetch** | 抓取网页内容 | `ask` | ✅ SSRF 防护 |
113
113
  | **WebSearch** | 网页搜索 | `ask` | 需要 API Key |
114
+ | **GitTool** | GitHub PR 自动化(审查/合并/评论) | `high` | ✅ 预检查 |
114
115
  | **AskUserQuestion** | 向用户提问 | `always-allow` | — |
115
116
 
116
117
  [![npm version](https://img.shields.io/npm/v/@raolin2025/claude-code-node.svg)](https://www.npmjs.com/package/@raolin2025/claude-code-node) [![GitHub](https://img.shields.io/badge/GitHub-bg1avd%2Fclaude--code--node-blue)](https://github.com/bg1avd/claude-code-node) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -429,3 +430,119 @@ sudo systemctl start cc-notify
429
430
  # 查看日志
430
431
  journalctl -u cc-notify -f
431
432
  ```
433
+
434
+ ## 🔧 GitTool - PR 自动化管理
435
+
436
+ GitTool 是 GitHub PR 自动化管理工具,支持查看、审查、合并 PR,以及批量操作和智能分析。
437
+
438
+ ### 前置配置
439
+
440
+ ```bash
441
+ # 1. 创建 GitHub Personal Access Token (classic, 有 repo 权限)
442
+ export GITHUB_TOKEN=ghp_xxx
443
+
444
+ # 2. 设置仓库信息
445
+ export GITHUB_OWNER=your_org
446
+ export GITHUB_REPO=your_repo
447
+
448
+ # 可选:启用 LLM 智能分析
449
+ export DEEPSEEK_API_KEY=sk-xxx
450
+ export DEEPSEEK_API_BASE=https://api.deepseek.com/v1
451
+ ```
452
+
453
+ 或在 `~/.claude-code/config.json` 中配置:
454
+
455
+ ```json
456
+ {
457
+ "github": {
458
+ "owner": "your_org",
459
+ "repo": "your_repo"
460
+ },
461
+ "llm": {
462
+ "apiKey": "sk-xxx",
463
+ "apiBase": "https://api.deepseek.com/v1",
464
+ "model": "deepseek-chat"
465
+ },
466
+ "reviewRules": {
467
+ "checks": {
468
+ "codeQuality": true,
469
+ "security": true,
470
+ "tests": true,
471
+ "docs": true
472
+ }
473
+ }
474
+ }
475
+ ```
476
+
477
+ ### 使用示例
478
+
479
+ 在 REPL 中调用 GitTool:
480
+
481
+ ```
482
+ /GitTool list-prs
483
+ /GitTool review-pr 123 --auto-comment false
484
+ /GitTool check-mergeable 123
485
+ /GitTool approve 123 "Looks good"
486
+ /GitTool merge-pr 123 --method squash
487
+ ```
488
+
489
+ 命令行脚本(`test-git-tool.mjs`):
490
+
491
+ ```bash
492
+ # 列出 PR
493
+ node test-git-tool.mjs list
494
+
495
+ # 审查 PR(规则检查,无 LLM)
496
+ node test-git-tool.mjs review 123
497
+
498
+ # 使用 LLM 智能分析
499
+ DEEPSEEK_API_KEY=sk-xxx node test-git-tool.mjs review-llm 123
500
+
501
+ # 检查可合并性
502
+ node test-git-tool.mjs check-mergeable 123
503
+
504
+ # Approve PR
505
+ node test-git-tool.mjs approve 123 "Approved"
506
+
507
+ # 提交审查意见
508
+ node test-git-tool.mjs comment 123 "Please add tests"
509
+
510
+ # 行级评论(自动计算 diff position)
511
+ node test-git-tool.mjs comment 123 "Fix needed" --path src/index.js --line 42
512
+ ```
513
+
514
+ ### 自动化工作流
515
+
516
+ - **每日自动审查**: 设置 cron 运行 `auto-review-all`
517
+ - **自动合并**: 为满足条件的 PR 添加 `auto-merge` 标签,运行 `auto-merge-eligible`
518
+ - **集成 OpenClaw Heartbeat**: 通过 `cron` 工具定期执行
519
+
520
+ ```bash
521
+ # 每天 10:00 自动审查所有 PR
522
+ cron add "0 10 * * *" "session:git-automation" \
523
+ --payload '{"kind":"agentTurn","message":"/GitTool auto-review-all"}'
524
+ ```
525
+
526
+ ### 合并策略
527
+
528
+ PRMergePolicy 支持以下检查(可配置):
529
+
530
+ - ✅ 最少 Approvals 数量
531
+ - ✅ CI 状态全部通过
532
+ - ✅ 无 `changes_requested`
533
+ - ✅ 分支保护规则
534
+ - ✅ 自动合并标签(如 `auto-merge`)
535
+ - ✅ 合并冲突检测
536
+
537
+ ### 安全与权限
538
+
539
+ - GitTool 工具注册为 `high` 权限级别(合并操作需要确认)
540
+ - 操作会被记录在审计日志中
541
+ - 禁止合并到受保护分支(main/master)
542
+
543
+ ### 与 OpenClaw 集成
544
+
545
+ - GitTool 已内置到 cc-node 工具集中
546
+ - 可通过 `/tools` 查看
547
+ - 审查结果可与 QQ/Telegram 通道集成,发送通知
548
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * GitTool 集成测试
3
+ * 测试工具在真实环境中的集成情况
4
+ */
5
+
6
+ import { test, describe, beforeEach } from 'node:test'
7
+ import assert from 'node:assert'
8
+
9
+ // 模拟 GitHub API 响应
10
+ function mockGitHubAPI() {
11
+ return {
12
+ token: 'mock-token',
13
+ owner: 'test-owner',
14
+ repo: 'test-repo',
15
+ baseUrl: 'https://api.github.com',
16
+ request: async (endpoint, options = {}) => {
17
+ if (endpoint.includes('/pulls')) {
18
+ return [{ number: 1, title: 'Test PR', state: 'open', user: { login: 'tester' } }]
19
+ }
20
+ if (endpoint.includes('/pulls/1')) {
21
+ return {
22
+ number: 1,
23
+ title: 'Test PR',
24
+ body: 'Test',
25
+ user: { login: 'tester' },
26
+ head: { ref: 'feature', sha: 'abc123' },
27
+ base: { ref: 'main' },
28
+ mergeable: true,
29
+ changed_files: 1,
30
+ additions: 1,
31
+ deletions: 0,
32
+ labels: []
33
+ }
34
+ }
35
+ return {}
36
+ }
37
+ }
38
+ }
39
+
40
+ // 导入 GitTool 类
41
+ import { GitTool } from '../tools/git-tool.js'
42
+
43
+ describe('GitTool Integration', () => {
44
+ let tool
45
+
46
+ beforeEach(() => {
47
+ // 创建 GitTool 实例,使用 mock 配置
48
+ tool = new GitTool({
49
+ owner: 'test-owner',
50
+ repo: 'test-repo',
51
+ token: 'mock-token'
52
+ })
53
+ // 注入 mock 的 GitHub API
54
+ tool.github = mockGitHubAPI()
55
+ })
56
+
57
+ test('should list PRs', async () => {
58
+ const result = await tool.execute({ action: 'list-prs', limit: 10 })
59
+ assert.ok(result.count >= 0)
60
+ assert.ok(Array.isArray(result.prs))
61
+ })
62
+
63
+ test('should get PR details', async () => {
64
+ const result = await tool.execute({ action: 'get-pr', prNumber: 1 })
65
+ assert.strictEqual(result.number, 1)
66
+ assert.ok(result.title.length > 0)
67
+ })
68
+
69
+ test('should check mergeable', async () => {
70
+ const result = await tool.execute({ action: 'check-mergeable', prNumber: 1 })
71
+ assert.ok('mergeable' in result)
72
+ assert.ok('checks' in result)
73
+ assert.ok('violations' in result)
74
+ })
75
+
76
+ test('should validate parameters before execution', async () => {
77
+ await assert.rejects(
78
+ tool.execute({ action: 'list-prs', state: 'invalid' }),
79
+ /Invalid enum value/
80
+ )
81
+ })
82
+
83
+ test('should handle unknown action', async () => {
84
+ await assert.rejects(
85
+ tool.execute({ action: 'unknown-action' }),
86
+ /Unknown action/
87
+ )
88
+ })
89
+ })
@@ -0,0 +1,144 @@
1
+ /**
2
+ * GitTool 单元测试
3
+ */
4
+
5
+ import { test, describe } from 'node:test'
6
+ import assert from 'node:assert'
7
+
8
+ import { gitTool, createToolDef, GitTool } from '../tools/git-tool.js'
9
+ import { parseDiff, splitDiffByFile, getPositionInDiff } from '../git/utils/diff-parser.js'
10
+
11
+ // ============================================
12
+ // Diff Parser Tests
13
+ // ============================================
14
+ describe('Diff Parser', () => {
15
+ const sampleDiff = `diff --git a/src/index.js b/src/index.js
16
+ --- a/src/index.js
17
+ +++ b/src/index.js
18
+ @@ -1,5 +1,5 @@
19
+ function add(a, b) {
20
+ - return a + b
21
+ + return a + b + 0
22
+ }
23
+ module.exports = { add }
24
+ `
25
+
26
+ test('parseDiff should parse hunk headers', () => {
27
+ const hunks = parseDiff(sampleDiff)
28
+ assert.strictEqual(hunks.length, 1)
29
+ assert.strictEqual(hunks[0].oldStart, 1)
30
+ assert.strictEqual(hunks[0].newStart, 1)
31
+ })
32
+
33
+ test('splitDiffByFile should return file map with correct file', () => {
34
+ const fileMap = splitDiffByFile(sampleDiff)
35
+ assert.ok(fileMap.has('src/index.js'), 'Missing src/index.js in fileMap')
36
+ const hunks = fileMap.get('src/index.js')
37
+ assert.ok(Array.isArray(hunks))
38
+ assert.ok(hunks.length >= 1, `Expected at least 1 hunk, got ${hunks.length}`)
39
+ })
40
+
41
+ test('getPositionInDiff should calculate position for added line', () => {
42
+ const fileMap = splitDiffByFile(sampleDiff)
43
+ const hunks = fileMap.get('src/index.js')
44
+
45
+ // The line " return a + b + 0" is at new line 2
46
+ const position = getPositionInDiff(hunks, 2)
47
+ assert.ok(position !== null, 'Position should not be null for existing line')
48
+ assert.ok(position > 1, `Position ${position} should be > 1`)
49
+ })
50
+ })
51
+
52
+ // ============================================
53
+ // GitTool ToolDef Tests
54
+ // ============================================
55
+ describe('GitTool ToolDef', () => {
56
+ test('should have correct name', () => {
57
+ assert.strictEqual(gitTool.name, 'GitTool')
58
+ })
59
+
60
+ test('should have description', () => {
61
+ assert.ok(typeof gitTool.description === 'string' && gitTool.description.length > 10)
62
+ })
63
+
64
+ test('should have parameters schema', () => {
65
+ const params = gitTool.parameters
66
+ assert.strictEqual(params.type, 'object')
67
+ assert.ok('action' in params.properties)
68
+ assert.ok('prNumber' in params.properties)
69
+ })
70
+
71
+ test('should require action parameter', () => {
72
+ const required = gitTool.parameters.required
73
+ assert.ok(Array.isArray(required))
74
+ assert.ok(required.includes('action'))
75
+ })
76
+
77
+ test('should support all expected actions', () => {
78
+ const actions = gitTool.parameters.properties.action.enum
79
+ const expected = [
80
+ 'list-prs', 'get-pr', 'review-pr', 'merge-pr',
81
+ 'comment', 'approve', 'request-changes',
82
+ 'check-mergeable', 'auto-review-all', 'auto-merge-eligible'
83
+ ]
84
+ expected.forEach(a => assert.ok(actions.includes(a), `Missing action: ${a}`))
85
+ })
86
+
87
+ test('should have high permission level', () => {
88
+ assert.strictEqual(gitTool.permissionLevel, 'high')
89
+ })
90
+
91
+ test('should have handler function', () => {
92
+ assert.ok(typeof gitTool.handler === 'function')
93
+ })
94
+ })
95
+
96
+ // ============================================
97
+ // GitTool Factory Tests
98
+ // ============================================
99
+ describe('GitTool Factory', () => {
100
+ test('createToolDef should return ToolDef instance with handler', () => {
101
+ const toolDef = createToolDef({})
102
+ assert.ok(toolDef instanceof Object)
103
+ assert.ok(typeof toolDef.handler === 'function')
104
+ assert.strictEqual(toolDef.name, 'GitTool')
105
+ })
106
+
107
+ test('GitTool class should be instantiable with config', () => {
108
+ const instance = new GitTool({ owner: 'test', repo: 'test' })
109
+ assert.ok(instance instanceof GitTool)
110
+ assert.ok(typeof instance.execute === 'function')
111
+ })
112
+ })
113
+
114
+ // ============================================
115
+ // Parameter Validation Tests
116
+ // ============================================
117
+ describe('Parameter Validation', () => {
118
+ test('comment action should validate prNumber and body before GitHub init', async () => {
119
+ // Pass dummy token to bypass GitHub token check early
120
+ const tool = new GitTool({
121
+ owner: 'test',
122
+ repo: 'test',
123
+ token: 'dummy'
124
+ })
125
+
126
+ await assert.rejects(
127
+ tool.execute({ action: 'comment' }),
128
+ /prNumber and body required/
129
+ )
130
+ })
131
+
132
+ test('merge-pr action should validate prNumber', async () => {
133
+ const tool = new GitTool({
134
+ owner: 'test',
135
+ repo: 'test',
136
+ token: 'dummy'
137
+ })
138
+
139
+ await assert.rejects(
140
+ tool.execute({ action: 'merge-pr' }),
141
+ /prNumber required/
142
+ )
143
+ })
144
+ })
@@ -491,28 +491,31 @@ async function main() {
491
491
  // 确保 socket 目录存在
492
492
  mkdirSync(SOCK_DIR, { recursive: true });
493
493
 
494
- // v1.1: PID file lock — atomic create, prevent multiple instances
495
- try {
496
- const fd = openSync(config.pidFile, "wx");
497
- writeFileSync(fd, String(process.pid));
498
- closeSync(fd);
499
- } catch (err) {
500
- if (err.code === "EEXIST") {
494
+ // M9 fix: PID file lock — atomic create with retry, no unlink+write race
495
+ let pidAcquired = false;
496
+ for (let attempt = 0; attempt < 3; attempt++) {
497
+ try {
498
+ const fd = openSync(config.pidFile, "wx");
499
+ writeFileSync(fd, String(process.pid));
500
+ closeSync(fd);
501
+ pidAcquired = true;
502
+ break;
503
+ } catch (err) {
504
+ if (err.code !== "EEXIST") throw err;
501
505
  const oldPid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
502
506
  try {
503
507
  process.kill(oldPid, 0);
504
508
  console.error("cc-notify already running (PID " + oldPid + "). Use --stop first.");
505
509
  process.exit(1);
506
510
  } catch {
507
- try {
508
- unlinkSync(config.pidFile);
509
- } catch {}
510
- writeFileSync(config.pidFile, String(process.pid));
511
+ try { unlinkSync(config.pidFile); } catch {}
512
+ if (attempt < 2) await sleep(100);
511
513
  }
512
- } else {
513
- throw err;
514
514
  }
515
515
  }
516
+ if (!pidAcquired) {
517
+ writeFileSync(config.pidFile, String(process.pid));
518
+ }
516
519
 
517
520
  const cleanup = () => {
518
521
  log("Shutting down...");
package/src/core/cli.js CHANGED
@@ -138,6 +138,7 @@ Commands:
138
138
  /channel CMD — Manage notification channels (list|send|test)
139
139
  /cost — Show API cost report
140
140
  /compact — Manually compact conversation context
141
+ /allow [tool] — Allow a tool for the current session (default: all)
141
142
  /exit — Exit (also Ctrl+C)
142
143
  /quit — Same as /exit
143
144
  `
@@ -241,6 +242,8 @@ export async function main() {
241
242
  session = await sessionManager.create()
242
243
  }
243
244
 
245
+ // M1 fix: tokenBudget 必须在 engineConfig 之前定义,否则 TDZ ReferenceError
246
+ const tokenBudget = new TokenBudget({ maxTokens: config.get('maxBudgetTokens') || 200_000 })
244
247
  const costTracker = new CostTracker({ model })
245
248
 
246
249
  const engineConfig = new QueryEngineConfig({
@@ -252,11 +255,17 @@ export async function main() {
252
255
  })
253
256
  const engine = new QueryEngine(engineConfig)
254
257
 
255
- // M5: 恢复会话历史和状态
258
+ // M5: 恢复会话历史和状态 — 完整恢复所有角色(含 tool_calls、tool 结果)
256
259
  if (session?.messages?.length) {
257
260
  for (const msg of session.messages) {
258
- if (msg.role === 'user') engine.state.messages.push({ role: 'user', content: msg.content })
259
- else if (msg.role === 'assistant') engine.state.messages.push({ role: 'assistant', content: msg.content })
261
+ const entry = { role: msg.role, content: msg.content }
262
+ if (msg.role === 'assistant' && msg.toolCalls?.length > 0) {
263
+ entry.toolCalls = msg.toolCalls
264
+ }
265
+ if (msg.role === 'tool' && msg.tool_call_id) {
266
+ entry.tool_call_id = msg.tool_call_id
267
+ }
268
+ engine.state.messages.push(entry)
260
269
  }
261
270
  // 恢复 turn count
262
271
  if (session.state?.turnCount) engine.state.turnCount = session.state.turnCount
@@ -268,7 +277,6 @@ export async function main() {
268
277
  }
269
278
  }
270
279
 
271
- const tokenBudget = new TokenBudget({ maxTokens: config.get('maxBudgetTokens') || 200_000 })
272
280
 
273
281
  const channelManager = new ChannelManager({
274
282
  channels: config.get('channels') || {},
@@ -277,8 +285,16 @@ export async function main() {
277
285
 
278
286
  // 一次性输入模式
279
287
  if (cliArgs.oneShot) {
288
+ // 一次性模式下用户已明确表达了执行意图,自动批准所有工具调用
289
+ if (engine.permissionChecker.mode === 'ask') {
290
+ engine.config.onConfirmTool = async () => true
291
+ }
280
292
  const result = await engine.processMessage(cliArgs.oneShot)
281
293
  console.log(result.response)
294
+ // 保存会话
295
+ session = await sessionManager.create(`one-shot: ${cliArgs.oneShot.slice(0, 50)}`)
296
+ await sessionManager.appendMessage({ role: 'user', content: cliArgs.oneShot })
297
+ await sessionManager.appendMessage({ role: 'assistant', content: result.response })
282
298
  if (channelManager.list().length > 0) {
283
299
  await channelManager.sendTemplate('task-done', {
284
300
  task: cliArgs.oneShot.slice(0, 80),
@@ -293,6 +309,19 @@ export async function main() {
293
309
 
294
310
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
295
311
 
312
+ // 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
313
+ if (permissionMode === 'ask') {
314
+ engine.config.onConfirmTool = async (toolName, input) => {
315
+ return new Promise((resolve) => {
316
+ const snippet = JSON.stringify(input).slice(0, 120) || '(no params)'
317
+ rl.question(`\n⚠️ Allow tool "${toolName}"?\n Input: ${snippet}\n (y/N) `, (answer) => {
318
+ resolve(answer.toLowerCase().startsWith('y'))
319
+ })
320
+ })
321
+ }
322
+ }
323
+ engine.config.readline = rl
324
+
296
325
  console.log(BANNER)
297
326
  console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
298
327
  console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
@@ -304,7 +333,7 @@ export async function main() {
304
333
  console.log()
305
334
  rl.prompt()
306
335
 
307
- // 共享的消息处理函数(REPL 和 socket 都用)
336
+ // REPL 消息处理包装(留作扩展点)
308
337
  async function processInput(input) {
309
338
  return engine.processMessage(input)
310
339
  }
@@ -380,6 +409,12 @@ export async function main() {
380
409
  }
381
410
  break
382
411
  }
412
+ case 'allow': {
413
+ const allowTool = rest.join(' ') || '*'
414
+ engine.permissionChecker.allowForSession(allowTool, '*')
415
+ console.log(`✅ Tool "${allowTool}" allowed for this session`)
416
+ break
417
+ }
383
418
  case 'cost':
384
419
  console.log(engine.costTracker.formatReport())
385
420
  break
@@ -415,6 +450,11 @@ export async function main() {
415
450
  console.log()
416
451
  await sessionManager.appendMessage({ role: 'user', content: input })
417
452
  await sessionManager.appendMessage({ role: 'assistant', content: result.response })
453
+ // 保存引擎状态到会话
454
+ session.state = session.state || {}
455
+ session.state.turnCount = engine.state.turnCount
456
+ session.state.costHistory = engine.costTracker.history.slice(-50)
457
+ await sessionManager.save(session)
418
458
  if (verbose) console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
419
459
  // 显示费用(即使非 verbose 也显示)
420
460
  if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {