u1s1-cli 1.15.0 → 1.15.1

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.
@@ -65,15 +65,48 @@ export declare function withMcpPermissions(settings: Record<string, unknown>): R
65
65
  * --settings 的优先级高于项目设置,在这里写回默认值。
66
66
  */
67
67
  export declare const CLAUDE_SETTINGS_ENV_PINNED: Readonly<Record<string, string>>;
68
+ /** 去掉连接/凭证相关项(SETTINGS_FORBIDDEN、env 里的 SETTINGS_ENV_FORBIDDEN),钉上 CLAUDE_SETTINGS_ENV_PINNED。 */
69
+ export declare function sanitizeClaudeSettings(settings: Record<string, unknown>): Record<string, unknown>;
68
70
  /**
69
71
  * 网关的 settings → 写给 --settings 的对象:去掉连接/凭证相关项,钉上 CLAUDE_SETTINGS_ENV_PINNED;
70
72
  * model 用调用方选好的(用户在 u1s1 config 里定的、仍在阵容里的模型),没给时保留网关的,
71
73
  * 网关也没有就用 default_model。
72
74
  */
73
75
  export declare function claudeSettingsFromConfig(config: AgentConfig, model?: string | null): Record<string, unknown>;
76
+ /** 调用方的 --settings 有问题(缺值、读不了、不是 JSON 对象);runClaude 报出来后以非 0 退出。 */
77
+ export declare class ClaudeSettingsArgError extends Error {
78
+ name: string;
79
+ }
80
+ /**
81
+ * Claude Code 的 --settings 只认最后一个(2.1.323 实测),用户参数里再带一个就把启动器的整份设置盖掉。
82
+ * 所以从用户参数里把它们全抽出来(`--settings <值>` 与 `--settings=<值>`,按出现顺序;`--` 之后不动),
83
+ * 由启动器合并进自己写的那份。值可以是文件路径,也可以是 JSON 字符串(与 Claude Code 一样)。
84
+ */
85
+ export declare function extractSettingsArgs(userArgs: readonly string[]): {
86
+ rest: string[];
87
+ sources: string[];
88
+ };
89
+ /** 读一个 --settings 的值:以 `{` 开头按 JSON 字符串解析,否则当文件路径(相对 cwd)。必须是 JSON 对象。 */
90
+ export declare function loadCallerSettings(source: string, cwd?: string): Record<string, unknown>;
91
+ /**
92
+ * 把调用方的设置合并进启动器的:对象逐层合并;hooks 每个事件的数组拼接;其余数组拼接去重;
93
+ * 标量(含 model、statusLine 这类整项)以调用方为准。合并后调用方还要再过 sanitizeClaudeSettings 与
94
+ * withMcpPermissions,不能借此换凭证、换地址、改请求形状。
95
+ */
96
+ export declare function mergeClaudeSettings(base: Record<string, unknown>, caller: Record<string, unknown>): Record<string, unknown>;
97
+ /** 启动器的设置 + 调用方的设置(按顺序)→ 清洗 → MCP 权限:写进 --settings 文件的最终内容。 */
98
+ export declare function finalClaudeSettings(launcher: Record<string, unknown>, callers: readonly Record<string, unknown>[], withMcp: boolean): Record<string, unknown>;
74
99
  export declare function writeClaudeSettings(file: string, settings: Record<string, unknown>): void;
75
100
  /** 桌面端复用:同一份注入逻辑(embed.ts 导出)。 */
76
101
  export declare function agentLaunchEnv(agent: "claude", input: ClaudeLaunchInput): NodeJS.ProcessEnv;
102
+ /**
103
+ * 带调用方 --settings 时,合并结果写进这个进程自己的文件:Claude Code 会监视 --settings 文件、改了就重读,
104
+ * 几个进程共用一份的话,后起的会把先起的那个的 hooks(调用方按会话区分的回调)换成自己的。
105
+ * 没有调用方 --settings 时内容只取决于网关配置,仍用共用的 settingsFile。
106
+ */
107
+ export declare function processSettingsFile(paths: Pick<ClaudePaths, "root">, pid?: number): string;
108
+ /** 删掉上次没来得及清理(进程被强杀)的每进程设置文件;进程还在的不动。 */
109
+ export declare function cleanStaleSettingsFiles(root: string, alive?: (pid: number) => boolean): void;
77
110
  /**
78
111
  * `u1s1 claude [参数…]`。返回子进程的退出码(启动前就失败返回 1)。
79
112
  */
@@ -7,6 +7,9 @@
7
7
  * ANTHROPIC_AUTH_TOKEN=<本地 token> 代理只认 Bearer;不能用 ANTHROPIC_API_KEY(发 x-api-key)
8
8
  * DISABLE_AUTOUPDATER=1 更新由启动器管(runtime.ts)
9
9
  * --settings <启动器写出的文件> 网关下发的模型阵容等;文件里没有任何凭证
10
+ * 调用方自己传的 --settings(文件或 JSON,可多个)不转给客户端,
11
+ * 按顺序合并进这份文件再清洗(Claude Code 只认最后一个 --settings),
12
+ * 见 extractSettingsArgs / mergeClaudeSettings
10
13
  * --mcp-config <启动器写出的文件> u1s1 工具服务(搜索、读网页、生图,见 mcp-server.ts);
11
14
  * 文件里只引用环境变量名,代理地址和本地 token 的值在子进程环境里
12
15
  *
@@ -14,10 +17,10 @@
14
17
  * 再显式写入上面几项;本地 token 只对本进程的签名代理有效,进程退出即作废。
15
18
  * 不改二进制,不删除也不禁用它自带的登录方式。
16
19
  */
17
- import { rmSync } from "node:fs";
18
- import { join } from "node:path";
20
+ import { readdirSync, readFileSync, rmSync } from "node:fs";
21
+ import { join, resolve } from "node:path";
19
22
  import { writePrivateFile } from "../config.js";
20
- import { lang } from "../i18n.js";
23
+ import { lang, tr } from "../i18n.js";
21
24
  import { MCP_ENV_NAMES, MCP_SERVER_NAME, MCP_TRACE_ENV, mcpInjectionDisabled, mcpLaunchEnv, mcpServerCommand, } from "../mcp-server.js";
22
25
  import { AGENT_PROFILES, agentsRoot, prepareAgentLaunch, runPreparedAgent, } from "./launch.js";
23
26
  import { runtimePaths } from "./runtime.js";
@@ -145,13 +148,17 @@ export function withMcpPermissions(settings) {
145
148
  permissions["ask"] = mergeRules(permissions["ask"], CLAUDE_MCP_ASK_TOOLS);
146
149
  return { ...settings, permissions };
147
150
  }
148
- /** settings.env 里绝不能出现的键(契约上网关也不会发,这里再兜一层)。 */
149
- const SETTINGS_ENV_FORBIDDEN = new Set([
150
- "ANTHROPIC_BASE_URL",
151
- "ANTHROPIC_AUTH_TOKEN",
152
- "ANTHROPIC_API_KEY",
153
- "CLAUDE_CODE_OAUTH_TOKEN",
154
- "CLAUDE_CONFIG_DIR",
151
+ /**
152
+ * settings.env 里绝不能出现的键(契约上网关也不会发,这里再兜一层;调用方的 --settings 合并进来后同样过这一遍):
153
+ * CLAUDE_ENV_DROP 里换凭证、换地址、换云厂商、改 u1s1 工具服务连接的那些。模型变量可以由调用方改
154
+ * (等同于改 model);两个请求形状变量另由 CLAUDE_SETTINGS_ENV_PINNED 钉回默认值。
155
+ */
156
+ const SETTINGS_ENV_ALLOWED_FROM_DROP = new Set([
157
+ "ANTHROPIC_MODEL",
158
+ "ANTHROPIC_SMALL_FAST_MODEL",
159
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
160
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
161
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
155
162
  ]);
156
163
  /** 会让客户端去别处取凭证的设置项。 */
157
164
  const SETTINGS_FORBIDDEN = new Set(["apiKeyHelper"]);
@@ -165,35 +172,149 @@ export const CLAUDE_SETTINGS_ENV_PINNED = {
165
172
  CLAUDE_CODE_ATTRIBUTION_HEADER: "1",
166
173
  CLAUDE_CODE_EXTRA_BODY: "{}",
167
174
  };
168
- /**
169
- * 网关的 settings → 写给 --settings 的对象:去掉连接/凭证相关项,钉上 CLAUDE_SETTINGS_ENV_PINNED;
170
- * model 用调用方选好的(用户在 u1s1 config 里定的、仍在阵容里的模型),没给时保留网关的,
171
- * 网关也没有就用 default_model。
172
- */
173
- export function claudeSettingsFromConfig(config, model) {
175
+ const SETTINGS_ENV_FORBIDDEN = new Set(CLAUDE_ENV_DROP.filter((key) => !SETTINGS_ENV_ALLOWED_FROM_DROP.has(key) && !(key in CLAUDE_SETTINGS_ENV_PINNED)));
176
+ function isPlainObject(value) {
177
+ return !!value && typeof value === "object" && !Array.isArray(value);
178
+ }
179
+ /** 去掉连接/凭证相关项(SETTINGS_FORBIDDEN、env 里的 SETTINGS_ENV_FORBIDDEN),钉上 CLAUDE_SETTINGS_ENV_PINNED。 */
180
+ export function sanitizeClaudeSettings(settings) {
174
181
  const out = {};
175
- for (const [key, value] of Object.entries(config.settings ?? {})) {
182
+ for (const [key, value] of Object.entries(settings)) {
176
183
  if (!SETTINGS_FORBIDDEN.has(key))
177
184
  out[key] = value;
178
185
  }
179
- const rawEnv = out["env"];
180
- if (rawEnv && typeof rawEnv === "object" && !Array.isArray(rawEnv)) {
181
- const env = {};
182
- for (const [key, value] of Object.entries(rawEnv)) {
186
+ const env = {};
187
+ if (isPlainObject(out["env"])) {
188
+ for (const [key, value] of Object.entries(out["env"])) {
183
189
  if (!SETTINGS_ENV_FORBIDDEN.has(key))
184
190
  env[key] = value;
185
191
  }
186
- out["env"] = { ...env, ...CLAUDE_SETTINGS_ENV_PINNED };
187
- }
188
- else {
189
- out["env"] = { ...CLAUDE_SETTINGS_ENV_PINNED };
190
192
  }
193
+ out["env"] = { ...env, ...CLAUDE_SETTINGS_ENV_PINNED };
194
+ return out;
195
+ }
196
+ /**
197
+ * 网关的 settings → 写给 --settings 的对象:去掉连接/凭证相关项,钉上 CLAUDE_SETTINGS_ENV_PINNED;
198
+ * model 用调用方选好的(用户在 u1s1 config 里定的、仍在阵容里的模型),没给时保留网关的,
199
+ * 网关也没有就用 default_model。
200
+ */
201
+ export function claudeSettingsFromConfig(config, model) {
202
+ const out = sanitizeClaudeSettings(config.settings ?? {});
191
203
  if (model)
192
204
  out["model"] = model;
193
205
  else if (out["model"] === undefined && config.defaultModel)
194
206
  out["model"] = config.defaultModel;
195
207
  return out;
196
208
  }
209
+ // ---------------------------------------------------------------- 调用方的 --settings
210
+ /** 调用方的 --settings 有问题(缺值、读不了、不是 JSON 对象);runClaude 报出来后以非 0 退出。 */
211
+ export class ClaudeSettingsArgError extends Error {
212
+ name = "ClaudeSettingsArgError";
213
+ }
214
+ /**
215
+ * Claude Code 的 --settings 只认最后一个(2.1.323 实测),用户参数里再带一个就把启动器的整份设置盖掉。
216
+ * 所以从用户参数里把它们全抽出来(`--settings <值>` 与 `--settings=<值>`,按出现顺序;`--` 之后不动),
217
+ * 由启动器合并进自己写的那份。值可以是文件路径,也可以是 JSON 字符串(与 Claude Code 一样)。
218
+ */
219
+ export function extractSettingsArgs(userArgs) {
220
+ const rest = [];
221
+ const sources = [];
222
+ for (let i = 0; i < userArgs.length; i++) {
223
+ const arg = userArgs[i];
224
+ if (arg === "--") {
225
+ rest.push(...userArgs.slice(i));
226
+ break;
227
+ }
228
+ if (arg === "--settings") {
229
+ const value = userArgs[i + 1];
230
+ if (value === undefined || value === "")
231
+ throw new ClaudeSettingsArgError(tr("--settings 后面缺少文件路径或 JSON。", "--settings needs a file path or a JSON string."));
232
+ sources.push(value);
233
+ i++;
234
+ }
235
+ else if (arg.startsWith("--settings=")) {
236
+ const value = arg.slice("--settings=".length);
237
+ if (value === "")
238
+ throw new ClaudeSettingsArgError(tr("--settings= 后面缺少文件路径或 JSON。", "--settings= needs a file path or a JSON string."));
239
+ sources.push(value);
240
+ }
241
+ else {
242
+ rest.push(arg);
243
+ }
244
+ }
245
+ return { rest, sources };
246
+ }
247
+ /** 读一个 --settings 的值:以 `{` 开头按 JSON 字符串解析,否则当文件路径(相对 cwd)。必须是 JSON 对象。 */
248
+ export function loadCallerSettings(source, cwd = process.cwd()) {
249
+ const inline = source.trimStart().startsWith("{");
250
+ let text;
251
+ if (inline) {
252
+ text = source;
253
+ }
254
+ else {
255
+ const file = resolve(cwd, source);
256
+ try {
257
+ text = readFileSync(file, "utf8");
258
+ }
259
+ catch (e) {
260
+ const reason = e.code ?? e.message;
261
+ throw new ClaudeSettingsArgError(tr(`读不了 --settings 文件 ${file}(${reason})。`, `Couldn't read the --settings file ${file} (${reason}).`));
262
+ }
263
+ }
264
+ const what = inline ? tr("--settings 的 JSON", "The --settings JSON") : tr(`--settings 文件 ${resolve(cwd, source)}`, `The --settings file ${resolve(cwd, source)}`);
265
+ let parsed;
266
+ try {
267
+ parsed = JSON.parse(text);
268
+ }
269
+ catch (e) {
270
+ throw new ClaudeSettingsArgError(tr(`${what} 不是合法的 JSON:${e.message}`, `${what} is not valid JSON: ${e.message}`));
271
+ }
272
+ if (!isPlainObject(parsed))
273
+ throw new ClaudeSettingsArgError(tr(`${what} 必须是一个 JSON 对象。`, `${what} must be a JSON object.`));
274
+ return parsed;
275
+ }
276
+ function mergeArrays(base, extra, dedupe) {
277
+ if (!dedupe)
278
+ return [...base, ...extra];
279
+ const out = [...base];
280
+ const seen = new Set(out.map((item) => JSON.stringify(item)));
281
+ for (const item of extra) {
282
+ const key = JSON.stringify(item);
283
+ if (!seen.has(key)) {
284
+ seen.add(key);
285
+ out.push(item);
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ function mergeValue(base, extra, path) {
291
+ if (isPlainObject(base) && isPlainObject(extra)) {
292
+ const out = { ...base };
293
+ for (const [key, value] of Object.entries(extra)) {
294
+ if (key === "__proto__")
295
+ continue;
296
+ out[key] = Object.hasOwn(out, key) ? mergeValue(out[key], value, [...path, key]) : value;
297
+ }
298
+ return out;
299
+ }
300
+ // hooks.<事件> 按原样拼接(调用方的回调排在后面);其余数组(permissions.allow/ask/deny 等)拼接去重
301
+ if (Array.isArray(base) && Array.isArray(extra))
302
+ return mergeArrays(base, extra, !(path.length === 2 && path[0] === "hooks"));
303
+ return extra;
304
+ }
305
+ /**
306
+ * 把调用方的设置合并进启动器的:对象逐层合并;hooks 每个事件的数组拼接;其余数组拼接去重;
307
+ * 标量(含 model、statusLine 这类整项)以调用方为准。合并后调用方还要再过 sanitizeClaudeSettings 与
308
+ * withMcpPermissions,不能借此换凭证、换地址、改请求形状。
309
+ */
310
+ export function mergeClaudeSettings(base, caller) {
311
+ return mergeValue(base, caller, []);
312
+ }
313
+ /** 启动器的设置 + 调用方的设置(按顺序)→ 清洗 → MCP 权限:写进 --settings 文件的最终内容。 */
314
+ export function finalClaudeSettings(launcher, callers, withMcp) {
315
+ const merged = sanitizeClaudeSettings(callers.reduce(mergeClaudeSettings, launcher));
316
+ return withMcp ? withMcpPermissions(merged) : merged;
317
+ }
197
318
  export function writeClaudeSettings(file, settings) {
198
319
  writePrivateFile(file, JSON.stringify(settings, null, 2) + "\n");
199
320
  }
@@ -204,10 +325,59 @@ export function agentLaunchEnv(agent, input) {
204
325
  return claudeLaunchEnv(input);
205
326
  }
206
327
  // ---------------------------------------------------------------- run
328
+ const PROCESS_SETTINGS = /^settings-(\d+)\.json$/;
329
+ /**
330
+ * 带调用方 --settings 时,合并结果写进这个进程自己的文件:Claude Code 会监视 --settings 文件、改了就重读,
331
+ * 几个进程共用一份的话,后起的会把先起的那个的 hooks(调用方按会话区分的回调)换成自己的。
332
+ * 没有调用方 --settings 时内容只取决于网关配置,仍用共用的 settingsFile。
333
+ */
334
+ export function processSettingsFile(paths, pid = process.pid) {
335
+ return join(paths.root, `settings-${pid}.json`);
336
+ }
337
+ function pidAlive(pid) {
338
+ try {
339
+ process.kill(pid, 0);
340
+ return true;
341
+ }
342
+ catch (e) {
343
+ return e.code === "EPERM";
344
+ }
345
+ }
346
+ /** 删掉上次没来得及清理(进程被强杀)的每进程设置文件;进程还在的不动。 */
347
+ export function cleanStaleSettingsFiles(root, alive = pidAlive) {
348
+ let names;
349
+ try {
350
+ names = readdirSync(root);
351
+ }
352
+ catch {
353
+ return;
354
+ }
355
+ for (const name of names) {
356
+ const m = PROCESS_SETTINGS.exec(name);
357
+ if (m && !alive(Number(m[1])))
358
+ rmSync(join(root, name), { force: true });
359
+ }
360
+ }
207
361
  /**
208
362
  * `u1s1 claude [参数…]`。返回子进程的退出码(启动前就失败返回 1)。
209
363
  */
210
364
  export async function runClaude(userArgs) {
365
+ // 调用方的 --settings 先读出来:有错在登录、联网之前就报。只查版本的快速路径原样不动。
366
+ let launchArgs = userArgs;
367
+ let callerSettings = [];
368
+ if (!isBareVersionQuery(userArgs)) {
369
+ try {
370
+ const { rest, sources } = extractSettingsArgs(userArgs);
371
+ launchArgs = rest;
372
+ callerSettings = sources.map((source) => loadCallerSettings(source));
373
+ }
374
+ catch (e) {
375
+ if (!(e instanceof ClaudeSettingsArgError))
376
+ throw e;
377
+ console.error(e.message);
378
+ return 1;
379
+ }
380
+ }
211
381
  const paths = claudePaths();
212
382
  const prepared = await prepareAgentLaunch(AGENT_PROFILES.claude, paths);
213
383
  if (typeof prepared === "number")
@@ -216,7 +386,9 @@ export async function runClaude(userArgs) {
216
386
  ? undefined
217
387
  : mcpLaunchEnv({ proxyBaseUrl: prepared.proxy.baseUrl, localToken: prepared.proxy.localKey, features: prepared.features, lang: lang() });
218
388
  const settings = claudeSettingsFromConfig(prepared.config, prepared.model);
219
- writeClaudeSettings(paths.settingsFile, mcp ? withMcpPermissions(settings) : settings);
389
+ cleanStaleSettingsFiles(paths.root);
390
+ const settingsFile = callerSettings.length ? processSettingsFile(paths) : paths.settingsFile;
391
+ writeClaudeSettings(settingsFile, finalClaudeSettings(settings, callerSettings, !!mcp));
220
392
  if (mcp)
221
393
  writePrivateFile(paths.mcpConfigFile, JSON.stringify(claudeMcpConfig(), null, 2) + "\n");
222
394
  else
@@ -228,6 +400,12 @@ export async function runClaude(userArgs) {
228
400
  configDir: paths.configDir,
229
401
  mcp,
230
402
  });
231
- const args = claudeLaunchArgs(paths.settingsFile, userArgs, mcp ? paths.mcpConfigFile : undefined);
232
- return runPreparedAgent({ agent: "claude", paths, prepared, args, env, userArgs });
403
+ const args = claudeLaunchArgs(settingsFile, launchArgs, mcp ? paths.mcpConfigFile : undefined);
404
+ try {
405
+ return await runPreparedAgent({ agent: "claude", paths, prepared, args, env, userArgs });
406
+ }
407
+ finally {
408
+ if (settingsFile !== paths.settingsFile)
409
+ rmSync(settingsFile, { force: true });
410
+ }
233
411
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {