chatccc 0.2.18 → 0.2.19

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
@@ -1,616 +1,616 @@
1
- import { existsSync, readFileSync, copyFileSync, writeFileSync } from "node:fs";
2
- import { execFileSync } from "node:child_process";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
-
8
- import { printServiceDidNotStart } from "./exit-banner.ts";
9
- import { appendStartupTrace, setupFileLogging } from "./shared.ts";
10
- import {
11
- anthropicConfigDisplay,
12
- autoDetectCodexPath,
13
- autoDetectCursorPath,
14
- normalizeOptionalConfigField,
15
- readToolCliPath,
16
- } from "./config-utils.ts";
17
-
18
- // 重新导出 config-utils 中的纯函数/常量,保持对外 API 不变
19
- // (历史上这些符号都从 ./config.ts 导入;新代码可直接从 ./config-utils.ts 导入以避免触发本文件的副作用)
20
- export {
21
- DEFAULT_GIT_TIMEOUT_SECONDS,
22
- MIN_GIT_TIMEOUT_SECONDS,
23
- MAX_GIT_TIMEOUT_SECONDS,
24
- parseGitTimeoutSeconds,
25
- normalizeOptionalConfigField,
26
- isAnthropicConfigEmpty,
27
- anthropicConfigDisplay,
28
- } from "./config-utils.ts";
29
- export type { ParsedGitTimeout } from "./config-utils.ts";
30
-
31
- // ---------------------------------------------------------------------------
32
- // Paths & logging
33
- // ---------------------------------------------------------------------------
34
-
35
- export const __dirname = dirname(fileURLToPath(import.meta.url));
36
- export const PROJECT_ROOT = join(__dirname, "..");
37
- export const PID_FILE = join(PROJECT_ROOT, "state", "runtime.pid");
38
-
39
- export const LOG_DIR = join(PROJECT_ROOT, "logs");
40
- export const fileLog = setupFileLogging(LOG_DIR, "index");
41
-
42
- export const CHAT_LOGS_DIR = join(PROJECT_ROOT, "state", "chat_logs");
43
-
44
- export async function appendChatLog(chatId: string, sender: string, text: string): Promise<void> {
45
- try {
46
- await mkdir(CHAT_LOGS_DIR, { recursive: true });
47
- const line = JSON.stringify({ ts: Date.now(), sender, text: text.slice(0, 200) }) + "\n";
48
- await appendFile(join(CHAT_LOGS_DIR, `${chatId}.jsonl`), line);
49
- } catch {
50
- // 静默失败,不影响主流程
51
- }
52
- }
53
-
54
- // ---------------------------------------------------------------------------
55
- // Config file loading
56
- // ---------------------------------------------------------------------------
57
-
58
- export interface ClaudeConfig {
59
- /** 是否启用 Claude Code Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
60
- enabled: boolean;
61
- model: string;
62
- effort: string;
63
- apiKey: string;
64
- baseUrl: string;
65
- }
66
-
67
- export interface CursorConfig {
68
- /** 是否启用 Cursor Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
69
- enabled: boolean;
70
- /** Cursor Agent CLI 可执行文件绝对路径;留空时由运行时按 LocalAppData / PATH 兜底 */
71
- path: string;
72
- model: string;
73
- }
74
-
75
- export interface CodexConfig {
76
- /** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
77
- enabled: boolean;
78
- /** Codex CLI 可执行文件绝对路径;留空时退回到 PATH 中的 `codex` */
79
- path: string;
80
- model: string;
81
- effort: string;
82
- }
83
-
84
- export interface FeishuConfig {
85
- appId: string;
86
- appSecret: string;
87
- }
88
-
89
- export interface AppConfig {
90
- feishu: FeishuConfig;
91
- port: number;
92
- gitTimeoutSeconds: number;
93
- claude: ClaudeConfig;
94
- cursor: CursorConfig;
95
- codex: CodexConfig;
96
- }
97
-
98
- const CONFIG_FILE = join(PROJECT_ROOT, "config.json");
99
- const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
100
-
101
- /**
102
- * 是否处于 vitest 测试环境。
103
- *
104
- * 由于 `src/config.ts` 在模块顶层立即执行 `loadConfig()`,而很多生产模块
105
- * (session.ts、adapters/* 等)又会 import config.ts,因此**任何**一个
106
- * import 了这些生产模块的单测都会间接触发 config.ts 顶层副作用——这是不
107
- * 应该的:单测不应该改动工作区的文件系统。
108
- *
109
- * vitest 在运行测试时会自动设置 `VITEST=true`,据此跳过自动复制 sample 的
110
- * 写文件副作用即可(顶层依然会正常加载默认值与可能已存在的 config.json)。
111
- */
112
- const IS_TEST_ENV = process.env.VITEST === "true" || process.env.NODE_ENV === "test";
113
-
114
- /**
115
- * Windows 上用 `where`、其它平台用 `which` 查找命令的绝对路径。
116
- * 命令未安装、命令查找进程出错都视为"找不到",返回 null。
117
- */
118
- function whichSync(cmd: string): string | null {
119
- try {
120
- const tool = process.platform === "win32" ? "where" : "which";
121
- const out = execFileSync(tool, [cmd], {
122
- stdio: ["ignore", "pipe", "ignore"],
123
- windowsHide: true,
124
- timeout: 5000,
125
- });
126
- const first = out.toString().split(/\r?\n/)[0]?.trim();
127
- return first || null;
128
- } catch {
129
- return null;
130
- }
131
- }
132
-
133
- /**
134
- * 在刚从 config.sample.json 复制出来的 config.json 上立即探测一次 cursor/codex
135
- * 路径,命中就回写,便于用户无需手动编辑就拿到可运行的默认配置。
136
- *
137
- * 仅在"复制 sample 这一刻"调用,已存在的 config.json 不会触发——避免悄悄改写
138
- * 用户主动留空的字段。
139
- */
140
- function autofillToolPathsAfterSampleCopy(configFile: string): void {
141
- let raw: string;
142
- try {
143
- raw = readFileSync(configFile, "utf-8");
144
- } catch {
145
- return;
146
- }
147
- let parsed: Record<string, unknown>;
148
- try {
149
- parsed = JSON.parse(raw) as Record<string, unknown>;
150
- } catch {
151
- return;
152
- }
153
-
154
- const cursor = (parsed.cursor as Record<string, unknown> | undefined) ?? {};
155
- const codex = (parsed.codex as Record<string, unknown> | undefined) ?? {};
156
- const cursorEmpty =
157
- (typeof cursor.path !== "string" || cursor.path.trim() === "") &&
158
- (typeof cursor.command !== "string" || (cursor.command as string).trim() === "");
159
- const codexEmpty =
160
- (typeof codex.path !== "string" || codex.path.trim() === "") &&
161
- (typeof codex.command !== "string" || (codex.command as string).trim() === "");
162
-
163
- let mutated = false;
164
- if (cursorEmpty) {
165
- const detected = autoDetectCursorPath({
166
- platform: process.platform,
167
- localAppData: process.env.LOCALAPPDATA,
168
- existsSync,
169
- whichSync,
170
- });
171
- if (detected) {
172
- parsed.cursor = { ...cursor, path: detected };
173
- mutated = true;
174
- console.log(`[CONFIG] 已自动探测 Cursor CLI 路径: ${detected}`);
175
- } else {
176
- console.log("[CONFIG] 未探测到 Cursor CLI,cursor.path 留空(运行时按 PATH 兜底)。");
177
- }
178
- }
179
- if (codexEmpty) {
180
- const detected = autoDetectCodexPath({ whichSync });
181
- if (detected) {
182
- parsed.codex = { ...codex, path: detected };
183
- mutated = true;
184
- console.log(`[CONFIG] 已自动探测 Codex CLI 路径: ${detected}`);
185
- } else {
186
- console.log("[CONFIG] 未探测到 Codex CLI,codex.path 留空(运行时退回 PATH 中的 codex)。");
187
- }
188
- }
189
-
190
- if (mutated) {
191
- try {
192
- writeFileSync(configFile, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
193
- } catch (err) {
194
- console.error(
195
- `[CONFIG] 自动探测路径回写 config.json 失败: ${(err as Error).message}`,
196
- );
197
- }
198
- }
199
- }
200
-
201
- function loadConfig(): AppConfig {
202
- const defaults: AppConfig = {
203
- feishu: { appId: "", appSecret: "" },
204
- port: 18080,
205
- gitTimeoutSeconds: 180,
206
- claude: { enabled: false, model: "", effort: "", apiKey: "", baseUrl: "" },
207
- cursor: { enabled: false, path: "", model: "claude-opus-4-7-max" },
208
- codex: { enabled: false, path: "", model: "", effort: "" },
209
- };
210
-
211
- if (!existsSync(CONFIG_FILE)) {
212
- if (IS_TEST_ENV) {
213
- // 测试环境下绝不写文件,直接走默认值
214
- return defaults;
215
- }
216
- if (existsSync(CONFIG_SAMPLE_FILE)) {
217
- console.log(`[CONFIG] config.json 不存在,基于 config.sample.json 创建...`);
218
- try {
219
- copyFileSync(CONFIG_SAMPLE_FILE, CONFIG_FILE);
220
- } catch (err) {
221
- console.error(`[CONFIG] 无法从 config.sample.json 创建 config.json: ${(err as Error).message}`);
222
- return defaults;
223
- }
224
- // 复制完成立即探测 CLI 路径并回写,让用户开箱即用而无须手编 config.json。
225
- autofillToolPathsAfterSampleCopy(CONFIG_FILE);
226
- } else {
227
- console.error(`[CONFIG] config.json 和 config.sample.json 都不存在,使用默认配置。`);
228
- return defaults;
229
- }
230
- }
231
-
232
- let raw: string;
233
- try {
234
- raw = readFileSync(CONFIG_FILE, "utf-8");
235
- } catch (err) {
236
- console.error(`[CONFIG] 无法读取 config.json: ${(err as Error).message}`);
237
- return defaults;
238
- }
239
-
240
- let parsed: Partial<AppConfig> & {
241
- claude?: Partial<ClaudeConfig> & { enabled?: unknown };
242
- cursor?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown };
243
- codex?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
244
- };
245
- try {
246
- parsed = JSON.parse(raw);
247
- } catch (err) {
248
- console.error(`[CONFIG] config.json 不是合法 JSON: ${(err as Error).message}`);
249
- return defaults;
250
- }
251
-
252
- const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
253
- const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
254
- const cursorRaw = parsed.cursor ?? {};
255
- const codexRaw = parsed.codex ?? {};
256
-
257
- // 兼容旧字段 `command`:命中时打印一次性 warning 提示用户改名
258
- const onLegacyField = (label: string, value: string): void => {
259
- console.warn(
260
- `[CONFIG] ${label}.command 字段已废弃,请改为 ${label}.path(当前仍按旧字段读到 "${value}")。`,
261
- );
262
- };
263
-
264
- /**
265
- * 解析 `<agent>.enabled` 字段:
266
- * - 显式 boolean → 用原值
267
- * - 缺省 / 其它类型 → 按 `nonEmptyFn()` 推断("有任意字段非空" 即视为启用),向后兼容旧 config.json
268
- */
269
- const resolveEnabled = (raw: unknown, nonEmptyFn: () => boolean): boolean => {
270
- if (typeof raw === "boolean") return raw;
271
- return nonEmptyFn();
272
- };
273
-
274
- const claudeNonEmpty = (): boolean =>
275
- Boolean(
276
- (typeof claude.model === "string" && claude.model.trim()) ||
277
- (typeof claude.effort === "string" && claude.effort.trim()) ||
278
- (typeof claude.apiKey === "string" && claude.apiKey.trim()) ||
279
- (typeof claude.baseUrl === "string" && claude.baseUrl.trim()),
280
- );
281
- const cursorNonEmpty = (): boolean =>
282
- Boolean(
283
- (typeof cursorRaw.path === "string" && cursorRaw.path.trim()) ||
284
- (typeof cursorRaw.command === "string" && (cursorRaw.command as string).trim()) ||
285
- (typeof cursorRaw.model === "string" && (cursorRaw.model as string).trim()),
286
- );
287
- const codexNonEmpty = (): boolean =>
288
- Boolean(
289
- (typeof codexRaw.path === "string" && codexRaw.path.trim()) ||
290
- (typeof codexRaw.command === "string" && (codexRaw.command as string).trim()) ||
291
- (typeof codexRaw.model === "string" && (codexRaw.model as string).trim()) ||
292
- (typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()),
293
- );
294
-
295
- return {
296
- feishu: {
297
- appId: feishu.appId ?? "",
298
- appSecret: feishu.appSecret ?? "",
299
- },
300
- port: typeof parsed.port === "number" ? parsed.port : 18080,
301
- gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
302
- claude: {
303
- enabled: resolveEnabled(claude.enabled, claudeNonEmpty),
304
- model: normalizeOptionalConfigField(claude.model, { label: "claude.model" }),
305
- effort: normalizeOptionalConfigField(claude.effort, { label: "claude.effort" }),
306
- apiKey: claude.apiKey ?? "",
307
- baseUrl: claude.baseUrl ?? "",
308
- },
309
- cursor: {
310
- enabled: resolveEnabled(cursorRaw.enabled, cursorNonEmpty),
311
- path: readToolCliPath(cursorRaw, { label: "cursor", onLegacyField }),
312
- model: normalizeOptionalConfigField(cursorRaw.model, { label: "cursor.model", fallback: "claude-opus-4-7-max" }),
313
- },
314
- codex: {
315
- enabled: resolveEnabled(codexRaw.enabled, codexNonEmpty),
316
- path: readToolCliPath(codexRaw, { label: "codex", onLegacyField }),
317
- model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
318
- effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
319
- },
320
- };
321
- }
322
-
323
- /**
324
- * 全局可变 config 对象。
325
- *
326
- * 故意用 `const + Object.assign(config, ...)` 而非 `let config = ...`:
327
- * 这样 `config` 这个 binding 永远指向同一个引用,下游模块只要在**函数体内**
328
- * 读 `config.xxx` 就能自动看到最新值(如 codex-adapter.ts 的 config.codex.*)。
329
- * 这是 setup → service「在线切换」复用 config 的核心机制。
330
- */
331
- export const config: AppConfig = loadConfig();
332
-
333
- // 注:历史上这里曾把 config.claude.apiKey / baseUrl 写入 process.env 以便
334
- // @anthropic-ai/claude-agent-sdk 子进程读取,但这会污染主进程的环境变量。
335
- // 现在改为:在 ClaudeAdapter 构造时把这两个值传进去,由 adapter 在调用 SDK
336
- // 时通过 SDK 的 `Options.env` 字段(仅作用于子进程)传递,主进程 env 保持
337
- // 干净。详见 src/adapters/claude-adapter.ts buildSdkEnv()。
338
-
339
- // ---------------------------------------------------------------------------
340
- // Re-exported config values
341
- // ---------------------------------------------------------------------------
342
- //
343
- // 这些值用 `export let` 而非 `const`,是为了支持 `reloadConfigFromDisk()`:
344
- // setup → service「在线切换」时,向导刚把新值写入 config.json,需要让进程
345
- // 内的这些常量也跟着更新。ES module 的 live binding 保证:导入端通过命名导入
346
- // 拿到的是 module 内的 slot,slot 在导出方被重新赋值后,导入端**自动看到新值**
347
- // (前提是导入端在函数体内读,不是在模块顶层读)。
348
-
349
- export const USE_LOCAL = process.argv.includes("--local");
350
- export let APP_ID = config.feishu.appId;
351
- export let APP_SECRET = config.feishu.appSecret;
352
- export const BASE_URL = "https://open.feishu.cn/open-apis";
353
- export const CHATCCC_PORT = config.port;
354
-
355
- /** 与 CHATCCC_PORT 一致,供 --local 连接本机中继 */
356
- export const LOCAL_RELAY_URL = `ws://127.0.0.1:${CHATCCC_PORT}`;
357
-
358
- export let CLAUDE_MODEL = config.claude.model;
359
- export let CLAUDE_EFFORT = config.claude.effort;
360
- /** Anthropic 兼容网关的 API key(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
361
- export let CLAUDE_API_KEY = config.claude.apiKey;
362
- /** Anthropic 兼容网关的 base URL(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
363
- export let CLAUDE_BASE_URL = config.claude.baseUrl;
364
-
365
- // ---------------------------------------------------------------------------
366
- // /git 超时配置(实际值来自 config.json,纯函数与常量见 ./config-utils.ts)
367
- // ---------------------------------------------------------------------------
368
-
369
- export let GIT_TIMEOUT_SECONDS = config.gitTimeoutSeconds;
370
- export let GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
371
-
372
- /** 探测 cursor-agent 安装路径(优先配置,其次 LocalAppData,最后默认 agent) */
373
- function detectCursorAgent(): string {
374
- if (config.cursor.path) return config.cursor.path;
375
- const localAppData = process.env.LOCALAPPDATA;
376
- if (localAppData) {
377
- const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
378
- if (existsSync(defaultPath)) return defaultPath;
379
- }
380
- return "agent";
381
- }
382
- /**
383
- * spawn 第一参数:要启动的 Cursor Agent 可执行文件路径。
384
- * 命名沿用 Node.js spawn(command, args) 的"command"语义,
385
- * 与 config.json 的 `cursor.path` 字段不冲突。
386
- */
387
- export let CURSOR_AGENT_COMMAND = detectCursorAgent();
388
-
389
- function resolveCursorAgentArgs(): string[] {
390
- let args = "-p --force --output-format stream-json --stream-partial-output";
391
- const model = config.cursor.model;
392
- if (model.trim() !== "") {
393
- args += ` --model ${model}`;
394
- }
395
- return args.split(/\s+/).filter(Boolean);
396
- }
397
-
398
- /** Cursor agent 参数:-p 非交互模式,--force 强制允许命令(yolo),stream-json 流式 JSONL 输出 */
399
- export let CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
400
-
401
- // ---------------------------------------------------------------------------
402
- // reloadConfigFromDisk — setup → service「在线切换」时刷新进程内 config
403
- // ---------------------------------------------------------------------------
404
- //
405
- // 触发场景:setup 模式下用户在向导里填好凭证、刚把 config.json 写入磁盘,
406
- // 紧接着要在**同一个进程**里启动飞书 service。如果不调用本函数,进程内的
407
- // APP_ID / APP_SECRET 还是 chatccc 启动时(凭证空)的旧值,飞书 API 调用必失败。
408
- //
409
- // 故意不重新触发 sample 复制 / cursor 自动探测等副作用:
410
- // reload 是「读取最新磁盘配置同步到内存」,不应该再写文件。
411
- //
412
- // 注意:CHATCCC_PORT / LOCAL_RELAY_URL 不重新赋值——setup HTTP server 已经
413
- // 监听在原端口上,原地切换复用同一个 server,重新读端口只会引入混乱。
414
-
415
- /**
416
- * 把已加载好的 AppConfig 赋值到 module-level export let 常量里。
417
- *
418
- * 拆出独立函数(不直接 inline 进 reloadConfigFromDisk)的目的:
419
- * 1. 让测试可以用任意 AppConfig 验证"赋值映射"正确性,无需碰文件系统
420
- * 2. 把"读盘"和"赋值"两个职责分开,便于将来支持其它 config 来源
421
- */
422
- export function applyLoadedConfig(next: AppConfig): void {
423
- // 就地更新 config 对象:保留原引用,让 codex-adapter 等"直接 import config"
424
- // 的下游模块在下次访问 config.codex.* 时就能拿到新值。
425
- Object.assign(config, next);
426
-
427
- APP_ID = next.feishu.appId;
428
- APP_SECRET = next.feishu.appSecret;
429
- CLAUDE_MODEL = next.claude.model;
430
- CLAUDE_EFFORT = next.claude.effort;
431
- CLAUDE_API_KEY = next.claude.apiKey;
432
- CLAUDE_BASE_URL = next.claude.baseUrl;
433
- GIT_TIMEOUT_SECONDS = next.gitTimeoutSeconds;
434
- GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
435
- CURSOR_AGENT_COMMAND = detectCursorAgent();
436
- CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
437
- }
438
-
439
- export function reloadConfigFromDisk(): void {
440
- applyLoadedConfig(loadConfig());
441
- }
442
-
443
- // 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
444
- // 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
445
- export const DEFAULT_CWD_FILE = join(PROJECT_ROOT, "state", "working_dir.txt");
446
-
447
- /** 会话工具类型持久化文件 */
448
- export const SESSIONS_FILE = join(PROJECT_ROOT, "state", "sessions.json");
449
-
450
- /** 最近成功新建会话的工作路径记录(最多 10 条) */
451
- export const RECENT_DIRS_FILE = join(PROJECT_ROOT, "state", "recent_dirs.json");
452
- export const MAX_RECENT_DIRS = 10;
453
-
454
- /** 读取最近使用过的工作路径列表(最新的在前) */
455
- export async function getRecentDirs(): Promise<string[]> {
456
- try {
457
- const raw = await readFile(RECENT_DIRS_FILE, "utf-8");
458
- const arr = JSON.parse(raw);
459
- if (Array.isArray(arr)) return arr.filter((d: unknown) => typeof d === "string");
460
- } catch { /* file doesn't exist or corrupted */ }
461
- return [];
462
- }
463
-
464
- /** 添加一个路径到最近使用列表(去重、限制数量、最新的在前) */
465
- export async function addRecentDir(dir: string): Promise<void> {
466
- const dirs = await getRecentDirs();
467
- const filtered = dirs.filter(d => d !== dir);
468
- filtered.unshift(dir);
469
- const trimmed = filtered.slice(0, MAX_RECENT_DIRS);
470
- try {
471
- await mkdir(dirname(RECENT_DIRS_FILE), { recursive: true });
472
- await writeFile(RECENT_DIRS_FILE, JSON.stringify(trimmed, null, 2), "utf-8");
473
- } catch (err) {
474
- console.error(`[${ts()}] Failed to save recent_dirs.json: ${(err as Error).message}`);
475
- fileLog.flush();
476
- }
477
- }
478
-
479
- /** 读取 /cd 设置的默认工作路径。若文件不存在或路径已失效,回退到用户主目录。 */
480
- export async function getDefaultCwd(): Promise<string> {
481
- try {
482
- const content = await readFile(DEFAULT_CWD_FILE, "utf-8");
483
- const dir = content.trim();
484
- if (dir) {
485
- try {
486
- const s = await stat(dir);
487
- if (s.isDirectory()) return dir;
488
- } catch { /* path gone, fall through */ }
489
- }
490
- } catch { /* file doesn't exist yet */ }
491
- return homedir();
492
- }
493
-
494
- /** 设置新建会话的默认工作路径(由 /cd 命令调用) */
495
- export async function setDefaultCwd(dir: string): Promise<void> {
496
- await writeFile(DEFAULT_CWD_FILE, dir, "utf-8");
497
- }
498
-
499
- // ---------------------------------------------------------------------------
500
- // Tiny helpers
501
- // ---------------------------------------------------------------------------
502
-
503
- export function ts(): string {
504
- return new Date().toLocaleTimeString("zh-CN", { hour12: false });
505
- }
506
-
507
- /** 仅用于日志确认「已读到某个 App」,不泄露 Secret */
508
- export function maskAppId(id: string): string {
509
- if (!id) return "(空)";
510
- if (id.length <= 10) return `${id.slice(0, 4)}***`;
511
- return `${id.slice(0, 8)}…${id.slice(-4)}`;
512
- }
513
-
514
- /**
515
- * 启动时逐项说明配置读取结果。
516
- */
517
- export function reportEnvironmentVariableReadout(): void {
518
- const ok = (label: string, name: string, kind: "必填" | "可选", ok: boolean, detail: string): void => {
519
- const state = ok ? "成功" : "失败";
520
- console.log(` [${state}] [${kind}] ${name}`);
521
- console.log(` ${label}: ${detail}`);
522
- };
523
-
524
- console.log(" --- 配置读取结果(成功=已读入;失败=必填缺失或格式错误;默认=未设置则用内置值)---");
525
-
526
- const configExists = existsSync(CONFIG_FILE);
527
- console.log(
528
- ` [信息] config.json: ${configExists ? "存在" : "不存在"} → ${CONFIG_FILE}`
529
- );
530
- if (!configExists) {
531
- console.log(" config.json 不存在时已从 config.sample.json 自动创建,请编辑后重新启动。");
532
- }
533
-
534
- ok(
535
- "飞书应用",
536
- "feishu.appId",
537
- "必填",
538
- Boolean(APP_ID.trim()),
539
- APP_ID.trim() ? `已读入,摘要 ${maskAppId(APP_ID)}` : "未读入或为空"
540
- );
541
- ok(
542
- "飞书应用",
543
- "feishu.appSecret",
544
- "必填",
545
- Boolean(APP_SECRET.trim()),
546
- APP_SECRET.trim() ? "已读入(内容不在日志中显示)" : "未读入或为空"
547
- );
548
-
549
- console.log(` [默认] [可选] port`);
550
- console.log(` 监听端口: ${CHATCCC_PORT}`);
551
-
552
- console.log(` [默认] [可选] claude.model`);
553
- console.log(
554
- ` Claude 模型: ${anthropicConfigDisplay(CLAUDE_MODEL)}(留空时不向 SDK 传 model)`
555
- );
556
-
557
- console.log(` [默认] [可选] claude.effort`);
558
- console.log(
559
- ` 思考深度: ${anthropicConfigDisplay(CLAUDE_EFFORT)}(留空时不向 SDK 传 effort)`
560
- );
561
-
562
- console.log(` [默认] [可选] gitTimeoutSeconds`);
563
- console.log(` /git 命令超时: ${GIT_TIMEOUT_SECONDS}s`);
564
-
565
- console.log(" ------------------------------------------------------------------");
566
- }
567
-
568
- /** 飞书凭证缺失时打印可操作的说明并退出 */
569
- export function explainMissingFeishuCredentialsAndExit(): never {
570
- appendStartupTrace("explainMissingFeishuCredentialsAndExit: exiting", {
571
- hasAppId: Boolean(APP_ID.trim()),
572
- hasAppSecret: Boolean(APP_SECRET.trim()),
573
- });
574
- const missing: string[] = [];
575
- if (!APP_ID.trim()) missing.push("feishu.appId");
576
- if (!APP_SECRET.trim()) missing.push("feishu.appSecret");
577
-
578
- console.error("\n" + "=".repeat(64));
579
- console.error(" ChatCCC 启动失败:飞书应用凭证未就绪");
580
- console.error("=".repeat(64));
581
- console.error("\n【失败步骤】环境与变量检查(在连接飞书之前)");
582
- console.error(`\n【未配置的配置项】\n - ${missing.join("\n - ")}`);
583
- console.error(`\n【配置文件路径】\n ${CONFIG_FILE}`);
584
- console.error(
585
- " 处理: 编辑 config.json 填入飞书开放平台的 App ID / App Secret;\n" +
586
- " 如 config.json 不存在,可复制 config.sample.json 为 config.json 后编辑。"
587
- );
588
- console.error(`\n【程序包根目录】\n ${PROJECT_ROOT}`);
589
- console.error("\n" + "=".repeat(64) + "\n");
590
- printServiceDidNotStart(`未配置: ${missing.join("、")}`);
591
- process.exit(1);
592
- }
593
-
594
- /** 群描述中用于识别 Claude Code 会话的前缀 */
595
- export const CLAUDE_SESSION_PREFIX = "Claude Code Session:";
596
- /** 群描述中用于识别 Cursor 会话的前缀 */
597
- export const CURSOR_SESSION_PREFIX = "Cursor Session:";
598
- /** 群描述中用于识别 Codex 会话的前缀 */
599
- export const CODEX_SESSION_PREFIX = "Codex Session:";
600
-
601
- /** 根据 tool 名称返回对应的群描述前缀 */
602
- export function sessionPrefixForTool(tool: string): string {
603
- if (tool === "cursor") return CURSOR_SESSION_PREFIX;
604
- if (tool === "codex") return CODEX_SESSION_PREFIX;
605
- return CLAUDE_SESSION_PREFIX;
606
- }
607
-
608
- /** 根据 tool 名称返回用于状态展示的标签 */
609
- export function toolDisplayName(tool: string): string {
610
- if (tool === "cursor") return "Cursor";
611
- if (tool === "codex") return "Codex";
612
- return "Claude Code";
613
- }
614
-
615
- // 导出 config 对象供其他模块直接访问原始配置
1
+ import { existsSync, readFileSync, copyFileSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
+
8
+ import { printServiceDidNotStart } from "./exit-banner.ts";
9
+ import { appendStartupTrace, setupFileLogging } from "./shared.ts";
10
+ import {
11
+ anthropicConfigDisplay,
12
+ autoDetectCodexPath,
13
+ autoDetectCursorPath,
14
+ normalizeOptionalConfigField,
15
+ readToolCliPath,
16
+ } from "./config-utils.ts";
17
+
18
+ // 重新导出 config-utils 中的纯函数/常量,保持对外 API 不变
19
+ // (历史上这些符号都从 ./config.ts 导入;新代码可直接从 ./config-utils.ts 导入以避免触发本文件的副作用)
20
+ export {
21
+ DEFAULT_GIT_TIMEOUT_SECONDS,
22
+ MIN_GIT_TIMEOUT_SECONDS,
23
+ MAX_GIT_TIMEOUT_SECONDS,
24
+ parseGitTimeoutSeconds,
25
+ normalizeOptionalConfigField,
26
+ isAnthropicConfigEmpty,
27
+ anthropicConfigDisplay,
28
+ } from "./config-utils.ts";
29
+ export type { ParsedGitTimeout } from "./config-utils.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Paths & logging
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ export const PROJECT_ROOT = join(__dirname, "..");
37
+ export const PID_FILE = join(PROJECT_ROOT, "state", "runtime.pid");
38
+
39
+ export const LOG_DIR = join(PROJECT_ROOT, "logs");
40
+ export const fileLog = setupFileLogging(LOG_DIR, "index");
41
+
42
+ export const CHAT_LOGS_DIR = join(PROJECT_ROOT, "state", "chat_logs");
43
+
44
+ export async function appendChatLog(chatId: string, sender: string, text: string): Promise<void> {
45
+ try {
46
+ await mkdir(CHAT_LOGS_DIR, { recursive: true });
47
+ const line = JSON.stringify({ ts: Date.now(), sender, text: text.slice(0, 200) }) + "\n";
48
+ await appendFile(join(CHAT_LOGS_DIR, `${chatId}.jsonl`), line);
49
+ } catch {
50
+ // 静默失败,不影响主流程
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Config file loading
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export interface ClaudeConfig {
59
+ /** 是否启用 Claude Code Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
60
+ enabled: boolean;
61
+ model: string;
62
+ effort: string;
63
+ apiKey: string;
64
+ baseUrl: string;
65
+ }
66
+
67
+ export interface CursorConfig {
68
+ /** 是否启用 Cursor Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
69
+ enabled: boolean;
70
+ /** Cursor Agent CLI 可执行文件绝对路径;留空时由运行时按 LocalAppData / PATH 兜底 */
71
+ path: string;
72
+ model: string;
73
+ }
74
+
75
+ export interface CodexConfig {
76
+ /** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
77
+ enabled: boolean;
78
+ /** Codex CLI 可执行文件绝对路径;留空时退回到 PATH 中的 `codex` */
79
+ path: string;
80
+ model: string;
81
+ effort: string;
82
+ }
83
+
84
+ export interface FeishuConfig {
85
+ appId: string;
86
+ appSecret: string;
87
+ }
88
+
89
+ export interface AppConfig {
90
+ feishu: FeishuConfig;
91
+ port: number;
92
+ gitTimeoutSeconds: number;
93
+ claude: ClaudeConfig;
94
+ cursor: CursorConfig;
95
+ codex: CodexConfig;
96
+ }
97
+
98
+ const CONFIG_FILE = join(PROJECT_ROOT, "config.json");
99
+ const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
100
+
101
+ /**
102
+ * 是否处于 vitest 测试环境。
103
+ *
104
+ * 由于 `src/config.ts` 在模块顶层立即执行 `loadConfig()`,而很多生产模块
105
+ * (session.ts、adapters/* 等)又会 import config.ts,因此**任何**一个
106
+ * import 了这些生产模块的单测都会间接触发 config.ts 顶层副作用——这是不
107
+ * 应该的:单测不应该改动工作区的文件系统。
108
+ *
109
+ * vitest 在运行测试时会自动设置 `VITEST=true`,据此跳过自动复制 sample 的
110
+ * 写文件副作用即可(顶层依然会正常加载默认值与可能已存在的 config.json)。
111
+ */
112
+ const IS_TEST_ENV = process.env.VITEST === "true" || process.env.NODE_ENV === "test";
113
+
114
+ /**
115
+ * Windows 上用 `where`、其它平台用 `which` 查找命令的绝对路径。
116
+ * 命令未安装、命令查找进程出错都视为"找不到",返回 null。
117
+ */
118
+ function whichSync(cmd: string): string | null {
119
+ try {
120
+ const tool = process.platform === "win32" ? "where" : "which";
121
+ const out = execFileSync(tool, [cmd], {
122
+ stdio: ["ignore", "pipe", "ignore"],
123
+ windowsHide: true,
124
+ timeout: 5000,
125
+ });
126
+ const first = out.toString().split(/\r?\n/)[0]?.trim();
127
+ return first || null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 在刚从 config.sample.json 复制出来的 config.json 上立即探测一次 cursor/codex
135
+ * 路径,命中就回写,便于用户无需手动编辑就拿到可运行的默认配置。
136
+ *
137
+ * 仅在"复制 sample 这一刻"调用,已存在的 config.json 不会触发——避免悄悄改写
138
+ * 用户主动留空的字段。
139
+ */
140
+ function autofillToolPathsAfterSampleCopy(configFile: string): void {
141
+ let raw: string;
142
+ try {
143
+ raw = readFileSync(configFile, "utf-8");
144
+ } catch {
145
+ return;
146
+ }
147
+ let parsed: Record<string, unknown>;
148
+ try {
149
+ parsed = JSON.parse(raw) as Record<string, unknown>;
150
+ } catch {
151
+ return;
152
+ }
153
+
154
+ const cursor = (parsed.cursor as Record<string, unknown> | undefined) ?? {};
155
+ const codex = (parsed.codex as Record<string, unknown> | undefined) ?? {};
156
+ const cursorEmpty =
157
+ (typeof cursor.path !== "string" || cursor.path.trim() === "") &&
158
+ (typeof cursor.command !== "string" || (cursor.command as string).trim() === "");
159
+ const codexEmpty =
160
+ (typeof codex.path !== "string" || codex.path.trim() === "") &&
161
+ (typeof codex.command !== "string" || (codex.command as string).trim() === "");
162
+
163
+ let mutated = false;
164
+ if (cursorEmpty) {
165
+ const detected = autoDetectCursorPath({
166
+ platform: process.platform,
167
+ localAppData: process.env.LOCALAPPDATA,
168
+ existsSync,
169
+ whichSync,
170
+ });
171
+ if (detected) {
172
+ parsed.cursor = { ...cursor, path: detected };
173
+ mutated = true;
174
+ console.log(`[CONFIG] 已自动探测 Cursor CLI 路径: ${detected}`);
175
+ } else {
176
+ console.log("[CONFIG] 未探测到 Cursor CLI,cursor.path 留空(运行时按 PATH 兜底)。");
177
+ }
178
+ }
179
+ if (codexEmpty) {
180
+ const detected = autoDetectCodexPath({ whichSync });
181
+ if (detected) {
182
+ parsed.codex = { ...codex, path: detected };
183
+ mutated = true;
184
+ console.log(`[CONFIG] 已自动探测 Codex CLI 路径: ${detected}`);
185
+ } else {
186
+ console.log("[CONFIG] 未探测到 Codex CLI,codex.path 留空(运行时退回 PATH 中的 codex)。");
187
+ }
188
+ }
189
+
190
+ if (mutated) {
191
+ try {
192
+ writeFileSync(configFile, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
193
+ } catch (err) {
194
+ console.error(
195
+ `[CONFIG] 自动探测路径回写 config.json 失败: ${(err as Error).message}`,
196
+ );
197
+ }
198
+ }
199
+ }
200
+
201
+ function loadConfig(): AppConfig {
202
+ const defaults: AppConfig = {
203
+ feishu: { appId: "", appSecret: "" },
204
+ port: 18080,
205
+ gitTimeoutSeconds: 180,
206
+ claude: { enabled: false, model: "", effort: "", apiKey: "", baseUrl: "" },
207
+ cursor: { enabled: false, path: "", model: "claude-opus-4-7-max" },
208
+ codex: { enabled: false, path: "", model: "", effort: "" },
209
+ };
210
+
211
+ if (!existsSync(CONFIG_FILE)) {
212
+ if (IS_TEST_ENV) {
213
+ // 测试环境下绝不写文件,直接走默认值
214
+ return defaults;
215
+ }
216
+ if (existsSync(CONFIG_SAMPLE_FILE)) {
217
+ console.log(`[CONFIG] config.json 不存在,基于 config.sample.json 创建...`);
218
+ try {
219
+ copyFileSync(CONFIG_SAMPLE_FILE, CONFIG_FILE);
220
+ } catch (err) {
221
+ console.error(`[CONFIG] 无法从 config.sample.json 创建 config.json: ${(err as Error).message}`);
222
+ return defaults;
223
+ }
224
+ // 复制完成立即探测 CLI 路径并回写,让用户开箱即用而无须手编 config.json。
225
+ autofillToolPathsAfterSampleCopy(CONFIG_FILE);
226
+ } else {
227
+ console.error(`[CONFIG] config.json 和 config.sample.json 都不存在,使用默认配置。`);
228
+ return defaults;
229
+ }
230
+ }
231
+
232
+ let raw: string;
233
+ try {
234
+ raw = readFileSync(CONFIG_FILE, "utf-8");
235
+ } catch (err) {
236
+ console.error(`[CONFIG] 无法读取 config.json: ${(err as Error).message}`);
237
+ return defaults;
238
+ }
239
+
240
+ let parsed: Partial<AppConfig> & {
241
+ claude?: Partial<ClaudeConfig> & { enabled?: unknown };
242
+ cursor?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown };
243
+ codex?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
244
+ };
245
+ try {
246
+ parsed = JSON.parse(raw);
247
+ } catch (err) {
248
+ console.error(`[CONFIG] config.json 不是合法 JSON: ${(err as Error).message}`);
249
+ return defaults;
250
+ }
251
+
252
+ const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
253
+ const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
254
+ const cursorRaw = parsed.cursor ?? {};
255
+ const codexRaw = parsed.codex ?? {};
256
+
257
+ // 兼容旧字段 `command`:命中时打印一次性 warning 提示用户改名
258
+ const onLegacyField = (label: string, value: string): void => {
259
+ console.warn(
260
+ `[CONFIG] ${label}.command 字段已废弃,请改为 ${label}.path(当前仍按旧字段读到 "${value}")。`,
261
+ );
262
+ };
263
+
264
+ /**
265
+ * 解析 `<agent>.enabled` 字段:
266
+ * - 显式 boolean → 用原值
267
+ * - 缺省 / 其它类型 → 按 `nonEmptyFn()` 推断("有任意字段非空" 即视为启用),向后兼容旧 config.json
268
+ */
269
+ const resolveEnabled = (raw: unknown, nonEmptyFn: () => boolean): boolean => {
270
+ if (typeof raw === "boolean") return raw;
271
+ return nonEmptyFn();
272
+ };
273
+
274
+ const claudeNonEmpty = (): boolean =>
275
+ Boolean(
276
+ (typeof claude.model === "string" && claude.model.trim()) ||
277
+ (typeof claude.effort === "string" && claude.effort.trim()) ||
278
+ (typeof claude.apiKey === "string" && claude.apiKey.trim()) ||
279
+ (typeof claude.baseUrl === "string" && claude.baseUrl.trim()),
280
+ );
281
+ const cursorNonEmpty = (): boolean =>
282
+ Boolean(
283
+ (typeof cursorRaw.path === "string" && cursorRaw.path.trim()) ||
284
+ (typeof cursorRaw.command === "string" && (cursorRaw.command as string).trim()) ||
285
+ (typeof cursorRaw.model === "string" && (cursorRaw.model as string).trim()),
286
+ );
287
+ const codexNonEmpty = (): boolean =>
288
+ Boolean(
289
+ (typeof codexRaw.path === "string" && codexRaw.path.trim()) ||
290
+ (typeof codexRaw.command === "string" && (codexRaw.command as string).trim()) ||
291
+ (typeof codexRaw.model === "string" && (codexRaw.model as string).trim()) ||
292
+ (typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()),
293
+ );
294
+
295
+ return {
296
+ feishu: {
297
+ appId: feishu.appId ?? "",
298
+ appSecret: feishu.appSecret ?? "",
299
+ },
300
+ port: typeof parsed.port === "number" ? parsed.port : 18080,
301
+ gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
302
+ claude: {
303
+ enabled: resolveEnabled(claude.enabled, claudeNonEmpty),
304
+ model: normalizeOptionalConfigField(claude.model, { label: "claude.model" }),
305
+ effort: normalizeOptionalConfigField(claude.effort, { label: "claude.effort" }),
306
+ apiKey: claude.apiKey ?? "",
307
+ baseUrl: claude.baseUrl ?? "",
308
+ },
309
+ cursor: {
310
+ enabled: resolveEnabled(cursorRaw.enabled, cursorNonEmpty),
311
+ path: readToolCliPath(cursorRaw, { label: "cursor", onLegacyField }),
312
+ model: normalizeOptionalConfigField(cursorRaw.model, { label: "cursor.model", fallback: "claude-opus-4-7-max" }),
313
+ },
314
+ codex: {
315
+ enabled: resolveEnabled(codexRaw.enabled, codexNonEmpty),
316
+ path: readToolCliPath(codexRaw, { label: "codex", onLegacyField }),
317
+ model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
318
+ effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
319
+ },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * 全局可变 config 对象。
325
+ *
326
+ * 故意用 `const + Object.assign(config, ...)` 而非 `let config = ...`:
327
+ * 这样 `config` 这个 binding 永远指向同一个引用,下游模块只要在**函数体内**
328
+ * 读 `config.xxx` 就能自动看到最新值(如 codex-adapter.ts 的 config.codex.*)。
329
+ * 这是 setup → service「在线切换」复用 config 的核心机制。
330
+ */
331
+ export const config: AppConfig = loadConfig();
332
+
333
+ // 注:历史上这里曾把 config.claude.apiKey / baseUrl 写入 process.env 以便
334
+ // @anthropic-ai/claude-agent-sdk 子进程读取,但这会污染主进程的环境变量。
335
+ // 现在改为:在 ClaudeAdapter 构造时把这两个值传进去,由 adapter 在调用 SDK
336
+ // 时通过 SDK 的 `Options.env` 字段(仅作用于子进程)传递,主进程 env 保持
337
+ // 干净。详见 src/adapters/claude-adapter.ts buildSdkEnv()。
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Re-exported config values
341
+ // ---------------------------------------------------------------------------
342
+ //
343
+ // 这些值用 `export let` 而非 `const`,是为了支持 `reloadConfigFromDisk()`:
344
+ // setup → service「在线切换」时,向导刚把新值写入 config.json,需要让进程
345
+ // 内的这些常量也跟着更新。ES module 的 live binding 保证:导入端通过命名导入
346
+ // 拿到的是 module 内的 slot,slot 在导出方被重新赋值后,导入端**自动看到新值**
347
+ // (前提是导入端在函数体内读,不是在模块顶层读)。
348
+
349
+ export const USE_LOCAL = process.argv.includes("--local");
350
+ export let APP_ID = config.feishu.appId;
351
+ export let APP_SECRET = config.feishu.appSecret;
352
+ export const BASE_URL = "https://open.feishu.cn/open-apis";
353
+ export const CHATCCC_PORT = config.port;
354
+
355
+ /** 与 CHATCCC_PORT 一致,供 --local 连接本机中继 */
356
+ export const LOCAL_RELAY_URL = `ws://127.0.0.1:${CHATCCC_PORT}`;
357
+
358
+ export let CLAUDE_MODEL = config.claude.model;
359
+ export let CLAUDE_EFFORT = config.claude.effort;
360
+ /** Anthropic 兼容网关的 API key(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
361
+ export let CLAUDE_API_KEY = config.claude.apiKey;
362
+ /** Anthropic 兼容网关的 base URL(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
363
+ export let CLAUDE_BASE_URL = config.claude.baseUrl;
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // /git 超时配置(实际值来自 config.json,纯函数与常量见 ./config-utils.ts)
367
+ // ---------------------------------------------------------------------------
368
+
369
+ export let GIT_TIMEOUT_SECONDS = config.gitTimeoutSeconds;
370
+ export let GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
371
+
372
+ /** 探测 cursor-agent 安装路径(优先配置,其次 LocalAppData,最后默认 agent) */
373
+ function detectCursorAgent(): string {
374
+ if (config.cursor.path) return config.cursor.path;
375
+ const localAppData = process.env.LOCALAPPDATA;
376
+ if (localAppData) {
377
+ const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
378
+ if (existsSync(defaultPath)) return defaultPath;
379
+ }
380
+ return "agent";
381
+ }
382
+ /**
383
+ * spawn 第一参数:要启动的 Cursor Agent 可执行文件路径。
384
+ * 命名沿用 Node.js spawn(command, args) 的"command"语义,
385
+ * 与 config.json 的 `cursor.path` 字段不冲突。
386
+ */
387
+ export let CURSOR_AGENT_COMMAND = detectCursorAgent();
388
+
389
+ function resolveCursorAgentArgs(): string[] {
390
+ let args = "-p --force --output-format stream-json --stream-partial-output";
391
+ const model = config.cursor.model;
392
+ if (model.trim() !== "") {
393
+ args += ` --model ${model}`;
394
+ }
395
+ return args.split(/\s+/).filter(Boolean);
396
+ }
397
+
398
+ /** Cursor agent 参数:-p 非交互模式,--force 强制允许命令(yolo),stream-json 流式 JSONL 输出 */
399
+ export let CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // reloadConfigFromDisk — setup → service「在线切换」时刷新进程内 config
403
+ // ---------------------------------------------------------------------------
404
+ //
405
+ // 触发场景:setup 模式下用户在向导里填好凭证、刚把 config.json 写入磁盘,
406
+ // 紧接着要在**同一个进程**里启动飞书 service。如果不调用本函数,进程内的
407
+ // APP_ID / APP_SECRET 还是 chatccc 启动时(凭证空)的旧值,飞书 API 调用必失败。
408
+ //
409
+ // 故意不重新触发 sample 复制 / cursor 自动探测等副作用:
410
+ // reload 是「读取最新磁盘配置同步到内存」,不应该再写文件。
411
+ //
412
+ // 注意:CHATCCC_PORT / LOCAL_RELAY_URL 不重新赋值——setup HTTP server 已经
413
+ // 监听在原端口上,原地切换复用同一个 server,重新读端口只会引入混乱。
414
+
415
+ /**
416
+ * 把已加载好的 AppConfig 赋值到 module-level export let 常量里。
417
+ *
418
+ * 拆出独立函数(不直接 inline 进 reloadConfigFromDisk)的目的:
419
+ * 1. 让测试可以用任意 AppConfig 验证"赋值映射"正确性,无需碰文件系统
420
+ * 2. 把"读盘"和"赋值"两个职责分开,便于将来支持其它 config 来源
421
+ */
422
+ export function applyLoadedConfig(next: AppConfig): void {
423
+ // 就地更新 config 对象:保留原引用,让 codex-adapter 等"直接 import config"
424
+ // 的下游模块在下次访问 config.codex.* 时就能拿到新值。
425
+ Object.assign(config, next);
426
+
427
+ APP_ID = next.feishu.appId;
428
+ APP_SECRET = next.feishu.appSecret;
429
+ CLAUDE_MODEL = next.claude.model;
430
+ CLAUDE_EFFORT = next.claude.effort;
431
+ CLAUDE_API_KEY = next.claude.apiKey;
432
+ CLAUDE_BASE_URL = next.claude.baseUrl;
433
+ GIT_TIMEOUT_SECONDS = next.gitTimeoutSeconds;
434
+ GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
435
+ CURSOR_AGENT_COMMAND = detectCursorAgent();
436
+ CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
437
+ }
438
+
439
+ export function reloadConfigFromDisk(): void {
440
+ applyLoadedConfig(loadConfig());
441
+ }
442
+
443
+ // 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
444
+ // 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
445
+ export const DEFAULT_CWD_FILE = join(PROJECT_ROOT, "state", "working_dir.txt");
446
+
447
+ /** 会话工具类型持久化文件 */
448
+ export const SESSIONS_FILE = join(PROJECT_ROOT, "state", "sessions.json");
449
+
450
+ /** 最近成功新建会话的工作路径记录(最多 10 条) */
451
+ export const RECENT_DIRS_FILE = join(PROJECT_ROOT, "state", "recent_dirs.json");
452
+ export const MAX_RECENT_DIRS = 10;
453
+
454
+ /** 读取最近使用过的工作路径列表(最新的在前) */
455
+ export async function getRecentDirs(): Promise<string[]> {
456
+ try {
457
+ const raw = await readFile(RECENT_DIRS_FILE, "utf-8");
458
+ const arr = JSON.parse(raw);
459
+ if (Array.isArray(arr)) return arr.filter((d: unknown) => typeof d === "string");
460
+ } catch { /* file doesn't exist or corrupted */ }
461
+ return [];
462
+ }
463
+
464
+ /** 添加一个路径到最近使用列表(去重、限制数量、最新的在前) */
465
+ export async function addRecentDir(dir: string): Promise<void> {
466
+ const dirs = await getRecentDirs();
467
+ const filtered = dirs.filter(d => d !== dir);
468
+ filtered.unshift(dir);
469
+ const trimmed = filtered.slice(0, MAX_RECENT_DIRS);
470
+ try {
471
+ await mkdir(dirname(RECENT_DIRS_FILE), { recursive: true });
472
+ await writeFile(RECENT_DIRS_FILE, JSON.stringify(trimmed, null, 2), "utf-8");
473
+ } catch (err) {
474
+ console.error(`[${ts()}] Failed to save recent_dirs.json: ${(err as Error).message}`);
475
+ fileLog.flush();
476
+ }
477
+ }
478
+
479
+ /** 读取 /cd 设置的默认工作路径。若文件不存在或路径已失效,回退到用户主目录。 */
480
+ export async function getDefaultCwd(): Promise<string> {
481
+ try {
482
+ const content = await readFile(DEFAULT_CWD_FILE, "utf-8");
483
+ const dir = content.trim();
484
+ if (dir) {
485
+ try {
486
+ const s = await stat(dir);
487
+ if (s.isDirectory()) return dir;
488
+ } catch { /* path gone, fall through */ }
489
+ }
490
+ } catch { /* file doesn't exist yet */ }
491
+ return homedir();
492
+ }
493
+
494
+ /** 设置新建会话的默认工作路径(由 /cd 命令调用) */
495
+ export async function setDefaultCwd(dir: string): Promise<void> {
496
+ await writeFile(DEFAULT_CWD_FILE, dir, "utf-8");
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Tiny helpers
501
+ // ---------------------------------------------------------------------------
502
+
503
+ export function ts(): string {
504
+ return new Date().toLocaleTimeString("zh-CN", { hour12: false });
505
+ }
506
+
507
+ /** 仅用于日志确认「已读到某个 App」,不泄露 Secret */
508
+ export function maskAppId(id: string): string {
509
+ if (!id) return "(空)";
510
+ if (id.length <= 10) return `${id.slice(0, 4)}***`;
511
+ return `${id.slice(0, 8)}…${id.slice(-4)}`;
512
+ }
513
+
514
+ /**
515
+ * 启动时逐项说明配置读取结果。
516
+ */
517
+ export function reportEnvironmentVariableReadout(): void {
518
+ const ok = (label: string, name: string, kind: "必填" | "可选", ok: boolean, detail: string): void => {
519
+ const state = ok ? "成功" : "失败";
520
+ console.log(` [${state}] [${kind}] ${name}`);
521
+ console.log(` ${label}: ${detail}`);
522
+ };
523
+
524
+ console.log(" --- 配置读取结果(成功=已读入;失败=必填缺失或格式错误;默认=未设置则用内置值)---");
525
+
526
+ const configExists = existsSync(CONFIG_FILE);
527
+ console.log(
528
+ ` [信息] config.json: ${configExists ? "存在" : "不存在"} → ${CONFIG_FILE}`
529
+ );
530
+ if (!configExists) {
531
+ console.log(" config.json 不存在时已从 config.sample.json 自动创建,请编辑后重新启动。");
532
+ }
533
+
534
+ ok(
535
+ "飞书应用",
536
+ "feishu.appId",
537
+ "必填",
538
+ Boolean(APP_ID.trim()),
539
+ APP_ID.trim() ? `已读入,摘要 ${maskAppId(APP_ID)}` : "未读入或为空"
540
+ );
541
+ ok(
542
+ "飞书应用",
543
+ "feishu.appSecret",
544
+ "必填",
545
+ Boolean(APP_SECRET.trim()),
546
+ APP_SECRET.trim() ? "已读入(内容不在日志中显示)" : "未读入或为空"
547
+ );
548
+
549
+ console.log(` [默认] [可选] port`);
550
+ console.log(` 监听端口: ${CHATCCC_PORT}`);
551
+
552
+ console.log(` [默认] [可选] claude.model`);
553
+ console.log(
554
+ ` Claude 模型: ${anthropicConfigDisplay(CLAUDE_MODEL)}(留空时不向 SDK 传 model)`
555
+ );
556
+
557
+ console.log(` [默认] [可选] claude.effort`);
558
+ console.log(
559
+ ` 思考深度: ${anthropicConfigDisplay(CLAUDE_EFFORT)}(留空时不向 SDK 传 effort)`
560
+ );
561
+
562
+ console.log(` [默认] [可选] gitTimeoutSeconds`);
563
+ console.log(` /git 命令超时: ${GIT_TIMEOUT_SECONDS}s`);
564
+
565
+ console.log(" ------------------------------------------------------------------");
566
+ }
567
+
568
+ /** 飞书凭证缺失时打印可操作的说明并退出 */
569
+ export function explainMissingFeishuCredentialsAndExit(): never {
570
+ appendStartupTrace("explainMissingFeishuCredentialsAndExit: exiting", {
571
+ hasAppId: Boolean(APP_ID.trim()),
572
+ hasAppSecret: Boolean(APP_SECRET.trim()),
573
+ });
574
+ const missing: string[] = [];
575
+ if (!APP_ID.trim()) missing.push("feishu.appId");
576
+ if (!APP_SECRET.trim()) missing.push("feishu.appSecret");
577
+
578
+ console.error("\n" + "=".repeat(64));
579
+ console.error(" ChatCCC 启动失败:飞书应用凭证未就绪");
580
+ console.error("=".repeat(64));
581
+ console.error("\n【失败步骤】环境与变量检查(在连接飞书之前)");
582
+ console.error(`\n【未配置的配置项】\n - ${missing.join("\n - ")}`);
583
+ console.error(`\n【配置文件路径】\n ${CONFIG_FILE}`);
584
+ console.error(
585
+ " 处理: 编辑 config.json 填入飞书开放平台的 App ID / App Secret;\n" +
586
+ " 如 config.json 不存在,可复制 config.sample.json 为 config.json 后编辑。"
587
+ );
588
+ console.error(`\n【程序包根目录】\n ${PROJECT_ROOT}`);
589
+ console.error("\n" + "=".repeat(64) + "\n");
590
+ printServiceDidNotStart(`未配置: ${missing.join("、")}`);
591
+ process.exit(1);
592
+ }
593
+
594
+ /** 群描述中用于识别 Claude Code 会话的前缀 */
595
+ export const CLAUDE_SESSION_PREFIX = "Claude Code Session:";
596
+ /** 群描述中用于识别 Cursor 会话的前缀 */
597
+ export const CURSOR_SESSION_PREFIX = "Cursor Session:";
598
+ /** 群描述中用于识别 Codex 会话的前缀 */
599
+ export const CODEX_SESSION_PREFIX = "Codex Session:";
600
+
601
+ /** 根据 tool 名称返回对应的群描述前缀 */
602
+ export function sessionPrefixForTool(tool: string): string {
603
+ if (tool === "cursor") return CURSOR_SESSION_PREFIX;
604
+ if (tool === "codex") return CODEX_SESSION_PREFIX;
605
+ return CLAUDE_SESSION_PREFIX;
606
+ }
607
+
608
+ /** 根据 tool 名称返回用于状态展示的标签 */
609
+ export function toolDisplayName(tool: string): string {
610
+ if (tool === "cursor") return "Cursor";
611
+ if (tool === "codex") return "Codex";
612
+ return "Claude Code";
613
+ }
614
+
615
+ // 导出 config 对象供其他模块直接访问原始配置
616
616
  export { CONFIG_FILE, CONFIG_SAMPLE_FILE };