@nvae/llmswitch 0.2.0 → 0.5.0
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 +111 -197
- package/dist/adapters/claude.js +8 -5
- package/dist/adapters/codex.js +10 -4
- package/dist/bridge/manager.js +241 -146
- package/dist/bridge/runtime.js +199 -0
- package/dist/bridge/server.js +207 -80
- package/dist/bridge/state.js +345 -69
- package/dist/bridge/translate-response.js +205 -80
- package/dist/bridge/transport.js +439 -0
- package/dist/cli.js +7 -1
- package/dist/commands/bridge-cmd.js +15 -13
- package/dist/commands/home-cmd.js +8 -0
- package/dist/commands/launch-cmd.js +18 -0
- package/dist/commands/launch.js +2 -2
- package/dist/commands/prompts.js +157 -116
- package/dist/commands/setup-cmd.js +175 -0
- package/dist/commands/tool.js +18 -33
- package/dist/index.js +0 -0
- package/dist/presets/index.js +10 -1
- package/dist/store/profiles.js +40 -2
- package/dist/types.js +20 -3
- package/dist/utils/detect-format.js +178 -0
- package/dist/utils/fetch-models.js +54 -59
- package/dist/utils/proxy.js +14 -37
- package/dist/utils/version.js +9 -0
- package/package.json +6 -3
package/dist/commands/prompts.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
|
+
import { normalizeProxyValue } from "../types.js";
|
|
2
3
|
import { isApiFormat } from "../types.js";
|
|
3
4
|
import { formatLabel, supportedFormats } from "../formats/compatibility.js";
|
|
4
5
|
import { getPreset, presetsForTool } from "../presets/index.js";
|
|
5
|
-
import {
|
|
6
|
+
import { detectApiFormat } from "../utils/detect-format.js";
|
|
7
|
+
import { assertValidProfileName, listProfiles, profileExists, saveProfile, } from "../store/profiles.js";
|
|
6
8
|
import { fetchModelList, preferResolvedBaseUrl, } from "../utils/fetch-models.js";
|
|
7
9
|
import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
|
|
8
10
|
import { maskSecret } from "../utils/fs.js";
|
|
@@ -167,6 +169,48 @@ async function promptEditUpstream(tool, current) {
|
|
|
167
169
|
}
|
|
168
170
|
return { apiFormat: picked };
|
|
169
171
|
}
|
|
172
|
+
const NAME_GEN_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
173
|
+
/**
|
|
174
|
+
* 生成 5 位小写字母与数字的随机 profile 名称,并确保不与现有名称冲突。
|
|
175
|
+
*/
|
|
176
|
+
export function generateProfileName(tool) {
|
|
177
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
178
|
+
let name = "";
|
|
179
|
+
for (let i = 0; i < 5; i++) {
|
|
180
|
+
name += NAME_GEN_CHARS[Math.floor(Math.random() * NAME_GEN_CHARS.length)];
|
|
181
|
+
}
|
|
182
|
+
if (!profileExists(tool, name))
|
|
183
|
+
return name;
|
|
184
|
+
}
|
|
185
|
+
return `p${Date.now().toString(36).slice(-4)}`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* 显示名称默认值:基于现有 provider-N 递增(provider-1、provider-2 …)。
|
|
189
|
+
*/
|
|
190
|
+
export function suggestDisplayName(tool) {
|
|
191
|
+
let max = 0;
|
|
192
|
+
for (const profile of listProfiles(tool)) {
|
|
193
|
+
const match = /^provider-(\d+)$/i.exec(profile.displayName);
|
|
194
|
+
if (match)
|
|
195
|
+
max = Math.max(max, Number(match[1]));
|
|
196
|
+
}
|
|
197
|
+
return `provider-${max + 1}`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 供应商列表 label:显示名称 +(profile 名称 · 状态标记)。
|
|
201
|
+
* 例:DeepSeek(6ham6 · 已启用);显示名与名称相同时省略名称。
|
|
202
|
+
*/
|
|
203
|
+
export function formatProfileListLabel(profile, opts) {
|
|
204
|
+
const display = profile.displayName || profile.name;
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (profile.displayName !== profile.name)
|
|
207
|
+
parts.push(profile.name);
|
|
208
|
+
if (profile.name === opts.defaultName)
|
|
209
|
+
parts.push("默认");
|
|
210
|
+
if (profile.name === opts.activeName)
|
|
211
|
+
parts.push("已启用");
|
|
212
|
+
return parts.length ? `${display}(${parts.join(" · ")})` : display;
|
|
213
|
+
}
|
|
170
214
|
export async function promptProfileDraft(tool, partial = {}) {
|
|
171
215
|
p.intro(`为 ${tool} 添加供应商配置`);
|
|
172
216
|
const presets = presetsForTool(tool);
|
|
@@ -198,25 +242,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
198
242
|
}
|
|
199
243
|
let name = partial.name;
|
|
200
244
|
if (!name) {
|
|
201
|
-
|
|
202
|
-
message: "Profile 名称(用于命令行引用)",
|
|
203
|
-
placeholder: preset.id === "custom" ? "my-provider" : preset.id,
|
|
204
|
-
validate: (val) => {
|
|
205
|
-
if (!val?.trim())
|
|
206
|
-
return "名称不能为空";
|
|
207
|
-
try {
|
|
208
|
-
assertValidProfileName(val.trim());
|
|
209
|
-
}
|
|
210
|
-
catch (e) {
|
|
211
|
-
return e instanceof Error ? e.message : "名称无效";
|
|
212
|
-
}
|
|
213
|
-
if (profileExists(tool, val.trim())) {
|
|
214
|
-
return `「${val.trim()}」已存在`;
|
|
215
|
-
}
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
exitOnCancel(v);
|
|
219
|
-
name = v.trim();
|
|
245
|
+
name = generateProfileName(tool);
|
|
220
246
|
}
|
|
221
247
|
else {
|
|
222
248
|
assertValidProfileName(name);
|
|
@@ -227,40 +253,12 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
227
253
|
let displayName = partial.displayName;
|
|
228
254
|
if (!displayName) {
|
|
229
255
|
const v = await p.text({
|
|
230
|
-
message: "
|
|
231
|
-
initialValue: preset.id === "custom" ?
|
|
256
|
+
message: "显示名称(回车使用默认值,也可修改)",
|
|
257
|
+
initialValue: preset.id === "custom" ? suggestDisplayName(tool) : preset.displayName,
|
|
232
258
|
});
|
|
233
259
|
exitOnCancel(v);
|
|
234
260
|
displayName = v.trim() || name;
|
|
235
261
|
}
|
|
236
|
-
let apiFormat;
|
|
237
|
-
let bridgeMode;
|
|
238
|
-
if (partial.apiFormat) {
|
|
239
|
-
apiFormat = partial.apiFormat;
|
|
240
|
-
bridgeMode = partial.bridgeMode;
|
|
241
|
-
}
|
|
242
|
-
else if (preset.id === "custom") {
|
|
243
|
-
if (tool === "claude") {
|
|
244
|
-
// Claude custom: Chat Completions only, translated via local bridge.
|
|
245
|
-
apiFormat = "openai-chat";
|
|
246
|
-
bridgeMode = undefined;
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
const upstream = await promptOpenAiCompatibleUpstream(tool, {
|
|
250
|
-
apiFormat: "openai-chat",
|
|
251
|
-
bridgeMode: "chat",
|
|
252
|
-
});
|
|
253
|
-
apiFormat = upstream.apiFormat;
|
|
254
|
-
bridgeMode = upstream.bridgeMode;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
else {
|
|
258
|
-
apiFormat = preset.apiFormat;
|
|
259
|
-
bridgeMode = undefined;
|
|
260
|
-
}
|
|
261
|
-
if (!isApiFormat(apiFormat) || !supportedFormats(tool).includes(apiFormat)) {
|
|
262
|
-
throw new Error(`${tool} 不支持格式 ${apiFormat}。可用:${supportedFormats(tool).join(", ")}`);
|
|
263
|
-
}
|
|
264
262
|
let baseUrl = partial.baseUrl;
|
|
265
263
|
if (!baseUrl) {
|
|
266
264
|
const v = await p.text({
|
|
@@ -274,14 +272,6 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
274
272
|
exitOnCancel(v);
|
|
275
273
|
baseUrl = v.trim();
|
|
276
274
|
}
|
|
277
|
-
{
|
|
278
|
-
const before = baseUrl.trim().replace(/\/+$/, "");
|
|
279
|
-
const normalized = normalizeBaseUrlForFormat(apiFormat, baseUrl);
|
|
280
|
-
if (normalized !== before) {
|
|
281
|
-
p.log.info(`已自动将 Base URL 规范为 ${normalized}`);
|
|
282
|
-
}
|
|
283
|
-
baseUrl = normalized;
|
|
284
|
-
}
|
|
285
275
|
let apiKey = partial.apiKey;
|
|
286
276
|
if (apiKey === undefined) {
|
|
287
277
|
const v = await p.password({
|
|
@@ -293,15 +283,11 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
293
283
|
}
|
|
294
284
|
apiKey = v || "";
|
|
295
285
|
}
|
|
296
|
-
// Proxy before model fetch so
|
|
297
|
-
let
|
|
298
|
-
|
|
299
|
-
let proxyAll = partial.proxyAll;
|
|
300
|
-
if (proxyHttp === undefined &&
|
|
301
|
-
proxyHttps === undefined &&
|
|
302
|
-
proxyAll === undefined) {
|
|
286
|
+
// Proxy before probing / model fetch so requests go through the same upstream.
|
|
287
|
+
let proxyUrl = partial.proxy;
|
|
288
|
+
if (proxyUrl === undefined) {
|
|
303
289
|
const wantProxy = await p.confirm({
|
|
304
|
-
message: "
|
|
290
|
+
message: "是否配置上游代理?(拉取模型与后续该 provider 的请求都会走此代理)",
|
|
305
291
|
initialValue: false,
|
|
306
292
|
});
|
|
307
293
|
if (p.isCancel(wantProxy)) {
|
|
@@ -309,28 +295,75 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
309
295
|
process.exit(0);
|
|
310
296
|
}
|
|
311
297
|
if (wantProxy) {
|
|
312
|
-
|
|
298
|
+
proxyUrl =
|
|
313
299
|
(await promptText({
|
|
314
|
-
message: "
|
|
315
|
-
placeholder: "
|
|
316
|
-
|
|
317
|
-
proxyHttp =
|
|
318
|
-
(await promptText({
|
|
319
|
-
message: "HTTP_PROXY(可留空)",
|
|
320
|
-
placeholder: "http://127.0.0.1:5112",
|
|
321
|
-
})) || undefined;
|
|
322
|
-
proxyHttps =
|
|
323
|
-
(await promptText({
|
|
324
|
-
message: "HTTPS_PROXY(可留空)",
|
|
325
|
-
placeholder: "http://127.0.0.1:5112",
|
|
300
|
+
message: "代理地址(支持 http/https/socks5,可留空)",
|
|
301
|
+
placeholder: "socks5://127.0.0.1:1080",
|
|
302
|
+
validate: validateProxyUrl,
|
|
326
303
|
})) || undefined;
|
|
327
304
|
}
|
|
328
305
|
}
|
|
329
|
-
const proxy = buildProxyConfig({
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
306
|
+
const proxy = buildProxyConfig({ url: proxyUrl });
|
|
307
|
+
let apiFormat;
|
|
308
|
+
let bridgeMode;
|
|
309
|
+
if (partial.apiFormat) {
|
|
310
|
+
apiFormat = partial.apiFormat;
|
|
311
|
+
bridgeMode = partial.bridgeMode;
|
|
312
|
+
}
|
|
313
|
+
else if (preset.id !== "custom") {
|
|
314
|
+
apiFormat = preset.apiFormat;
|
|
315
|
+
bridgeMode = undefined;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// 自定义上游:自动识别接口类型,识别失败才让用户手动选择
|
|
319
|
+
const spin = p.spinner();
|
|
320
|
+
spin.start("正在识别接口类型…");
|
|
321
|
+
const detected = await detectApiFormat(tool, {
|
|
322
|
+
baseUrl,
|
|
323
|
+
apiKey: apiKey || "",
|
|
324
|
+
proxy,
|
|
325
|
+
});
|
|
326
|
+
if (detected.detected) {
|
|
327
|
+
apiFormat = detected.apiFormat;
|
|
328
|
+
bridgeMode = detected.bridgeMode;
|
|
329
|
+
spin.stop(detected.source === "ollama-tags"
|
|
330
|
+
? "已识别接口:Ollama 本地(OpenAI Chat Completions)"
|
|
331
|
+
: `已识别接口:${formatLabel(apiFormat)}`);
|
|
332
|
+
if (detected.resolvedBaseUrl &&
|
|
333
|
+
detected.resolvedBaseUrl !== baseUrl.trim().replace(/\/+$/, "")) {
|
|
334
|
+
p.log.info(`已根据可用接口将 Base URL 规范为 ${detected.resolvedBaseUrl}(原输入:${baseUrl})`);
|
|
335
|
+
baseUrl = detected.resolvedBaseUrl;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
spin.stop("未能自动识别接口类型");
|
|
340
|
+
p.log.warn("未能自动识别接口类型,请手动选择。");
|
|
341
|
+
if (tool === "claude") {
|
|
342
|
+
// Claude 自定义上游历史默认:Chat Completions,经本地桥转换
|
|
343
|
+
apiFormat = "openai-chat";
|
|
344
|
+
bridgeMode = undefined;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const upstream = await promptOpenAiCompatibleUpstream(tool, {
|
|
348
|
+
apiFormat: "openai-chat",
|
|
349
|
+
bridgeMode: "chat",
|
|
350
|
+
});
|
|
351
|
+
apiFormat = upstream.apiFormat;
|
|
352
|
+
bridgeMode = upstream.bridgeMode;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!isApiFormat(apiFormat) || !supportedFormats(tool).includes(apiFormat)) {
|
|
357
|
+
throw new Error(`${tool} 不支持格式 ${apiFormat}。可用:${supportedFormats(tool).join(", ")}`);
|
|
358
|
+
}
|
|
359
|
+
{
|
|
360
|
+
const before = baseUrl.trim().replace(/\/+$/, "");
|
|
361
|
+
const normalized = normalizeBaseUrlForFormat(apiFormat, baseUrl);
|
|
362
|
+
if (normalized !== before) {
|
|
363
|
+
p.log.info(`已自动将 Base URL 规范为 ${normalized}`);
|
|
364
|
+
}
|
|
365
|
+
baseUrl = normalized;
|
|
366
|
+
}
|
|
334
367
|
if (tool === "codex" && apiFormat === "openai-chat" && !bridgeMode) {
|
|
335
368
|
bridgeMode = "chat";
|
|
336
369
|
}
|
|
@@ -367,7 +400,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
367
400
|
updatedAt: new Date().toISOString(),
|
|
368
401
|
};
|
|
369
402
|
saveProfile(tool, profile);
|
|
370
|
-
p.log.success(`已保存供应商「${name}
|
|
403
|
+
p.log.success(`已保存供应商「${profile.displayName}」(${profile.name},名称与显示名称均可引用)`);
|
|
371
404
|
return profile;
|
|
372
405
|
}
|
|
373
406
|
/**
|
|
@@ -417,23 +450,13 @@ export async function promptEditProfile(tool, current) {
|
|
|
417
450
|
}
|
|
418
451
|
apiKey = v || "";
|
|
419
452
|
}
|
|
420
|
-
const
|
|
421
|
-
message: "
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
message: "HTTPS_PROXY(留空清除)",
|
|
426
|
-
initialValue: current.proxy?.https || "",
|
|
427
|
-
});
|
|
428
|
-
const all = await promptText({
|
|
429
|
-
message: "ALL_PROXY(留空清除)",
|
|
430
|
-
initialValue: current.proxy?.all || "",
|
|
431
|
-
});
|
|
432
|
-
const proxy = buildProxyConfig({
|
|
433
|
-
http: http || undefined,
|
|
434
|
-
https: https || undefined,
|
|
435
|
-
all: all || undefined,
|
|
453
|
+
const proxyInput = await promptText({
|
|
454
|
+
message: "代理地址(支持 http/https/socks5,留空清除)",
|
|
455
|
+
placeholder: "socks5://127.0.0.1:1080",
|
|
456
|
+
initialValue: current.proxy || "",
|
|
457
|
+
validate: validateProxyUrl,
|
|
436
458
|
});
|
|
459
|
+
const proxy = buildProxyConfig({ url: proxyInput || undefined });
|
|
437
460
|
const next = {
|
|
438
461
|
...current,
|
|
439
462
|
displayName,
|
|
@@ -503,7 +526,7 @@ async function selectModelsFromFetched(fetched, input) {
|
|
|
503
526
|
const preferredList = (input.preferredList || []).filter((m) => fetched.includes(m));
|
|
504
527
|
const presetList = (input.presetModels || []).filter((m) => fetched.includes(m));
|
|
505
528
|
const picked = await p.multiselect({
|
|
506
|
-
message: "
|
|
529
|
+
message: "选择启用模型(多选,空格选择,回车确认)",
|
|
507
530
|
options: fetched.map((id) => ({
|
|
508
531
|
value: id,
|
|
509
532
|
label: id,
|
|
@@ -551,10 +574,6 @@ async function manualModelsEntry(input) {
|
|
|
551
574
|
return { defaultModel, modelList };
|
|
552
575
|
}
|
|
553
576
|
export async function tryFetchModels(input) {
|
|
554
|
-
if (!input.apiKey.trim()) {
|
|
555
|
-
p.log.info("未填写 API Key,跳过自动拉取模型列表。");
|
|
556
|
-
return null;
|
|
557
|
-
}
|
|
558
577
|
const spin = p.spinner();
|
|
559
578
|
spin.start("正在从接口拉取模型列表…");
|
|
560
579
|
try {
|
|
@@ -581,15 +600,37 @@ export async function tryFetchModels(input) {
|
|
|
581
600
|
return null;
|
|
582
601
|
}
|
|
583
602
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
603
|
+
const SUPPORTED_PROXY_SCHEMES = [
|
|
604
|
+
"http",
|
|
605
|
+
"https",
|
|
606
|
+
"socks",
|
|
607
|
+
"socks4",
|
|
608
|
+
"socks4a",
|
|
609
|
+
"socks5",
|
|
610
|
+
"socks5h",
|
|
611
|
+
];
|
|
612
|
+
/**
|
|
613
|
+
* Validate a single proxy URL for the interactive prompts. Empty is allowed
|
|
614
|
+
* (clears the proxy). Returns an error message string when invalid.
|
|
615
|
+
*/
|
|
616
|
+
export function validateProxyUrl(value) {
|
|
617
|
+
const trimmed = value?.trim();
|
|
618
|
+
if (!trimmed)
|
|
593
619
|
return undefined;
|
|
594
|
-
|
|
620
|
+
let url;
|
|
621
|
+
try {
|
|
622
|
+
url = new URL(trimmed);
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return "代理地址格式无效,示例:http://127.0.0.1:7890 或 socks5://127.0.0.1:1080";
|
|
626
|
+
}
|
|
627
|
+
const scheme = url.protocol.replace(/:$/, "").toLowerCase();
|
|
628
|
+
if (!SUPPORTED_PROXY_SCHEMES.includes(scheme)) {
|
|
629
|
+
return `不支持的代理协议「${scheme}」。支持:http、https、socks5(含 socks/socks4/socks4a/socks5h)`;
|
|
630
|
+
}
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
/** Normalize a single proxy URL input into the stored value. */
|
|
634
|
+
export function buildProxyConfig(input) {
|
|
635
|
+
return normalizeProxyValue(input.url);
|
|
595
636
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { TOOLS, isTool } from "../types.js";
|
|
3
|
+
import { applyProfile } from "../adapters/index.js";
|
|
4
|
+
import { ensureDefaultProvider, getActiveProfile, listProfiles, requireProfile, } from "../store/profiles.js";
|
|
5
|
+
import { exitOnCancel, formatProfileListLabel, promptProfileDraft } from "./prompts.js";
|
|
6
|
+
import { launchTool, resolveBinary, which } from "./launch.js";
|
|
7
|
+
const TOOL_LABEL = {
|
|
8
|
+
claude: "Claude Code",
|
|
9
|
+
codex: "Codex",
|
|
10
|
+
opencode: "OpenCode",
|
|
11
|
+
};
|
|
12
|
+
export function registerSetupCommand(program) {
|
|
13
|
+
program
|
|
14
|
+
.command("setup")
|
|
15
|
+
.alias("init")
|
|
16
|
+
.description("引导式初始化:选择工具 → 添加供应商 → 选择模型 → 启用 → 启动")
|
|
17
|
+
.option("-t, --tool <tool>", "跳过工具选择,直接为指定工具引导")
|
|
18
|
+
.option("--json", "以 JSON 输出引导结果")
|
|
19
|
+
.action(async (opts) => {
|
|
20
|
+
if (opts.tool && !isTool(opts.tool)) {
|
|
21
|
+
throw new Error(`未知工具「${opts.tool}」。可选:${TOOLS.join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
const tool = opts.tool !== undefined && isTool(opts.tool)
|
|
24
|
+
? opts.tool
|
|
25
|
+
: await pickSetupTool();
|
|
26
|
+
const result = await runSetupWizard(tool);
|
|
27
|
+
if (opts.json) {
|
|
28
|
+
console.log(JSON.stringify(result, null, 2));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
p.outro(`${TOOL_LABEL[tool]} 配置完成。下次直接启动:llms launch ${tool}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** 工具选择:只列出已安装的工具;仅装一个时自动选中。 */
|
|
35
|
+
export async function pickSetupTool() {
|
|
36
|
+
const installed = TOOLS.filter((tool) => which(resolveBinary(tool)) !== null);
|
|
37
|
+
if (installed.length === 1) {
|
|
38
|
+
p.log.info(`检测到已安装 ${TOOL_LABEL[installed[0]]},直接进入配置。`);
|
|
39
|
+
return installed[0];
|
|
40
|
+
}
|
|
41
|
+
const selected = await p.select({
|
|
42
|
+
message: "选择要配置的工具",
|
|
43
|
+
options: TOOLS.map((tool) => ({
|
|
44
|
+
value: tool,
|
|
45
|
+
label: TOOL_LABEL[tool],
|
|
46
|
+
hint: installed.includes(tool)
|
|
47
|
+
? "已安装"
|
|
48
|
+
: "未检测到(启动前需安装或设置 *_BIN)",
|
|
49
|
+
})),
|
|
50
|
+
initialValue: installed[0] ?? TOOLS[0],
|
|
51
|
+
});
|
|
52
|
+
exitOnCancel(selected);
|
|
53
|
+
return selected;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 连贯启动流程:
|
|
57
|
+
* - 已有供应商 → 直接启动(当前启用 / 默认供应商)。
|
|
58
|
+
* - 无供应商 → 自动引导:添加供应商 → 选择模型 → 静默启用 → 自动启动。
|
|
59
|
+
*/
|
|
60
|
+
export async function runToolFlow(tool) {
|
|
61
|
+
const profiles = listProfiles(tool);
|
|
62
|
+
if (profiles.length === 0) {
|
|
63
|
+
if (!process.stdin.isTTY) {
|
|
64
|
+
throw new Error(`暂无 ${tool} 供应商,请先:llms ${tool} provider`);
|
|
65
|
+
}
|
|
66
|
+
p.log.step("尚未配置供应商,开始引导。");
|
|
67
|
+
const created = await promptProfileDraft(tool);
|
|
68
|
+
ensureDefaultProvider(tool);
|
|
69
|
+
try {
|
|
70
|
+
await launchTool({ tool, profile: created.name });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
74
|
+
p.log.error(msg);
|
|
75
|
+
p.log.warn(`供应商「${created.name}」已保存并启用。可用:llms launch ${tool},或设置 ${tool.toUpperCase()}_BIN 指定可执行文件。`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await launchTool({ tool });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
p.log.error(msg);
|
|
85
|
+
p.log.warn(`可用:llms launch ${tool} [模型],或设置 ${tool.toUpperCase()}_BIN 指定可执行文件。`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 引导式初始化:添加(或复用)供应商 → 启用 → 可选启动。
|
|
90
|
+
* 内部复用 provider / model / use / launch 的既有逻辑。
|
|
91
|
+
*/
|
|
92
|
+
export async function runSetupWizard(tool) {
|
|
93
|
+
p.intro(`初始化 ${TOOL_LABEL[tool]}`);
|
|
94
|
+
const existing = listProfiles(tool);
|
|
95
|
+
let profileName;
|
|
96
|
+
if (existing.length > 0) {
|
|
97
|
+
const choice = await p.select({
|
|
98
|
+
message: "检测到已有供应商配置,如何继续?",
|
|
99
|
+
options: [
|
|
100
|
+
{
|
|
101
|
+
value: "add",
|
|
102
|
+
label: "添加新供应商",
|
|
103
|
+
hint: "重新走一遍引导",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
value: "use",
|
|
107
|
+
label: "使用现有供应商",
|
|
108
|
+
hint: "直接启用并启动",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
initialValue: "add",
|
|
112
|
+
});
|
|
113
|
+
exitOnCancel(choice);
|
|
114
|
+
if (choice === "use") {
|
|
115
|
+
const active = getActiveProfile(tool)?.name ?? null;
|
|
116
|
+
const defaultName = ensureDefaultProvider(tool);
|
|
117
|
+
const picked = await p.select({
|
|
118
|
+
message: "选择要启用的供应商",
|
|
119
|
+
options: existing.map((profile) => ({
|
|
120
|
+
value: profile.name,
|
|
121
|
+
label: formatProfileListLabel(profile, {
|
|
122
|
+
defaultName,
|
|
123
|
+
activeName: active,
|
|
124
|
+
}),
|
|
125
|
+
})),
|
|
126
|
+
initialValue: active || defaultName || existing[0].name,
|
|
127
|
+
});
|
|
128
|
+
exitOnCancel(picked);
|
|
129
|
+
profileName = picked;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
profileName = (await promptProfileDraft(tool)).name;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
profileName = (await promptProfileDraft(tool)).name;
|
|
137
|
+
}
|
|
138
|
+
ensureDefaultProvider(tool);
|
|
139
|
+
const profile = requireProfile(tool, profileName);
|
|
140
|
+
// 静默启用:不再询问,用户可在 provider 菜单中关闭
|
|
141
|
+
const applied = await applyProfile(tool, profile);
|
|
142
|
+
p.log.success(`已启用 ${tool}/${profile.name}(${profile.displayName || profile.name}),默认模型 ${profile.models.default}`);
|
|
143
|
+
p.log.info(`配置文件:${applied.configPath}`);
|
|
144
|
+
p.log.info(applied.restartHint);
|
|
145
|
+
const result = {
|
|
146
|
+
tool,
|
|
147
|
+
profile: profile.name,
|
|
148
|
+
defaultModel: profile.models.default,
|
|
149
|
+
enabled: true,
|
|
150
|
+
launched: false,
|
|
151
|
+
configPath: applied.configPath,
|
|
152
|
+
};
|
|
153
|
+
const launch = await p.confirm({
|
|
154
|
+
message: `是否立即启动 ${TOOL_LABEL[tool]}?`,
|
|
155
|
+
initialValue: true,
|
|
156
|
+
});
|
|
157
|
+
if (p.isCancel(launch)) {
|
|
158
|
+
p.cancel("已取消");
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
if (launch) {
|
|
162
|
+
try {
|
|
163
|
+
const plan = await launchTool({ tool, profile: profile.name });
|
|
164
|
+
result.launched = true;
|
|
165
|
+
result.configPath = plan.configPath || result.configPath;
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
169
|
+
p.log.error(msg);
|
|
170
|
+
p.log.warn(`未能启动。稍后可用:llms launch ${tool},或设置 ${tool.toUpperCase()}_BIN 指定可执行文件。`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
p.outro(`已就绪:llms launch ${tool}`);
|
|
174
|
+
return result;
|
|
175
|
+
}
|
package/dist/commands/tool.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { applyProfile, deactivateProfile } from "../adapters/index.js";
|
|
3
3
|
import { formatLabel } from "../formats/compatibility.js";
|
|
4
|
-
import { deleteProfile, ensureDefaultProvider, getActiveProfile, getDefaultProfile, listProfiles, publicProfileView, requireProfile, saveProfile, setDefaultProfile, } from "../store/profiles.js";
|
|
4
|
+
import { deleteProfile, ensureDefaultProvider, getActiveProfile, getDefaultProfile, listProfiles, publicProfileView, requireProfile, resolveProfileOrThrow, saveProfile, setDefaultProfile, } from "../store/profiles.js";
|
|
5
5
|
import { formatProxySummary } from "../utils/proxy.js";
|
|
6
|
-
import {
|
|
6
|
+
import { runToolFlow } from "./setup-cmd.js";
|
|
7
|
+
import { exitOnCancel, formatProfileListLabel, promptEditProfile, promptProfileDraft, resolveModelsInteractive, } from "./prompts.js";
|
|
7
8
|
export function registerToolCommand(program, tool) {
|
|
8
9
|
const cmd = program
|
|
9
10
|
.command(tool)
|
|
10
11
|
.description(`管理 ${tool} 的供应商与模型配置`);
|
|
12
|
+
// llms <tool>(无子命令):连贯启动,未配置时自动引导
|
|
13
|
+
cmd.action(() => runToolFlow(tool));
|
|
11
14
|
cmd
|
|
12
15
|
.command("provider")
|
|
13
16
|
.description("管理模型供应商:添加 / 默认 / 启用禁用 / 查看 / 编辑 / 删除")
|
|
@@ -34,7 +37,7 @@ export function registerToolCommand(program, tool) {
|
|
|
34
37
|
.action(async (name, opts) => {
|
|
35
38
|
ensureDefaultProvider(tool);
|
|
36
39
|
const profileName = await resolveProfileName(tool, name);
|
|
37
|
-
const profile =
|
|
40
|
+
const profile = resolveProfileOrThrow(tool, profileName);
|
|
38
41
|
const result = await applyProfile(tool, profile);
|
|
39
42
|
if (opts?.json) {
|
|
40
43
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -104,7 +107,7 @@ async function runProviderManager(tool) {
|
|
|
104
107
|
...profiles.map((profile) => ({
|
|
105
108
|
value: profile.name,
|
|
106
109
|
// hint 仅高亮时可见,状态标在 label 上便于扫一眼认出默认项
|
|
107
|
-
label:
|
|
110
|
+
label: formatProfileListLabel(profile, {
|
|
108
111
|
defaultName,
|
|
109
112
|
activeName: active,
|
|
110
113
|
}),
|
|
@@ -123,7 +126,9 @@ async function runProviderManager(tool) {
|
|
|
123
126
|
}
|
|
124
127
|
if (selected === "__new__") {
|
|
125
128
|
await handleProviderAdd(tool);
|
|
126
|
-
|
|
129
|
+
// 添加完成后直接结束,不再返回供应商列表
|
|
130
|
+
p.outro("添加完成");
|
|
131
|
+
return;
|
|
127
132
|
}
|
|
128
133
|
await handleProviderActions(tool, selected);
|
|
129
134
|
}
|
|
@@ -131,20 +136,11 @@ async function runProviderManager(tool) {
|
|
|
131
136
|
async function handleProviderAdd(tool) {
|
|
132
137
|
const created = await promptProfileDraft(tool);
|
|
133
138
|
ensureDefaultProvider(tool);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
p.cancel("已取消");
|
|
140
|
-
process.exit(0);
|
|
141
|
-
}
|
|
142
|
-
if (enable) {
|
|
143
|
-
const result = await applyProfile(tool, created);
|
|
144
|
-
p.log.success(`已启用 ${tool}/${created.name}`);
|
|
145
|
-
p.log.info(`配置文件:${result.configPath}`);
|
|
146
|
-
p.log.info(result.restartHint);
|
|
147
|
-
}
|
|
139
|
+
// 静默启用:不再询问,用户可在 provider 菜单中关闭
|
|
140
|
+
const result = await applyProfile(tool, created);
|
|
141
|
+
p.log.success(`已启用 ${tool}/${created.name}(${created.displayName || created.name})`);
|
|
142
|
+
p.log.info(`配置文件:${result.configPath}`);
|
|
143
|
+
p.log.info(result.restartHint);
|
|
148
144
|
}
|
|
149
145
|
async function handleProviderActions(tool, profileName) {
|
|
150
146
|
while (true) {
|
|
@@ -318,8 +314,7 @@ async function configureProfileModels(tool, profile) {
|
|
|
318
314
|
}
|
|
319
315
|
async function resolveProfileName(tool, name) {
|
|
320
316
|
if (name) {
|
|
321
|
-
|
|
322
|
-
return name;
|
|
317
|
+
return resolveProfileOrThrow(tool, name).name;
|
|
323
318
|
}
|
|
324
319
|
ensureDefaultProvider(tool);
|
|
325
320
|
const profiles = listProfiles(tool);
|
|
@@ -332,7 +327,7 @@ async function resolveProfileName(tool, name) {
|
|
|
332
327
|
message: `选择 ${tool} 供应商`,
|
|
333
328
|
options: profiles.map((profile) => ({
|
|
334
329
|
value: profile.name,
|
|
335
|
-
label:
|
|
330
|
+
label: formatProfileListLabel(profile, {
|
|
336
331
|
defaultName,
|
|
337
332
|
activeName: active,
|
|
338
333
|
}),
|
|
@@ -343,16 +338,6 @@ async function resolveProfileName(tool, name) {
|
|
|
343
338
|
exitOnCancel(selected);
|
|
344
339
|
return selected;
|
|
345
340
|
}
|
|
346
|
-
/** 列表项 label 始终可见;hint 仅在高亮行显示。 */
|
|
347
|
-
function formatProviderListLabel(profile, opts) {
|
|
348
|
-
const name = profile.displayName || profile.name;
|
|
349
|
-
const tags = [];
|
|
350
|
-
if (profile.name === opts.defaultName)
|
|
351
|
-
tags.push("默认");
|
|
352
|
-
if (profile.name === opts.activeName)
|
|
353
|
-
tags.push("已启用");
|
|
354
|
-
return tags.length ? `${name}(${tags.join(" · ")})` : name;
|
|
355
|
-
}
|
|
356
341
|
function registerModelCommands(parent, tool) {
|
|
357
342
|
parent
|
|
358
343
|
.command("model")
|
|
@@ -362,7 +347,7 @@ function registerModelCommands(parent, tool) {
|
|
|
362
347
|
.action(async (opts) => {
|
|
363
348
|
p.intro(`配置 ${tool} 模型`);
|
|
364
349
|
const profile = opts.profile
|
|
365
|
-
?
|
|
350
|
+
? resolveProfileOrThrow(tool, opts.profile)
|
|
366
351
|
: requireProfile(tool, await resolveProfileName(tool));
|
|
367
352
|
await configureProfileModels(tool, profile);
|
|
368
353
|
if (opts.json) {
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/presets/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { supportedFormats } from "../formats/compatibility.js";
|
|
2
|
-
export const PRESET_IDS = ["custom", "openai", "anthropic"];
|
|
2
|
+
export const PRESET_IDS = ["custom", "openai", "anthropic", "ollama"];
|
|
3
3
|
export const PRESETS = [
|
|
4
4
|
{
|
|
5
5
|
id: "custom",
|
|
@@ -28,6 +28,15 @@ export const PRESETS = [
|
|
|
28
28
|
models: [],
|
|
29
29
|
tools: ["claude", "opencode"],
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
id: "ollama",
|
|
33
|
+
displayName: "Ollama(本地)",
|
|
34
|
+
apiFormat: "openai-chat",
|
|
35
|
+
baseUrl: "http://localhost:11434",
|
|
36
|
+
defaultModel: "",
|
|
37
|
+
models: [],
|
|
38
|
+
tools: ["claude", "codex", "opencode"],
|
|
39
|
+
},
|
|
31
40
|
];
|
|
32
41
|
export function isPresetId(value) {
|
|
33
42
|
return PRESET_IDS.includes(value);
|