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/src/config.ts CHANGED
@@ -62,10 +62,56 @@ export const CLAUDE_MODEL = process.env.CHATCCC_ANTHROPIC_MODEL?.trim() || "defa
62
62
 
63
63
  export const CLAUDE_EFFORT = process.env.CHATCCC_ANTHROPIC_EFFORT?.trim() || "default";
64
64
 
65
+ /** 探测 cursor-agent 安装路径(优先环境变量,其次 LocalAppData,最后默认 agent) */
66
+ function detectCursorAgent(): string {
67
+ if (process.env.CHATCCC_CURSOR_COMMAND?.trim()) return process.env.CHATCCC_CURSOR_COMMAND.trim();
68
+ const localAppData = process.env.LOCALAPPDATA;
69
+ if (localAppData) {
70
+ const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
71
+ if (existsSync(defaultPath)) return defaultPath;
72
+ }
73
+ return "agent";
74
+ }
75
+ export const CURSOR_AGENT_COMMAND = detectCursorAgent();
76
+ /** Cursor agent 参数:-p 非交互模式,stream-json 流式 JSONL 输出 */
77
+ export const CURSOR_AGENT_ARGS = (process.env.CHATCCC_CURSOR_ARGS?.trim() || "-p --output-format stream-json --stream-partial-output").split(/\s+/).filter(Boolean);
78
+
65
79
  // 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
66
- // 该路径仅影响通过 /new 新建的 Claude 会话,不影响已有会话的 resume。
80
+ // 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
67
81
  export const DEFAULT_CWD_FILE = join(PROJECT_ROOT, ".claude", "working_dir.txt");
68
82
 
83
+ /** 会话工具类型持久化文件 */
84
+ export const SESSIONS_FILE = join(PROJECT_ROOT, ".claude", "sessions.json");
85
+
86
+ /** 最近成功新建会话的工作路径记录(最多 10 条) */
87
+ export const RECENT_DIRS_FILE = join(PROJECT_ROOT, ".claude", "recent_dirs.json");
88
+ export const MAX_RECENT_DIRS = 10;
89
+
90
+ /** 读取最近使用过的工作路径列表(最新的在前) */
91
+ export async function getRecentDirs(): Promise<string[]> {
92
+ try {
93
+ const raw = await readFile(RECENT_DIRS_FILE, "utf-8");
94
+ const arr = JSON.parse(raw);
95
+ if (Array.isArray(arr)) return arr.filter((d: unknown) => typeof d === "string");
96
+ } catch { /* file doesn't exist or corrupted */ }
97
+ return [];
98
+ }
99
+
100
+ /** 添加一个路径到最近使用列表(去重、限制数量、最新的在前) */
101
+ export async function addRecentDir(dir: string): Promise<void> {
102
+ const dirs = await getRecentDirs();
103
+ const filtered = dirs.filter(d => d !== dir);
104
+ filtered.unshift(dir);
105
+ const trimmed = filtered.slice(0, MAX_RECENT_DIRS);
106
+ try {
107
+ await mkdir(dirname(RECENT_DIRS_FILE), { recursive: true });
108
+ await writeFile(RECENT_DIRS_FILE, JSON.stringify(trimmed, null, 2), "utf-8");
109
+ } catch (err) {
110
+ console.error(`[${ts()}] Failed to save recent_dirs.json: ${(err as Error).message}`);
111
+ fileLog.flush();
112
+ }
113
+ }
114
+
69
115
  /** 读取 /cd 设置的默认工作路径。若文件不存在或路径已失效,回退到用户主目录。 */
70
116
  export async function getDefaultCwd(): Promise<string> {
71
117
  try {
@@ -224,4 +270,19 @@ export function explainMissingFeishuCredentialsAndExit(): never {
224
270
  process.exit(1);
225
271
  }
226
272
 
227
- export const SESSION_DESC_PREFIX = "Claude Session:";
273
+ /** 群描述中用于识别 Claude Code 会话的前缀 */
274
+ export const CLAUDE_SESSION_PREFIX = "Claude Code Session:";
275
+ /** 群描述中用于识别 Cursor 会话的前缀 */
276
+ export const CURSOR_SESSION_PREFIX = "Cursor Session:";
277
+
278
+ /** 根据 tool 名称返回对应的群描述前缀 */
279
+ export function sessionPrefixForTool(tool: string): string {
280
+ if (tool === "cursor") return CURSOR_SESSION_PREFIX;
281
+ return CLAUDE_SESSION_PREFIX;
282
+ }
283
+
284
+ /** 根据 tool 名称返回用于状态展示的标签 */
285
+ export function toolDisplayName(tool: string): string {
286
+ if (tool === "cursor") return "Cursor";
287
+ return "Claude Code";
288
+ }
package/src/feishu-api.ts CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  BASE_URL,
8
8
  CHAT_LOGS_DIR,
9
9
  PROJECT_ROOT,
10
- SESSION_DESC_PREFIX,
10
+ CLAUDE_SESSION_PREFIX,
11
+ CURSOR_SESSION_PREFIX,
11
12
  ts,
12
13
  } from "./config.ts";
13
14
  import { buildButtons } from "./cards.ts";
@@ -219,12 +220,24 @@ export async function getChatInfo(
219
220
  };
220
221
  }
221
222
 
223
+ export function extractSessionInfo(description: string): { sessionId: string; tool: string } | null {
224
+ const PREFIXES: Array<{ prefix: string; tool: string }> = [
225
+ { prefix: CLAUDE_SESSION_PREFIX, tool: "claude" },
226
+ { prefix: CURSOR_SESSION_PREFIX, tool: "cursor" },
227
+ ];
228
+ for (const { prefix, tool } of PREFIXES) {
229
+ const idx = description.indexOf(prefix);
230
+ if (idx === -1) continue;
231
+ const after = description.slice(idx + prefix.length).trim();
232
+ const match = after.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
233
+ if (match) return { sessionId: match[1], tool };
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /** @deprecated 使用 extractSessionInfo 代替 */
222
239
  export function extractSessionId(description: string): string | null {
223
- const idx = description.indexOf(SESSION_DESC_PREFIX);
224
- if (idx === -1) return null;
225
- const after = description.slice(idx + SESSION_DESC_PREFIX.length).trim();
226
- const match = after.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
227
- return match ? match[1] : null;
240
+ return extractSessionInfo(description)?.sessionId ?? null;
228
241
  }
229
242
 
230
243
  // ---------------------------------------------------------------------------
@@ -325,9 +338,10 @@ export async function sendRestartCard(token: string): Promise<void> {
325
338
  config: { wide_screen_mode: true },
326
339
  header: { template: "green", title: { content: "ChatCCC Started", tag: "plain_text" } },
327
340
  elements: [
328
- { tag: "div", text: { tag: "lark_md", content: "Bot 已启动完成,可以继续使用。\n\n发送 **/new** 创建新会话,或直接在已有会话群中发消息。" } },
341
+ { tag: "div", text: { tag: "lark_md", content: "Bot 已启动完成,可以继续使用。\n\n使用 **/new**(默认 Claude Code)或 **/new claude** / **/new cursor** 创建新会话" } },
329
342
  buildButtons([
330
- { text: "新建会话(/new)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
343
+ { text: "新建 Claude Code 会话(/new claude)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
344
+ { text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
331
345
  { text: "重启Chat CCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
332
346
  { text: "查看/切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
333
347
  ]),
package/src/index.ts CHANGED
@@ -22,7 +22,6 @@
22
22
  */
23
23
 
24
24
  import { spawn } from "node:child_process";
25
- import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
26
25
  import { readdir, stat } from "node:fs/promises";
27
26
  import { resolve, dirname, basename } from "node:path";
28
27
 
@@ -49,13 +48,17 @@ import {
49
48
  getDefaultCwd,
50
49
  maskAppId,
51
50
  setDefaultCwd,
51
+ getRecentDirs,
52
+ addRecentDir,
53
+ sessionPrefixForTool,
54
+ toolDisplayName,
52
55
  ts,
53
56
  } from "./config.ts";
54
57
  import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
55
58
  import {
56
59
  addReaction,
57
60
  createGroupChat,
58
- extractSessionId,
61
+ extractSessionInfo,
59
62
  getChatInfo,
60
63
  getTenantAccessToken,
61
64
  recallMessage,
@@ -67,7 +70,7 @@ import {
67
70
  verifyAllPermissions,
68
71
  reportPermissionResults,
69
72
  } from "./feishu-api.ts";
70
- import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildSessionsCard } from "./cards.ts";
73
+ import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
71
74
  import { updateCardKitCard } from "./cardkit.ts";
72
75
  import {
73
76
  MAX_PROCESSED,
@@ -79,6 +82,7 @@ import {
79
82
  resetState,
80
83
  resumeAndPrompt,
81
84
  sessionInfoMap,
85
+ getAdapterForTool,
82
86
  } from "./session.ts";
83
87
 
84
88
  // ---------------------------------------------------------------------------
@@ -96,7 +100,11 @@ function getInnerEvent(data: Evt): InnerEvent {
96
100
  return (data.event ?? data) as InnerEvent;
97
101
  }
98
102
 
99
- function extractText(message: { message_type?: string; content?: string }): string {
103
+ /**
104
+ * 将飞书消息的原始 content JSON 结构转成可读文本,保留代码块等结构信息。
105
+ * 未知类型直接返回 JSON 原文,让 AI 自行理解。
106
+ */
107
+ function formatMessageContent(message: { message_type?: string; content?: string }): string {
100
108
  const contentStr = message.content ?? "{}";
101
109
  let content: Record<string, unknown>;
102
110
  try { content = JSON.parse(contentStr); } catch { return ""; }
@@ -108,7 +116,36 @@ function extractText(message: { message_type?: string; content?: string }): stri
108
116
  text = text.replace(/&nbsp;/gi, " ");
109
117
  return text.trim();
110
118
  }
111
- return "";
119
+
120
+ if (message.message_type === "post") {
121
+ return formatPostContent(content);
122
+ }
123
+
124
+ // 其他类型(image, file, audio, media, sticker)直接给原始 JSON
125
+ return contentStr;
126
+ }
127
+
128
+ function formatPostContent(content: Record<string, unknown>): string {
129
+ const paragraphs = content.content as unknown[][];
130
+ if (!Array.isArray(paragraphs)) return "";
131
+
132
+ const parts: string[] = [];
133
+ for (const line of paragraphs) {
134
+ if (!Array.isArray(line)) continue;
135
+ for (const elem of line) {
136
+ const el = elem as Record<string, unknown>;
137
+ if (!el || typeof el !== "object") continue;
138
+ const t = typeof el.text === "string" ? el.text : "";
139
+
140
+ if (el.tag === "code_block") {
141
+ const lang = typeof el.language === "string" ? el.language : "";
142
+ parts.push("```" + lang + "\n" + t + "\n```");
143
+ } else if (el.tag === "p" || el.tag === "text") {
144
+ if (t) parts.push(t);
145
+ }
146
+ }
147
+ }
148
+ return parts.join("\n").trim();
112
149
  }
113
150
 
114
151
  // ---------------------------------------------------------------------------
@@ -138,8 +175,12 @@ function parseCardAction(data: unknown): CardActionResult | null {
138
175
  }
139
176
  if (!cmd) return null;
140
177
 
141
- const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions" };
142
- const text = CMD_MAP[cmd] ?? "";
178
+ const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new cursor": "/new cursor", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions" };
179
+ let text = CMD_MAP[cmd] ?? "";
180
+ if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
181
+ const path = (action.value as Record<string, string>).path;
182
+ if (path) text = `/cd ${path}`;
183
+ }
143
184
  if (!text) return null;
144
185
 
145
186
  const chatId =
@@ -188,9 +229,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
188
229
  let sessionCwd: string | undefined;
189
230
  try {
190
231
  const chatInfo = await getChatInfo(cdToken, chatId);
191
- const sid = extractSessionId(chatInfo.description);
192
- if (sid) {
193
- const info = await getSessionInfo(sid);
232
+ const sessionInfoResult = extractSessionInfo(chatInfo.description);
233
+ if (sessionInfoResult) {
234
+ const adapter = getAdapterForTool(sessionInfoResult.tool);
235
+ const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
194
236
  sessionCwd = info?.cwd;
195
237
  }
196
238
  } catch { /* 非会话群或获取失败,不显示 */ }
@@ -223,6 +265,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
223
265
  const isUpdate = !!arg && targetDir !== currentDir;
224
266
  if (isUpdate) {
225
267
  await setDefaultCwd(targetDir);
268
+ await addRecentDir(targetDir);
226
269
  }
227
270
 
228
271
  // Read directory entries
@@ -247,12 +290,39 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
247
290
  return a.name.localeCompare(b.name);
248
291
  });
249
292
 
250
- const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
251
- await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
293
+ if (!arg) {
294
+ // /cd 无参数:展示卡片(含最近使用路径按钮)
295
+ const recentDirs = await getRecentDirs();
296
+ const card = buildCdCard(targetDir, withStats, recentDirs, sessionCwd);
297
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
298
+ method: "POST",
299
+ headers: {
300
+ Authorization: `Bearer ${cdToken}`,
301
+ "Content-Type": "application/json",
302
+ },
303
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
304
+ });
305
+ const respData: Record<string, any> = await resp.json().catch(() => ({}));
306
+ console.log(`[${ts()}] [CD] card sent, code=${respData.code}, msgId=${respData.data?.message_id ?? "N/A"}, recentDirs=${recentDirs.length}`);
307
+ } else {
308
+ // /cd <path>:切换目录,发送文本卡片
309
+ const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
310
+ await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
311
+ }
252
312
  return;
253
313
  }
254
314
 
255
- if (text === "/new") {
315
+ if (text === "/new" || text.startsWith("/new ")) {
316
+ const toolArg = text.slice(5).trim();
317
+ const tool = toolArg || "claude";
318
+ const validTools = ["claude", "cursor"];
319
+ if (!validTools.includes(tool)) {
320
+ const warnToken = await getTenantAccessToken();
321
+ await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor)。`, "red");
322
+ return;
323
+ }
324
+ const toolLabel = toolDisplayName(tool);
325
+
256
326
  if (!openId) {
257
327
  console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
258
328
  const warnToken = await getTenantAccessToken();
@@ -264,13 +334,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
264
334
 
265
335
  let sessionId: string;
266
336
  try {
267
- sessionId = await initClaudeSession();
268
- console.log(`[${ts()}] [STEP 1/4] Claude SDK session created: ${sessionId} → OK`);
337
+ sessionId = await initClaudeSession(tool);
338
+ console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
269
339
  } catch (err) {
270
340
  console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
271
341
  await sendCardReply(
272
342
  freshToken, chatId, "Error",
273
- `Failed to initialize Claude session:\n${(err as Error).message}`,
343
+ `Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
274
344
  "red"
275
345
  );
276
346
  return;
@@ -288,8 +358,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
288
358
 
289
359
  try {
290
360
  const initialName = `新会话-${sessionId}`;
291
- await updateChatInfo(freshToken, newChatId, initialName, `Claude Session: ${sessionId}`);
292
- console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" → OK`);
361
+ const descPrefix = sessionPrefixForTool(tool);
362
+ await updateChatInfo(freshToken, newChatId, initialName, `${descPrefix} ${sessionId}`);
363
+ console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
293
364
  } catch (err) {
294
365
  console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
295
366
  await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
@@ -297,9 +368,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
297
368
  }
298
369
 
299
370
  const cwd = await getDefaultCwd();
371
+ const adapter = getAdapterForTool(tool);
300
372
  await sendCardReply(
301
- freshToken, newChatId, "Claude Session Ready",
302
- `群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n**工作目录:** \`${cwd}\`\n\n直接在这里发消息即可与 Claude 对话。\n\n发送 **/sessions** 查看所有会话状态。`,
373
+ freshToken, newChatId, `${toolLabel} Session Ready`,
374
+ `群聊已创建,这是你的 **${toolLabel}** 会话群。\n\n**Session ID:** ${sessionId}\n**工作目录:** \`${cwd}\`\n\n直接在这里发消息即可与 ${toolLabel} 对话。\n\n发送 **/sessions** 查看所有会话状态。`,
303
375
  "green"
304
376
  );
305
377
 
@@ -312,10 +384,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
312
384
  const token = await getTenantAccessToken();
313
385
  const chatInfo = await getChatInfo(token, chatId);
314
386
  const description = chatInfo.description;
315
- const sessionId = extractSessionId(description);
387
+ const sessionInfo = extractSessionInfo(description);
316
388
 
317
- if (sessionId) {
318
- console.log(`[${ts()}] [RESUME] 克劳德会话群 detected, session=${sessionId}`);
389
+ if (sessionInfo) {
390
+ const sessionId = sessionInfo.sessionId;
391
+ const descriptionTool = sessionInfo.tool;
392
+ const toolLabel = toolDisplayName(descriptionTool);
393
+ console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
319
394
 
320
395
  const freshToken = await getTenantAccessToken();
321
396
 
@@ -351,6 +426,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
351
426
  const isActive = running && !running.stopped;
352
427
  const statusText = [
353
428
  `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
429
+ `**工具:** ${toolLabel}`,
354
430
  `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
355
431
  `**已对话轮数:** ${status?.turnCount ?? 0}`,
356
432
  `**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
@@ -389,6 +465,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
389
465
  turnCount: s.turnCount,
390
466
  elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
391
467
  model: s.model,
468
+ tool: s.tool,
392
469
  }));
393
470
  const card = buildSessionsCard(cardData);
394
471
  const sessionsResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
@@ -433,13 +510,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
433
510
  }
434
511
 
435
512
  try {
436
- await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp);
513
+ await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool);
437
514
  console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
438
515
  } catch (err) {
439
516
  console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
517
+ fileLog.flush();
440
518
  await sendCardReply(
441
519
  freshToken, chatId, "Error",
442
- `Failed to resume Claude session:\n${(err as Error).message}`,
520
+ `Failed to resume ${toolLabel} session:\n${(err as Error).message}`,
443
521
  "red"
444
522
  );
445
523
  }
@@ -579,7 +657,7 @@ async function main(): Promise<void> {
579
657
  }
580
658
  }
581
659
 
582
- const text = extractText(message);
660
+ const text = formatMessageContent(message);
583
661
  const sender = event.sender;
584
662
  const openId = sender?.sender_id?.open_id ?? "";
585
663
  const chatId = message.chat_id ?? "";
@@ -671,7 +749,7 @@ async function main(): Promise<void> {
671
749
  const event = getInnerEvent(data);
672
750
  const message = event.message;
673
751
  if (!message) return;
674
- const text = extractText(message);
752
+ const text = formatMessageContent(message);
675
753
  const openId = event.sender?.sender_id?.open_id ?? "";
676
754
  const chatId = message.chat_id ?? "";
677
755
  appendChatLog(chatId, openId, text);
@@ -697,8 +775,8 @@ async function main(): Promise<void> {
697
775
  const wsClient = new WSClient({
698
776
  appId: APP_ID,
699
777
  appSecret: APP_SECRET,
700
- onReady: () => resetState(),
701
- onReconnected: () => resetState(),
778
+ onReady: () => { resetState(); },
779
+ onReconnected: () => { resetState(); },
702
780
  });
703
781
 
704
782
  console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);