chatccc 0.2.4 → 0.2.6

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
@@ -1,6 +1,6 @@
1
1
  # ChatCCC
2
2
 
3
- **飞书聊天控制 Claude Code,未来支持 Cursor、Codex**
3
+ **飞书聊天控制 Claude Code / Cursor,未来支持 Codex**
4
4
 
5
5
  ---
6
6
 
@@ -8,13 +8,13 @@
8
8
 
9
9
  传统的 Claude Code(尤其是使用第三方 API 的,如 DeepSeek),需要坐在电脑桌前才能用。**离开电脑就没法用了。**
10
10
 
11
- ChatCCC 把 Claude Code 接入了飞书群聊:
11
+ ChatCCC 把 Claude Code 和 Cursor Agent 接入了飞书群聊:
12
12
 
13
- - **手机上也能用 Claude Code** —— 在飞书群里发消息就等于在终端输入指令,Claude 的思考和回复会流式展示在群卡片里
14
- - **多会话并行** —— 一个群就是一个 Claude 会话,完全隔离、互不干扰,并行工作效率更高
15
- - **体验丝滑** —— 思考过程完整,响应迅速
13
+ - **手机上也能用 Claude Code / Cursor** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
14
+ - **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
15
+ - **多工具切换** —— `/new claude` 创建 Claude Code 会话,`/new cursor` 创建 Cursor 会话,各取所长
16
16
 
17
- 一句话:**在任何设备上打开飞书,就能让 Claude 帮你写代码、排查问题、分析项目。**
17
+ 一句话:**在任何设备上打开飞书,就能让 Claude Code 或 Cursor 帮你写代码、排查问题、分析项目。**
18
18
 
19
19
  <p align="center">
20
20
  <img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="280" />
@@ -61,6 +61,28 @@ npm run dev
61
61
 
62
62
  启动后机器人通过 WebSocket 连接飞书服务器,日志会写入 `logs/` 目录。
63
63
 
64
+ #### Cursor Agent CLI(使用 Cursor 会话时需要)
65
+
66
+ ChatCCC **不捆绑** Cursor Agent CLI,需要用户自行安装。Cursor Agent CLI 目前主要有两种来源:
67
+
68
+ 1. **Cursor IDE 自带**:安装 [Cursor IDE](https://cursor.com) 后,部分版本会自带 `agent` 或 `cursor-agent` 命令
69
+ 2. **独立安装**:Cursor 正在逐步提供独立的 CLI 安装方式,安装后 `agent` 命令会出现在系统 PATH 中
70
+
71
+ 验证是否已安装:
72
+
73
+ ```bash
74
+ agent --version
75
+ ```
76
+
77
+ 若 `agent` 不在 PATH 中,可通过环境变量指定自定义命令:
78
+
79
+ ```env
80
+ CHATCCC_CURSOR_COMMAND=/path/to/agent
81
+ CHATCCC_CURSOR_ARGS=-p --output-format stream-json --stream-partial-output
82
+ ```
83
+
84
+ > **说明**:只使用 Claude Code(`/new claude` 或 `/new`)的用户无需安装 Cursor CLI。
85
+
64
86
  ### 2. 创建飞书应用
65
87
 
66
88
  打开 [飞书开放平台](https://open.feishu.cn),创建一个**企业自建应用**。
@@ -168,18 +190,21 @@ CHATCCC_PORT=18081
168
190
 
169
191
  ### 5. 开始使用
170
192
 
171
- 在飞书中找到你的机器人,发送 `/new`,机器人会自动创建一个群聊并把你的 Claude 会话绑定到该群。之后直接在群里发消息就能和 Claude 对话。
193
+ 在飞书中找到你的机器人,发送 `/new`(默认 Claude Code)或 `/new claude` / `/new cursor`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
172
194
 
173
195
  ### 可用指令
174
196
 
175
197
 
176
- | 指令 | 作用 |
177
- | ---------- | ---------------------------- |
178
- | `/new` | 创建新的 Claude 会话(绑定一个新群聊) |
179
- | `/stop` | 停止当前正在生成的回复 |
180
- | `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
181
- | `/cd` | 查看/切换工作目录 |
182
- | `/restart` | 重启机器人进程 |
198
+ | 指令 | 作用 |
199
+ | --------------- | ---------------------------- |
200
+ | `/new` | 创建新的 Claude Code 会话(默认) |
201
+ | `/new claude` | 创建新的 Claude Code 会话 |
202
+ | `/new cursor` | 创建新的 Cursor 会话 |
203
+ | `/stop` | 停止当前正在生成的回复 |
204
+ | `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
205
+ | `/cd` | 查看/切换工作目录 |
206
+ | `/sessions` | 查看所有会话状态 |
207
+ | `/restart` | 重启机器人进程 |
183
208
 
184
209
 
185
210
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type {
3
+ UnifiedBlock,
4
+ UnifiedStreamMessage,
5
+ UnifiedThinkingBlock,
6
+ UnifiedTextBlock,
7
+ UnifiedToolUseBlock,
8
+ UnifiedToolResultBlock,
9
+ UnifiedRedactedThinkingBlock,
10
+ UnifiedSearchResultBlock,
11
+ UnifiedCompactBoundaryBlock,
12
+ CreateSessionResult,
13
+ SessionInfo,
14
+ ToolAdapter,
15
+ } from "../adapters/adapter-interface.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // 编译期类型验证:确保 UnifiedBlock 联合类型覆盖了所有预期的 block 形状
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe("UnifiedBlock union type coverage", () => {
22
+ it("accepts thinking block", () => {
23
+ const b: UnifiedBlock = { type: "thinking", thinking: "Let me think..." };
24
+ expect(b.type).toBe("thinking");
25
+ });
26
+
27
+ it("accepts text block", () => {
28
+ const b: UnifiedBlock = { type: "text", text: "Hello" };
29
+ expect(b.type).toBe("text");
30
+ });
31
+
32
+ it("accepts tool_use block", () => {
33
+ const b: UnifiedBlock = { type: "tool_use", name: "Read", input: { file_path: "/tmp" } };
34
+ expect(b.type).toBe("tool_use");
35
+ });
36
+
37
+ it("accepts tool_result block with string content", () => {
38
+ const b: UnifiedBlock = { type: "tool_result", tool_use_id: "abc123", content: "result" };
39
+ expect(b.type).toBe("tool_result");
40
+ });
41
+
42
+ it("accepts tool_result block with error flag", () => {
43
+ const b: UnifiedBlock = { type: "tool_result", tool_use_id: "abc123", content: "error", is_error: true };
44
+ expect(b.is_error).toBe(true);
45
+ });
46
+
47
+ it("accepts redacted_thinking block", () => {
48
+ const b: UnifiedBlock = { type: "redacted_thinking" };
49
+ expect(b.type).toBe("redacted_thinking");
50
+ });
51
+
52
+ it("accepts search_result block", () => {
53
+ const b: UnifiedBlock = { type: "search_result", query: "what is TypeScript" };
54
+ expect(b.type).toBe("search_result");
55
+ });
56
+
57
+ it("accepts compact_boundary block", () => {
58
+ const b: UnifiedBlock = { type: "compact_boundary", trigger: "auto", pre_tokens: 10000, post_tokens: 5000 };
59
+ expect(b.type).toBe("compact_boundary");
60
+ });
61
+
62
+ it("accepts compact_boundary block without post_tokens", () => {
63
+ const b: UnifiedBlock = { type: "compact_boundary", trigger: "manual", pre_tokens: 20000 };
64
+ expect(b.type).toBe("compact_boundary");
65
+ });
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // UnifiedStreamMessage
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe("UnifiedStreamMessage", () => {
73
+ it("accepts assistant message with blocks", () => {
74
+ const m: UnifiedStreamMessage = {
75
+ type: "assistant",
76
+ blocks: [
77
+ { type: "thinking", thinking: "..." },
78
+ { type: "text", text: "answer" },
79
+ ],
80
+ };
81
+ expect(m.type).toBe("assistant");
82
+ expect(m.blocks).toHaveLength(2);
83
+ });
84
+
85
+ it("accepts user message with tool_result blocks", () => {
86
+ const m: UnifiedStreamMessage = {
87
+ type: "user",
88
+ blocks: [{ type: "tool_result", tool_use_id: "abc", content: "ok" }],
89
+ };
90
+ expect(m.type).toBe("user");
91
+ });
92
+
93
+ it("accepts system message", () => {
94
+ const m: UnifiedStreamMessage = {
95
+ type: "system",
96
+ blocks: [{ type: "compact_boundary", trigger: "auto", pre_tokens: 100 }],
97
+ };
98
+ expect(m.type).toBe("system");
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // CreateSessionResult
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("CreateSessionResult", () => {
107
+ it("accepts valid result", () => {
108
+ const r: CreateSessionResult = { sessionId: "uuid-12345" };
109
+ expect(r.sessionId).toBe("uuid-12345");
110
+ });
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // SessionInfo
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe("SessionInfo", () => {
118
+ it("accepts full info", () => {
119
+ const info: SessionInfo = {
120
+ sessionId: "abc",
121
+ cwd: "/home/user",
122
+ summary: "A session",
123
+ lastModified: 1234567890,
124
+ };
125
+ expect(info.sessionId).toBe("abc");
126
+ });
127
+
128
+ it("accepts minimal info (only sessionId)", () => {
129
+ const info: SessionInfo = { sessionId: "minimal" };
130
+ expect(info.cwd).toBeUndefined();
131
+ });
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // ToolAdapter interface 结构断言
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe("ToolAdapter interface structure", () => {
139
+ it("has correct property names via mock object", () => {
140
+ // 如果 ToolAdapter 接口的字段名或函数签名变更,该对象字面量将无法编译
141
+ const mock: ToolAdapter = {
142
+ displayName: "Test",
143
+ sessionDescPrefix: "Test Session:",
144
+ createSession: async () => ({ sessionId: "s" }),
145
+ prompt: async function* () {},
146
+ getSessionInfo: async () => undefined,
147
+ closeSession: async () => {},
148
+ };
149
+ expect(mock.displayName).toBe("Test");
150
+ expect(mock.sessionDescPrefix).toBe("Test Session:");
151
+ });
152
+ });
@@ -3,6 +3,7 @@ import {
3
3
  buildProgressCard,
4
4
  buildHelpCard,
5
5
  buildCdContent,
6
+ buildCdCard,
6
7
  buildSessionsCard,
7
8
  buildStatusCard,
8
9
  buildButtons,
@@ -148,9 +149,9 @@ describe("buildHelpCard", () => {
148
149
  it("includes action buttons", () => {
149
150
  const card = buildHelpCard("test");
150
151
  const parsed = JSON.parse(card);
151
- const action = parsed.elements[1];
152
+ const action = parsed.elements[2];
152
153
  expect(action.tag).toBe("action");
153
- expect(action.actions).toHaveLength(3);
154
+ expect(action.actions).toHaveLength(4);
154
155
  });
155
156
  });
156
157
 
@@ -199,6 +200,99 @@ describe("buildCdContent", () => {
199
200
  });
200
201
  });
201
202
 
203
+ // ---------------------------------------------------------------------------
204
+ // buildCdCard
205
+ // ---------------------------------------------------------------------------
206
+
207
+ describe("buildCdCard", () => {
208
+ const entries = [
209
+ { name: "src", isDir: true },
210
+ { name: "README.md", isDir: false },
211
+ ];
212
+
213
+ it("returns valid JSON with correct schema", () => {
214
+ const card = buildCdCard("/home/project", entries, []);
215
+ const parsed = JSON.parse(card);
216
+ expect(parsed.schema).toBe("2.0");
217
+ expect(parsed.header.title.content).toBe("工作路径");
218
+ expect(parsed.header.template).toBe("blue");
219
+ expect(parsed.config.wide_screen_mode).toBe(true);
220
+ });
221
+
222
+ it("shows current working directory in markdown", () => {
223
+ const card = buildCdCard("/home/project", entries, []);
224
+ const parsed = JSON.parse(card);
225
+ const mdElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "markdown");
226
+ const cwdMd = mdElements.find((e: any) => e.content.includes("新会话默认工作路径"));
227
+ expect(cwdMd).toBeDefined();
228
+ expect(cwdMd.content).toContain("/home/project");
229
+ });
230
+
231
+ it("shows sessionCwd when provided", () => {
232
+ const card = buildCdCard("/default", entries, [], "/session/path");
233
+ const parsed = JSON.parse(card);
234
+ const mdElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "markdown");
235
+ const sessionMd = mdElements.find((e: any) => e.content.includes("当前会话工作路径"));
236
+ expect(sessionMd).toBeDefined();
237
+ expect(sessionMd.content).toContain("/session/path");
238
+ });
239
+
240
+ it("does not show sessionCwd when not provided", () => {
241
+ const card = buildCdCard("/default", entries, []);
242
+ const parsed = JSON.parse(card);
243
+ const mdElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "markdown");
244
+ const sessionMd = mdElements.find((e: any) => e.content.includes("当前会话工作路径"));
245
+ expect(sessionMd).toBeUndefined();
246
+ });
247
+
248
+ it("shows recent dirs section with buttons", () => {
249
+ const recentDirs = ["/home/user/project1", "/home/user/project2"];
250
+ const card = buildCdCard("/current", entries, recentDirs);
251
+ const parsed = JSON.parse(card);
252
+ const mdElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "markdown");
253
+ const recentMd = mdElements.find((e: any) => e.content.includes("最近使用过的路径"));
254
+ expect(recentMd).toBeDefined();
255
+
256
+ const actionElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "action");
257
+ expect(actionElements.length).toBeGreaterThanOrEqual(1);
258
+ const recentAction = actionElements.find((e: any) =>
259
+ e.actions.some((a: any) => a.value?.action === "cd")
260
+ );
261
+ expect(recentAction).toBeDefined();
262
+ expect(recentAction.actions).toHaveLength(2);
263
+ expect(recentAction.actions[0].value.action).toBe("cd");
264
+ expect(recentAction.actions[0].value.path).toBe("/home/user/project1");
265
+ expect(recentAction.actions[1].value.path).toBe("/home/user/project2");
266
+ });
267
+
268
+ it("does not show recent dirs section when empty", () => {
269
+ const card = buildCdCard("/current", entries, []);
270
+ const parsed = JSON.parse(card);
271
+ const mdElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "markdown");
272
+ const recentMd = mdElements.find((e: any) => e.content.includes("最近使用过的路径"));
273
+ expect(recentMd).toBeUndefined();
274
+ });
275
+
276
+ it("truncates long paths in button text", () => {
277
+ const longPath = "/home/user/very/long/path/that/exceeds/thirty/six/chars";
278
+ const card = buildCdCard("/current", entries, [longPath]);
279
+ const parsed = JSON.parse(card);
280
+ const action: any = parsed.body.elements.find((e: any) => e.tag === "action");
281
+ const btnText = action.actions[0].text.content;
282
+ expect(btnText.startsWith("...")).toBe(true);
283
+ expect(btnText.length).toBeLessThanOrEqual(36);
284
+ });
285
+
286
+ it("shows directory listing", () => {
287
+ const card = buildCdCard("/current", entries, []);
288
+ const parsed = JSON.parse(card);
289
+ const mdElements: any[] = parsed.body.elements.filter((e: any) => e.tag === "markdown");
290
+ const listingMd = mdElements.find((e: any) => e.content.includes("📁 src/"));
291
+ expect(listingMd).toBeDefined();
292
+ expect(listingMd.content).toContain("📄 README.md");
293
+ });
294
+ });
295
+
202
296
  // ---------------------------------------------------------------------------
203
297
  // buildSessionsCard
204
298
  // ---------------------------------------------------------------------------
@@ -208,21 +302,23 @@ describe("buildSessionsCard", () => {
208
302
  const card = buildSessionsCard([]);
209
303
  const parsed = JSON.parse(card);
210
304
  expect(parsed.elements[0].text.content).toContain("没有会话记录");
305
+ expect(parsed.elements[0].text.content).toContain("/new");
211
306
  });
212
307
 
213
308
  it("returns valid JSON with session listing", () => {
214
309
  const card = buildSessionsCard([
215
- { sessionId: "abc123", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7" },
310
+ { sessionId: "abc123", active: true, turnCount: 5, elapsedSeconds: 120, model: "Claude Opus 4.7", tool: "claude" },
216
311
  ]);
217
312
  const parsed = JSON.parse(card);
218
313
  expect(parsed.elements[0].text.content).toContain("共 **1** 个会话");
219
314
  expect(parsed.elements[0].text.content).toContain("abc123");
220
315
  expect(parsed.elements[0].text.content).toContain("🟢 活跃");
316
+ expect(parsed.elements[0].text.content).toContain("Claude Code");
221
317
  });
222
318
 
223
319
  it("shows idle status for inactive sessions", () => {
224
320
  const card = buildSessionsCard([
225
- { sessionId: "xyz", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6" },
321
+ { sessionId: "xyz", active: false, turnCount: 0, elapsedSeconds: null, model: "Claude Sonnet 4.6", tool: "claude" },
226
322
  ]);
227
323
  const parsed = JSON.parse(card);
228
324
  expect(parsed.elements[0].text.content).toContain("⚪ 空闲");
@@ -230,12 +326,33 @@ describe("buildSessionsCard", () => {
230
326
 
231
327
  it("shows elapsed time for active sessions", () => {
232
328
  const card = buildSessionsCard([
233
- { sessionId: "active123", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7" },
329
+ { sessionId: "active123", active: true, turnCount: 3, elapsedSeconds: 95, model: "Claude Opus 4.7", tool: "claude" },
234
330
  ]);
235
331
  const parsed = JSON.parse(card);
236
332
  expect(parsed.elements[0].text.content).toContain("1分35秒");
237
333
  });
238
334
 
335
+ it("separates Claude Code and Cursor sessions", () => {
336
+ const card = buildSessionsCard([
337
+ { sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "default", tool: "claude" },
338
+ { sessionId: "c2", active: false, turnCount: 2, elapsedSeconds: null, model: "default", tool: "cursor" },
339
+ ]);
340
+ const parsed = JSON.parse(card);
341
+ const content: string = parsed.elements[0].text.content;
342
+ expect(content).toContain("Claude Code 会话");
343
+ expect(content).toContain("Cursor 会话");
344
+ });
345
+
346
+ it("omits Cursor section when no Cursor sessions", () => {
347
+ const card = buildSessionsCard([
348
+ { sessionId: "c1", active: false, turnCount: 1, elapsedSeconds: null, model: "default", tool: "claude" },
349
+ ]);
350
+ const parsed = JSON.parse(card);
351
+ const content: string = parsed.elements[0].text.content;
352
+ expect(content).toContain("Claude Code 会话");
353
+ expect(content).not.toContain("Cursor 会话");
354
+ });
355
+
239
356
  it("includes close button", () => {
240
357
  const card = buildSessionsCard([]);
241
358
  const parsed = JSON.parse(card);