cc-im 1.0.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/.env.example +43 -0
- package/README.md +219 -0
- package/dist/access/access-control.js +12 -0
- package/dist/claude/cli-runner.js +215 -0
- package/dist/claude/stream-parser.js +42 -0
- package/dist/claude/types.js +30 -0
- package/dist/cli.js +102 -0
- package/dist/commands/handler.js +439 -0
- package/dist/config.js +151 -0
- package/dist/constants.js +94 -0
- package/dist/feishu/card-builder.js +172 -0
- package/dist/feishu/cardkit-manager.js +208 -0
- package/dist/feishu/client.js +25 -0
- package/dist/feishu/event-handler.js +527 -0
- package/dist/feishu/message-sender.js +201 -0
- package/dist/hook/hook-script.js +124 -0
- package/dist/hook/permission-server.js +206 -0
- package/dist/index.js +172 -0
- package/dist/logger.js +80 -0
- package/dist/queue/request-queue.js +51 -0
- package/dist/sanitize.js +43 -0
- package/dist/session/session-manager.js +283 -0
- package/dist/shared/active-chats.js +44 -0
- package/dist/shared/types.js +4 -0
- package/dist/shared/utils.js +164 -0
- package/dist/telegram/client.js +30 -0
- package/dist/telegram/event-handler.js +343 -0
- package/dist/telegram/message-sender.js +223 -0
- package/package.json +50 -0
package/.env.example
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 平台配置
|
|
2
|
+
# 可以同时启用多个平台,只需配置对应的凭证即可
|
|
3
|
+
# - 配置了 TELEGRAM_BOT_TOKEN 即启用 Telegram
|
|
4
|
+
# - 配置了 FEISHU_APP_ID 和 FEISHU_APP_SECRET 即启用飞书
|
|
5
|
+
# - 可以两者都配置,同时运行多个平台的 bot
|
|
6
|
+
|
|
7
|
+
# 飞书应用凭证(启用飞书平台需要)
|
|
8
|
+
# 需要在飞书开放平台为应用开通以下权限:
|
|
9
|
+
# - im:message:send_as_bot 以应用的身份发消息
|
|
10
|
+
# - im:message 获取与发送单聊、群组消息
|
|
11
|
+
# - im:message:patch_as_bot 更新应用发送的消息(权限卡片更新)
|
|
12
|
+
# - cardkit:card 创建与更新 CardKit 卡片(流式输出必需)
|
|
13
|
+
FEISHU_APP_ID=your_app_id
|
|
14
|
+
FEISHU_APP_SECRET=your_app_secret
|
|
15
|
+
|
|
16
|
+
# Telegram Bot Token(启用 Telegram 平台需要,通过 @BotFather 获取)
|
|
17
|
+
TELEGRAM_BOT_TOKEN=your_bot_token
|
|
18
|
+
|
|
19
|
+
# 白名单用户 ID(逗号分隔,留空表示不限制)
|
|
20
|
+
# 飞书: open_id 格式(如 ou_xxxx)
|
|
21
|
+
# Telegram: 用户数字 ID(如 123456789)
|
|
22
|
+
ALLOWED_USER_IDS=
|
|
23
|
+
|
|
24
|
+
# Claude CLI 路径
|
|
25
|
+
CLAUDE_CLI_PATH=claude
|
|
26
|
+
|
|
27
|
+
# Claude Code 工作目录
|
|
28
|
+
CLAUDE_WORK_DIR=.
|
|
29
|
+
|
|
30
|
+
# 允许 /cd 切换的基础目录(逗号分隔,留空则限制在 CLAUDE_WORK_DIR 下)
|
|
31
|
+
ALLOWED_BASE_DIRS=
|
|
32
|
+
|
|
33
|
+
# 是否跳过 Claude CLI 权限检查(默认 false,生产环境建议保持 false)
|
|
34
|
+
CLAUDE_SKIP_PERMISSIONS=false
|
|
35
|
+
|
|
36
|
+
# Claude CLI 执行超时(毫秒,默认 300000 即5分钟)
|
|
37
|
+
CLAUDE_TIMEOUT_MS=300000
|
|
38
|
+
|
|
39
|
+
# 权限确认 Hook 服务端口(默认 18900,仅 CLAUDE_SKIP_PERMISSIONS=false 时使用)
|
|
40
|
+
HOOK_SERVER_PORT=18900
|
|
41
|
+
|
|
42
|
+
# 日志文件存储目录(默认 ~/.cc-im/logs)
|
|
43
|
+
LOG_DIR=
|
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# cc-im
|
|
2
|
+
|
|
3
|
+
多平台(飞书 & Telegram)机器人 ↔ Claude Code CLI 桥接服务。
|
|
4
|
+
|
|
5
|
+
用户在飞书或 Telegram 中发消息,服务器接收后调用 Claude Code 执行,并将输出实时流式推送回聊天窗口。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- **多平台支持**:飞书和 Telegram,可同时运行或单独使用
|
|
10
|
+
- **流式输出**:飞书端使用 CardKit 打字机效果,Telegram 端通过 editMessage 实时更新
|
|
11
|
+
- **会话管理**:每用户独立 session,支持 `/new` 重置
|
|
12
|
+
- **并发控制**:同会话串行执行,不同会话可并发,最多排队 3 条消息
|
|
13
|
+
- **长消息分片**:超长内容自动拆分为多条消息
|
|
14
|
+
- **权限确认**:通过 Hook 机制实现工具调用的交互式审批
|
|
15
|
+
- **白名单**:通过环境变量或配置文件控制访问
|
|
16
|
+
- **停止按钮**:执行过程中可随时停止
|
|
17
|
+
|
|
18
|
+
## 快速开始
|
|
19
|
+
|
|
20
|
+
### 同时运行多个平台
|
|
21
|
+
|
|
22
|
+
可以同时启用飞书和 Telegram,只需配置两个平台的凭证即可:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export FEISHU_APP_ID=your_app_id
|
|
26
|
+
export FEISHU_APP_SECRET=your_app_secret
|
|
27
|
+
export TELEGRAM_BOT_TOKEN=your_bot_token
|
|
28
|
+
npx cc-im
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
服务会自动检测已配置的平台并启动对应的 bot。
|
|
32
|
+
|
|
33
|
+
### Telegram 平台
|
|
34
|
+
|
|
35
|
+
1. 通过 [@BotFather](https://t.me/BotFather) 创建 Bot,获取 Token
|
|
36
|
+
2. 配置并启动:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 方式一:环境变量
|
|
40
|
+
export TELEGRAM_BOT_TOKEN=your_bot_token
|
|
41
|
+
npx cc-im
|
|
42
|
+
|
|
43
|
+
# 方式二:从源码运行
|
|
44
|
+
pnpm install
|
|
45
|
+
cp .env.example .env
|
|
46
|
+
# 编辑 .env,填入 TELEGRAM_BOT_TOKEN
|
|
47
|
+
pnpm dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
3. 在 Telegram 中找到你的 Bot,发送 `/start` 开始使用
|
|
51
|
+
|
|
52
|
+
### 飞书平台
|
|
53
|
+
|
|
54
|
+
1. 在[飞书开放平台](https://open.feishu.cn)创建应用
|
|
55
|
+
2. 开启机器人能力
|
|
56
|
+
3. 添加权限:`im:message`、`im:message:send_as_bot`、`im:message:patch_as_bot`、`cardkit:card`
|
|
57
|
+
4. 事件订阅中启用 **长连接模式**,订阅以下事件:
|
|
58
|
+
- `im.message.receive_v1` — 接收消息
|
|
59
|
+
- `card.action.trigger` — 卡片交互(停止按钮)
|
|
60
|
+
5. 发布应用
|
|
61
|
+
6. 配置并启动:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
export FEISHU_APP_ID=your_app_id
|
|
65
|
+
export FEISHU_APP_SECRET=your_app_secret
|
|
66
|
+
npx cc-im
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 从源码构建
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone <repo-url>
|
|
73
|
+
cd cc-im
|
|
74
|
+
pnpm install
|
|
75
|
+
cp .env.example .env
|
|
76
|
+
# 编辑 .env 填入对应平台凭证
|
|
77
|
+
|
|
78
|
+
pnpm dev # 开发模式
|
|
79
|
+
pnpm build # 编译
|
|
80
|
+
pnpm start # 生产模式
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 命令列表
|
|
84
|
+
|
|
85
|
+
| 命令 | 说明 |
|
|
86
|
+
|------|------|
|
|
87
|
+
| `/start` | 显示欢迎信息(Telegram) |
|
|
88
|
+
| `/help` | 显示帮助信息 |
|
|
89
|
+
| `/new` | 开始新会话 |
|
|
90
|
+
| `/cd <path>` | 切换工作目录(同时重置会话) |
|
|
91
|
+
| `/pwd` | 查看当前工作目录 |
|
|
92
|
+
| `/list` | 列出所有项目的工作区 |
|
|
93
|
+
| `/cost` | 查看 Claude API 用量和费用 |
|
|
94
|
+
| `/status` | 查看当前会话状态 |
|
|
95
|
+
| `/model [name]` | 查看或切换模型 |
|
|
96
|
+
| `/doctor` | 运行 Claude 诊断 |
|
|
97
|
+
| `/compact [topic]` | 压缩当前对话上下文 |
|
|
98
|
+
| `/todos` | 查看待办事项 |
|
|
99
|
+
|
|
100
|
+
### 权限相关命令
|
|
101
|
+
|
|
102
|
+
当 `CLAUDE_SKIP_PERMISSIONS=false`(默认)时,Claude Code 执行敏感操作(如 Bash 命令、写文件)会弹出权限确认卡片:
|
|
103
|
+
|
|
104
|
+
| 命令 | 说明 |
|
|
105
|
+
|------|------|
|
|
106
|
+
| `/allow` 或 `/y` | 允许当前待确认的操作 |
|
|
107
|
+
| `/deny` 或 `/n` | 拒绝当前待确认的操作 |
|
|
108
|
+
| `/allowall` | 允许所有待确认的操作 |
|
|
109
|
+
| `/pending` | 查看当前待确认的操作列表 |
|
|
110
|
+
|
|
111
|
+
## 环境变量
|
|
112
|
+
|
|
113
|
+
| 变量 | 说明 | 默认值 |
|
|
114
|
+
|------|------|--------|
|
|
115
|
+
| `PLATFORM` | 平台选择(`telegram` 或 `feishu`),留空自动检测 | 自动检测 |
|
|
116
|
+
| `FEISHU_APP_ID` | 飞书应用 App ID | 飞书平台必填 |
|
|
117
|
+
| `FEISHU_APP_SECRET` | 飞书应用 App Secret | 飞书平台必填 |
|
|
118
|
+
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | Telegram 平台必填 |
|
|
119
|
+
| `ALLOWED_USER_IDS` | 白名单用户 ID,逗号分隔,留空不限制 | 空(不限制) |
|
|
120
|
+
| `CLAUDE_CLI_PATH` | Claude CLI 可执行文件路径 | `claude` |
|
|
121
|
+
| `CLAUDE_WORK_DIR` | 默认工作目录 | 当前目录 |
|
|
122
|
+
| `ALLOWED_BASE_DIRS` | 允许 `/cd` 切换的基础目录,逗号分隔 | 同 `CLAUDE_WORK_DIR` |
|
|
123
|
+
| `CLAUDE_SKIP_PERMISSIONS` | 跳过权限检查(生产环境建议 `false`) | `false` |
|
|
124
|
+
| `CLAUDE_TIMEOUT_MS` | 执行超时(毫秒) | `300000`(5分钟) |
|
|
125
|
+
| `HOOK_SERVER_PORT` | 权限确认 Hook 服务端口 | `18900` |
|
|
126
|
+
| `LOG_DIR` | 日志文件存储目录 | `~/.cc-im/logs` |
|
|
127
|
+
|
|
128
|
+
### 白名单用户 ID 格式
|
|
129
|
+
|
|
130
|
+
- **飞书**:open_id 格式,如 `ou_xxxx`
|
|
131
|
+
- **Telegram**:用户数字 ID,如 `123456789`(可通过 [@userinfobot](https://t.me/userinfobot) 获取)
|
|
132
|
+
|
|
133
|
+
## 配置文件
|
|
134
|
+
|
|
135
|
+
除环境变量外,也支持通过 `~/.cc-im/config.json` 文件配置:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"platform": "telegram",
|
|
140
|
+
"telegramBotToken": "your_bot_token",
|
|
141
|
+
"allowedUserIds": ["123456789"],
|
|
142
|
+
"claudeCliPath": "/usr/local/bin/claude",
|
|
143
|
+
"claudeWorkDir": "/home/user/projects",
|
|
144
|
+
"allowedBaseDirs": ["/home/user/projects", "/tmp"],
|
|
145
|
+
"claudeSkipPermissions": false,
|
|
146
|
+
"claudeTimeoutMs": 300000,
|
|
147
|
+
"logDir": "/var/log/cc-im"
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
环境变量优先级高于配置文件。
|
|
152
|
+
|
|
153
|
+
## 应用数据目录
|
|
154
|
+
|
|
155
|
+
默认数据目录:`~/.cc-im`(常量 `APP_HOME`)
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
~/.cc-im/
|
|
159
|
+
├── config.json # 配置文件
|
|
160
|
+
├── data/
|
|
161
|
+
│ └── sessions.json # 会话持久化数据
|
|
162
|
+
└── logs/ # 日志文件(可通过 LOG_DIR 自定义)
|
|
163
|
+
├── 2026-02-14.log
|
|
164
|
+
└── 2026-02-15.log
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## 权限确认机制
|
|
168
|
+
|
|
169
|
+
当 `CLAUDE_SKIP_PERMISSIONS=false` 时,系统会通过 PreToolUse Hook 拦截敏感操作:
|
|
170
|
+
|
|
171
|
+
1. Claude Code 尝试调用工具(如执行 Bash 命令)
|
|
172
|
+
2. Hook 脚本将请求发送到权限确认服务(端口由 `HOOK_SERVER_PORT` 指定)
|
|
173
|
+
3. 服务向用户发送权限确认卡片/消息
|
|
174
|
+
4. 用户回复 `/allow` 或 `/deny`
|
|
175
|
+
5. 决定结果返回给 Claude Code,继续或中止操作
|
|
176
|
+
|
|
177
|
+
以下只读工具会自动放行,无需确认:
|
|
178
|
+
`Read`、`Glob`、`Grep`、`WebFetch`、`WebSearch`、`Task`、`TodoRead`
|
|
179
|
+
|
|
180
|
+
## 项目结构
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
src/
|
|
184
|
+
├── index.ts # 入口,多平台并行初始化
|
|
185
|
+
├── config.ts # 配置加载(环境变量 + ~/.cc-im/config.json)
|
|
186
|
+
├── constants.ts # 系统常量(节流、长度限制、错误码等)
|
|
187
|
+
├── logger.ts # 带标签的日志系统(自动脱敏)
|
|
188
|
+
├── sanitize.ts # 日志脱敏规则
|
|
189
|
+
├── cli.ts # CLI 入口
|
|
190
|
+
├── access/
|
|
191
|
+
│ └── access-control.ts # 白名单访问控制
|
|
192
|
+
├── claude/
|
|
193
|
+
│ ├── cli-runner.ts # Claude CLI 子进程管理
|
|
194
|
+
│ ├── stream-parser.ts # stream-json 格式解析
|
|
195
|
+
│ └── types.ts # Claude 消息类型定义
|
|
196
|
+
├── commands/
|
|
197
|
+
│ └── handler.ts # 平台无关的命令处理器
|
|
198
|
+
├── feishu/
|
|
199
|
+
│ ├── client.ts # 飞书 SDK 初始化
|
|
200
|
+
│ ├── event-handler.ts # 飞书事件处理
|
|
201
|
+
│ ├── message-sender.ts # 飞书消息发送封装
|
|
202
|
+
│ ├── card-builder.ts # 飞书卡片构建(JSON 1.0 + 2.0)
|
|
203
|
+
│ └── cardkit-manager.ts # CardKit 卡片生命周期管理
|
|
204
|
+
├── telegram/
|
|
205
|
+
│ ├── client.ts # Telegraf 初始化
|
|
206
|
+
│ ├── event-handler.ts # Telegram 事件处理
|
|
207
|
+
│ └── message-sender.ts # Telegram 消息发送
|
|
208
|
+
├── hook/
|
|
209
|
+
│ ├── permission-server.ts # 权限确认 HTTP 服务
|
|
210
|
+
│ └── hook-script.ts # Claude Code PreToolUse Hook
|
|
211
|
+
├── session/
|
|
212
|
+
│ └── session-manager.ts # 会话管理(持久化到 data/sessions.json)
|
|
213
|
+
└── queue/
|
|
214
|
+
└── request-queue.ts # 请求队列与并发控制
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class AccessControl {
|
|
2
|
+
allowedUserIds;
|
|
3
|
+
constructor(allowedUserIds) {
|
|
4
|
+
this.allowedUserIds = new Set(allowedUserIds);
|
|
5
|
+
}
|
|
6
|
+
isAllowed(userId) {
|
|
7
|
+
// Empty whitelist = allow all (dev mode)
|
|
8
|
+
if (this.allowedUserIds.size === 0)
|
|
9
|
+
return true;
|
|
10
|
+
return this.allowedUserIds.has(userId);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { parseStreamLine, extractTextDelta, extractThinkingDelta, extractResult } from './stream-parser.js';
|
|
4
|
+
import { isStreamInit, isContentBlockStart, isContentBlockDelta, isContentBlockStop } from './types.js';
|
|
5
|
+
export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, options) {
|
|
6
|
+
const args = [
|
|
7
|
+
'-p',
|
|
8
|
+
'--output-format', 'stream-json',
|
|
9
|
+
'--verbose',
|
|
10
|
+
'--include-partial-messages',
|
|
11
|
+
];
|
|
12
|
+
if (options?.skipPermissions) {
|
|
13
|
+
args.push('--dangerously-skip-permissions');
|
|
14
|
+
}
|
|
15
|
+
if (options?.model) {
|
|
16
|
+
args.push('--model', options.model);
|
|
17
|
+
}
|
|
18
|
+
if (sessionId) {
|
|
19
|
+
args.push('--resume', sessionId);
|
|
20
|
+
}
|
|
21
|
+
args.push('--', prompt);
|
|
22
|
+
const env = { ...process.env };
|
|
23
|
+
if (options?.chatId) {
|
|
24
|
+
env.CC_BOT_CHAT_ID = options.chatId;
|
|
25
|
+
}
|
|
26
|
+
if (options?.hookPort) {
|
|
27
|
+
env.CC_BOT_HOOK_PORT = String(options.hookPort);
|
|
28
|
+
}
|
|
29
|
+
if (options?.threadRootMsgId) {
|
|
30
|
+
env.CC_BOT_THREAD_ROOT_MSG_ID = options.threadRootMsgId;
|
|
31
|
+
}
|
|
32
|
+
if (options?.threadId) {
|
|
33
|
+
env.CC_BOT_THREAD_ID = options.threadId;
|
|
34
|
+
}
|
|
35
|
+
if (options?.platform) {
|
|
36
|
+
env.CC_BOT_PLATFORM = options.platform;
|
|
37
|
+
}
|
|
38
|
+
const child = spawn(cliPath, args, {
|
|
39
|
+
cwd: workDir,
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
env,
|
|
42
|
+
});
|
|
43
|
+
let accumulated = '';
|
|
44
|
+
let accumulatedThinking = '';
|
|
45
|
+
let completed = false;
|
|
46
|
+
let model = '';
|
|
47
|
+
let toolStats = {};
|
|
48
|
+
let timeoutHandle = null;
|
|
49
|
+
const pendingToolInputs = new Map();
|
|
50
|
+
// 设置超时
|
|
51
|
+
if (options?.timeoutMs && options.timeoutMs > 0) {
|
|
52
|
+
timeoutHandle = setTimeout(() => {
|
|
53
|
+
if (!completed && !child.killed) {
|
|
54
|
+
child.kill('SIGTERM');
|
|
55
|
+
callbacks.onError(`执行超时(${options.timeoutMs}ms),已终止进程`);
|
|
56
|
+
}
|
|
57
|
+
}, options.timeoutMs);
|
|
58
|
+
}
|
|
59
|
+
const rl = createInterface({ input: child.stdout });
|
|
60
|
+
rl.on('line', (line) => {
|
|
61
|
+
const event = parseStreamLine(line);
|
|
62
|
+
if (!event)
|
|
63
|
+
return;
|
|
64
|
+
if (isStreamInit(event)) {
|
|
65
|
+
model = event.model;
|
|
66
|
+
callbacks.onSessionId?.(event.session_id);
|
|
67
|
+
}
|
|
68
|
+
const delta = extractTextDelta(event);
|
|
69
|
+
if (delta) {
|
|
70
|
+
accumulated += delta.text;
|
|
71
|
+
callbacks.onText(accumulated);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const thinking = extractThinkingDelta(event);
|
|
75
|
+
if (thinking) {
|
|
76
|
+
accumulatedThinking += thinking.text;
|
|
77
|
+
callbacks.onThinking?.(accumulatedThinking);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (isContentBlockStart(event) && event.event.content_block.type === 'tool_use') {
|
|
81
|
+
const { name } = event.event.content_block;
|
|
82
|
+
if (name)
|
|
83
|
+
pendingToolInputs.set(event.event.index, { name, json: '' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (isContentBlockDelta(event) && event.event.delta.type === 'input_json_delta') {
|
|
87
|
+
const pending = pendingToolInputs.get(event.event.index);
|
|
88
|
+
if (pending)
|
|
89
|
+
pending.json += event.event.delta.partial_json ?? '';
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (isContentBlockStop(event)) {
|
|
93
|
+
const pending = pendingToolInputs.get(event.event.index);
|
|
94
|
+
if (pending) {
|
|
95
|
+
toolStats[pending.name] = (toolStats[pending.name] || 0) + 1;
|
|
96
|
+
let input;
|
|
97
|
+
try {
|
|
98
|
+
input = JSON.parse(pending.json);
|
|
99
|
+
}
|
|
100
|
+
catch { /* empty input */ }
|
|
101
|
+
callbacks.onToolUse?.(pending.name, input);
|
|
102
|
+
pendingToolInputs.delete(event.event.index);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const result = extractResult(event);
|
|
107
|
+
if (result) {
|
|
108
|
+
completed = true;
|
|
109
|
+
if (timeoutHandle) {
|
|
110
|
+
clearTimeout(timeoutHandle);
|
|
111
|
+
}
|
|
112
|
+
result.accumulated = accumulated;
|
|
113
|
+
result.model = model;
|
|
114
|
+
result.toolStats = toolStats;
|
|
115
|
+
if (!accumulated && result.result) {
|
|
116
|
+
accumulated = result.result;
|
|
117
|
+
}
|
|
118
|
+
callbacks.onComplete(result);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// 保留首部和尾部的 stderr,避免丢失关键错误信息
|
|
122
|
+
const MAX_HEAD_LEN = 4 * 1024; // 保留前 4KB
|
|
123
|
+
const MAX_TAIL_LEN = 6 * 1024; // 保留后 6KB
|
|
124
|
+
let stderrHead = '';
|
|
125
|
+
let stderrTail = '';
|
|
126
|
+
let stderrTotal = 0;
|
|
127
|
+
let headFull = false;
|
|
128
|
+
child.stderr?.on('data', (chunk) => {
|
|
129
|
+
const text = chunk.toString();
|
|
130
|
+
stderrTotal += text.length;
|
|
131
|
+
if (!headFull) {
|
|
132
|
+
const headRoom = MAX_HEAD_LEN - stderrHead.length;
|
|
133
|
+
if (headRoom > 0) {
|
|
134
|
+
stderrHead += text.slice(0, headRoom);
|
|
135
|
+
if (stderrHead.length >= MAX_HEAD_LEN) {
|
|
136
|
+
headFull = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
stderrTail += text;
|
|
141
|
+
if (stderrTail.length > MAX_TAIL_LEN) {
|
|
142
|
+
stderrTail = stderrTail.slice(-MAX_TAIL_LEN);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
let exitCode = null;
|
|
146
|
+
let rlClosed = false;
|
|
147
|
+
let childClosed = false;
|
|
148
|
+
const finalize = () => {
|
|
149
|
+
if (!rlClosed || !childClosed)
|
|
150
|
+
return;
|
|
151
|
+
if (timeoutHandle) {
|
|
152
|
+
clearTimeout(timeoutHandle);
|
|
153
|
+
}
|
|
154
|
+
if (!completed) {
|
|
155
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
156
|
+
// 组合首尾 stderr 信息
|
|
157
|
+
let stderrData = '';
|
|
158
|
+
if (stderrTotal <= MAX_HEAD_LEN + MAX_TAIL_LEN) {
|
|
159
|
+
// 内容未超限,直接使用
|
|
160
|
+
stderrData = stderrHead + (headFull ? stderrTail : '');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// 内容超限,显示首尾部分
|
|
164
|
+
stderrData = stderrHead + `\n\n... (省略 ${stderrTotal - MAX_HEAD_LEN - MAX_TAIL_LEN} 字节) ...\n\n` + stderrTail;
|
|
165
|
+
}
|
|
166
|
+
callbacks.onError(stderrData || `Claude CLI exited with code ${exitCode}`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Completed without a result event — treat accumulated text as result
|
|
170
|
+
callbacks.onComplete({
|
|
171
|
+
success: true,
|
|
172
|
+
result: accumulated,
|
|
173
|
+
accumulated,
|
|
174
|
+
cost: 0,
|
|
175
|
+
durationMs: 0,
|
|
176
|
+
model,
|
|
177
|
+
numTurns: 0,
|
|
178
|
+
toolStats,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
child.on('close', (code) => {
|
|
184
|
+
exitCode = code;
|
|
185
|
+
childClosed = true;
|
|
186
|
+
finalize();
|
|
187
|
+
});
|
|
188
|
+
// 使用 rl 的 close 事件而非 child 的 close,确保所有行都处理完毕
|
|
189
|
+
rl.on('close', () => {
|
|
190
|
+
rlClosed = true;
|
|
191
|
+
finalize();
|
|
192
|
+
});
|
|
193
|
+
child.on('error', (err) => {
|
|
194
|
+
if (timeoutHandle) {
|
|
195
|
+
clearTimeout(timeoutHandle);
|
|
196
|
+
}
|
|
197
|
+
if (!completed) {
|
|
198
|
+
callbacks.onError(`Failed to start Claude CLI: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
// spawn 失败时可能不触发 close 事件,手动标记以确保 finalize 执行
|
|
201
|
+
childClosed = true;
|
|
202
|
+
finalize();
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
process: child,
|
|
206
|
+
abort: () => {
|
|
207
|
+
if (timeoutHandle) {
|
|
208
|
+
clearTimeout(timeoutHandle);
|
|
209
|
+
}
|
|
210
|
+
if (!child.killed) {
|
|
211
|
+
child.kill('SIGTERM');
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isContentBlockDelta, isStreamResult } from './types.js';
|
|
2
|
+
export function parseStreamLine(line) {
|
|
3
|
+
const trimmed = line.trim();
|
|
4
|
+
if (!trimmed)
|
|
5
|
+
return null;
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(trimmed);
|
|
8
|
+
if (typeof parsed === 'object' && parsed !== null && 'type' in parsed) {
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function extractTextDelta(event) {
|
|
18
|
+
if (isContentBlockDelta(event) && event.event.delta?.type === 'text_delta' && event.event.delta.text) {
|
|
19
|
+
return { text: event.event.delta.text };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export function extractThinkingDelta(event) {
|
|
24
|
+
if (isContentBlockDelta(event) && event.event.delta?.type === 'thinking_delta' && event.event.delta.thinking) {
|
|
25
|
+
return { text: event.event.delta.thinking };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function extractResult(event) {
|
|
30
|
+
if (isStreamResult(event)) {
|
|
31
|
+
return {
|
|
32
|
+
success: event.subtype === 'success',
|
|
33
|
+
result: event.result,
|
|
34
|
+
accumulated: '',
|
|
35
|
+
cost: event.total_cost_usd,
|
|
36
|
+
durationMs: event.duration_ms,
|
|
37
|
+
numTurns: event.num_turns,
|
|
38
|
+
toolStats: {},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function isStreamInit(event) {
|
|
2
|
+
return event.type === 'system' && 'subtype' in event && event.subtype === 'init';
|
|
3
|
+
}
|
|
4
|
+
export function isContentBlockDelta(event) {
|
|
5
|
+
return (event.type === 'stream_event' &&
|
|
6
|
+
'event' in event &&
|
|
7
|
+
typeof event.event === 'object' &&
|
|
8
|
+
event.event !== null &&
|
|
9
|
+
'type' in event.event &&
|
|
10
|
+
event.event.type === 'content_block_delta');
|
|
11
|
+
}
|
|
12
|
+
export function isStreamResult(event) {
|
|
13
|
+
return event.type === 'result' && 'subtype' in event;
|
|
14
|
+
}
|
|
15
|
+
export function isContentBlockStart(event) {
|
|
16
|
+
return (event.type === 'stream_event' &&
|
|
17
|
+
'event' in event &&
|
|
18
|
+
typeof event.event === 'object' &&
|
|
19
|
+
event.event !== null &&
|
|
20
|
+
'type' in event.event &&
|
|
21
|
+
event.event.type === 'content_block_start');
|
|
22
|
+
}
|
|
23
|
+
export function isContentBlockStop(event) {
|
|
24
|
+
return (event.type === 'stream_event' &&
|
|
25
|
+
'event' in event &&
|
|
26
|
+
typeof event.event === 'object' &&
|
|
27
|
+
event.event !== null &&
|
|
28
|
+
'type' in event.event &&
|
|
29
|
+
event.event.type === 'content_block_stop');
|
|
30
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync, openSync, closeSync } from 'fs';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
5
|
+
import { APP_HOME } from './constants.js';
|
|
6
|
+
const PID_FILE = join(APP_HOME, 'pid');
|
|
7
|
+
const logger = createLogger('CLI');
|
|
8
|
+
function getPidFromFile() {
|
|
9
|
+
if (existsSync(PID_FILE)) {
|
|
10
|
+
try {
|
|
11
|
+
return parseInt(readFileSync(PID_FILE, 'utf-8'), 10);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
function isRunning(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function stop() {
|
|
29
|
+
const pid = getPidFromFile();
|
|
30
|
+
if (pid && isRunning(pid)) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid);
|
|
33
|
+
logger.info(`已停止服务 (PID: ${pid})`);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const error = e;
|
|
37
|
+
logger.error('停止失败:', error.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
logger.info('服务未运行');
|
|
43
|
+
}
|
|
44
|
+
if (existsSync(PID_FILE)) {
|
|
45
|
+
unlinkSync(PID_FILE);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function start() {
|
|
49
|
+
// 先检查旧的 PID 文件
|
|
50
|
+
const oldPid = getPidFromFile();
|
|
51
|
+
if (oldPid && isRunning(oldPid)) {
|
|
52
|
+
logger.info(`服务已在运行中 (PID: ${oldPid})`);
|
|
53
|
+
logger.info(`请先运行: cc-im stop`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
// 清理失效的 PID 文件
|
|
57
|
+
if (oldPid && !isRunning(oldPid) && existsSync(PID_FILE)) {
|
|
58
|
+
unlinkSync(PID_FILE);
|
|
59
|
+
}
|
|
60
|
+
if (!existsSync(APP_HOME)) {
|
|
61
|
+
mkdirSync(APP_HOME, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
// 使用 exclusive 模式写入 PID 文件,防止竞态条件
|
|
64
|
+
let fd = null;
|
|
65
|
+
try {
|
|
66
|
+
// 'wx' 模式:如果文件存在会抛出错误,原子操作
|
|
67
|
+
fd = openSync(PID_FILE, 'wx');
|
|
68
|
+
writeFileSync(fd, String(process.pid));
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
fd = null;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (fd !== null) {
|
|
74
|
+
try {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
}
|
|
79
|
+
const error = err;
|
|
80
|
+
if (error.code === 'EEXIST') {
|
|
81
|
+
logger.error('服务正在启动中或 PID 文件被锁定,请稍后再试');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const { main } = await import('./index.js');
|
|
88
|
+
await main();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
if (existsSync(PID_FILE)) {
|
|
92
|
+
unlinkSync(PID_FILE);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const command = process.argv[2];
|
|
97
|
+
if (command === 'stop') {
|
|
98
|
+
await stop();
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
await start();
|
|
102
|
+
}
|