easyrouter-config 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +798 -234
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,98 +5,126 @@ import {
5
5
  intro,
6
6
  outro,
7
7
  text,
8
- select,
8
+ multiselect,
9
9
  confirm,
10
10
  spinner,
11
- isCancel,
11
+ isCancel as isCancel2,
12
12
  cancel,
13
- log,
13
+ log as log3,
14
14
  note
15
15
  } from "@clack/prompts";
16
- import { execa } from "execa";
17
- import pc from "picocolors";
16
+ import pc6 from "picocolors";
17
+
18
+ // src/cli/args.ts
18
19
  import { parseArgs } from "util";
19
- var VERSION = true ? "1.0.5" : "0.0.0-dev";
20
+ import pc from "picocolors";
21
+
22
+ // src/config/constants.ts
23
+ var VERSION = true ? "1.0.7" : "0.0.0-dev";
20
24
  var PRIMARY_HOST = "https://easyrouter.io";
21
25
  var FALLBACK_HOSTS = ["https://ezr.sh"];
22
- var PROBE_TIMEOUT_MS = 5e3;
26
+ var HOST_PROBE_TIMEOUT_MS = 5e3;
27
+ var FETCH_MODELS_TIMEOUT_MS = 8e3;
28
+ var VERIFY_TIMEOUT_MS = 1e4;
29
+ var SUBPROCESS_IDLE_TIMEOUT_MS = 6e4;
30
+ var SUBPROCESS_HARD_TIMEOUT_MS = 3e5;
31
+ var PROGRESS_THROTTLE_MS = 200;
23
32
  var CODEX_FALLBACK_MODEL = "gpt-5.5";
33
+
34
+ // src/clients/types.ts
35
+ var ALL_CLIENT_IDS = ["claude", "codex", "openclaw", "hermes"];
36
+
37
+ // src/cli/args.ts
24
38
  function parseCliArgs() {
25
- try {
26
- const { values } = parseArgs({
27
- args: process.argv.slice(2),
28
- options: {
29
- "api-key": { type: "string", short: "k" },
30
- only: { type: "string" },
31
- "skip-verify": { type: "boolean" },
32
- verbose: { type: "boolean", short: "v" },
33
- help: { type: "boolean", short: "h" },
34
- version: { type: "boolean", short: "V" }
35
- },
36
- allowPositionals: false,
37
- strict: false
38
- });
39
- return {
40
- apiKey: values["api-key"],
41
- only: values.only,
42
- skipVerify: values["skip-verify"],
43
- verbose: values.verbose,
44
- help: values.help,
45
- version: values.version
46
- };
47
- } catch {
48
- return {};
39
+ const { values } = parseArgs({
40
+ args: process.argv.slice(2),
41
+ options: {
42
+ "api-key": { type: "string", short: "k" },
43
+ only: { type: "string" },
44
+ "skip-verify": { type: "boolean" },
45
+ verbose: { type: "boolean", short: "v" },
46
+ help: { type: "boolean", short: "h" },
47
+ version: { type: "boolean", short: "V" }
48
+ },
49
+ allowPositionals: false,
50
+ strict: false
51
+ });
52
+ return {
53
+ apiKey: values["api-key"],
54
+ only: parseOnlyValue(values.only),
55
+ skipVerify: values["skip-verify"],
56
+ verbose: values.verbose,
57
+ help: values.help,
58
+ version: values.version
59
+ };
60
+ }
61
+ function parseOnlyValue(raw) {
62
+ if (!raw) return void 0;
63
+ if (raw === "all") return [...ALL_CLIENT_IDS];
64
+ const tokens = raw.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
65
+ for (const t of tokens) {
66
+ if (!ALL_CLIENT_IDS.includes(t)) {
67
+ throw new Error(
68
+ `--only \u5305\u542B\u672A\u77E5\u5BA2\u6237\u7AEF "${t}"\u3002
69
+ \u53EF\u9009\u503C\uFF1A${ALL_CLIENT_IDS.join(", ")} \u6216 all`
70
+ );
71
+ }
49
72
  }
73
+ return tokens;
50
74
  }
51
75
  function printHelp() {
52
76
  console.log(`
53
77
  ${pc.bold(pc.cyan("easyrouter-config"))} ${pc.dim("v" + VERSION)}
54
- \u4E00\u952E\u628A EasyRouter \u63A5\u5165 Claude Code & Codex\uFF08\u6309\u91CF\u4ED8\u8D39\uFF09
78
+ \u4E00\u952E\u628A EasyRouter \u63A5\u5165 Claude Code / Codex / OpenClaw / Hermes\uFF08\u6309\u91CF\u4ED8\u8D39\uFF09
55
79
 
56
80
  ${pc.bold("\u7528\u6CD5:")}
57
- ${pc.green("npx -y easyrouter-config")} \u4EA4\u4E92\u5F0F\uFF08\u63A8\u8350\uFF09
58
- ${pc.green("npx -y easyrouter-config -k sk-xxx")} \u4E00\u884C\u547D\u4EE4\u914D\u7F6E
59
- ${pc.green("npx -y easyrouter-config -k sk-xxx --only claude")} \u53EA\u914D Claude Code
81
+ ${pc.green("npx -y easyrouter-config")} \u4EA4\u4E92\u5F0F\uFF08\u63A8\u8350 \u2014 \u9009\u4F60\u60F3\u914D\u7684\u5BA2\u6237\u7AEF\uFF09
82
+ ${pc.green("npx -y easyrouter-config -k sk-xxx")} \u975E\u4EA4\u4E92\uFF1A\u9ED8\u8BA4\u914D Claude Code + Codex
83
+ ${pc.green("npx -y easyrouter-config -k sk-xxx --only claude")} \u53EA\u914D Claude Code
84
+ ${pc.green("npx -y easyrouter-config -k sk-xxx --only claude,codex,openclaw")} \u914D\u591A\u4E2A
85
+ ${pc.green("npx -y easyrouter-config -k sk-xxx --only all")} \u914D\u5168\u90E8 4 \u4E2A\u5BA2\u6237\u7AEF
60
86
 
61
87
  ${pc.bold("\u53C2\u6570:")}
62
88
  -k, --api-key <key> EasyRouter API Key\uFF08\u53EF\u7701\u7565 sk- \u524D\u7F00\uFF09
63
- --only <tool> claude | codex \uFF08\u53EA\u914D\u5176\u4E00\uFF09
89
+ --only <list> \u9017\u53F7\u5206\u9694\u7684\u5BA2\u6237\u7AEF\uFF1A${ALL_CLIENT_IDS.join("|")}|all
64
90
  --skip-verify \u8DF3\u8FC7\u8FDE\u901A\u6027\u9A8C\u8BC1
65
- -v, --verbose \u663E\u793A\u5E95\u5C42\u7EC6\u8282\uFF08\u5305\u62EC BaseURL \u63A2\u6D4B\u8FC7\u7A0B\uFF09
91
+ -v, --verbose \u663E\u793A\u5E95\u5C42\u7EC6\u8282\uFF08\u542B BaseURL \u63A2\u6D4B\u3001\u6A21\u578B\u6311\u9009\u7B49\uFF09
66
92
  -h, --help \u663E\u793A\u5E2E\u52A9
67
93
  -V, --version \u663E\u793A\u7248\u672C
68
94
 
69
95
  ${pc.bold("\u5185\u7F6E BaseURL:")}
70
- Claude Code \u2192 ${PRIMARY_HOST}
71
- Codex \u2192 ${PRIMARY_HOST}/v1
96
+ EasyRouter \u2192 ${PRIMARY_HOST}
97
+ \u8DEF\u5F84\u4E0E\u8BA4\u8BC1\u7531\u5404\u5BA2\u6237\u7AEF\u81EA\u884C\u5904\u7406\uFF08OpenAI \u517C\u5BB9\u7528 /v1\uFF0CClaude \u76F4\u8FDE\u6839\u57DF\u540D\uFF09
72
98
 
73
99
  ${pc.bold("\u539F\u7406:")}
74
- Claude Code \u914D\u7F6E\u7531 ${pc.underline("zcf")} (https://github.com/UfoMiao/zcf) \u81EA\u52A8\u5199\u5165
75
- Codex \u914D\u7F6E\u7531\u672C\u5DE5\u5177\u76F4\u63A5\u5199\u5165\uFF08\u66F4\u53EF\u63A7\u3001\u66F4\u7A33\u5B9A\uFF09
76
-
77
- \u5B9E\u9645\u5199\u5165\u7684\u6587\u4EF6\uFF1A
78
- \u2022 Claude Code \u2192 ~/.claude/settings.json
79
- \u2022 Codex \u2192 ~/.codex/config.toml + ~/.codex/auth.json
100
+ Claude Code & Codex \u2192 \u8C03\u7528 ${pc.underline("zcf")} (UfoMiao/zcf) \u5B50\u8FDB\u7A0B
101
+ OpenClaw \u2192 \u8C03\u7528\u672C\u5730 ${pc.underline("openclaw onboard")} \u5B50\u547D\u4EE4\uFF08\u9700\u5148\u88C5 openclaw\uFF09
102
+ Hermes \u2192 \u8C03\u7528\u672C\u5730 ${pc.underline("hermes config set")} \u5B50\u547D\u4EE4\uFF08\u9700\u5148\u88C5 hermes\uFF09
80
103
  `);
81
104
  }
105
+
106
+ // src/config/host.ts
107
+ import { log } from "@clack/prompts";
108
+ import pc2 from "picocolors";
82
109
  async function pickAvailableHost(verbose) {
83
110
  const candidates = [PRIMARY_HOST, ...FALLBACK_HOSTS];
84
111
  for (const host of candidates) {
85
112
  const ok = await probeHost(host);
86
113
  if (verbose) {
87
- const tag = ok ? pc.green("\u2713") : pc.red("\u2717");
114
+ const tag = ok ? pc2.green("\u2713") : pc2.red("\u2717");
88
115
  log.info(`${tag} \u63A2\u6D4B ${host} \u2192 ${ok ? "\u53EF\u8FBE" : "\u4E0D\u53EF\u8FBE"}`);
89
116
  }
90
117
  if (ok) return host;
91
118
  }
92
- if (verbose) log.warn(`\u6240\u6709\u5019\u9009\u57DF\u540D\u5747\u4E0D\u53EF\u8FBE\uFF0C\u4F7F\u7528\u4E3B\u57DF\u540D ${PRIMARY_HOST}`);
119
+ if (verbose)
120
+ log.warn(`\u6240\u6709\u5019\u9009\u57DF\u540D\u5747\u4E0D\u53EF\u8FBE\uFF0C\u4F7F\u7528\u4E3B\u57DF\u540D ${PRIMARY_HOST}`);
93
121
  return PRIMARY_HOST;
94
122
  }
95
123
  async function probeHost(host) {
96
124
  const url = host.replace(/\/+$/, "") + "/v1/models";
97
125
  try {
98
126
  const ctrl = new AbortController();
99
- const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
127
+ const timer = setTimeout(() => ctrl.abort(), HOST_PROBE_TIMEOUT_MS);
100
128
  const res = await fetch(url, { method: "HEAD", signal: ctrl.signal });
101
129
  clearTimeout(timer);
102
130
  return res.status > 0;
@@ -104,64 +132,75 @@ async function probeHost(host) {
104
132
  return false;
105
133
  }
106
134
  }
107
- var FETCH_MODELS_TIMEOUT_MS = 8e3;
108
- async function fetchEasyRouterModels(host, verbose) {
109
- const url = host.replace(/\/+$/, "") + "/api/pricing";
135
+
136
+ // src/config/apiKey.ts
137
+ function normalizeApiKey(raw) {
138
+ const trimmed = raw.trim();
139
+ if (!trimmed) return trimmed;
140
+ if (trimmed.startsWith("sk-")) return trimmed;
141
+ return "sk-" + trimmed;
142
+ }
143
+ function maskKey(key) {
144
+ if (key.length <= 8) return "*".repeat(key.length);
145
+ return key.slice(0, 6) + "*".repeat(Math.max(4, key.length - 10)) + key.slice(-4);
146
+ }
147
+
148
+ // src/utils/verify.ts
149
+ async function verifyConnection(baseUrl, apiKey) {
150
+ const probeUrl = baseUrl.replace(/\/+$/, "") + "/v1/models";
110
151
  try {
111
152
  const ctrl = new AbortController();
112
- const timer = setTimeout(() => ctrl.abort(), FETCH_MODELS_TIMEOUT_MS);
113
- const res = await fetch(url, { signal: ctrl.signal });
153
+ const timer = setTimeout(() => ctrl.abort(), VERIFY_TIMEOUT_MS);
154
+ const res = await fetch(probeUrl, {
155
+ method: "GET",
156
+ headers: {
157
+ Authorization: `Bearer ${apiKey}`,
158
+ "anthropic-version": "2023-06-01"
159
+ },
160
+ signal: ctrl.signal
161
+ });
114
162
  clearTimeout(timer);
115
- if (!res.ok) {
116
- if (verbose) log.warn(`\u62C9\u53D6\u6A21\u578B\u5217\u8868\u5931\u8D25\uFF1AHTTP ${res.status}\uFF08\u5C06\u4F7F\u7528\u515C\u5E95\u6A21\u578B\uFF09`);
117
- return [];
118
- }
119
- const json = await res.json();
120
- const models = Array.isArray(json?.data) ? json.data : [];
121
- if (verbose) log.info(`\u2713 \u62C9\u53D6\u5230 ${models.length} \u4E2A EasyRouter \u6A21\u578B`);
122
- return models;
163
+ return { ok: res.ok, status: res.status };
123
164
  } catch (err) {
124
- if (verbose) log.warn(`\u62C9\u53D6\u6A21\u578B\u5217\u8868\u5F02\u5E38\uFF1A${err?.message || err}\uFF08\u5C06\u4F7F\u7528\u515C\u5E95\u6A21\u578B\uFF09`);
125
- return [];
165
+ return { ok: false, error: err?.message || String(err) };
126
166
  }
127
167
  }
128
- function pickBestCodexModel(models) {
129
- const isLlm = (m) => (m.tags || "").toUpperCase().includes("LLM");
130
- const supportsOpenAI = (m) => !m.supported_endpoint_types || m.supported_endpoint_types.includes("openai");
131
- const isGpt5 = (m) => /^gpt-5(\b|[.\-])/i.test(m.model_name);
132
- const isSmallTier = (m) => /(^|[\-_.])(mini|nano|small|lite|tiny)([\-_.]|$)/i.test(m.model_name);
133
- const byNameDesc = (a, b) => b.model_name.localeCompare(a.model_name, "en");
134
- const gpt5Big = models.filter((m) => isGpt5(m) && isLlm(m) && supportsOpenAI(m) && !isSmallTier(m)).sort(byNameDesc);
135
- if (gpt5Big.length) return gpt5Big[0].model_name;
136
- const gpt5Any = models.filter((m) => isGpt5(m) && isLlm(m) && supportsOpenAI(m)).sort(byNameDesc);
137
- if (gpt5Any.length) return gpt5Any[0].model_name;
138
- const anyBig = models.filter((m) => isLlm(m) && supportsOpenAI(m) && !isSmallTier(m)).sort(byNameDesc);
139
- if (anyBig.length) return anyBig[0].model_name;
140
- return null;
141
- }
142
- async function resolveCodexModel(host, interactive, verbose) {
143
- const models = await fetchEasyRouterModels(host, verbose);
144
- const auto = pickBestCodexModel(models);
145
- if (auto) {
146
- if (verbose) log.info(`\u81EA\u52A8\u9009\u62E9 Codex \u6A21\u578B\uFF1A${pc.cyan(auto)}`);
147
- return auto;
148
- }
149
- if (interactive && models.length) {
150
- const candidates = models.filter((m) => /^gpt-5|codex/i.test(m.model_name)).sort((a, b) => b.model_name.localeCompare(a.model_name, "en")).slice(0, 8);
151
- if (candidates.length) {
152
- const picked = await select({
153
- message: "\u672A\u81EA\u52A8\u8BC6\u522B Codex \u63A8\u8350\u6A21\u578B\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9",
154
- options: candidates.map((m) => ({ value: m.model_name, label: m.model_name }))
155
- });
156
- if (!isCancel(picked)) return picked;
168
+
169
+ // src/clients/detect.ts
170
+ import { execa } from "execa";
171
+ async function detectCommand(opts) {
172
+ const args = opts.args ?? ["--version"];
173
+ const timeout = opts.timeoutMs ?? 5e3;
174
+ try {
175
+ const result = await execa(opts.command, args, {
176
+ reject: false,
177
+ timeout,
178
+ windowsHide: true,
179
+ stdio: ["ignore", "pipe", "pipe"]
180
+ });
181
+ if (result?.errno === "ENOENT" || result?.code === "ENOENT") {
182
+ return { installed: false };
157
183
  }
184
+ if (result?.failed && /\bENOENT\b|not found|command not found/i.test(
185
+ String(result?.shortMessage || result?.stderr || "")
186
+ )) {
187
+ return { installed: false };
188
+ }
189
+ const out = ((result?.stdout || "") + "\n" + (result?.stderr || "")).split(/\r?\n/)[0]?.trim();
190
+ const v = out?.match(/v?(\d+\.\d+(?:\.\d+)?(?:[\-+][\w.]+)?)/)?.[1];
191
+ return { installed: true, version: v };
192
+ } catch (err) {
193
+ if (err?.code === "ENOENT" || /ENOENT/.test(String(err?.message))) {
194
+ return { installed: false };
195
+ }
196
+ return { installed: false };
158
197
  }
159
- if (verbose) log.warn(`\u65E0\u6CD5\u81EA\u52A8\u9009\u62E9 Codex \u6A21\u578B\uFF0C\u4F7F\u7528\u515C\u5E95\uFF1A${CODEX_FALLBACK_MODEL}`);
160
- return CODEX_FALLBACK_MODEL;
161
198
  }
162
- var IDLE_TIMEOUT_MS = 6e4;
163
- var HARD_TIMEOUT_MS = 3e5;
164
- async function configureViaZcf(opts) {
199
+
200
+ // src/clients/zcf-runner.ts
201
+ import { execa as execa2 } from "execa";
202
+ import pc3 from "picocolors";
203
+ async function runZcf(opts) {
165
204
  const args = [
166
205
  "-y",
167
206
  "zcf",
@@ -170,7 +209,7 @@ async function configureViaZcf(opts) {
170
209
  // --skip-prompt 非交互模式
171
210
  "-T",
172
211
  opts.target,
173
- // 'cc' | 'codex'(用文档推荐的简写 -T)
212
+ // 'cc' | 'codex'
174
213
  // EasyRouter 是 OpenAI 兼容网关,统一用 api_key(Bearer Authorization)
175
214
  "--api-type",
176
215
  "api_key",
@@ -180,10 +219,8 @@ async function configureViaZcf(opts) {
180
219
  opts.baseUrl,
181
220
  "-r",
182
221
  "backup",
183
- // 已有配置自动备份
184
222
  "--mcp-services",
185
223
  "skip",
186
- // 不装 MCP(即便 zcf 偶尔忽略也无伤)
187
224
  "--workflows",
188
225
  "skip",
189
226
  "--output-styles",
@@ -201,8 +238,7 @@ async function configureViaZcf(opts) {
201
238
  let lastDataAt = Date.now();
202
239
  let killedReason = null;
203
240
  let lastProgressAt = 0;
204
- const PROGRESS_THROTTLE_MS = 200;
205
- const child = execa(cmd, args, {
241
+ const child = execa2(cmd, args, {
206
242
  stdio: ["ignore", "pipe", "pipe"],
207
243
  env: {
208
244
  ...process.env,
@@ -234,8 +270,10 @@ async function configureViaZcf(opts) {
234
270
  child.stdout?.on("data", (c) => handleChunk(c, "out"));
235
271
  child.stderr?.on("data", (c) => handleChunk(c, "err"));
236
272
  const idleTimer = setInterval(() => {
237
- if (Date.now() - lastDataAt > IDLE_TIMEOUT_MS) {
238
- killedReason = `\u5B50\u8FDB\u7A0B\u5DF2 ${Math.floor(IDLE_TIMEOUT_MS / 1e3)} \u79D2\u65E0\u8F93\u51FA\uFF08\u7591\u4F3C\u5361\u5728\u4EA4\u4E92\u5F0F\u63D0\u95EE\u6216\u7F51\u7EDC\u95EE\u9898\uFF09`;
273
+ if (Date.now() - lastDataAt > SUBPROCESS_IDLE_TIMEOUT_MS) {
274
+ killedReason = `\u5B50\u8FDB\u7A0B\u5DF2 ${Math.floor(
275
+ SUBPROCESS_IDLE_TIMEOUT_MS / 1e3
276
+ )} \u79D2\u65E0\u8F93\u51FA\uFF08\u7591\u4F3C\u5361\u5728\u4EA4\u4E92\u5F0F\u63D0\u95EE\u6216\u7F51\u7EDC\u95EE\u9898\uFF09`;
239
277
  child.kill("SIGTERM");
240
278
  setTimeout(() => {
241
279
  try {
@@ -246,7 +284,9 @@ async function configureViaZcf(opts) {
246
284
  }
247
285
  }, 5e3);
248
286
  const hardTimer = setTimeout(() => {
249
- killedReason = `\u5B50\u8FDB\u7A0B\u8D85\u8FC7 ${Math.floor(HARD_TIMEOUT_MS / 1e3)} \u79D2\u672A\u7ED3\u675F\uFF08\u5F3A\u5236\u8D85\u65F6\uFF09`;
287
+ killedReason = `\u5B50\u8FDB\u7A0B\u8D85\u8FC7 ${Math.floor(
288
+ SUBPROCESS_HARD_TIMEOUT_MS / 1e3
289
+ )} \u79D2\u672A\u7ED3\u675F\uFF08\u5F3A\u5236\u8D85\u65F6\uFF09`;
250
290
  child.kill("SIGTERM");
251
291
  setTimeout(() => {
252
292
  try {
@@ -254,7 +294,7 @@ async function configureViaZcf(opts) {
254
294
  } catch {
255
295
  }
256
296
  }, 5e3);
257
- }, HARD_TIMEOUT_MS);
297
+ }, SUBPROCESS_HARD_TIMEOUT_MS);
258
298
  const result = await child.catch((e) => e);
259
299
  clearInterval(idleTimer);
260
300
  clearTimeout(hardTimer);
@@ -269,7 +309,7 @@ async function configureViaZcf(opts) {
269
309
  \u2022 npm \u955C\u50CF/\u7F51\u7EDC\u6162\uFF0Cnpx \u4E0B\u8F7D zcf \u5305\u5361\u4F4F
270
310
  \u2022 \u9632\u706B\u5899\u62E6\u622A\u4E86 registry.npmjs.org
271
311
 
272
- \u5EFA\u8BAE\uFF1A\u7528 ${pc.green("--verbose")} \u91CD\u65B0\u8FD0\u884C\u67E5\u770B\u8BE6\u7EC6\u65E5\u5FD7`
312
+ \u5EFA\u8BAE\uFF1A\u7528 ${pc3.green("--verbose")} \u91CD\u65B0\u8FD0\u884C\u67E5\u770B\u8BE6\u7EC6\u65E5\u5FD7`
273
313
  );
274
314
  }
275
315
  if (exitCode !== 0 || signal) {
@@ -278,7 +318,7 @@ async function configureViaZcf(opts) {
278
318
  `zcf \u5F02\u5E38\u9000\u51FA\uFF08exit=${exitCode}\uFF09:
279
319
  ${detail}
280
320
 
281
- \u{1F4A1} \u7528 ${pc.green("--verbose")} \u91CD\u65B0\u8FD0\u884C\u53EF\u770B\u5230\u5B8C\u6574\u65E5\u5FD7`
321
+ \u{1F4A1} \u7528 ${pc3.green("--verbose")} \u91CD\u65B0\u8FD0\u884C\u53EF\u770B\u5230\u5B8C\u6574\u65E5\u5FD7`
282
322
  );
283
323
  }
284
324
  const combined = capturedOut + "\n" + capturedErr;
@@ -292,35 +332,544 @@ ${detail}
292
332
  );
293
333
  }
294
334
  }
295
- async function verifyConnection(baseUrl, apiKey) {
296
- const probeUrl = baseUrl.replace(/\/+$/, "") + "/v1/models";
335
+
336
+ // src/clients/claude.ts
337
+ var claudeClient = {
338
+ id: "claude",
339
+ label: "Claude Code",
340
+ hint: "~/.claude/settings.json",
341
+ launchCommand: "claude",
342
+ async detect() {
343
+ const r = await detectCommand({ command: "claude" });
344
+ return {
345
+ installed: r.installed,
346
+ version: r.version,
347
+ installHint: "\u524D\u5F80 https://claude.com/claude-code \u5B89\u88C5\uFF0C\u6216\u8FD0\u884C\uFF1A\n npm install -g @anthropic-ai/claude-code"
348
+ };
349
+ },
350
+ async configure(ctx) {
351
+ await runZcf({
352
+ target: "cc",
353
+ baseUrl: ctx.host,
354
+ apiKey: ctx.apiKey,
355
+ verbose: ctx.verbose,
356
+ onProgress: ctx.onProgress
357
+ });
358
+ return { configPaths: ["~/.claude/settings.json"] };
359
+ }
360
+ };
361
+
362
+ // src/config/models.ts
363
+ import { log as log2, select, isCancel } from "@clack/prompts";
364
+ import pc4 from "picocolors";
365
+ function toOpenClawCost(m) {
366
+ const ratio = m.model_ratio ?? 0;
367
+ const compRatio = m.completion_ratio ?? 1;
368
+ const cacheRatio = m.cache_ratio ?? 0;
369
+ const input = round6(ratio * 2e-3);
370
+ const output = round6(ratio * compRatio * 2e-3);
371
+ const cacheRead = round6(ratio * cacheRatio * 2e-3);
372
+ const cacheWrite = round6(input * 1.25);
373
+ const tiered = m.tiered_pricing;
374
+ const contextWindow = tiered?.input?.threshold ?? tiered?.output?.threshold ?? tiered?.cache_read?.threshold ?? 131072;
375
+ const maxTokens = Math.min(32768, Math.max(4096, Math.floor(contextWindow / 4)));
376
+ return {
377
+ cost: { input, output, cacheRead, cacheWrite },
378
+ contextWindow,
379
+ maxTokens
380
+ };
381
+ }
382
+ function round6(n) {
383
+ return Math.round(n * 1e6) / 1e6;
384
+ }
385
+ async function fetchEasyRouterModels(host, verbose) {
386
+ const url = host.replace(/\/+$/, "") + "/api/pricing";
297
387
  try {
298
388
  const ctrl = new AbortController();
299
- const timer = setTimeout(() => ctrl.abort(), 1e4);
300
- const res = await fetch(probeUrl, {
301
- method: "GET",
302
- headers: {
303
- Authorization: `Bearer ${apiKey}`,
304
- "anthropic-version": "2023-06-01"
305
- },
306
- signal: ctrl.signal
307
- });
389
+ const timer = setTimeout(() => ctrl.abort(), FETCH_MODELS_TIMEOUT_MS);
390
+ const res = await fetch(url, { signal: ctrl.signal });
308
391
  clearTimeout(timer);
309
- return { ok: res.ok, status: res.status };
392
+ if (!res.ok) {
393
+ if (verbose)
394
+ log2.warn(`\u62C9\u53D6\u6A21\u578B\u5217\u8868\u5931\u8D25\uFF1AHTTP ${res.status}\uFF08\u5C06\u4F7F\u7528\u515C\u5E95\u6A21\u578B\uFF09`);
395
+ return [];
396
+ }
397
+ const json = await res.json();
398
+ const models = Array.isArray(json?.data) ? json.data : [];
399
+ if (verbose) log2.info(`\u2713 \u62C9\u53D6\u5230 ${models.length} \u4E2A EasyRouter \u6A21\u578B`);
400
+ return models;
310
401
  } catch (err) {
311
- return { ok: false, error: err?.message || String(err) };
402
+ if (verbose)
403
+ log2.warn(`\u62C9\u53D6\u6A21\u578B\u5217\u8868\u5F02\u5E38\uFF1A${err?.message || err}\uFF08\u5C06\u4F7F\u7528\u515C\u5E95\u6A21\u578B\uFF09`);
404
+ return [];
312
405
  }
313
406
  }
314
- function normalizeApiKey(raw) {
315
- const trimmed = raw.trim();
316
- if (!trimmed) return trimmed;
317
- if (trimmed.startsWith("sk-")) return trimmed;
318
- return "sk-" + trimmed;
407
+ function filterOpenAICompatibleLLMs(models) {
408
+ return models.filter((m) => {
409
+ const isLlm = (m.tags || "").toUpperCase().includes("LLM");
410
+ const supportsOpenAI = !m.supported_endpoint_types || m.supported_endpoint_types.length === 0 || m.supported_endpoint_types.includes("openai");
411
+ return isLlm && supportsOpenAI;
412
+ });
319
413
  }
320
- function maskKey(key) {
321
- if (key.length <= 8) return "*".repeat(key.length);
322
- return key.slice(0, 6) + "*".repeat(Math.max(4, key.length - 10)) + key.slice(-4);
414
+ function pickBestCodexModel(models) {
415
+ const isLlm = (m) => (m.tags || "").toUpperCase().includes("LLM");
416
+ const supportsOpenAI = (m) => !m.supported_endpoint_types || m.supported_endpoint_types.includes("openai");
417
+ const isGpt5 = (m) => /^gpt-5(\b|[.\-])/i.test(m.model_name);
418
+ const isSmallTier = (m) => /(^|[\-_.])(mini|nano|small|lite|tiny)([\-_.]|$)/i.test(m.model_name);
419
+ const extractVersion = (name) => {
420
+ const m = name.match(/^gpt-(\d+(?:\.\d+)*)/i);
421
+ return m ? m[1] : "0";
422
+ };
423
+ const compareVersion = (a, b) => {
424
+ const pa = a.split(".").map(Number);
425
+ const pb = b.split(".").map(Number);
426
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
427
+ const x = pa[i] || 0;
428
+ const y = pb[i] || 0;
429
+ if (x !== y) return x - y;
430
+ }
431
+ return 0;
432
+ };
433
+ const pickFromPool = (pool) => {
434
+ if (!pool.length) return null;
435
+ const byVersion = /* @__PURE__ */ new Map();
436
+ for (const m of pool) {
437
+ const v = extractVersion(m.model_name);
438
+ if (!byVersion.has(v)) byVersion.set(v, []);
439
+ byVersion.get(v).push(m);
440
+ }
441
+ const maxVersion = Array.from(byVersion.keys()).sort(compareVersion).pop();
442
+ const sameVersion = byVersion.get(maxVersion);
443
+ const withCodex = sameVersion.filter((m) => /codex/i.test(m.model_name));
444
+ if (withCodex.length) {
445
+ return withCodex.sort(
446
+ (a, b) => a.model_name.localeCompare(b.model_name)
447
+ )[0].model_name;
448
+ }
449
+ return sameVersion.sort(
450
+ (a, b) => a.model_name.localeCompare(b.model_name)
451
+ )[0].model_name;
452
+ };
453
+ const tier1 = pickFromPool(
454
+ models.filter(
455
+ (m) => isGpt5(m) && isLlm(m) && supportsOpenAI(m) && !isSmallTier(m)
456
+ )
457
+ );
458
+ if (tier1) return tier1;
459
+ const tier2 = pickFromPool(
460
+ models.filter((m) => isGpt5(m) && isLlm(m) && supportsOpenAI(m))
461
+ );
462
+ if (tier2) return tier2;
463
+ const tier3 = pickFromPool(
464
+ models.filter((m) => isLlm(m) && supportsOpenAI(m) && !isSmallTier(m))
465
+ );
466
+ if (tier3) return tier3;
467
+ return null;
323
468
  }
469
+ async function resolveCodexModel(host, interactive, verbose) {
470
+ const models = await fetchEasyRouterModels(host, verbose);
471
+ const auto = pickBestCodexModel(models);
472
+ if (auto) {
473
+ if (verbose) log2.info(`\u81EA\u52A8\u9009\u62E9 Codex \u6A21\u578B\uFF1A${pc4.cyan(auto)}`);
474
+ return auto;
475
+ }
476
+ if (interactive && models.length) {
477
+ const candidates = models.filter((m) => /^gpt-5|codex/i.test(m.model_name)).sort((a, b) => b.model_name.localeCompare(a.model_name, "en")).slice(0, 8);
478
+ if (candidates.length) {
479
+ const picked = await select({
480
+ message: "\u672A\u81EA\u52A8\u8BC6\u522B Codex \u63A8\u8350\u6A21\u578B\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9",
481
+ options: candidates.map((m) => ({
482
+ value: m.model_name,
483
+ label: m.model_name
484
+ }))
485
+ });
486
+ if (!isCancel(picked)) return picked;
487
+ }
488
+ }
489
+ if (verbose)
490
+ log2.warn(`\u65E0\u6CD5\u81EA\u52A8\u9009\u62E9 Codex \u6A21\u578B\uFF0C\u4F7F\u7528\u515C\u5E95\uFF1A${CODEX_FALLBACK_MODEL}`);
491
+ return CODEX_FALLBACK_MODEL;
492
+ }
493
+
494
+ // src/clients/codex.ts
495
+ var codexClient = {
496
+ id: "codex",
497
+ label: "Codex",
498
+ hint: "~/.codex/config.toml + auth.json",
499
+ launchCommand: "codex",
500
+ async detect() {
501
+ const r = await detectCommand({ command: "codex" });
502
+ return {
503
+ installed: r.installed,
504
+ version: r.version,
505
+ installHint: "\u524D\u5F80 https://github.com/openai/codex \u5B89\u88C5\uFF0C\u6216\u8FD0\u884C\uFF1A\n npm install -g @openai/codex\n # \u6216 macOS: brew install --cask codex"
506
+ };
507
+ },
508
+ async configure(ctx) {
509
+ const interactive = typeof process !== "undefined" && !!process.stdout.isTTY;
510
+ const model = await resolveCodexModel(ctx.host, interactive, ctx.verbose);
511
+ const codexBaseUrl = ctx.host.replace(/\/+$/, "") + "/v1";
512
+ await runZcf({
513
+ target: "codex",
514
+ baseUrl: codexBaseUrl,
515
+ apiKey: ctx.apiKey,
516
+ apiModel: model,
517
+ verbose: ctx.verbose,
518
+ onProgress: ctx.onProgress
519
+ });
520
+ return {
521
+ configPaths: ["~/.codex/config.toml", "~/.codex/auth.json"],
522
+ model
523
+ };
524
+ }
525
+ };
526
+
527
+ // src/clients/openclaw.ts
528
+ import { execa as execa3 } from "execa";
529
+ import { promises as fs } from "fs";
530
+ import { homedir } from "os";
531
+ import { join, dirname } from "path";
532
+ import pc5 from "picocolors";
533
+ var OPENCLAW_PROVIDER_ID = "custom-easyrouter-io";
534
+ var openClawClient = {
535
+ id: "openclaw",
536
+ label: "OpenClaw",
537
+ hint: "~/.openclaw/openclaw.json",
538
+ launchCommand: "openclaw",
539
+ async detect() {
540
+ const r = await detectCommand({ command: "openclaw" });
541
+ return {
542
+ installed: r.installed,
543
+ version: r.version,
544
+ installHint: "\u524D\u5F80 https://openclaw.ai \u5B89\u88C5\uFF0C\u6216\u8FD0\u884C\uFF1A\n curl -fsSL https://openclaw.ai/install.sh | bash\n # \u6216 npm install -g openclaw\n # \u6216 macOS: brew install openclaw-cli"
545
+ };
546
+ },
547
+ async configure(ctx) {
548
+ const interactive = typeof process !== "undefined" && !!process.stdout.isTTY;
549
+ const allModels = await fetchEasyRouterModels(ctx.host, ctx.verbose);
550
+ const llms = filterOpenAICompatibleLLMs(allModels);
551
+ const defaultModel = await resolveCodexModel(
552
+ ctx.host,
553
+ interactive,
554
+ ctx.verbose
555
+ );
556
+ const baseUrl = ctx.host.replace(/\/+$/, "") + "/v1";
557
+ const args = [
558
+ "onboard",
559
+ "--non-interactive",
560
+ "--auth-choice",
561
+ "custom-api-key",
562
+ "--custom-base-url",
563
+ baseUrl,
564
+ "--custom-model-id",
565
+ defaultModel,
566
+ "--custom-api-key",
567
+ ctx.apiKey,
568
+ "--custom-compatibility",
569
+ "openai",
570
+ "--secret-input-mode",
571
+ "plaintext",
572
+ "--accept-risk",
573
+ // 自定义 provider 必填
574
+ "--skip-health"
575
+ // 不要求 gateway daemon 启动
576
+ ];
577
+ await runOpenClawSubprocess(args, ctx);
578
+ let registered = 0;
579
+ if (llms.length > 1) {
580
+ registered = await patchOpenClawJson(llms, defaultModel, ctx);
581
+ }
582
+ return {
583
+ configPaths: ["~/.openclaw/openclaw.json"],
584
+ model: registered > 1 ? `${defaultModel} (+${registered - 1} more)` : defaultModel
585
+ };
586
+ }
587
+ };
588
+ async function patchOpenClawJson(llms, defaultModel, ctx) {
589
+ const configPath = join(homedir(), ".openclaw", "openclaw.json");
590
+ let json;
591
+ try {
592
+ const raw = await fs.readFile(configPath, "utf8");
593
+ json = JSON.parse(raw);
594
+ } catch (err) {
595
+ if (ctx.verbose) {
596
+ console.warn(
597
+ `[openclaw] \u8DF3\u8FC7 patch\uFF1A\u8BFB ${configPath} \u5931\u8D25\uFF08${err?.code || err?.message}\uFF09`
598
+ );
599
+ }
600
+ return 1;
601
+ }
602
+ const providers = json?.models?.providers;
603
+ if (!providers || typeof providers !== "object") {
604
+ if (ctx.verbose) {
605
+ console.warn(`[openclaw] \u8DF3\u8FC7 patch\uFF1Amodels.providers \u6BB5\u4E0D\u5B58\u5728`);
606
+ }
607
+ return 1;
608
+ }
609
+ const provider = providers[OPENCLAW_PROVIDER_ID];
610
+ if (!provider || !Array.isArray(provider.models)) {
611
+ if (ctx.verbose) {
612
+ console.warn(
613
+ `[openclaw] \u8DF3\u8FC7 patch\uFF1Amodels.providers.${OPENCLAW_PROVIDER_ID}.models \u4E0D\u662F\u6570\u7EC4`
614
+ );
615
+ }
616
+ return 1;
617
+ }
618
+ const existingById = /* @__PURE__ */ new Map();
619
+ provider.models.forEach((m, i) => {
620
+ existingById.set(m.id, i);
621
+ });
622
+ const before = provider.models.length;
623
+ let updated = 0;
624
+ const agentsModels = json?.agents?.defaults?.models ?? null;
625
+ for (const m of llms) {
626
+ const { cost, contextWindow, maxTokens } = toOpenClawCost(m);
627
+ const idx = existingById.get(m.model_name);
628
+ if (idx !== void 0) {
629
+ const existing = provider.models[idx];
630
+ if (existing.cost?.input === 0 && existing.cost?.output === 0) {
631
+ existing.cost = cost;
632
+ existing.contextWindow = contextWindow;
633
+ existing.maxTokens = maxTokens;
634
+ updated++;
635
+ }
636
+ } else {
637
+ provider.models.push({
638
+ id: m.model_name,
639
+ name: m.model_name + " (EasyRouter)",
640
+ contextWindow,
641
+ maxTokens,
642
+ input: ["text"],
643
+ cost,
644
+ reasoning: false
645
+ });
646
+ }
647
+ if (agentsModels !== null) {
648
+ const agentKey = `${OPENCLAW_PROVIDER_ID}/${m.model_name}`;
649
+ const existing = agentsModels[agentKey];
650
+ if (!existing || Object.keys(existing).length === 0) {
651
+ agentsModels[agentKey] = { alias: agentKey };
652
+ }
653
+ }
654
+ }
655
+ const added = provider.models.length - before;
656
+ if (added === 0 && updated === 0) {
657
+ if (ctx.verbose) {
658
+ console.warn(
659
+ `[openclaw] \u6240\u6709 ${llms.length} \u4E2A\u6A21\u578B\u5747\u5DF2\u662F\u6700\u65B0\uFF0C\u8DF3\u8FC7\u5199\u5165`
660
+ );
661
+ }
662
+ return provider.models.length;
663
+ }
664
+ await fs.copyFile(configPath, configPath + ".bak." + Date.now());
665
+ await atomicWriteJson(configPath, json);
666
+ if (ctx.verbose) {
667
+ const parts = [];
668
+ if (added > 0) parts.push(`\u65B0\u589E ${added} \u4E2A`);
669
+ if (updated > 0) parts.push(`\u4FEE\u6B63 cost/ctx ${updated} \u4E2A`);
670
+ console.warn(
671
+ `[openclaw] \u2713 ${parts.join("\uFF0C")}\uFF0C\u5171 ${provider.models.length} \u4E2A\uFF08\u9ED8\u8BA4\uFF1A${defaultModel}\uFF09`
672
+ );
673
+ }
674
+ return provider.models.length;
675
+ }
676
+ async function atomicWriteJson(path, obj) {
677
+ const tmp = path + ".tmp." + process.pid;
678
+ await fs.mkdir(dirname(path), { recursive: true });
679
+ await fs.writeFile(tmp, JSON.stringify(obj, null, 2) + "\n", "utf8");
680
+ await fs.rename(tmp, path);
681
+ }
682
+ async function runOpenClawSubprocess(args, ctx) {
683
+ let capturedOut = "";
684
+ let capturedErr = "";
685
+ let lastDataAt = Date.now();
686
+ let killedReason = null;
687
+ let lastProgressAt = 0;
688
+ const child = execa3("openclaw", args, {
689
+ stdio: ["ignore", "pipe", "pipe"],
690
+ env: { ...process.env, FORCE_COLOR: "0" },
691
+ reject: false,
692
+ windowsHide: true
693
+ });
694
+ const handleChunk = (chunk, target) => {
695
+ const text2 = typeof chunk === "string" ? chunk : chunk.toString("utf8");
696
+ if (target === "out") capturedOut += text2;
697
+ else capturedErr += text2;
698
+ lastDataAt = Date.now();
699
+ if (ctx.onProgress) {
700
+ const now = Date.now();
701
+ if (now - lastProgressAt >= PROGRESS_THROTTLE_MS) {
702
+ lastProgressAt = now;
703
+ const lines = text2.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
704
+ const last = lines[lines.length - 1];
705
+ if (last) ctx.onProgress(last.slice(0, 60));
706
+ }
707
+ }
708
+ if (ctx.verbose) process.stderr.write(text2);
709
+ };
710
+ child.stdout?.on("data", (c) => handleChunk(c, "out"));
711
+ child.stderr?.on("data", (c) => handleChunk(c, "err"));
712
+ const idleTimer = setInterval(() => {
713
+ if (Date.now() - lastDataAt > SUBPROCESS_IDLE_TIMEOUT_MS) {
714
+ killedReason = `openclaw \u5DF2 ${Math.floor(
715
+ SUBPROCESS_IDLE_TIMEOUT_MS / 1e3
716
+ )} \u79D2\u65E0\u8F93\u51FA\uFF08\u7591\u4F3C\u5361\u4F4F\uFF09`;
717
+ child.kill("SIGTERM");
718
+ setTimeout(() => {
719
+ try {
720
+ child.kill("SIGKILL");
721
+ } catch {
722
+ }
723
+ }, 5e3);
724
+ }
725
+ }, 5e3);
726
+ const hardTimer = setTimeout(() => {
727
+ killedReason = `openclaw \u8D85\u8FC7 ${Math.floor(
728
+ SUBPROCESS_HARD_TIMEOUT_MS / 1e3
729
+ )} \u79D2\u672A\u7ED3\u675F\uFF08\u5F3A\u5236\u8D85\u65F6\uFF09`;
730
+ child.kill("SIGTERM");
731
+ setTimeout(() => {
732
+ try {
733
+ child.kill("SIGKILL");
734
+ } catch {
735
+ }
736
+ }, 5e3);
737
+ }, SUBPROCESS_HARD_TIMEOUT_MS);
738
+ const result = await child.catch((e) => e);
739
+ clearInterval(idleTimer);
740
+ clearTimeout(hardTimer);
741
+ const exitCode = result?.exitCode ?? null;
742
+ if (killedReason) {
743
+ throw new Error(
744
+ `openclaw \u8C03\u7528\u5931\u8D25\uFF1A${killedReason}
745
+
746
+ \u5EFA\u8BAE\uFF1A\u7528 ${pc5.green("--verbose")} \u91CD\u65B0\u8FD0\u884C\u67E5\u770B\u8BE6\u7EC6\u65E5\u5FD7`
747
+ );
748
+ }
749
+ if (exitCode !== 0) {
750
+ const detail = (capturedErr || capturedOut || "").slice(-2e3) || `exit ${exitCode}`;
751
+ throw new Error(
752
+ `openclaw \u5F02\u5E38\u9000\u51FA\uFF08exit=${exitCode}\uFF09:
753
+ ${detail}
754
+
755
+ \u{1F4A1} \u7528 ${pc5.green("--verbose")} \u91CD\u65B0\u8FD0\u884C\u53EF\u770B\u5230\u5B8C\u6574\u65E5\u5FD7`
756
+ );
757
+ }
758
+ }
759
+
760
+ // src/clients/hermes.ts
761
+ import { execa as execa4 } from "execa";
762
+ import { promises as fs2 } from "fs";
763
+ import { homedir as homedir2 } from "os";
764
+ import { join as join2 } from "path";
765
+ var HERMES_PROVIDER_NAME = "easyrouter";
766
+ var HERMES_KEY_ENV = "EASYROUTER_API_KEY";
767
+ var hermesClient = {
768
+ id: "hermes",
769
+ label: "Hermes",
770
+ hint: "~/.hermes/config.yaml + .env",
771
+ launchCommand: "hermes",
772
+ async detect() {
773
+ const r = await detectCommand({ command: "hermes" });
774
+ return {
775
+ installed: r.installed,
776
+ version: r.version,
777
+ installHint: "\u524D\u5F80 https://hermes.nousresearch.com \u5B89\u88C5\uFF0C\u6216\u8FD0\u884C\uFF1A\n curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
778
+ };
779
+ },
780
+ async configure(ctx) {
781
+ const interactive = typeof process !== "undefined" && !!process.stdout.isTTY;
782
+ const defaultModel = await resolveCodexModel(
783
+ ctx.host,
784
+ interactive,
785
+ ctx.verbose
786
+ );
787
+ const baseUrl = ctx.host.replace(/\/+$/, "") + "/v1";
788
+ await runHermesConfigSet(
789
+ `providers.${HERMES_PROVIDER_NAME}.base_url`,
790
+ baseUrl,
791
+ ctx
792
+ );
793
+ if (ctx.onProgress) ctx.onProgress(`provider \u6CE8\u518C`);
794
+ await runHermesConfigSet(
795
+ `providers.${HERMES_PROVIDER_NAME}.key_env`,
796
+ HERMES_KEY_ENV,
797
+ ctx
798
+ );
799
+ await runHermesConfigSet(
800
+ `providers.${HERMES_PROVIDER_NAME}.kind`,
801
+ "openai",
802
+ ctx
803
+ );
804
+ const fullDefaultId = `${HERMES_PROVIDER_NAME}/${defaultModel}`;
805
+ await runHermesConfigSet("model.default", fullDefaultId, ctx);
806
+ await runHermesConfigSet("model.provider", HERMES_PROVIDER_NAME, ctx);
807
+ await runHermesConfigSet("model.base_url", baseUrl, ctx);
808
+ if (ctx.onProgress) ctx.onProgress(`\u9ED8\u8BA4 model = ${defaultModel}`);
809
+ const envPath = join2(homedir2(), ".hermes", ".env");
810
+ await fs2.mkdir(join2(homedir2(), ".hermes"), { recursive: true });
811
+ let envContent = "";
812
+ try {
813
+ envContent = await fs2.readFile(envPath, "utf8");
814
+ } catch (err) {
815
+ if (err.code !== "ENOENT") throw err;
816
+ }
817
+ envContent = upsertEnvVar(envContent, HERMES_KEY_ENV, ctx.apiKey);
818
+ await fs2.writeFile(envPath, envContent, { encoding: "utf8", mode: 384 });
819
+ if (ctx.onProgress) ctx.onProgress("\u5199\u5165 ~/.hermes/.env");
820
+ return {
821
+ configPaths: ["~/.hermes/config.yaml", "~/.hermes/.env"],
822
+ model: defaultModel
823
+ };
824
+ }
825
+ };
826
+ async function runHermesConfigSet(key, value, ctx) {
827
+ const result = await execa4("hermes", ["config", "set", key, value], {
828
+ reject: false,
829
+ timeout: 3e4,
830
+ windowsHide: true,
831
+ env: { ...process.env, FORCE_COLOR: "0" },
832
+ stdio: ["ignore", "pipe", "pipe"]
833
+ });
834
+ if (ctx.verbose && (result.stdout || result.stderr)) {
835
+ process.stderr.write(
836
+ `[hermes config set ${key}]
837
+ ${result.stdout}
838
+ ${result.stderr}
839
+ `
840
+ );
841
+ }
842
+ if (result.exitCode !== 0) {
843
+ const detail = (result.stderr || result.stdout || "").slice(-1e3);
844
+ throw new Error(
845
+ `hermes config set ${key} \u5931\u8D25\uFF08exit=${result.exitCode}\uFF09:
846
+ ${detail}`
847
+ );
848
+ }
849
+ }
850
+ function upsertEnvVar(content, key, value) {
851
+ const escaped = value.replace(/(["\\])/g, "\\$1");
852
+ const line = `${key}="${escaped}"`;
853
+ const re = new RegExp(`^${key}=.*$`, "m");
854
+ if (re.test(content)) {
855
+ return content.replace(re, line);
856
+ }
857
+ const trailing = content.length === 0 || content.endsWith("\n") ? "" : "\n";
858
+ return content + trailing + line + "\n";
859
+ }
860
+
861
+ // src/clients/registry.ts
862
+ var CLIENT_REGISTRY = {
863
+ claude: claudeClient,
864
+ codex: codexClient,
865
+ openclaw: openClawClient,
866
+ hermes: hermesClient
867
+ };
868
+ function listClients() {
869
+ return Object.values(CLIENT_REGISTRY);
870
+ }
871
+
872
+ // src/index.ts
324
873
  async function main() {
325
874
  const args = parseCliArgs();
326
875
  if (args.help) {
@@ -332,15 +881,16 @@ async function main() {
332
881
  return;
333
882
  }
334
883
  console.log();
335
- intro(pc.bgCyan(pc.black(" EasyRouter Config ")) + pc.dim(" v" + VERSION));
884
+ intro(
885
+ pc6.bgCyan(pc6.black(" EasyRouter Config ")) + pc6.dim(" v" + VERSION)
886
+ );
336
887
  const verbose = !!args.verbose;
337
- const selectedHost = await pickAvailableHost(verbose);
338
- const claudeBaseUrl = selectedHost;
339
- const codexBaseUrl = selectedHost.replace(/\/+$/, "") + "/v1";
888
+ const interactive = !args.apiKey;
889
+ const host = await pickAvailableHost(verbose);
340
890
  let apiKey;
341
891
  if (args.apiKey) {
342
892
  apiKey = normalizeApiKey(args.apiKey);
343
- log.info(`API Key: ${pc.dim(maskKey(apiKey))}\uFF08\u6765\u81EA\u547D\u4EE4\u884C\u53C2\u6570\uFF09`);
893
+ log3.info(`API Key: ${pc6.dim(maskKey(apiKey))}\uFF08\u6765\u81EA\u547D\u4EE4\u884C\u53C2\u6570\uFF09`);
344
894
  } else {
345
895
  const input = await text({
346
896
  message: "\u8BF7\u8F93\u5165\u4F60\u7684 EasyRouter API Key",
@@ -351,134 +901,148 @@ async function main() {
351
901
  return void 0;
352
902
  }
353
903
  });
354
- if (isCancel(input)) {
904
+ if (isCancel2(input)) {
355
905
  cancel("\u5DF2\u53D6\u6D88");
356
906
  process.exit(0);
357
907
  }
358
908
  apiKey = normalizeApiKey(input);
359
909
  }
360
- let configClaude = true;
361
- let configCodex = true;
362
- if (args.only === "claude") {
363
- configCodex = false;
364
- } else if (args.only === "codex") {
365
- configClaude = false;
366
- } else if (!args.apiKey) {
367
- const target = await select({
368
- message: "\u8BF7\u9009\u62E9\u8981\u914D\u7F6E\u7684\u5BA2\u6237\u7AEF",
369
- options: [
370
- { value: "both", label: "Claude Code + Codex", hint: "\u4E24\u4E2A\u90FD\u914D\uFF08\u63A8\u8350\uFF09" },
371
- { value: "claude", label: "\u4EC5 Claude Code", hint: "\u53EA\u914D ~/.claude/settings.json" },
372
- { value: "codex", label: "\u4EC5 Codex", hint: "\u53EA\u914D ~/.codex/config.toml" }
373
- ],
374
- initialValue: "both"
375
- });
376
- if (isCancel(target)) {
377
- cancel("\u5DF2\u53D6\u6D88");
378
- process.exit(0);
379
- }
380
- if (target === "claude") configCodex = false;
381
- else if (target === "codex") configClaude = false;
382
- }
383
- note(
384
- [
385
- `${pc.bold("\u8BA1\u8D39\u6A21\u5F0F")} \u6309\u91CF\u4ED8\u8D39`,
386
- configClaude ? `${pc.bold("Claude URL")} ${PRIMARY_HOST}` : null,
387
- configCodex ? `${pc.bold("Codex URL")} ${PRIMARY_HOST}/v1` : null,
388
- `${pc.bold("API Key")} ${maskKey(apiKey)}`,
389
- `${pc.bold("\u5C06\u914D\u7F6E")} ${[
390
- configClaude && "Claude Code",
391
- configCodex && "Codex"
392
- ].filter(Boolean).join(" + ")}`
393
- ].filter(Boolean).join("\n"),
394
- "\u914D\u7F6E\u9884\u89C8"
395
- );
396
- if (!args.apiKey) {
910
+ const selected = await pickClients(args.only, interactive);
911
+ if (selected.length === 0) {
912
+ cancel("\u672A\u9009\u62E9\u4EFB\u4F55\u5BA2\u6237\u7AEF");
913
+ process.exit(0);
914
+ }
915
+ const detection = await detectAll(selected, verbose);
916
+ const missing = detection.filter((d) => !d.availability.installed);
917
+ if (missing.length > 0) {
918
+ printMissingHints(missing);
919
+ process.exit(1);
920
+ }
921
+ printSummary({ apiKey, selected });
922
+ if (interactive) {
397
923
  const ok = await confirm({ message: "\u7EE7\u7EED\uFF1F", initialValue: true });
398
- if (isCancel(ok) || !ok) {
924
+ if (isCancel2(ok) || !ok) {
399
925
  cancel("\u5DF2\u53D6\u6D88");
400
926
  process.exit(0);
401
927
  }
402
928
  }
403
- if (configClaude) {
404
- const useSpinner = !verbose && process.stdout.isTTY;
405
- const s = useSpinner ? spinner() : null;
406
- s?.start("\u914D\u7F6E Claude Code");
407
- if (!s) log.info("\u6B63\u5728\u914D\u7F6E Claude Code\uFF08\u9996\u6B21\u8FD0\u884C\u9700\u4E0B\u8F7D zcf\uFF0C\u7EA6 30~60 \u79D2\uFF09...");
408
- try {
409
- await configureViaZcf({
410
- target: "cc",
411
- baseUrl: claudeBaseUrl,
412
- apiKey,
413
- verbose,
414
- // 节流的进度回调:spinner 不会高频闪烁
415
- onProgress: (line) => s?.message(`\u914D\u7F6E Claude Code \xB7 ${pc.dim(line)}`)
416
- });
417
- s?.stop(pc.green("\u2713 Claude Code \u914D\u7F6E\u5B8C\u6210 ") + pc.dim("\u2192 ~/.claude/settings.json"));
418
- if (!s) log.success(pc.green("\u2713 Claude Code \u914D\u7F6E\u5B8C\u6210 \u2192 ~/.claude/settings.json"));
419
- } catch (err) {
420
- s?.stop(pc.red("\u2717 Claude Code \u914D\u7F6E\u5931\u8D25"));
421
- console.error(err.message);
422
- process.exit(1);
423
- }
424
- }
425
- if (configCodex) {
426
- const codexModel = await resolveCodexModel(
427
- selectedHost,
428
- /* interactive */
429
- !args.apiKey,
430
- verbose
431
- );
432
- const useSpinner = !verbose && process.stdout.isTTY;
433
- const s = useSpinner ? spinner() : null;
434
- s?.start("\u914D\u7F6E Codex");
435
- if (!s) log.info("\u6B63\u5728\u914D\u7F6E Codex...");
436
- try {
437
- await configureViaZcf({
438
- target: "codex",
439
- baseUrl: codexBaseUrl,
440
- apiKey,
441
- apiModel: codexModel,
442
- verbose,
443
- onProgress: (line) => s?.message(`\u914D\u7F6E Codex \xB7 ${pc.dim(line)}`)
444
- });
445
- s?.stop(pc.green("\u2713 Codex \u914D\u7F6E\u5B8C\u6210 ") + pc.dim(`\u2192 model=${codexModel}`));
446
- if (!s) log.success(pc.green(`\u2713 Codex \u914D\u7F6E\u5B8C\u6210 \u2192 model=${codexModel}`));
447
- } catch (err) {
448
- s?.stop(pc.red("\u2717 Codex \u914D\u7F6E\u5931\u8D25"));
449
- console.error(err.message);
450
- process.exit(1);
451
- }
929
+ for (const client of selected) {
930
+ await configureOne(client, { host, apiKey, verbose });
452
931
  }
453
932
  if (!args.skipVerify) {
454
933
  const useSpinner = process.stdout.isTTY;
455
934
  const s = useSpinner ? spinner() : null;
456
935
  s?.start("\u9A8C\u8BC1\u8FDE\u901A\u6027");
457
- if (!s) log.info("\u6B63\u5728\u9A8C\u8BC1\u8FDE\u901A\u6027...");
458
- const result = await verifyConnection(claudeBaseUrl, apiKey);
936
+ if (!s) log3.info("\u6B63\u5728\u9A8C\u8BC1\u8FDE\u901A\u6027...");
937
+ const result = await verifyConnection(host, apiKey);
459
938
  if (result.ok) {
460
- const msg = pc.green("\u2713 \u94FE\u8DEF\u9A8C\u8BC1\u901A\u8FC7");
461
- s?.stop(msg) ?? log.success(msg);
939
+ const msg = pc6.green("\u2713 \u94FE\u8DEF\u9A8C\u8BC1\u901A\u8FC7");
940
+ s?.stop(msg) ?? log3.success(msg);
462
941
  } else {
463
- const msg = pc.yellow(
942
+ const msg = pc6.yellow(
464
943
  `\u26A0 \u9A8C\u8BC1\u672A\u901A\u8FC7${result.status ? ` (HTTP ${result.status})` : ""}` + (result.error ? `: ${result.error}` : "") + ` \u2014\u2014 \u914D\u7F6E\u5DF2\u5199\u5165\uFF0C\u53EF\u624B\u52A8\u6D4B\u8BD5`
465
944
  );
466
- s?.stop(msg) ?? log.warn(msg);
945
+ s?.stop(msg) ?? log3.warn(msg);
467
946
  }
468
947
  }
469
- const tips = [];
470
- if (configClaude) tips.push(pc.cyan("claude") + pc.dim(" # \u542F\u52A8 Claude Code"));
471
- if (configCodex) tips.push(pc.cyan("codex") + pc.dim(" # \u542F\u52A8 Codex"));
948
+ const tips = selected.map(
949
+ (c) => pc6.cyan(c.launchCommand) + pc6.dim(` # \u542F\u52A8 ${c.label}`)
950
+ );
472
951
  note(tips.join("\n"), "\u{1F389} \u5168\u90E8\u5B8C\u6210\uFF01\u73B0\u5728\u53EF\u4EE5\u8FD0\u884C\uFF1A");
473
952
  outro(
474
- pc.dim("\u611F\u8C22\u4F7F\u7528 EasyRouter ") + pc.underline(pc.cyan(PRIMARY_HOST)) + pc.dim(" \xB7 Claude Code auto-configuration is powered by ") + pc.underline("UfoMiao/zcf")
953
+ pc6.dim("\u611F\u8C22\u4F7F\u7528 EasyRouter ") + pc6.underline(pc6.cyan(PRIMARY_HOST)) + pc6.dim(" \xB7 Claude Code auto-configuration is powered by ") + pc6.underline("UfoMiao/zcf")
475
954
  );
476
955
  }
956
+ async function pickClients(only, interactive) {
957
+ if (only && only.length) {
958
+ return only.map((id) => CLIENT_REGISTRY[id]);
959
+ }
960
+ if (!interactive) {
961
+ return [CLIENT_REGISTRY.claude, CLIENT_REGISTRY.codex];
962
+ }
963
+ const all = listClients();
964
+ const picked = await multiselect({
965
+ message: "\u8BF7\u9009\u62E9\u8981\u914D\u7F6E\u7684\u5BA2\u6237\u7AEF\uFF08\u7A7A\u683C\u5207\u6362\uFF0C\u56DE\u8F66\u786E\u8BA4\uFF09",
966
+ options: all.map((c) => ({
967
+ value: c.id,
968
+ label: c.label,
969
+ hint: c.hint
970
+ })),
971
+ initialValues: [],
972
+ required: false
973
+ });
974
+ if (isCancel2(picked)) {
975
+ cancel("\u5DF2\u53D6\u6D88");
976
+ process.exit(0);
977
+ }
978
+ return picked.map((id) => CLIENT_REGISTRY[id]);
979
+ }
980
+ async function detectAll(clients, verbose) {
981
+ const results = [];
982
+ for (const c of clients) {
983
+ const availability = await c.detect();
984
+ results.push({ client: c, availability });
985
+ if (verbose) {
986
+ if (availability.installed) {
987
+ log3.info(
988
+ `\u2713 ${c.label}${availability.version ? ` v${availability.version}` : ""} \u5DF2\u5B89\u88C5`
989
+ );
990
+ } else {
991
+ log3.warn(`\u2717 ${c.label} \u672A\u68C0\u6D4B\u5230`);
992
+ }
993
+ }
994
+ }
995
+ return results;
996
+ }
997
+ function printMissingHints(missing) {
998
+ console.error();
999
+ console.error(pc6.red("\u2717 \u4EE5\u4E0B\u5BA2\u6237\u7AEF\u5C1A\u672A\u5B89\u88C5\uFF0C\u8BF7\u5148\u5B89\u88C5\u518D\u8FD0\u884C\u672C\u547D\u4EE4\uFF1A"));
1000
+ console.error();
1001
+ for (const { client, availability } of missing) {
1002
+ const hint = availability.installHint || "\u8BF7\u67E5\u770B\u5B98\u65B9\u6587\u6863";
1003
+ console.error(` ${pc6.bold(client.label)}\uFF1A`);
1004
+ hint.split("\n").forEach((line) => {
1005
+ console.error(` ${pc6.dim(line)}`);
1006
+ });
1007
+ console.error();
1008
+ }
1009
+ console.error(pc6.dim("\u5B89\u88C5\u5B8C\u6210\u540E\u91CD\u65B0\u8FD0\u884C\u672C\u547D\u4EE4\u5373\u53EF\u7EE7\u7EED\u914D\u7F6E\u3002"));
1010
+ }
1011
+ function printSummary(opts) {
1012
+ const lines = [
1013
+ `${pc6.bold("\u8BA1\u8D39\u6A21\u5F0F")} \u6309\u91CF\u4ED8\u8D39`,
1014
+ `${pc6.bold("BaseURL")} ${PRIMARY_HOST}`,
1015
+ `${pc6.bold("API Key")} ${maskKey(opts.apiKey)}`,
1016
+ `${pc6.bold("\u5C06\u914D\u7F6E")} ${opts.selected.map((c) => c.label).join(" + ")}`
1017
+ ];
1018
+ note(lines.join("\n"), "\u914D\u7F6E\u9884\u89C8");
1019
+ }
1020
+ async function configureOne(client, ctx) {
1021
+ const useSpinner = !ctx.verbose && process.stdout.isTTY;
1022
+ const s = useSpinner ? spinner() : null;
1023
+ s?.start(`\u914D\u7F6E ${client.label}`);
1024
+ if (!s) log3.info(`\u6B63\u5728\u914D\u7F6E ${client.label}...`);
1025
+ try {
1026
+ const result = await client.configure({
1027
+ host: ctx.host,
1028
+ apiKey: ctx.apiKey,
1029
+ verbose: ctx.verbose,
1030
+ onProgress: (line) => s?.message(`\u914D\u7F6E ${client.label} \xB7 ${pc6.dim(line)}`)
1031
+ });
1032
+ const summary = `\u2192 ${result.configPaths.join(" + ")}` + (result.model ? ` model=${result.model}` : "");
1033
+ s?.stop(pc6.green(`\u2713 ${client.label} \u914D\u7F6E\u5B8C\u6210 `) + pc6.dim(summary));
1034
+ if (!s) log3.success(pc6.green(`\u2713 ${client.label} \u914D\u7F6E\u5B8C\u6210 ${summary}`));
1035
+ } catch (err) {
1036
+ s?.stop(pc6.red(`\u2717 ${client.label} \u914D\u7F6E\u5931\u8D25`));
1037
+ console.error(err.message);
1038
+ process.exit(1);
1039
+ }
1040
+ }
477
1041
  main().catch((err) => {
478
1042
  console.error();
479
- console.error(pc.red("\u2717 \u81F4\u547D\u9519\u8BEF\uFF1A"), err?.message || err);
1043
+ console.error(pc6.red("\u2717 \u81F4\u547D\u9519\u8BEF\uFF1A"), err?.message || err);
480
1044
  if (err?.stack && process.env.DEBUG) {
481
- console.error(pc.dim(err.stack));
1045
+ console.error(pc6.dim(err.stack));
482
1046
  }
483
1047
  process.exit(1);
484
1048
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easyrouter-config",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "🚀 一键把 EasyRouter 接入 Claude Code & Codex —— 粘贴 Key 即用",
5
5
  "type": "module",
6
6
  "bin": {