@nvae/llmswitch 0.4.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/cli.js +7 -1
- package/dist/commands/bridge-cmd.js +2 -2
- 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 +110 -65
- 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 +38 -0
- package/dist/utils/detect-format.js +178 -0
- package/dist/utils/fetch-models.js +9 -3
- package/dist/utils/version.js +9 -0
- package/package.json +2 -1
package/dist/commands/prompts.js
CHANGED
|
@@ -3,7 +3,8 @@ import { normalizeProxyValue } from "../types.js";
|
|
|
3
3
|
import { isApiFormat } from "../types.js";
|
|
4
4
|
import { formatLabel, supportedFormats } from "../formats/compatibility.js";
|
|
5
5
|
import { getPreset, presetsForTool } from "../presets/index.js";
|
|
6
|
-
import {
|
|
6
|
+
import { detectApiFormat } from "../utils/detect-format.js";
|
|
7
|
+
import { assertValidProfileName, listProfiles, profileExists, saveProfile, } from "../store/profiles.js";
|
|
7
8
|
import { fetchModelList, preferResolvedBaseUrl, } from "../utils/fetch-models.js";
|
|
8
9
|
import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
|
|
9
10
|
import { maskSecret } from "../utils/fs.js";
|
|
@@ -168,6 +169,48 @@ async function promptEditUpstream(tool, current) {
|
|
|
168
169
|
}
|
|
169
170
|
return { apiFormat: picked };
|
|
170
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
|
+
}
|
|
171
214
|
export async function promptProfileDraft(tool, partial = {}) {
|
|
172
215
|
p.intro(`为 ${tool} 添加供应商配置`);
|
|
173
216
|
const presets = presetsForTool(tool);
|
|
@@ -199,25 +242,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
199
242
|
}
|
|
200
243
|
let name = partial.name;
|
|
201
244
|
if (!name) {
|
|
202
|
-
|
|
203
|
-
message: "Profile 名称(用于命令行引用)",
|
|
204
|
-
placeholder: preset.id === "custom" ? "my-provider" : preset.id,
|
|
205
|
-
validate: (val) => {
|
|
206
|
-
if (!val?.trim())
|
|
207
|
-
return "名称不能为空";
|
|
208
|
-
try {
|
|
209
|
-
assertValidProfileName(val.trim());
|
|
210
|
-
}
|
|
211
|
-
catch (e) {
|
|
212
|
-
return e instanceof Error ? e.message : "名称无效";
|
|
213
|
-
}
|
|
214
|
-
if (profileExists(tool, val.trim())) {
|
|
215
|
-
return `「${val.trim()}」已存在`;
|
|
216
|
-
}
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
exitOnCancel(v);
|
|
220
|
-
name = v.trim();
|
|
245
|
+
name = generateProfileName(tool);
|
|
221
246
|
}
|
|
222
247
|
else {
|
|
223
248
|
assertValidProfileName(name);
|
|
@@ -228,40 +253,12 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
228
253
|
let displayName = partial.displayName;
|
|
229
254
|
if (!displayName) {
|
|
230
255
|
const v = await p.text({
|
|
231
|
-
message: "
|
|
232
|
-
initialValue: preset.id === "custom" ?
|
|
256
|
+
message: "显示名称(回车使用默认值,也可修改)",
|
|
257
|
+
initialValue: preset.id === "custom" ? suggestDisplayName(tool) : preset.displayName,
|
|
233
258
|
});
|
|
234
259
|
exitOnCancel(v);
|
|
235
260
|
displayName = v.trim() || name;
|
|
236
261
|
}
|
|
237
|
-
let apiFormat;
|
|
238
|
-
let bridgeMode;
|
|
239
|
-
if (partial.apiFormat) {
|
|
240
|
-
apiFormat = partial.apiFormat;
|
|
241
|
-
bridgeMode = partial.bridgeMode;
|
|
242
|
-
}
|
|
243
|
-
else if (preset.id === "custom") {
|
|
244
|
-
if (tool === "claude") {
|
|
245
|
-
// Claude custom: Chat Completions only, translated via local bridge.
|
|
246
|
-
apiFormat = "openai-chat";
|
|
247
|
-
bridgeMode = undefined;
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
const upstream = await promptOpenAiCompatibleUpstream(tool, {
|
|
251
|
-
apiFormat: "openai-chat",
|
|
252
|
-
bridgeMode: "chat",
|
|
253
|
-
});
|
|
254
|
-
apiFormat = upstream.apiFormat;
|
|
255
|
-
bridgeMode = upstream.bridgeMode;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
apiFormat = preset.apiFormat;
|
|
260
|
-
bridgeMode = undefined;
|
|
261
|
-
}
|
|
262
|
-
if (!isApiFormat(apiFormat) || !supportedFormats(tool).includes(apiFormat)) {
|
|
263
|
-
throw new Error(`${tool} 不支持格式 ${apiFormat}。可用:${supportedFormats(tool).join(", ")}`);
|
|
264
|
-
}
|
|
265
262
|
let baseUrl = partial.baseUrl;
|
|
266
263
|
if (!baseUrl) {
|
|
267
264
|
const v = await p.text({
|
|
@@ -275,14 +272,6 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
275
272
|
exitOnCancel(v);
|
|
276
273
|
baseUrl = v.trim();
|
|
277
274
|
}
|
|
278
|
-
{
|
|
279
|
-
const before = baseUrl.trim().replace(/\/+$/, "");
|
|
280
|
-
const normalized = normalizeBaseUrlForFormat(apiFormat, baseUrl);
|
|
281
|
-
if (normalized !== before) {
|
|
282
|
-
p.log.info(`已自动将 Base URL 规范为 ${normalized}`);
|
|
283
|
-
}
|
|
284
|
-
baseUrl = normalized;
|
|
285
|
-
}
|
|
286
275
|
let apiKey = partial.apiKey;
|
|
287
276
|
if (apiKey === undefined) {
|
|
288
277
|
const v = await p.password({
|
|
@@ -294,7 +283,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
294
283
|
}
|
|
295
284
|
apiKey = v || "";
|
|
296
285
|
}
|
|
297
|
-
// Proxy before model fetch so
|
|
286
|
+
// Proxy before probing / model fetch so requests go through the same upstream.
|
|
298
287
|
let proxyUrl = partial.proxy;
|
|
299
288
|
if (proxyUrl === undefined) {
|
|
300
289
|
const wantProxy = await p.confirm({
|
|
@@ -315,6 +304,66 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
315
304
|
}
|
|
316
305
|
}
|
|
317
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
|
+
}
|
|
318
367
|
if (tool === "codex" && apiFormat === "openai-chat" && !bridgeMode) {
|
|
319
368
|
bridgeMode = "chat";
|
|
320
369
|
}
|
|
@@ -351,7 +400,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
351
400
|
updatedAt: new Date().toISOString(),
|
|
352
401
|
};
|
|
353
402
|
saveProfile(tool, profile);
|
|
354
|
-
p.log.success(`已保存供应商「${name}
|
|
403
|
+
p.log.success(`已保存供应商「${profile.displayName}」(${profile.name},名称与显示名称均可引用)`);
|
|
355
404
|
return profile;
|
|
356
405
|
}
|
|
357
406
|
/**
|
|
@@ -477,7 +526,7 @@ async function selectModelsFromFetched(fetched, input) {
|
|
|
477
526
|
const preferredList = (input.preferredList || []).filter((m) => fetched.includes(m));
|
|
478
527
|
const presetList = (input.presetModels || []).filter((m) => fetched.includes(m));
|
|
479
528
|
const picked = await p.multiselect({
|
|
480
|
-
message: "
|
|
529
|
+
message: "选择启用模型(多选,空格选择,回车确认)",
|
|
481
530
|
options: fetched.map((id) => ({
|
|
482
531
|
value: id,
|
|
483
532
|
label: id,
|
|
@@ -525,10 +574,6 @@ async function manualModelsEntry(input) {
|
|
|
525
574
|
return { defaultModel, modelList };
|
|
526
575
|
}
|
|
527
576
|
export async function tryFetchModels(input) {
|
|
528
|
-
if (!input.apiKey.trim()) {
|
|
529
|
-
p.log.info("未填写 API Key,跳过自动拉取模型列表。");
|
|
530
|
-
return null;
|
|
531
|
-
}
|
|
532
577
|
const spin = p.spinner();
|
|
533
578
|
spin.start("正在从接口拉取模型列表…");
|
|
534
579
|
try {
|
|
@@ -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);
|
package/dist/store/profiles.js
CHANGED
|
@@ -63,6 +63,44 @@ export function requireProfile(tool, name) {
|
|
|
63
63
|
}
|
|
64
64
|
return profile;
|
|
65
65
|
}
|
|
66
|
+
/** 归一化引用串:小写并去掉分隔符(kimi-openai ↔ kimiOpenai)。 */
|
|
67
|
+
export function normalizeReference(value) {
|
|
68
|
+
return value.toLowerCase().replace(/[_\s./-]+/g, "");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 按用户输入解析 profile:优先名称精确匹配,其次显示名称精确匹配,
|
|
72
|
+
* 再尝试归一化(大小写/分隔符)与包含匹配;多义时返回 null。
|
|
73
|
+
*/
|
|
74
|
+
export function resolveProfile(tool, query) {
|
|
75
|
+
const trimmed = query.trim();
|
|
76
|
+
if (!trimmed)
|
|
77
|
+
return null;
|
|
78
|
+
const byName = readProfile(tool, trimmed);
|
|
79
|
+
if (byName)
|
|
80
|
+
return byName;
|
|
81
|
+
const profiles = listProfiles(tool);
|
|
82
|
+
const normalized = normalizeReference(trimmed);
|
|
83
|
+
const exactDisplay = profiles.find((p) => normalizeReference(p.displayName) === normalized);
|
|
84
|
+
if (exactDisplay)
|
|
85
|
+
return exactDisplay;
|
|
86
|
+
const fuzzy = profiles.filter((p) => normalizeReference(p.displayName).includes(normalized) ||
|
|
87
|
+
normalizeReference(p.name).includes(normalized));
|
|
88
|
+
return fuzzy.length === 1 ? fuzzy[0] : null;
|
|
89
|
+
}
|
|
90
|
+
export function resolveProfileOrThrow(tool, query) {
|
|
91
|
+
const profile = resolveProfile(tool, query);
|
|
92
|
+
if (!profile) {
|
|
93
|
+
const profiles = listProfiles(tool);
|
|
94
|
+
if (profiles.length === 0) {
|
|
95
|
+
throw new Error(`暂无 ${tool} 供应商。请先:llms ${tool} provider`);
|
|
96
|
+
}
|
|
97
|
+
const names = profiles
|
|
98
|
+
.map((p) => `${p.name}(${p.displayName})`)
|
|
99
|
+
.join(", ");
|
|
100
|
+
throw new Error(`未找到匹配「${query}」的供应商。现有:${names}。可通过名称或显示名称引用。`);
|
|
101
|
+
}
|
|
102
|
+
return profile;
|
|
103
|
+
}
|
|
66
104
|
export function saveProfile(tool, profile) {
|
|
67
105
|
assertValidProfileName(profile.name);
|
|
68
106
|
if (!isApiFormat(profile.apiFormat)) {
|