@raolin2025/claude-code-node 1.1.0 → 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.md CHANGED
@@ -377,3 +377,55 @@ cc-node
377
377
  - ✅ **任务完成**(one-shot 模式自动通知)
378
378
  - ❌ **执行出错**(自动通知错误内容)
379
379
  - 🔄 **手动发送**(`/channel send` 命令)
380
+
381
+ ## 🔔 后台运行 (cc-notify)
382
+
383
+ cc-notify 是独立的通知守护进程,**不需要 cc-node 在前台运行**,开机自启后随时可用。
384
+
385
+ ### 三种运行方式
386
+
387
+ | 方式 | 命令 | 说明 |
388
+ |------|------|------|
389
+ | **前台** | `cc-notify` | 调试用,Ctrl+C 退出 |
390
+ | **后台守护** | `cc-notify --daemon` | 脱离终端后台运行 |
391
+ | **系统服务** | `systemctl start cc-notify` | 开机自启,最推荐 |
392
+
393
+ ### 手机交互
394
+
395
+ 在 Telegram 上给 Bot 发消息:
396
+
397
+ ```
398
+ 你好 → 当作一次性任务发给 cc-node 执行
399
+ /ping → 检查服务是否在线
400
+ /run ls -la → 执行 shell 命令
401
+ /notify 任务完成! → 向所有通道广播通知
402
+ /status → 查看服务状态
403
+ ```
404
+
405
+ ### HTTP API(守护模式可用)
406
+
407
+ ```bash
408
+ # 发送通知
409
+ curl -X POST http://localhost:3456/send \
410
+ -H 'Content-Type: application/json' \
411
+ -d '{"text":"构建完成 ✅"}'
412
+
413
+ # 查看状态
414
+ curl http://localhost:3456/status
415
+ ```
416
+
417
+ ### systemd 开机自启
418
+
419
+ ```bash
420
+ # 安装服务
421
+ sudo cp cc-notify.service /etc/systemd/system/
422
+ # 编辑 Token
423
+ sudo vim /etc/systemd/system/cc-notify.service
424
+ # 启用
425
+ sudo systemctl daemon-reload
426
+ sudo systemctl enable cc-notify
427
+ sudo systemctl start cc-notify
428
+
429
+ # 查看日志
430
+ journalctl -u cc-notify -f
431
+ ```
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "1.1.0",
4
- "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened",
3
+ "version": "1.2.0",
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",
7
7
  "bin": {
8
- "cc-node": "src/index.js"
8
+ "cc-node": "src/index.js",
9
+ "cc-notify": "src/channel/notify-daemon.js"
9
10
  },
10
11
  "scripts": {
11
12
  "start": "node src/index.js",
@@ -13,7 +14,15 @@
13
14
  "test": "node --test src/__tests__/*.js",
14
15
  "repl": "node src/index.js"
15
16
  },
16
- "keywords": ["claude", "code", "agent", "cli", "llm", "ai", "security"],
17
+ "keywords": [
18
+ "claude",
19
+ "code",
20
+ "agent",
21
+ "cli",
22
+ "llm",
23
+ "ai",
24
+ "security"
25
+ ],
17
26
  "license": "MIT",
18
27
  "author": "bg1avd",
19
28
  "repository": {
@@ -0,0 +1,491 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cc-notify — 轻量通知守护进程
4
+ *
5
+ * 独立于 cc-node 运行,常驻后台,提供:
6
+ * 1. Telegram Bot 长轮询监听(手机发消息 → 处理 → 回复)
7
+ * 2. HTTP API 接口(其他程序调用发通知)
8
+ * 3. Webhook 接收器(接收外部事件触发通知)
9
+ *
10
+ * 用法:
11
+ * cc-notify # 前台运行
12
+ * cc-notify --daemon # 后台守护进程
13
+ * cc-notify --daemon --pidfile /tmp/cc.pid # 指定 PID 文件
14
+ * cc-notify --stop # 停止守护进程
15
+ * cc-notify --status # 查看状态
16
+ *
17
+ * # HTTP API(守护模式可用)
18
+ * curl -X POST http://localhost:3456/send -d '{"text":"hello"}'
19
+ * curl http://localhost:3456/status
20
+ */
21
+
22
+ import { createServer } from 'http'
23
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from 'fs'
24
+ import { resolve, join } from 'path'
25
+ import { homedir } from 'os'
26
+ import { spawn, execSync } from 'child_process'
27
+
28
+ // ============================================================
29
+ // 配置
30
+ // ============================================================
31
+
32
+ const DEFAULT_PORT = 3456
33
+ const DEFAULT_PID_FILE = join(homedir(), '.cc-notify.pid')
34
+ const DEFAULT_LOG_FILE = join(homedir(), '.cc-notify.log')
35
+ const POLL_INTERVAL_MS = 3000 // Telegram 长轮询间隔
36
+
37
+ function loadConfig() {
38
+ // 1. 环境变量
39
+ const config = {
40
+ channels: {},
41
+ defaultChannel: process.env.CC_NODE_CHANNEL_DEFAULT || null,
42
+ port: parseInt(process.env.CC_NOTIFY_PORT || '3456', 10),
43
+ pidFile: process.env.CC_NOTIFY_PID_FILE || DEFAULT_PID_FILE,
44
+ logFile: process.env.CC_NOTIFY_LOG_FILE || DEFAULT_LOG_FILE,
45
+ ccNodePath: process.env.CC_NODE_PATH || 'cc-node',
46
+ }
47
+
48
+ // 2. 从 .claude-code/config.json 加载
49
+ for (const dir of [process.cwd(), homedir()]) {
50
+ const cfgPath = join(dir, '.claude-code', 'config.json')
51
+ if (existsSync(cfgPath)) {
52
+ try {
53
+ const data = JSON.parse(readFileSync(cfgPath, 'utf8'))
54
+ if (data.channels) {
55
+ Object.assign(config.channels, data.channels)
56
+ }
57
+ if (data.defaultChannel && !config.defaultChannel) {
58
+ config.defaultChannel = data.defaultChannel
59
+ }
60
+ if (data.notify?.port) config.port = data.notify.port
61
+ if (data.notify?.ccNodePath) config.ccNodePath = data.notify.ccNodePath
62
+ } catch {}
63
+ }
64
+ }
65
+
66
+ // 3. 环境变量覆盖(CC_NODE_CHANNEL_*)
67
+ for (const [key, value] of Object.entries(process.env)) {
68
+ if (!key.startsWith('CC_NODE_CHANNEL_')) continue
69
+ const rest = key.slice('CC_NODE_CHANNEL_'.length)
70
+ if (rest === 'DEFAULT') continue
71
+ const parts = rest.split('_')
72
+ const type = parts[0].toLowerCase()
73
+ const param = parts.slice(1).join('_').toLowerCase()
74
+ if (!config.channels[type]) config.channels[type] = { type }
75
+ const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
76
+ config.channels[type][camelKey] = value
77
+ }
78
+
79
+ return config
80
+ }
81
+
82
+ // ============================================================
83
+ // 通道适配器(复用 cc-node 的逻辑,独立实现避免依赖)
84
+ // ============================================================
85
+
86
+ async function sendTelegram(config, text) {
87
+ const url = `https://api.telegram.org/bot${config.token}/sendMessage`
88
+ const res = await fetch(url, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ chat_id: config.chatId, text, parse_mode: 'Markdown' }),
92
+ })
93
+ return res.json()
94
+ }
95
+
96
+ async function sendWebhook(url, text, parseMode) {
97
+ const res = await fetch(url, {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ text, msgtype: parseMode === 'markdown' ? 'markdown' : 'text' }),
101
+ })
102
+ return res.text()
103
+ }
104
+
105
+ async function sendToChannel(channels, defaultChannel, text) {
106
+ const targets = defaultChannel ? [defaultChannel] : Object.keys(channels)
107
+ const results = []
108
+ for (const name of targets) {
109
+ const ch = channels[name]
110
+ if (!ch) { results.push({ channel: name, ok: false, error: 'not configured' }); continue }
111
+ try {
112
+ if (ch.type === 'telegram') {
113
+ const r = await sendTelegram(ch, text)
114
+ results.push({ channel: name, ok: r.ok || false, result: r })
115
+ } else if (ch.webhookUrl) {
116
+ const r = await sendWebhook(ch.webhookUrl, text)
117
+ results.push({ channel: name, ok: true, result: r })
118
+ } else {
119
+ results.push({ channel: name, ok: false, error: 'unknown type' })
120
+ }
121
+ } catch (e) {
122
+ results.push({ channel: name, ok: false, error: e.message })
123
+ }
124
+ }
125
+ return results
126
+ }
127
+
128
+ // ============================================================
129
+ // Telegram Bot 长轮询(接收手机消息)
130
+ // ============================================================
131
+
132
+ class TelegramListener {
133
+ constructor(config) {
134
+ this.config = config
135
+ this.lastUpdateId = 0
136
+ this.running = false
137
+ this.handlers = [] // 消息处理器
138
+ }
139
+
140
+ onMessage(handler) {
141
+ this.handlers.push(handler)
142
+ }
143
+
144
+ async start() {
145
+ const ch = this.config.channels.telegram
146
+ if (!ch?.token) {
147
+ log('Telegram listener: no token, skipping')
148
+ return
149
+ }
150
+ this.running = true
151
+ log('Telegram listener: started (long polling)')
152
+ this._poll()
153
+ }
154
+
155
+ stop() {
156
+ this.running = false
157
+ }
158
+
159
+ async _poll() {
160
+ while (this.running) {
161
+ try {
162
+ const url = `https://api.telegram.org/bot${this.config.channels.telegram.token}/getUpdates`
163
+ const res = await fetch(url, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({
167
+ offset: this.lastUpdateId + 1,
168
+ timeout: 30, // 长轮询超时
169
+ allowed_updates: ['message'],
170
+ }),
171
+ })
172
+ const data = await res.json()
173
+
174
+ if (data.ok && data.result?.length) {
175
+ for (const update of data.result) {
176
+ this.lastUpdateId = update.update_id
177
+ if (update.message?.text) {
178
+ const msg = {
179
+ text: update.message.text,
180
+ chatId: update.message.chat.id,
181
+ from: update.message.from?.username || update.message.from?.first_name || 'unknown',
182
+ date: new Date(update.message.date * 1000),
183
+ }
184
+ log(`Telegram msg from ${msg.from}: ${msg.text.slice(0, 80)}`)
185
+ for (const handler of this.handlers) {
186
+ try { await handler(msg) } catch (e) { log(`Handler error: ${e.message}`) }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ } catch (e) {
192
+ log(`Telegram poll error: ${e.message}`)
193
+ await sleep(5000) // 出错后等 5 秒重试
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ // ============================================================
200
+ // HTTP API 服务
201
+ // ============================================================
202
+
203
+ class HttpServer {
204
+ constructor(config, channels) {
205
+ this.config = config
206
+ this.channels = channels
207
+ this.server = null
208
+ }
209
+
210
+ start() {
211
+ this.server = createServer(async (req, res) => {
212
+ const url = new URL(req.url, `http://localhost:${this.config.port}`)
213
+
214
+ // CORS
215
+ res.setHeader('Access-Control-Allow-Origin', '*')
216
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
217
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
218
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
219
+
220
+ try {
221
+ if (req.method === 'GET' && url.pathname === '/status') {
222
+ res.writeHead(200, { 'Content-Type': 'application/json' })
223
+ res.end(JSON.stringify({
224
+ status: 'running',
225
+ channels: Object.keys(this.channels),
226
+ defaultChannel: this.config.defaultChannel,
227
+ uptime: process.uptime(),
228
+ }))
229
+ } else if (req.method === 'POST' && url.pathname === '/send') {
230
+ const body = await readBody(req)
231
+ const { text, channel, parseMode } = JSON.parse(body)
232
+ if (!text) {
233
+ res.writeHead(400, { 'Content-Type': 'application/json' })
234
+ res.end(JSON.stringify({ error: 'text is required' }))
235
+ return
236
+ }
237
+ const results = await sendToChannel(
238
+ this.channels,
239
+ channel || this.config.defaultChannel,
240
+ text
241
+ )
242
+ res.writeHead(200, { 'Content-Type': 'application/json' })
243
+ res.end(JSON.stringify({ results }))
244
+ } else {
245
+ res.writeHead(404, { 'Content-Type': 'application/json' })
246
+ res.end(JSON.stringify({ error: 'not found' }))
247
+ }
248
+ } catch (e) {
249
+ res.writeHead(500, { 'Content-Type': 'application/json' })
250
+ res.end(JSON.stringify({ error: e.message }))
251
+ }
252
+ })
253
+
254
+ this.server.listen(this.config.port, () => {
255
+ log(`HTTP API listening on http://localhost:${this.config.port}`)
256
+ })
257
+ }
258
+
259
+ stop() {
260
+ this.server?.close()
261
+ }
262
+ }
263
+
264
+ // ============================================================
265
+ // 消息处理(收到的消息如何处理)
266
+ // ============================================================
267
+
268
+ async function handleIncomingMessage(msg, config, channels) {
269
+ const text = msg.text
270
+
271
+ // 命令处理
272
+ if (text.startsWith('/')) {
273
+ const [cmd, ...args] = text.split(' ')
274
+ switch (cmd) {
275
+ case '/start':
276
+ case '/help':
277
+ return `🤖 *cc-notify* — AI Code Agent 通知服务\n\nCommands:\n/ping — 检查服务状态\n/run <cmd> — 执行一次性命令\n/notify <text> — 发送通知到所有通道\n/status — 查看状态`
278
+ case '/ping':
279
+ return '🏓 pong!'
280
+ case '/status':
281
+ return `📊 cc-notify status\nChannels: ${Object.keys(channels).join(', ') || 'none'}\nUptime: ${Math.floor(process.uptime())}s`
282
+ case '/notify':
283
+ const notifyText = args.join(' ')
284
+ if (!notifyText) return 'Usage: /notify <text>'
285
+ const results = await sendToChannel(channels, config.defaultChannel, notifyText)
286
+ return results.map(r => r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`).join('\n')
287
+ case '/run':
288
+ const cmd = args.join(' ')
289
+ if (!cmd) return 'Usage: /run <command>'
290
+ return await runOneShot(config.ccNodePath, cmd)
291
+ default:
292
+ return `Unknown command: ${cmd}\nType /help for available commands`
293
+ }
294
+ }
295
+
296
+ // 普通消息 → 当作一次性任务执行
297
+ return await runOneShot(config.ccNodePath, text)
298
+ }
299
+
300
+ /** 调用 cc-node 执行一次性命令 */
301
+ function runOneShot(ccNodePath, input) {
302
+ return new Promise((resolve) => {
303
+ const timeout = 60000 // 60 秒超时
304
+ const timer = setTimeout(() => {
305
+ child.kill()
306
+ resolve('⏰ 执行超时(60秒)')
307
+ }, timeout)
308
+
309
+ try {
310
+ const child = spawn(ccNodePath, [], { stdio: ['pipe', 'pipe', 'pipe'] })
311
+ let stdout = ''
312
+ let stderr = ''
313
+ child.stdout.on('data', (d) => { stdout += d.toString() })
314
+ child.stderr.on('data', (d) => { stderr += d.toString() })
315
+ child.stdin.write(input + '\n')
316
+ child.stdin.end()
317
+ child.on('close', (code) => {
318
+ clearTimeout(timer)
319
+ if (code === 0 && stdout.trim()) {
320
+ resolve(stdout.trim().slice(0, 4000)) // Telegram 消息长度限制
321
+ } else if (stderr.trim()) {
322
+ resolve(`❌ Error: ${stderr.trim().slice(0, 1000)}`)
323
+ } else {
324
+ resolve(stdout.trim().slice(0, 4000) || '(no output)')
325
+ }
326
+ })
327
+ } catch (e) {
328
+ clearTimeout(timer)
329
+ resolve(`❌ Failed to run cc-node: ${e.message}`)
330
+ }
331
+ })
332
+ }
333
+
334
+ // ============================================================
335
+ // 守护进程管理
336
+ // ============================================================
337
+
338
+ function startDaemon(config) {
339
+ log('Starting cc-notify daemon...')
340
+
341
+ // 检查是否已在运行
342
+ if (existsSync(config.pidFile)) {
343
+ const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
344
+ try {
345
+ process.kill(pid, 0) // 检查进程是否存活
346
+ console.error(`cc-notify already running (PID ${pid})`)
347
+ console.log('Use --stop to stop it first')
348
+ process.exit(1)
349
+ } catch {
350
+ // 进程已死,清理旧 PID 文件
351
+ unlinkSync(config.pidFile)
352
+ }
353
+ }
354
+
355
+ // 用子进程启动自己
356
+ const child = spawn(process.execPath, [import.meta.url], {
357
+ detached: true,
358
+ stdio: 'ignore',
359
+ env: { ...process.env, CC_NOTIFY_DAEMON: '1' },
360
+ })
361
+ child.unref()
362
+ console.log(`cc-notify daemon started (PID ${child.pid})`)
363
+ console.log(`PID file: ${config.pidFile}`)
364
+ console.log(`Log file: ${config.logFile}`)
365
+ console.log(`HTTP API: http://localhost:${config.port}`)
366
+ process.exit(0)
367
+ }
368
+
369
+ function stopDaemon(config) {
370
+ if (!existsSync(config.pidFile)) {
371
+ console.log('cc-notify is not running')
372
+ process.exit(0)
373
+ }
374
+ const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
375
+ try {
376
+ process.kill(pid, 'SIGTERM')
377
+ console.log(`cc-notify stopped (PID ${pid})`)
378
+ } catch {
379
+ console.log(`Process ${pid} not found, cleaning PID file`)
380
+ }
381
+ try { unlinkSync(config.pidFile) } catch {}
382
+ process.exit(0)
383
+ }
384
+
385
+ function showStatus(config) {
386
+ if (!existsSync(config.pidFile)) {
387
+ console.log('cc-notify is not running')
388
+ process.exit(0)
389
+ }
390
+ const pid = parseInt(readFileSync(config.pidFile, 'utf8').trim(), 10)
391
+ try {
392
+ process.kill(pid, 0)
393
+ console.log(`cc-notify running (PID ${pid})`)
394
+ // 尝试从 HTTP API 获取详细状态
395
+ fetch(`http://localhost:${config.port}/status`)
396
+ .then(r => r.json())
397
+ .then(data => console.log('Status:', JSON.stringify(data, null, 2)))
398
+ .catch(() => console.log('(HTTP API not responding)'))
399
+ } catch {
400
+ console.log(`PID ${pid} is dead, cleaning up`)
401
+ try { unlinkSync(config.pidFile) } catch {}
402
+ }
403
+ }
404
+
405
+ // ============================================================
406
+ // 工具函数
407
+ // ============================================================
408
+
409
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
410
+
411
+ function readBody(req) {
412
+ return new Promise((resolve) => {
413
+ let body = ''
414
+ req.on('data', (d) => { body += d })
415
+ req.on('end', () => resolve(body))
416
+ })
417
+ }
418
+
419
+ function log(msg) {
420
+ const ts = new Date().toISOString().slice(11, 19)
421
+ const line = `[${ts}] ${msg}\n`
422
+ process.stdout.write(line)
423
+ try {
424
+ const config = loadConfig()
425
+ if (config.logFile) {
426
+ appendFileSync(config.logFile, line)
427
+ }
428
+ } catch {}
429
+ }
430
+
431
+ // ============================================================
432
+ // 主入口
433
+ // ============================================================
434
+
435
+ async function main() {
436
+ const config = loadConfig()
437
+
438
+ // 解析命令行
439
+ const args = process.argv.slice(2)
440
+ const isDaemon = args.includes('--daemon')
441
+ const isStop = args.includes('--stop')
442
+ const isStatus = args.includes('--status')
443
+
444
+ if (isStop) return stopDaemon(config)
445
+ if (isStatus) return showStatus(config)
446
+ if (isDaemon) return startDaemon(config)
447
+
448
+ // 写 PID 文件
449
+ writeFileSync(config.pidFile, String(process.pid))
450
+
451
+ // 优雅退出
452
+ const cleanup = () => {
453
+ log('Shutting down...')
454
+ try { unlinkSync(config.pidFile) } catch {}
455
+ process.exit(0)
456
+ }
457
+ process.on('SIGTERM', cleanup)
458
+ process.on('SIGINT', cleanup)
459
+
460
+ log('cc-notify starting...')
461
+ log(`Channels: ${Object.keys(config.channels).join(', ') || 'none'}`)
462
+ log(`Default: ${config.defaultChannel || 'none'}`)
463
+
464
+ // 启动 Telegram 监听
465
+ const tgListener = new TelegramListener(config)
466
+ tgListener.onMessage(async (msg) => {
467
+ const reply = await handleIncomingMessage(msg, config, config.channels)
468
+ // 回复到 Telegram
469
+ if (config.channels.telegram?.token && msg.chatId) {
470
+ await sendTelegram(
471
+ { token: config.channels.telegram.token, chatId: msg.chatId },
472
+ reply
473
+ )
474
+ }
475
+ })
476
+ await tgListener.start()
477
+
478
+ // 启动 HTTP API
479
+ const httpServer = new HttpServer(config, config.channels)
480
+ httpServer.start()
481
+
482
+ log('cc-notify ready ✅')
483
+
484
+ // 保持进程存活
485
+ setInterval(() => {}, 60000)
486
+ }
487
+
488
+ main().catch((err) => {
489
+ console.error('Fatal:', err)
490
+ process.exit(1)
491
+ })