chatccc 0.2.16 → 0.2.17
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 +24 -36
- 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 +9 -8
- package/src/adapters/cursor-adapter.ts +1 -1
- package/src/config-utils.ts +177 -0
- package/src/config.ts +616 -382
- 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
|
@@ -54,7 +54,44 @@ interface SdkMessageLike {
|
|
|
54
54
|
export interface ClaudeAdapterOptions {
|
|
55
55
|
model: string;
|
|
56
56
|
effort: string;
|
|
57
|
-
|
|
57
|
+
/** 判断字段是否为"不传给 SDK"的占位(项目约定:空字符串/全空白) */
|
|
58
|
+
isEmpty: (value: string) => boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Anthropic 兼容网关的 API key。
|
|
61
|
+
* 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_API_KEY 环境变量;
|
|
62
|
+
* 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
|
|
63
|
+
* 永远不会写入主进程的 process.env,避免污染其他依赖 env 的代码。
|
|
64
|
+
*/
|
|
65
|
+
apiKey?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Anthropic 兼容网关的 base URL。
|
|
68
|
+
* 非空(trim 后)时会被注入到 SDK 子进程的 ANTHROPIC_BASE_URL 环境变量;
|
|
69
|
+
* 留空 / 全空白 → 不覆盖,沿用主进程 process.env / 系统环境变量。
|
|
70
|
+
*/
|
|
71
|
+
baseUrl?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// buildSdkEnv — 为 SDK 子进程构造 env
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// 行为契约(详见单测 "createClaudeAdapter — env 注入"):
|
|
78
|
+
// - apiKey 与 baseUrl 都为空(trim 后)→ 返回 undefined,让 SDK 走默认行为
|
|
79
|
+
// (即 process.env),避免无意义的拷贝。
|
|
80
|
+
// - 任一非空 → 返回 process.env 的浅拷贝,并按需覆盖 ANTHROPIC_API_KEY /
|
|
81
|
+
// ANTHROPIC_BASE_URL;其余 env 字段保持不变(PATH、HOME 等子进程必需)。
|
|
82
|
+
// - 主进程 process.env 永不被写入,主进程其他模块对 env 的读取不受影响。
|
|
83
|
+
function buildSdkEnv(
|
|
84
|
+
apiKey: string | undefined,
|
|
85
|
+
baseUrl: string | undefined,
|
|
86
|
+
): Record<string, string | undefined> | undefined {
|
|
87
|
+
const apiKeyTrim = (apiKey ?? "").trim();
|
|
88
|
+
const baseUrlTrim = (baseUrl ?? "").trim();
|
|
89
|
+
if (!apiKeyTrim && !baseUrlTrim) return undefined;
|
|
90
|
+
|
|
91
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
92
|
+
if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
|
|
93
|
+
if (baseUrlTrim) env.ANTHROPIC_BASE_URL = baseUrlTrim;
|
|
94
|
+
return env;
|
|
58
95
|
}
|
|
59
96
|
|
|
60
97
|
// ---------------------------------------------------------------------------
|
|
@@ -65,7 +102,9 @@ function buildSessionOptions(
|
|
|
65
102
|
cwd: string,
|
|
66
103
|
model: string,
|
|
67
104
|
effort: string,
|
|
68
|
-
|
|
105
|
+
isEmpty: (value: string) => boolean,
|
|
106
|
+
apiKey: string | undefined,
|
|
107
|
+
baseUrl: string | undefined,
|
|
69
108
|
): Record<string, unknown> {
|
|
70
109
|
const o: Record<string, unknown> = {
|
|
71
110
|
cwd,
|
|
@@ -74,8 +113,10 @@ function buildSessionOptions(
|
|
|
74
113
|
autoCompactEnabled: true,
|
|
75
114
|
settingSources: ["project", "local"],
|
|
76
115
|
};
|
|
77
|
-
if (!
|
|
78
|
-
if (!
|
|
116
|
+
if (!isEmpty(model)) o.model = model;
|
|
117
|
+
if (!isEmpty(effort)) o.effort = effort;
|
|
118
|
+
const env = buildSdkEnv(apiKey, baseUrl);
|
|
119
|
+
if (env) o.env = env;
|
|
79
120
|
return o;
|
|
80
121
|
}
|
|
81
122
|
|
|
@@ -150,12 +191,16 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
150
191
|
readonly sessionDescPrefix = "Claude Code Session:";
|
|
151
192
|
private model: string;
|
|
152
193
|
private effort: string;
|
|
153
|
-
private
|
|
194
|
+
private isEmpty: (value: string) => boolean;
|
|
195
|
+
private apiKey: string | undefined;
|
|
196
|
+
private baseUrl: string | undefined;
|
|
154
197
|
|
|
155
198
|
constructor(options: ClaudeAdapterOptions) {
|
|
156
199
|
this.model = options.model;
|
|
157
200
|
this.effort = options.effort;
|
|
158
|
-
this.
|
|
201
|
+
this.isEmpty = options.isEmpty;
|
|
202
|
+
this.apiKey = options.apiKey;
|
|
203
|
+
this.baseUrl = options.baseUrl;
|
|
159
204
|
}
|
|
160
205
|
|
|
161
206
|
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
@@ -163,7 +208,9 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
163
208
|
cwd,
|
|
164
209
|
this.model,
|
|
165
210
|
this.effort,
|
|
166
|
-
this.
|
|
211
|
+
this.isEmpty,
|
|
212
|
+
this.apiKey,
|
|
213
|
+
this.baseUrl,
|
|
167
214
|
);
|
|
168
215
|
const session = unstable_v2_createSession(sessionOpts as any);
|
|
169
216
|
|
|
@@ -205,7 +252,9 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
205
252
|
cwd,
|
|
206
253
|
this.model,
|
|
207
254
|
this.effort,
|
|
208
|
-
this.
|
|
255
|
+
this.isEmpty,
|
|
256
|
+
this.apiKey,
|
|
257
|
+
this.baseUrl,
|
|
209
258
|
);
|
|
210
259
|
const session = unstable_v2_resumeSession(
|
|
211
260
|
sessionId,
|
|
@@ -22,14 +22,15 @@ import {
|
|
|
22
22
|
defaultCodexSessionMetaStore,
|
|
23
23
|
type CodexSessionMetaStore,
|
|
24
24
|
} from "./codex-session-meta-store.ts";
|
|
25
|
+
import { config } from "../config.ts";
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// 命令与参数
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
29
30
|
|
|
30
|
-
/** 可通过
|
|
31
|
+
/** 可通过 config.json codex.path 自定义 Codex 可执行文件路径 */
|
|
31
32
|
function detectCodexCommand(): string {
|
|
32
|
-
return
|
|
33
|
+
return config.codex.path || "codex";
|
|
33
34
|
}
|
|
34
35
|
const CODEX_COMMAND = detectCodexCommand();
|
|
35
36
|
|
|
@@ -41,16 +42,16 @@ const CODEX_BASE_ARGS = [
|
|
|
41
42
|
"--skip-git-repo-check",
|
|
42
43
|
];
|
|
43
44
|
|
|
44
|
-
/** codex
|
|
45
|
+
/** codex 模型;留空("")表示不传 --model,由 codex config.toml 决定 */
|
|
45
46
|
function resolveCodexModel(): string | null {
|
|
46
|
-
const m =
|
|
47
|
-
return m
|
|
47
|
+
const m = config.codex.model;
|
|
48
|
+
return m.trim() !== "" ? m : null;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
/** codex 努力程度(映射为 -c model_reasoning_effort=<value
|
|
51
|
+
/** codex 努力程度(映射为 -c model_reasoning_effort=<value>);留空表示不传 */
|
|
51
52
|
function resolveCodexEffort(): string | null {
|
|
52
|
-
const e =
|
|
53
|
-
return e
|
|
53
|
+
const e = config.codex.effort;
|
|
54
|
+
return e.trim() !== "" ? e : null;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
// ---------------------------------------------------------------------------
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// cursor-adapter.ts — Cursor Agent CLI 适配器
|
|
3
3
|
// =============================================================================
|
|
4
4
|
// 通过 agent -p --output-format stream-json 与 Cursor agent 交互。
|
|
5
|
-
// 命令行可通过
|
|
5
|
+
// 命令行可通过 config.json cursor.path / cursor.model 自定义。
|
|
6
6
|
// =============================================================================
|
|
7
7
|
|
|
8
8
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// 纯函数/常量工具集
|
|
3
|
+
//
|
|
4
|
+
// 这些工具与 fs / 进程状态完全无关(CLI 路径探测函数虽然要查文件/PATH,但所有
|
|
5
|
+
// I/O 通过参数注入,函数本身仍然是纯函数,可被单测无副作用地调用)。
|
|
6
|
+
// 其它生产代码应优先从 `./config.ts` 引用(`config.ts` 会 re-export 这里的符号),
|
|
7
|
+
// 仅在需要避免触发 config.ts 副作用的场景(如单测)才直接 import 本文件。
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 把 model / effort 等"可选向 SDK/CLI 透传"的字段标准化为字符串。
|
|
14
|
+
*
|
|
15
|
+
* 项目内部统一约定:`""`(空字符串/全空白)表示**不向 SDK/CLI 传该字段**。
|
|
16
|
+
* 旧版本曾使用字面量 `"default"` 表达同样的意思,本函数兼容性地把它视作 `""`,
|
|
17
|
+
* 并在每次启动时打印一次 warning,提示用户更新 config.json。
|
|
18
|
+
*
|
|
19
|
+
* - `value` 不是字符串(例如 undefined / 缺失)→ 使用 `fallback`(默认 `""`)。
|
|
20
|
+
* - `value` 去除空白后等于 `"default"`(不区分大小写)→ 视作 `""` 并 warn。
|
|
21
|
+
* - 其余情况原样返回(不裁剪两端空白,留给具体调用方决定)。
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeOptionalConfigField(
|
|
24
|
+
value: unknown,
|
|
25
|
+
options: { label: string; fallback?: string },
|
|
26
|
+
): string {
|
|
27
|
+
const fallback = options.fallback ?? "";
|
|
28
|
+
if (typeof value !== "string") return fallback;
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (trimmed.toLowerCase() === "default") {
|
|
31
|
+
console.warn(
|
|
32
|
+
`[CONFIG] ${options.label} 的值 "${trimmed}" 已废弃,请改为 ""(空字符串)表示不向 SDK/CLI 传该字段;本次启动按 "" 处理。`,
|
|
33
|
+
);
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// /git 超时配置相关
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** /git 命令默认超时秒数(用户未配置时使用) */
|
|
44
|
+
export const DEFAULT_GIT_TIMEOUT_SECONDS = 180;
|
|
45
|
+
/** /git 超时允许的下限/上限(防止 0、负数、过大值导致行为异常) */
|
|
46
|
+
export const MIN_GIT_TIMEOUT_SECONDS = 1;
|
|
47
|
+
export const MAX_GIT_TIMEOUT_SECONDS = 3600; // 1 小时
|
|
48
|
+
|
|
49
|
+
export interface ParsedGitTimeout {
|
|
50
|
+
/** 实际使用的超时秒数(无效时回退为 default) */
|
|
51
|
+
seconds: number;
|
|
52
|
+
/** 用户提供的原始字符串是否为合法整数秒(true 表示采纳了用户值或未提供) */
|
|
53
|
+
valid: boolean;
|
|
54
|
+
/** 用户原始字符串 */
|
|
55
|
+
raw?: string;
|
|
56
|
+
/** 是否使用了内置默认值(即用户未提供有效值) */
|
|
57
|
+
usingDefault: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseGitTimeoutSeconds(
|
|
61
|
+
raw: string | number | undefined,
|
|
62
|
+
defaultSeconds = DEFAULT_GIT_TIMEOUT_SECONDS,
|
|
63
|
+
): ParsedGitTimeout {
|
|
64
|
+
if (typeof raw === "number") {
|
|
65
|
+
if (
|
|
66
|
+
!Number.isFinite(raw) ||
|
|
67
|
+
!Number.isInteger(raw) ||
|
|
68
|
+
raw < MIN_GIT_TIMEOUT_SECONDS ||
|
|
69
|
+
raw > MAX_GIT_TIMEOUT_SECONDS
|
|
70
|
+
) {
|
|
71
|
+
return { seconds: defaultSeconds, valid: false, raw: String(raw), usingDefault: true };
|
|
72
|
+
}
|
|
73
|
+
return { seconds: raw, valid: true, raw: String(raw), usingDefault: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const trimmed = raw?.trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
return { seconds: defaultSeconds, valid: true, usingDefault: true };
|
|
79
|
+
}
|
|
80
|
+
const n = Number(trimmed);
|
|
81
|
+
if (
|
|
82
|
+
!Number.isFinite(n) ||
|
|
83
|
+
!Number.isInteger(n) ||
|
|
84
|
+
n < MIN_GIT_TIMEOUT_SECONDS ||
|
|
85
|
+
n > MAX_GIT_TIMEOUT_SECONDS
|
|
86
|
+
) {
|
|
87
|
+
return { seconds: defaultSeconds, valid: false, raw: trimmed, usingDefault: true };
|
|
88
|
+
}
|
|
89
|
+
return { seconds: n, valid: true, raw: trimmed, usingDefault: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 判断 model / effort 等"可选向 SDK/CLI 透传"的字段是否为"不传值"。
|
|
94
|
+
* 项目内部统一以空字符串(含全空白)表示该语义。
|
|
95
|
+
*/
|
|
96
|
+
export function isAnthropicConfigEmpty(value: string): boolean {
|
|
97
|
+
return value.trim() === "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 状态展示用:留空时显示 `(留空)`,否则原样返回。 */
|
|
101
|
+
export function anthropicConfigDisplay(value: string): string {
|
|
102
|
+
return isAnthropicConfigEmpty(value) ? "(留空)" : value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// CLI 可执行文件路径探测(依赖 fs/PATH,但通过参数注入保持纯函数特性)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export interface PathDetectorDeps {
|
|
110
|
+
/** 用于检查文件是否存在的同步函数(生产环境注入 fs.existsSync) */
|
|
111
|
+
existsSync: (path: string) => boolean;
|
|
112
|
+
/** 通过 `where` (Win) / `which` (Mac/Linux) 解析命令绝对路径,找不到返回 null */
|
|
113
|
+
whichSync: (cmd: string) => string | null;
|
|
114
|
+
/** process.env.LOCALAPPDATA(Windows 上 Cursor IDE 默认安装根) */
|
|
115
|
+
localAppData?: string | undefined;
|
|
116
|
+
/** process.platform(用于判定 Windows 默认安装路径是否适用) */
|
|
117
|
+
platform: NodeJS.Platform;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 探测 Cursor Agent CLI 的可执行文件绝对路径。
|
|
122
|
+
*
|
|
123
|
+
* 优先级(依次尝试,命中即返回):
|
|
124
|
+
* 1. Windows + `%LOCALAPPDATA%\cursor-agent\agent.cmd`(Cursor IDE 默认安装位置)
|
|
125
|
+
* 2. PATH 中查找 `cursor-agent`(独立 CLI 安装常见名)
|
|
126
|
+
* 3. PATH 中查找 `agent`(旧版默认名)
|
|
127
|
+
*
|
|
128
|
+
* 全部命中失败时返回 `null`,调用方可选择留空让运行时回退到 PATH 兜底。
|
|
129
|
+
*/
|
|
130
|
+
export function autoDetectCursorPath(deps: PathDetectorDeps): string | null {
|
|
131
|
+
if (deps.platform === "win32" && deps.localAppData) {
|
|
132
|
+
const localPath = join(deps.localAppData, "cursor-agent", "agent.cmd");
|
|
133
|
+
if (deps.existsSync(localPath)) return localPath;
|
|
134
|
+
}
|
|
135
|
+
return deps.whichSync("cursor-agent") ?? deps.whichSync("agent");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 探测 Codex CLI 的可执行文件绝对路径。
|
|
140
|
+
*
|
|
141
|
+
* Codex 没有固定安装位置,只通过 PATH 查找 `codex` 命令。
|
|
142
|
+
* 找不到时返回 `null`,由调用方决定留空或后续重试。
|
|
143
|
+
*/
|
|
144
|
+
export function autoDetectCodexPath(
|
|
145
|
+
deps: Pick<PathDetectorDeps, "whichSync">,
|
|
146
|
+
): string | null {
|
|
147
|
+
return deps.whichSync("codex");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// CLI 路径字段(cursor.path / codex.path)的兼容性读取
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 从 raw config 对象中读取 cursor / codex 的 CLI 路径,兼容旧字段名 `command`。
|
|
156
|
+
*
|
|
157
|
+
* - 优先读 `path`,回退到旧字段 `command`(升级前的 config.json 仍然可用)
|
|
158
|
+
* - 回退命中时通过 `onLegacyField` 回调通知调用方(用于打印一次性 warning)
|
|
159
|
+
* - 不要在此函数里写文件,迁移交给上层
|
|
160
|
+
*/
|
|
161
|
+
export function readToolCliPath(
|
|
162
|
+
raw: { path?: unknown; command?: unknown } | undefined,
|
|
163
|
+
options: {
|
|
164
|
+
/** 工具标签,仅用于 warning 文案,例如 "cursor" / "codex" */
|
|
165
|
+
label: string;
|
|
166
|
+
/** 命中旧字段时调用,调用方负责打印或记录 warning */
|
|
167
|
+
onLegacyField?: (label: string, legacyValue: string) => void;
|
|
168
|
+
},
|
|
169
|
+
): string {
|
|
170
|
+
if (!raw) return "";
|
|
171
|
+
if (typeof raw.path === "string" && raw.path.trim() !== "") return raw.path;
|
|
172
|
+
if (typeof raw.command === "string" && raw.command.trim() !== "") {
|
|
173
|
+
options.onLegacyField?.(options.label, raw.command);
|
|
174
|
+
return raw.command;
|
|
175
|
+
}
|
|
176
|
+
return "";
|
|
177
|
+
}
|