chatccc 0.2.16 → 0.2.18
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 +105 -111
- package/bin/chatccc.mjs +2 -14
- package/config.sample.json +26 -0
- package/images/avatars/status_busy.png +0 -0
- package/images/avatars/status_idle.png +0 -0
- package/images/avatars/status_new.png +0 -0
- package/images/img_readme_0.jpg +0 -0
- package/images/img_readme_1.jpg +0 -0
- package/images/img_readme_callback.png +0 -0
- package/images/img_readme_event.png +0 -0
- package/images/img_readme_permission.png +0 -0
- package/package.json +13 -12
- package/src/__tests__/cards.test.ts +3 -3
- package/src/__tests__/claude-adapter.test.ts +301 -10
- package/src/__tests__/config-reload.test.ts +171 -0
- package/src/__tests__/config.test.ts +259 -2
- package/src/__tests__/crash-logging.test.ts +210 -0
- package/src/__tests__/session.test.ts +2 -2
- package/src/__tests__/web-ui.test.ts +178 -0
- package/src/adapters/claude-adapter.ts +57 -8
- package/src/adapters/codex-adapter.ts +294 -293
- package/src/adapters/cursor-adapter.ts +1 -1
- package/src/config-utils.ts +177 -0
- package/src/config.ts +412 -178
- package/src/exit-banner.ts +12 -2
- package/src/feishu-api.ts +4 -15
- package/src/index.ts +178 -64
- package/src/session.ts +27 -12
- package/src/shared.ts +189 -16
- package/src/web-ui.ts +1591 -0
- package/.env.example +0 -44
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, copyFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -6,6 +7,26 @@ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
6
7
|
|
|
7
8
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
8
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";
|
|
9
30
|
|
|
10
31
|
// ---------------------------------------------------------------------------
|
|
11
32
|
// Paths & logging
|
|
@@ -31,93 +52,326 @@ export async function appendChatLog(chatId: string, sender: string, text: string
|
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
// ---------------------------------------------------------------------------
|
|
34
|
-
//
|
|
55
|
+
// Config file loading
|
|
35
56
|
// ---------------------------------------------------------------------------
|
|
36
57
|
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export const CHATCCC_PORT = parseInt(process.env.CHATCCC_PORT?.trim() ?? "18080", 10);
|
|
58
|
+
export interface ClaudeConfig {
|
|
59
|
+
/** 是否启用 Claude Code Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
|
|
60
|
+
enabled: boolean;
|
|
61
|
+
model: string;
|
|
62
|
+
effort: string;
|
|
63
|
+
apiKey: string;
|
|
64
|
+
baseUrl: string;
|
|
65
|
+
}
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
export interface CursorConfig {
|
|
68
|
+
/** 是否启用 Cursor Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
/** Cursor Agent CLI 可执行文件绝对路径;留空时由运行时按 LocalAppData / PATH 兜底 */
|
|
71
|
+
path: string;
|
|
72
|
+
model: string;
|
|
73
|
+
}
|
|
50
74
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
export interface CodexConfig {
|
|
76
|
+
/** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
|
|
77
|
+
enabled: boolean;
|
|
78
|
+
/** Codex CLI 可执行文件绝对路径;留空时退回到 PATH 中的 `codex` */
|
|
79
|
+
path: string;
|
|
80
|
+
model: string;
|
|
81
|
+
effort: string;
|
|
54
82
|
}
|
|
55
83
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
export interface FeishuConfig {
|
|
85
|
+
appId: string;
|
|
86
|
+
appSecret: string;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
export
|
|
89
|
+
export interface AppConfig {
|
|
90
|
+
feishu: FeishuConfig;
|
|
91
|
+
port: number;
|
|
92
|
+
gitTimeoutSeconds: number;
|
|
93
|
+
claude: ClaudeConfig;
|
|
94
|
+
cursor: CursorConfig;
|
|
95
|
+
codex: CodexConfig;
|
|
96
|
+
}
|
|
62
97
|
|
|
63
|
-
|
|
98
|
+
const CONFIG_FILE = join(PROJECT_ROOT, "config.json");
|
|
99
|
+
const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
|
|
64
100
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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";
|
|
68
113
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
|
84
131
|
}
|
|
85
132
|
|
|
86
133
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
134
|
+
* 在刚从 config.sample.json 复制出来的 config.json 上立即探测一次 cursor/codex
|
|
135
|
+
* 路径,命中就回写,便于用户无需手动编辑就拿到可运行的默认配置。
|
|
136
|
+
*
|
|
137
|
+
* 仅在"复制 sample 这一刻"调用,已存在的 config.json 不会触发——避免悄悄改写
|
|
138
|
+
* 用户主动留空的字段。
|
|
91
139
|
*/
|
|
92
|
-
|
|
93
|
-
raw: string
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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;
|
|
99
238
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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;
|
|
108
250
|
}
|
|
109
|
-
|
|
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
|
+
};
|
|
110
321
|
}
|
|
111
322
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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;
|
|
117
371
|
|
|
118
|
-
/** 探测 cursor-agent
|
|
372
|
+
/** 探测 cursor-agent 安装路径(优先配置,其次 LocalAppData,最后默认 agent) */
|
|
119
373
|
function detectCursorAgent(): string {
|
|
120
|
-
if (
|
|
374
|
+
if (config.cursor.path) return config.cursor.path;
|
|
121
375
|
const localAppData = process.env.LOCALAPPDATA;
|
|
122
376
|
if (localAppData) {
|
|
123
377
|
const defaultPath = join(localAppData, "cursor-agent", "agent.cmd");
|
|
@@ -125,22 +379,66 @@ function detectCursorAgent(): string {
|
|
|
125
379
|
}
|
|
126
380
|
return "agent";
|
|
127
381
|
}
|
|
128
|
-
|
|
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();
|
|
129
388
|
|
|
130
389
|
function resolveCursorAgentArgs(): string[] {
|
|
131
|
-
const custom = process.env.CHATCCC_CURSOR_ARGS?.trim();
|
|
132
|
-
if (custom) return custom.split(/\s+/).filter(Boolean);
|
|
133
|
-
|
|
134
390
|
let args = "-p --force --output-format stream-json --stream-partial-output";
|
|
135
|
-
const model =
|
|
136
|
-
if (model
|
|
391
|
+
const model = config.cursor.model;
|
|
392
|
+
if (model.trim() !== "") {
|
|
137
393
|
args += ` --model ${model}`;
|
|
138
394
|
}
|
|
139
395
|
return args.split(/\s+/).filter(Boolean);
|
|
140
396
|
}
|
|
141
397
|
|
|
142
398
|
/** Cursor agent 参数:-p 非交互模式,--force 强制允许命令(yolo),stream-json 流式 JSONL 输出 */
|
|
143
|
-
export
|
|
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
|
+
}
|
|
144
442
|
|
|
145
443
|
// 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
|
|
146
444
|
// 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
|
|
@@ -190,8 +488,6 @@ export async function getDefaultCwd(): Promise<string> {
|
|
|
190
488
|
} catch { /* path gone, fall through */ }
|
|
191
489
|
}
|
|
192
490
|
} catch { /* file doesn't exist yet */ }
|
|
193
|
-
// 用户未通过 /cd 设置过工作路径时,回退到操作系统用户主目录
|
|
194
|
-
// Windows: C:\Users\<用户名> Linux: /home/<用户名>
|
|
195
491
|
return homedir();
|
|
196
492
|
}
|
|
197
493
|
|
|
@@ -216,106 +512,55 @@ export function maskAppId(id: string): string {
|
|
|
216
512
|
}
|
|
217
513
|
|
|
218
514
|
/**
|
|
219
|
-
*
|
|
220
|
-
* (.env 需由 tsx --env-file 或系统注入到 process.env 后才算「已读入」。)
|
|
515
|
+
* 启动时逐项说明配置读取结果。
|
|
221
516
|
*/
|
|
222
517
|
export function reportEnvironmentVariableReadout(): void {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const rawId = get("CHATCCC_APP_ID");
|
|
226
|
-
const rawSecret = get("CHATCCC_APP_SECRET");
|
|
227
|
-
const rawPort = process.env.CHATCCC_PORT?.trim();
|
|
228
|
-
const rawModel = process.env.CHATCCC_ANTHROPIC_MODEL?.trim();
|
|
229
|
-
const rawEffort = process.env.CHATCCC_ANTHROPIC_EFFORT?.trim();
|
|
230
|
-
|
|
231
|
-
const portBad =
|
|
232
|
-
rawPort !== undefined &&
|
|
233
|
-
rawPort !== "" &&
|
|
234
|
-
(Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535);
|
|
235
|
-
|
|
236
|
-
const row = (label: string, name: string, kind: "必填" | "可选", ok: boolean, detail: string): void => {
|
|
518
|
+
const ok = (label: string, name: string, kind: "必填" | "可选", ok: boolean, detail: string): void => {
|
|
237
519
|
const state = ok ? "成功" : "失败";
|
|
238
520
|
console.log(` [${state}] [${kind}] ${name}`);
|
|
239
521
|
console.log(` ${label}: ${detail}`);
|
|
240
522
|
};
|
|
241
523
|
|
|
242
|
-
console.log(" ---
|
|
524
|
+
console.log(" --- 配置读取结果(成功=已读入;失败=必填缺失或格式错误;默认=未设置则用内置值)---");
|
|
243
525
|
|
|
244
|
-
const
|
|
526
|
+
const configExists = existsSync(CONFIG_FILE);
|
|
245
527
|
console.log(
|
|
246
|
-
` [信息]
|
|
528
|
+
` [信息] config.json: ${configExists ? "存在" : "不存在"} → ${CONFIG_FILE}`
|
|
247
529
|
);
|
|
248
|
-
if (!
|
|
249
|
-
console.log(
|
|
250
|
-
" 若使用全局 chatccc:当前目录无 .env 时不会自动加载;请 cd 到含 .env 的目录或设置系统环境变量。"
|
|
251
|
-
);
|
|
530
|
+
if (!configExists) {
|
|
531
|
+
console.log(" config.json 不存在时已从 config.sample.json 自动创建,请编辑后重新启动。");
|
|
252
532
|
}
|
|
253
533
|
|
|
254
|
-
|
|
534
|
+
ok(
|
|
255
535
|
"飞书应用",
|
|
256
|
-
"
|
|
536
|
+
"feishu.appId",
|
|
257
537
|
"必填",
|
|
258
|
-
Boolean(
|
|
259
|
-
|
|
538
|
+
Boolean(APP_ID.trim()),
|
|
539
|
+
APP_ID.trim() ? `已读入,摘要 ${maskAppId(APP_ID)}` : "未读入或为空"
|
|
260
540
|
);
|
|
261
|
-
|
|
541
|
+
ok(
|
|
262
542
|
"飞书应用",
|
|
263
|
-
"
|
|
543
|
+
"feishu.appSecret",
|
|
264
544
|
"必填",
|
|
265
|
-
Boolean(
|
|
266
|
-
|
|
545
|
+
Boolean(APP_SECRET.trim()),
|
|
546
|
+
APP_SECRET.trim() ? "已读入(内容不在日志中显示)" : "未读入或为空"
|
|
267
547
|
);
|
|
268
548
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
} else if (rawPort) {
|
|
272
|
-
row("监听端口", "CHATCCC_PORT", "可选", true, `已读入,使用 ${CHATCCC_PORT}`);
|
|
273
|
-
} else {
|
|
274
|
-
console.log(` [默认] [可选] CHATCCC_PORT`);
|
|
275
|
-
console.log(` 监听端口: 未在环境中设置,使用内置默认 ${CHATCCC_PORT}`);
|
|
276
|
-
}
|
|
549
|
+
console.log(` [默认] [可选] port`);
|
|
550
|
+
console.log(` 监听端口: ${CHATCCC_PORT}`);
|
|
277
551
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
console.log(
|
|
283
|
-
` Claude 模型: 未设置 → ${anthropicConfigDisplay(CLAUDE_MODEL)}(不区分大小写的 default 时不传入 SDK)`
|
|
284
|
-
);
|
|
285
|
-
}
|
|
552
|
+
console.log(` [默认] [可选] claude.model`);
|
|
553
|
+
console.log(
|
|
554
|
+
` Claude 模型: ${anthropicConfigDisplay(CLAUDE_MODEL)}(留空时不向 SDK 传 model)`
|
|
555
|
+
);
|
|
286
556
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
console.log(
|
|
292
|
-
` 思考深度: 未设置 → ${anthropicConfigDisplay(CLAUDE_EFFORT)}(不区分大小写的 default 时不传入 SDK)`
|
|
293
|
-
);
|
|
294
|
-
}
|
|
557
|
+
console.log(` [默认] [可选] claude.effort`);
|
|
558
|
+
console.log(
|
|
559
|
+
` 思考深度: ${anthropicConfigDisplay(CLAUDE_EFFORT)}(留空时不向 SDK 传 effort)`
|
|
560
|
+
);
|
|
295
561
|
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
}
|
|
562
|
+
console.log(` [默认] [可选] gitTimeoutSeconds`);
|
|
563
|
+
console.log(` /git 命令超时: ${GIT_TIMEOUT_SECONDS}s`);
|
|
319
564
|
|
|
320
565
|
console.log(" ------------------------------------------------------------------");
|
|
321
566
|
}
|
|
@@ -326,35 +571,21 @@ export function explainMissingFeishuCredentialsAndExit(): never {
|
|
|
326
571
|
hasAppId: Boolean(APP_ID.trim()),
|
|
327
572
|
hasAppSecret: Boolean(APP_SECRET.trim()),
|
|
328
573
|
});
|
|
329
|
-
const hasEnvFile = existsSync(ENV_FILE_CWD);
|
|
330
574
|
const missing: string[] = [];
|
|
331
|
-
if (!APP_ID.trim()) missing.push("
|
|
332
|
-
if (!APP_SECRET.trim()) missing.push("
|
|
575
|
+
if (!APP_ID.trim()) missing.push("feishu.appId");
|
|
576
|
+
if (!APP_SECRET.trim()) missing.push("feishu.appSecret");
|
|
333
577
|
|
|
334
578
|
console.error("\n" + "=".repeat(64));
|
|
335
579
|
console.error(" ChatCCC 启动失败:飞书应用凭证未就绪");
|
|
336
580
|
console.error("=".repeat(64));
|
|
337
581
|
console.error("\n【失败步骤】环境与变量检查(在连接飞书之前)");
|
|
338
|
-
console.error(`\n
|
|
339
|
-
console.error(`\n
|
|
340
|
-
console.error(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
" - 等号两侧不要加引号除非值里需要\n" +
|
|
346
|
-
" - 保存为 UTF-8,避免错误编码\n" +
|
|
347
|
-
" - 若用全局命令 chatccc:必须在放 .env 的目录下执行(先 cd 到项目根)"
|
|
348
|
-
);
|
|
349
|
-
} else {
|
|
350
|
-
console.error(
|
|
351
|
-
" 状态: 文件不存在。\n" +
|
|
352
|
-
" 处理: 复制 .env.example 为 .env 并填入飞书开放平台的 App ID / App Secret;\n" +
|
|
353
|
-
" 或在系统环境变量中设置上述两个变量后重开终端。\n" +
|
|
354
|
-
" 若使用全局 chatccc:请先 cd 到项目根目录再运行,以便加载该目录下的 .env。"
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
console.error(`\n【程序包根目录(与「工作目录」可能不同)】\n ${PROJECT_ROOT}`);
|
|
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}`);
|
|
358
589
|
console.error("\n" + "=".repeat(64) + "\n");
|
|
359
590
|
printServiceDidNotStart(`未配置: ${missing.join("、")}`);
|
|
360
591
|
process.exit(1);
|
|
@@ -379,4 +610,7 @@ export function toolDisplayName(tool: string): string {
|
|
|
379
610
|
if (tool === "cursor") return "Cursor";
|
|
380
611
|
if (tool === "codex") return "Codex";
|
|
381
612
|
return "Claude Code";
|
|
382
|
-
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 导出 config 对象供其他模块直接访问原始配置
|
|
616
|
+
export { CONFIG_FILE, CONFIG_SAMPLE_FILE };
|