chatccc 0.2.5 → 0.2.7
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 +84 -20
- package/package.json +1 -1
- package/src/__tests__/adapter-interface.test.ts +151 -151
- package/src/__tests__/cards.test.ts +417 -296
- package/src/__tests__/claude-adapter.test.ts +528 -528
- package/src/__tests__/config.test.ts +123 -0
- package/src/__tests__/cursor-adapter.test.ts +663 -0
- package/src/__tests__/cursor-session-meta-store.test.ts +212 -0
- package/src/__tests__/fixtures/cursor_partial_only.jsonl +5 -0
- package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +13 -0
- package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +12 -0
- package/src/__tests__/git-command.test.ts +288 -0
- package/src/__tests__/session.test.ts +475 -295
- package/src/adapters/adapter-interface.ts +151 -126
- package/src/adapters/claude-adapter.ts +257 -256
- package/src/adapters/cursor-adapter.ts +402 -0
- package/src/adapters/cursor-session-meta-store.ts +154 -0
- package/src/cards.ts +133 -22
- package/src/config.ts +152 -4
- package/src/feishu-api.ts +23 -9
- package/src/git-command.ts +202 -0
- package/src/index.ts +164 -35
- package/src/session.ts +620 -450
package/src/cards.ts
CHANGED
|
@@ -114,10 +114,12 @@ export function buildHelpCard(userText: string): string {
|
|
|
114
114
|
header: { template: "blue", title: { content: "ChatCCC", tag: "plain_text" } },
|
|
115
115
|
elements: [
|
|
116
116
|
{ tag: "div", text: { tag: "lark_md", content: `你发送了: ${userText}` } },
|
|
117
|
+
{ tag: "div", text: { tag: "lark_md", content: "使用 **/new**(默认 Claude Code)或 **/new claude** / **/new cursor** 创建新会话" } },
|
|
117
118
|
buildButtons([
|
|
118
|
-
{ text: "
|
|
119
|
-
{ text: "
|
|
120
|
-
{ text: "
|
|
119
|
+
{ text: "新建 Claude Code 会话(/new claude)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
120
|
+
{ text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
|
|
121
|
+
{ text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
|
|
122
|
+
{ text: "切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
|
|
121
123
|
]),
|
|
122
124
|
],
|
|
123
125
|
});
|
|
@@ -159,37 +161,146 @@ export function buildCdContent(
|
|
|
159
161
|
return lines.join("\n");
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
//
|
|
164
|
+
// 工作路径卡片(/cd 无参数时使用,含最近使用路径按钮)
|
|
165
|
+
//
|
|
166
|
+
// 必须使用 v1 卡片格式(无 schema 字段、elements 放顶层)。原因:
|
|
167
|
+
// 此卡片通过 `/im/v1/messages?msg_type=interactive` 端点直接发出,content
|
|
168
|
+
// 字段就是卡片 JSON。该端点不接受 schema 2.0 的原始 JSON 作为 content
|
|
169
|
+
// (schema 2.0 卡片必须先通过 CardKit /cardkit/v1/cards 创建得到 card_id,
|
|
170
|
+
// 再以 `{type:"card", data:{card_id}}` 包装发出,参见 sendCardKitMessage)。
|
|
171
|
+
// 之前用过 schema 2.0 + body.elements 的结构,飞书会静默拒绝该消息,
|
|
172
|
+
// 导致 /cd 无任何回复——故此处与 buildSessionsCard/buildStatusCard 保持
|
|
173
|
+
// 同一 v1 结构。
|
|
174
|
+
export function buildCdCard(
|
|
175
|
+
dirPath: string,
|
|
176
|
+
entries: { name: string; isDir: boolean }[],
|
|
177
|
+
recentDirs: string[],
|
|
178
|
+
sessionCwd?: string,
|
|
179
|
+
maxFiles = 100,
|
|
180
|
+
): string {
|
|
181
|
+
const display = entries.slice(0, maxFiles);
|
|
182
|
+
const overflow = entries.length > maxFiles ? `\n...(共 ${entries.length} 个条目,仅显示前 ${maxFiles} 个)` : "";
|
|
183
|
+
const listing = display.map(e => e.isDir ? `📁 ${e.name}/` : `📄 ${e.name}`).join("\n");
|
|
184
|
+
|
|
185
|
+
const elements: object[] = [];
|
|
186
|
+
|
|
187
|
+
if (sessionCwd) {
|
|
188
|
+
elements.push({
|
|
189
|
+
tag: "div",
|
|
190
|
+
text: { tag: "lark_md", content: `**当前会话工作路径:** \`${sessionCwd}\`` },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
elements.push({
|
|
194
|
+
tag: "div",
|
|
195
|
+
text: { tag: "lark_md", content: `**新会话默认工作路径:** \`${dirPath}\`` },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (recentDirs.length > 0) {
|
|
199
|
+
elements.push({ tag: "hr" });
|
|
200
|
+
elements.push({
|
|
201
|
+
tag: "div",
|
|
202
|
+
text: { tag: "lark_md", content: "**最近使用过的路径(点击切换):**" },
|
|
203
|
+
});
|
|
204
|
+
elements.push({
|
|
205
|
+
tag: "action",
|
|
206
|
+
actions: recentDirs.map(d => {
|
|
207
|
+
const label = d.length > 36 ? `...${d.slice(-33)}` : d;
|
|
208
|
+
return {
|
|
209
|
+
tag: "button",
|
|
210
|
+
text: { tag: "plain_text", content: label },
|
|
211
|
+
type: "default",
|
|
212
|
+
value: { action: "cd", path: d },
|
|
213
|
+
};
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
elements.push({ tag: "hr" });
|
|
219
|
+
elements.push({
|
|
220
|
+
tag: "div",
|
|
221
|
+
text: {
|
|
222
|
+
tag: "lark_md",
|
|
223
|
+
content: [
|
|
224
|
+
`**目录内容** (最多 ${maxFiles} 个):`,
|
|
225
|
+
listing,
|
|
226
|
+
overflow,
|
|
227
|
+
].join("\n"),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return JSON.stringify({
|
|
232
|
+
config: { wide_screen_mode: true },
|
|
233
|
+
header: {
|
|
234
|
+
template: "blue",
|
|
235
|
+
title: { tag: "plain_text", content: "工作路径" },
|
|
236
|
+
},
|
|
237
|
+
elements,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 所有会话列表卡片(Claude Code 优先,然后 Cursor)
|
|
163
242
|
export function buildSessionsCard(sessions: Array<{
|
|
164
243
|
sessionId: string;
|
|
165
244
|
active: boolean;
|
|
166
245
|
turnCount: number;
|
|
167
246
|
elapsedSeconds: number | null;
|
|
168
247
|
model: string;
|
|
248
|
+
tool: string;
|
|
169
249
|
}>): string {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
250
|
+
// 按 tool 分组排序:Claude Code 在前,Cursor 在后
|
|
251
|
+
const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor");
|
|
252
|
+
const cursorSessions = sessions.filter(s => s.tool === "cursor");
|
|
253
|
+
const hasClaudeCode = claudeCodeSessions.length > 0;
|
|
254
|
+
const hasCursor = cursorSessions.length > 0;
|
|
255
|
+
|
|
256
|
+
if (sessions.length === 0) {
|
|
257
|
+
return JSON.stringify({
|
|
258
|
+
config: { wide_screen_mode: true },
|
|
259
|
+
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
260
|
+
elements: [
|
|
261
|
+
{ tag: "div", text: { tag: "lark_md", content: "当前没有会话记录。\n\n使用 **/new**(默认 Claude Code)、**/new claude** 或 **/new cursor** 创建新会话。" } },
|
|
262
|
+
{ tag: "hr" },
|
|
263
|
+
{ tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const formatSession = (s: typeof sessions[0], i: number) => {
|
|
269
|
+
const status = s.active ? "🟢 活跃" : "⚪ 空闲";
|
|
270
|
+
const shortId = s.sessionId.length > 16 ? s.sessionId.slice(0, 16) + "..." : s.sessionId;
|
|
271
|
+
let extra = "";
|
|
272
|
+
if (s.active && s.elapsedSeconds !== null) {
|
|
273
|
+
const mins = Math.floor(s.elapsedSeconds / 60);
|
|
274
|
+
const secs = s.elapsedSeconds % 60;
|
|
275
|
+
extra = ` | 本轮: ${mins}分${secs}秒`;
|
|
276
|
+
}
|
|
277
|
+
const toolLabel = s.tool === "cursor" ? "Cursor" : "Claude Code";
|
|
278
|
+
return `**${i + 1}.** \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
|
|
282
|
+
let idx = 0;
|
|
283
|
+
|
|
284
|
+
if (hasClaudeCode) {
|
|
285
|
+
lines.push("**Claude Code 会话:**", "");
|
|
286
|
+
for (const s of claudeCodeSessions) {
|
|
287
|
+
lines.push(formatSession(s, idx++));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (hasCursor) {
|
|
292
|
+
if (hasClaudeCode) lines.push("", "**Cursor 会话:**", "");
|
|
293
|
+
else lines.push("**Cursor 会话:**", "");
|
|
294
|
+
for (const s of cursorSessions) {
|
|
295
|
+
lines.push(formatSession(s, idx++));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
187
298
|
|
|
188
299
|
return JSON.stringify({
|
|
189
300
|
config: { wide_screen_mode: true },
|
|
190
301
|
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
191
302
|
elements: [
|
|
192
|
-
{ tag: "div", text: { tag: "lark_md", content:
|
|
303
|
+
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
193
304
|
{ tag: "hr" },
|
|
194
305
|
{
|
|
195
306
|
tag: "action",
|
package/src/config.ts
CHANGED
|
@@ -62,13 +62,122 @@ 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
|
-
|
|
66
|
-
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// /git 超时配置(CHATCCC_GIT_TIMEOUT_SECONDS)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** /git 命令默认超时秒数(用户未配置时使用) */
|
|
70
|
+
export const DEFAULT_GIT_TIMEOUT_SECONDS = 180;
|
|
71
|
+
/** /git 超时允许的下限/上限(防止 0、负数、过大值导致行为异常) */
|
|
72
|
+
export const MIN_GIT_TIMEOUT_SECONDS = 1;
|
|
73
|
+
export const MAX_GIT_TIMEOUT_SECONDS = 3600; // 1 小时
|
|
74
|
+
|
|
75
|
+
export interface ParsedGitTimeout {
|
|
76
|
+
/** 实际使用的超时秒数(无效时回退为 default) */
|
|
77
|
+
seconds: number;
|
|
78
|
+
/** 用户提供的原始字符串是否为合法整数秒(true 表示采纳了用户值或未提供) */
|
|
79
|
+
valid: boolean;
|
|
80
|
+
/** 用户原始输入;未设置时为 undefined */
|
|
81
|
+
raw?: string;
|
|
82
|
+
/** 是否使用了内置默认值(即用户未提供有效值) */
|
|
83
|
+
usingDefault: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 解析 CHATCCC_GIT_TIMEOUT_SECONDS 字符串为有效秒数。
|
|
88
|
+
* - 未设置 / 空 → 使用默认值,valid=true、usingDefault=true
|
|
89
|
+
* - 有效整数且在 [MIN, MAX] 区间 → 使用用户值,valid=true、usingDefault=false
|
|
90
|
+
* - 非整数 / 越界 / NaN → 使用默认值,valid=false、usingDefault=true(同时保留原始字符串供报错)
|
|
91
|
+
*/
|
|
92
|
+
export function parseGitTimeoutSeconds(
|
|
93
|
+
raw: string | undefined,
|
|
94
|
+
defaultSeconds = DEFAULT_GIT_TIMEOUT_SECONDS,
|
|
95
|
+
): ParsedGitTimeout {
|
|
96
|
+
const trimmed = raw?.trim();
|
|
97
|
+
if (!trimmed) {
|
|
98
|
+
return { seconds: defaultSeconds, valid: true, usingDefault: true };
|
|
99
|
+
}
|
|
100
|
+
const n = Number(trimmed);
|
|
101
|
+
if (
|
|
102
|
+
!Number.isFinite(n) ||
|
|
103
|
+
!Number.isInteger(n) ||
|
|
104
|
+
n < MIN_GIT_TIMEOUT_SECONDS ||
|
|
105
|
+
n > MAX_GIT_TIMEOUT_SECONDS
|
|
106
|
+
) {
|
|
107
|
+
return { seconds: defaultSeconds, valid: false, raw: trimmed, usingDefault: true };
|
|
108
|
+
}
|
|
109
|
+
return { seconds: n, valid: true, raw: trimmed, usingDefault: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const gitTimeoutParsed = parseGitTimeoutSeconds(process.env.CHATCCC_GIT_TIMEOUT_SECONDS);
|
|
113
|
+
/** /git 命令实际使用的超时秒数 */
|
|
114
|
+
export const GIT_TIMEOUT_SECONDS = gitTimeoutParsed.seconds;
|
|
115
|
+
/** /git 命令实际使用的超时毫秒数(runGitCommand 直接使用) */
|
|
116
|
+
export const GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
|
|
117
|
+
|
|
118
|
+
/** 探测 cursor-agent 安装路径(优先环境变量,其次 LocalAppData,最后默认 agent) */
|
|
119
|
+
function detectCursorAgent(): string {
|
|
120
|
+
if (process.env.CHATCCC_CURSOR_COMMAND?.trim()) return process.env.CHATCCC_CURSOR_COMMAND.trim();
|
|
121
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
122
|
+
if (localAppData) {
|
|
123
|
+
const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
|
|
124
|
+
if (existsSync(defaultPath)) return defaultPath;
|
|
125
|
+
}
|
|
126
|
+
return "agent";
|
|
127
|
+
}
|
|
128
|
+
export const CURSOR_AGENT_COMMAND = detectCursorAgent();
|
|
129
|
+
|
|
130
|
+
function resolveCursorAgentArgs(): string[] {
|
|
131
|
+
const custom = process.env.CHATCCC_CURSOR_ARGS?.trim();
|
|
132
|
+
if (custom) return custom.split(/\s+/).filter(Boolean);
|
|
133
|
+
|
|
134
|
+
let args = "-p --force --output-format stream-json --stream-partial-output";
|
|
135
|
+
const model = process.env.CHATCCC_CURSOR_MODEL?.trim();
|
|
136
|
+
if (model && model !== "default") {
|
|
137
|
+
args += ` --model ${model}`;
|
|
138
|
+
}
|
|
139
|
+
return args.split(/\s+/).filter(Boolean);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Cursor agent 参数:-p 非交互模式,--force 强制允许命令(yolo),stream-json 流式 JSONL 输出 */
|
|
143
|
+
export const CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
|
|
67
144
|
|
|
68
145
|
// 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
|
|
69
|
-
// 该路径仅影响通过 /new
|
|
146
|
+
// 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
|
|
70
147
|
export const DEFAULT_CWD_FILE = join(PROJECT_ROOT, ".claude", "working_dir.txt");
|
|
71
148
|
|
|
149
|
+
/** 会话工具类型持久化文件 */
|
|
150
|
+
export const SESSIONS_FILE = join(PROJECT_ROOT, ".claude", "sessions.json");
|
|
151
|
+
|
|
152
|
+
/** 最近成功新建会话的工作路径记录(最多 10 条) */
|
|
153
|
+
export const RECENT_DIRS_FILE = join(PROJECT_ROOT, ".claude", "recent_dirs.json");
|
|
154
|
+
export const MAX_RECENT_DIRS = 10;
|
|
155
|
+
|
|
156
|
+
/** 读取最近使用过的工作路径列表(最新的在前) */
|
|
157
|
+
export async function getRecentDirs(): Promise<string[]> {
|
|
158
|
+
try {
|
|
159
|
+
const raw = await readFile(RECENT_DIRS_FILE, "utf-8");
|
|
160
|
+
const arr = JSON.parse(raw);
|
|
161
|
+
if (Array.isArray(arr)) return arr.filter((d: unknown) => typeof d === "string");
|
|
162
|
+
} catch { /* file doesn't exist or corrupted */ }
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 添加一个路径到最近使用列表(去重、限制数量、最新的在前) */
|
|
167
|
+
export async function addRecentDir(dir: string): Promise<void> {
|
|
168
|
+
const dirs = await getRecentDirs();
|
|
169
|
+
const filtered = dirs.filter(d => d !== dir);
|
|
170
|
+
filtered.unshift(dir);
|
|
171
|
+
const trimmed = filtered.slice(0, MAX_RECENT_DIRS);
|
|
172
|
+
try {
|
|
173
|
+
await mkdir(dirname(RECENT_DIRS_FILE), { recursive: true });
|
|
174
|
+
await writeFile(RECENT_DIRS_FILE, JSON.stringify(trimmed, null, 2), "utf-8");
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`[${ts()}] Failed to save recent_dirs.json: ${(err as Error).message}`);
|
|
177
|
+
fileLog.flush();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
72
181
|
/** 读取 /cd 设置的默认工作路径。若文件不存在或路径已失效,回退到用户主目录。 */
|
|
73
182
|
export async function getDefaultCwd(): Promise<string> {
|
|
74
183
|
try {
|
|
@@ -184,6 +293,30 @@ export function reportEnvironmentVariableReadout(): void {
|
|
|
184
293
|
);
|
|
185
294
|
}
|
|
186
295
|
|
|
296
|
+
const rawGitTimeout = process.env.CHATCCC_GIT_TIMEOUT_SECONDS?.trim();
|
|
297
|
+
if (!rawGitTimeout) {
|
|
298
|
+
console.log(` [默认] [可选] CHATCCC_GIT_TIMEOUT_SECONDS`);
|
|
299
|
+
console.log(
|
|
300
|
+
` /git 命令超时: 未设置 → 使用内置默认 ${DEFAULT_GIT_TIMEOUT_SECONDS}s`
|
|
301
|
+
);
|
|
302
|
+
} else if (!gitTimeoutParsed.valid) {
|
|
303
|
+
row(
|
|
304
|
+
"/git 命令超时",
|
|
305
|
+
"CHATCCC_GIT_TIMEOUT_SECONDS",
|
|
306
|
+
"可选",
|
|
307
|
+
false,
|
|
308
|
+
`值无效 "${rawGitTimeout}",需为 ${MIN_GIT_TIMEOUT_SECONDS}–${MAX_GIT_TIMEOUT_SECONDS} 的整数秒;已回退为默认 ${DEFAULT_GIT_TIMEOUT_SECONDS}s`,
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
row(
|
|
312
|
+
"/git 命令超时",
|
|
313
|
+
"CHATCCC_GIT_TIMEOUT_SECONDS",
|
|
314
|
+
"可选",
|
|
315
|
+
true,
|
|
316
|
+
`已读入 → ${GIT_TIMEOUT_SECONDS}s`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
187
320
|
console.log(" ------------------------------------------------------------------");
|
|
188
321
|
}
|
|
189
322
|
|
|
@@ -227,4 +360,19 @@ export function explainMissingFeishuCredentialsAndExit(): never {
|
|
|
227
360
|
process.exit(1);
|
|
228
361
|
}
|
|
229
362
|
|
|
230
|
-
|
|
363
|
+
/** 群描述中用于识别 Claude Code 会话的前缀 */
|
|
364
|
+
export const CLAUDE_SESSION_PREFIX = "Claude Code Session:";
|
|
365
|
+
/** 群描述中用于识别 Cursor 会话的前缀 */
|
|
366
|
+
export const CURSOR_SESSION_PREFIX = "Cursor Session:";
|
|
367
|
+
|
|
368
|
+
/** 根据 tool 名称返回对应的群描述前缀 */
|
|
369
|
+
export function sessionPrefixForTool(tool: string): string {
|
|
370
|
+
if (tool === "cursor") return CURSOR_SESSION_PREFIX;
|
|
371
|
+
return CLAUDE_SESSION_PREFIX;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 根据 tool 名称返回用于状态展示的标签 */
|
|
375
|
+
export function toolDisplayName(tool: string): string {
|
|
376
|
+
if (tool === "cursor") return "Cursor";
|
|
377
|
+
return "Claude Code";
|
|
378
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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,11 +338,12 @@ 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
|
|
341
|
+
{ tag: "div", text: { tag: "lark_md", content: "Bot 已启动完成,可以继续使用。\n\n使用 **/new**(默认 Claude Code)或 **/new claude** / **/new cursor** 创建新会话" } },
|
|
329
342
|
buildButtons([
|
|
330
|
-
{ text: "
|
|
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
|
-
{ text: "
|
|
346
|
+
{ text: "切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
|
|
333
347
|
]),
|
|
334
348
|
],
|
|
335
349
|
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// git-command.ts — /git 命令的执行与结果格式化
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// 解析自 /git 后的原始字符串作为 shell 参数,在指定 cwd 下通过 shell 调起
|
|
5
|
+
// `git ...`,收集 stdout/stderr 与退出码。带超时(kill)与逐路输出字节上限
|
|
6
|
+
// (超过即截断),避免长输出撑爆内存或刷屏。
|
|
7
|
+
//
|
|
8
|
+
// 抽成独立模块是为了便于在不依赖飞书 SDK / 网络的前提下跑单元测试。
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
import { truncateContent } from "./cards.ts";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// 类型
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface GitCommandResult {
|
|
20
|
+
/** 进程退出码;spawn 失败或被 kill 时为 null */
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
durationMs: number;
|
|
25
|
+
/** 任意一路输出超过 maxBytes 上限,已被截断 */
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
/** 命令超过 timeoutMs 被强制终止 */
|
|
28
|
+
timedOut: boolean;
|
|
29
|
+
/** spawn 自身错误(如 git 不在 PATH);正常退出(含非 0)时为 undefined */
|
|
30
|
+
spawnError?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RunGitOptions {
|
|
34
|
+
/** 命令最大允许执行毫秒数;超过则 SIGKILL,默认 60_000 */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
/** stdout 与 stderr 各自最多采集多少字节;默认 64 KiB */
|
|
37
|
+
maxBytes?: number;
|
|
38
|
+
/** 注入测试桩用:自定义 spawn 函数;默认使用 node:child_process 的 spawn */
|
|
39
|
+
spawnImpl?: typeof spawn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// runGitCommand —— 在 cwd 下执行 `git <args>`
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 在 `cwd` 目录下执行 `git <args>`。
|
|
48
|
+
* - 通过 shell 执行(`shell: true`),允许用户使用引号、管道等 shell 语法
|
|
49
|
+
* - stdout/stderr 各自最多采集 `maxBytes` 字节,超过则置 truncated=true 并丢弃后续片段
|
|
50
|
+
* - 超时则 SIGKILL 并置 timedOut=true,仍返回已收集的部分输出
|
|
51
|
+
*
|
|
52
|
+
* 注意:本函数 **不会抛错**——任何失败都通过返回值传递(退出码、spawnError 等),
|
|
53
|
+
* 调用方需通过 exitCode/spawnError/timedOut/truncated 判断结果。
|
|
54
|
+
*/
|
|
55
|
+
export function runGitCommand(
|
|
56
|
+
args: string,
|
|
57
|
+
cwd: string,
|
|
58
|
+
opts: RunGitOptions = {},
|
|
59
|
+
): Promise<GitCommandResult> {
|
|
60
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
61
|
+
const maxBytes = opts.maxBytes ?? 64 * 1024;
|
|
62
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
|
|
65
|
+
return new Promise<GitCommandResult>((resolve) => {
|
|
66
|
+
let child: ReturnType<typeof spawn>;
|
|
67
|
+
try {
|
|
68
|
+
child = spawnImpl(`git ${args}`, {
|
|
69
|
+
cwd,
|
|
70
|
+
shell: true,
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
resolve({
|
|
75
|
+
exitCode: null,
|
|
76
|
+
stdout: "",
|
|
77
|
+
stderr: "",
|
|
78
|
+
durationMs: Date.now() - startTime,
|
|
79
|
+
truncated: false,
|
|
80
|
+
timedOut: false,
|
|
81
|
+
spawnError: err instanceof Error ? err.message : String(err),
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let stdout = "";
|
|
87
|
+
let stderr = "";
|
|
88
|
+
let stdoutBytes = 0;
|
|
89
|
+
let stderrBytes = 0;
|
|
90
|
+
let truncated = false;
|
|
91
|
+
let timedOut = false;
|
|
92
|
+
let spawnError: string | undefined;
|
|
93
|
+
|
|
94
|
+
const collect = (chunk: Buffer, current: { bytes: number; text: string }): void => {
|
|
95
|
+
const room = maxBytes - current.bytes;
|
|
96
|
+
if (room <= 0) {
|
|
97
|
+
truncated = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const slice = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
101
|
+
current.text += slice.toString("utf-8");
|
|
102
|
+
current.bytes += slice.length;
|
|
103
|
+
if (chunk.length > room) truncated = true;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const stdoutState = { bytes: 0, text: "" };
|
|
107
|
+
const stderrState = { bytes: 0, text: "" };
|
|
108
|
+
|
|
109
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
110
|
+
collect(chunk, stdoutState);
|
|
111
|
+
stdout = stdoutState.text;
|
|
112
|
+
stdoutBytes = stdoutState.bytes;
|
|
113
|
+
});
|
|
114
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
115
|
+
collect(chunk, stderrState);
|
|
116
|
+
stderr = stderrState.text;
|
|
117
|
+
stderrBytes = stderrState.bytes;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const killTimer = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
try {
|
|
123
|
+
child.kill("SIGKILL");
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore: 进程可能已退出
|
|
126
|
+
}
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
|
|
129
|
+
child.on("error", (err: Error) => {
|
|
130
|
+
spawnError = err.message;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
child.on("close", (code: number | null) => {
|
|
134
|
+
clearTimeout(killTimer);
|
|
135
|
+
// 仅引用未直接使用的字节计数变量以避免编译告警
|
|
136
|
+
void stdoutBytes; void stderrBytes;
|
|
137
|
+
resolve({
|
|
138
|
+
exitCode: code,
|
|
139
|
+
stdout,
|
|
140
|
+
stderr,
|
|
141
|
+
durationMs: Date.now() - startTime,
|
|
142
|
+
truncated,
|
|
143
|
+
timedOut,
|
|
144
|
+
spawnError,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// formatGitResult —— 把执行结果渲染为飞书卡片用的 markdown
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 渲染 `/git` 执行结果为发送给用户的 markdown 字符串。
|
|
156
|
+
* stdout/stderr 单路各最多保留 `maxLines` 行 / `maxChars` 字符(沿用 truncateContent),
|
|
157
|
+
* 命令本身的截断状态(truncated/timedOut/spawnError)也会在头部说明。
|
|
158
|
+
*/
|
|
159
|
+
export function formatGitResult(
|
|
160
|
+
args: string,
|
|
161
|
+
cwd: string,
|
|
162
|
+
result: GitCommandResult,
|
|
163
|
+
opts: { maxLines?: number; maxChars?: number } = {},
|
|
164
|
+
): string {
|
|
165
|
+
const maxLines = opts.maxLines ?? 50;
|
|
166
|
+
const maxChars = opts.maxChars ?? 6000;
|
|
167
|
+
const sec = (result.durationMs / 1000).toFixed(2);
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
|
|
170
|
+
lines.push(`**\$ git ${args}**`);
|
|
171
|
+
lines.push(`工作目录: \`${cwd}\``);
|
|
172
|
+
const exitDisplay = result.exitCode === null ? "(无)" : String(result.exitCode);
|
|
173
|
+
lines.push(`退出码: \`${exitDisplay}\` | 用时: \`${sec}s\``);
|
|
174
|
+
if (result.timedOut) lines.push(`⏱️ 命令超时被强制终止`);
|
|
175
|
+
if (result.truncated) lines.push(`⚠️ 输出超出采集上限,已截断`);
|
|
176
|
+
if (result.spawnError) lines.push(`❌ 启动失败: ${result.spawnError}`);
|
|
177
|
+
|
|
178
|
+
const stdoutTrim = result.stdout.trim();
|
|
179
|
+
const stderrTrim = result.stderr.trim();
|
|
180
|
+
|
|
181
|
+
if (stdoutTrim) {
|
|
182
|
+
lines.push("", "**stdout:**", "```", truncateContent(stdoutTrim, maxLines, maxChars), "```");
|
|
183
|
+
}
|
|
184
|
+
if (stderrTrim) {
|
|
185
|
+
lines.push("", "**stderr:**", "```", truncateContent(stderrTrim, maxLines, maxChars), "```");
|
|
186
|
+
}
|
|
187
|
+
if (!stdoutTrim && !stderrTrim && !result.spawnError) {
|
|
188
|
+
lines.push("", "_(命令无输出)_");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// 头部颜色:成功绿色,失败红色,超时黄色
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export function gitResultHeaderTemplate(result: GitCommandResult): string {
|
|
199
|
+
if (result.timedOut || result.spawnError) return "yellow";
|
|
200
|
+
if (result.exitCode === 0) return "green";
|
|
201
|
+
return "red";
|
|
202
|
+
}
|