@nvae/llmswitch 0.4.0 → 0.6.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 +97 -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,17 @@
|
|
|
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 { launchTool } from "./launch.js";
|
|
8
|
+
import { exitOnCancel, formatProfileListLabel, promptEditProfile, promptProfileDraft, resolveModelsInteractive, } from "./prompts.js";
|
|
7
9
|
export function registerToolCommand(program, tool) {
|
|
8
10
|
const cmd = program
|
|
9
11
|
.command(tool)
|
|
10
12
|
.description(`管理 ${tool} 的供应商与模型配置`);
|
|
13
|
+
// llms <tool>(无子命令):连贯启动,未配置时自动引导
|
|
14
|
+
cmd.action(() => runToolFlow(tool));
|
|
11
15
|
cmd
|
|
12
16
|
.command("provider")
|
|
13
17
|
.description("管理模型供应商:添加 / 默认 / 启用禁用 / 查看 / 编辑 / 删除")
|
|
@@ -34,7 +38,11 @@ export function registerToolCommand(program, tool) {
|
|
|
34
38
|
.action(async (name, opts) => {
|
|
35
39
|
ensureDefaultProvider(tool);
|
|
36
40
|
const profileName = await resolveProfileName(tool, name);
|
|
37
|
-
|
|
41
|
+
let profile = resolveProfileOrThrow(tool, profileName);
|
|
42
|
+
// 交互模式下:启用前可选切换默认模型
|
|
43
|
+
if (!opts?.json && process.stdin.isTTY) {
|
|
44
|
+
profile = await maybePickDefaultModel(tool, profile);
|
|
45
|
+
}
|
|
38
46
|
const result = await applyProfile(tool, profile);
|
|
39
47
|
if (opts?.json) {
|
|
40
48
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -45,6 +53,9 @@ export function registerToolCommand(program, tool) {
|
|
|
45
53
|
if (result.backupPath)
|
|
46
54
|
console.log(`备份:${result.backupPath}`);
|
|
47
55
|
console.log(result.restartHint);
|
|
56
|
+
if (process.stdin.isTTY) {
|
|
57
|
+
await maybeLaunchNow(tool, profile);
|
|
58
|
+
}
|
|
48
59
|
});
|
|
49
60
|
cmd
|
|
50
61
|
.command("current")
|
|
@@ -104,7 +115,7 @@ async function runProviderManager(tool) {
|
|
|
104
115
|
...profiles.map((profile) => ({
|
|
105
116
|
value: profile.name,
|
|
106
117
|
// hint 仅高亮时可见,状态标在 label 上便于扫一眼认出默认项
|
|
107
|
-
label:
|
|
118
|
+
label: formatProfileListLabel(profile, {
|
|
108
119
|
defaultName,
|
|
109
120
|
activeName: active,
|
|
110
121
|
}),
|
|
@@ -123,7 +134,9 @@ async function runProviderManager(tool) {
|
|
|
123
134
|
}
|
|
124
135
|
if (selected === "__new__") {
|
|
125
136
|
await handleProviderAdd(tool);
|
|
126
|
-
|
|
137
|
+
// 添加完成后直接结束,不再返回供应商列表
|
|
138
|
+
p.outro("添加完成");
|
|
139
|
+
return;
|
|
127
140
|
}
|
|
128
141
|
await handleProviderActions(tool, selected);
|
|
129
142
|
}
|
|
@@ -131,20 +144,11 @@ async function runProviderManager(tool) {
|
|
|
131
144
|
async function handleProviderAdd(tool) {
|
|
132
145
|
const created = await promptProfileDraft(tool);
|
|
133
146
|
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
|
-
}
|
|
147
|
+
// 静默启用:不再询问,用户可在 provider 菜单中关闭
|
|
148
|
+
const result = await applyProfile(tool, created);
|
|
149
|
+
p.log.success(`已启用 ${tool}/${created.name}(${created.displayName || created.name})`);
|
|
150
|
+
p.log.info(`配置文件:${result.configPath}`);
|
|
151
|
+
p.log.info(result.restartHint);
|
|
148
152
|
}
|
|
149
153
|
async function handleProviderActions(tool, profileName) {
|
|
150
154
|
while (true) {
|
|
@@ -289,6 +293,77 @@ function printProfileDetails(tool, profile, flags) {
|
|
|
289
293
|
.filter(Boolean)
|
|
290
294
|
.join("\n"), flags.title || `${tool} / ${profile.name}`);
|
|
291
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* 启用供应商前:若模型列表内有多个候选,交互选择默认模型;
|
|
298
|
+
* 只有一个模型时直接跳过。支持手动输入不在列表中的模型 ID。
|
|
299
|
+
*/
|
|
300
|
+
async function maybePickDefaultModel(tool, profile) {
|
|
301
|
+
const list = profile.models.list.length
|
|
302
|
+
? profile.models.list
|
|
303
|
+
: [profile.models.default];
|
|
304
|
+
if (list.length <= 1)
|
|
305
|
+
return profile;
|
|
306
|
+
const picked = await p.select({
|
|
307
|
+
message: `选择 ${profile.displayName || profile.name} 的默认模型`,
|
|
308
|
+
options: [
|
|
309
|
+
...list.map((model) => ({
|
|
310
|
+
value: model,
|
|
311
|
+
label: model,
|
|
312
|
+
hint: model === profile.models.default ? "当前默认" : undefined,
|
|
313
|
+
})),
|
|
314
|
+
{ value: "__manual__", label: "手动输入模型 ID" },
|
|
315
|
+
],
|
|
316
|
+
initialValue: profile.models.default,
|
|
317
|
+
});
|
|
318
|
+
exitOnCancel(picked);
|
|
319
|
+
let model = picked;
|
|
320
|
+
if (picked === "__manual__") {
|
|
321
|
+
const input = await p.text({
|
|
322
|
+
message: "模型 ID",
|
|
323
|
+
placeholder: profile.models.default,
|
|
324
|
+
});
|
|
325
|
+
if (p.isCancel(input) || !input.trim()) {
|
|
326
|
+
p.cancel("已取消");
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
model = input.trim();
|
|
330
|
+
}
|
|
331
|
+
if (model === profile.models.default)
|
|
332
|
+
return profile;
|
|
333
|
+
const updated = {
|
|
334
|
+
...profile,
|
|
335
|
+
models: {
|
|
336
|
+
...profile.models,
|
|
337
|
+
default: model,
|
|
338
|
+
list: profile.models.list.includes(model)
|
|
339
|
+
? profile.models.list
|
|
340
|
+
: [...profile.models.list, model],
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
saveProfile(tool, updated);
|
|
344
|
+
return requireProfile(tool, updated.name);
|
|
345
|
+
}
|
|
346
|
+
/** 启用完成后:询问是否立即启动该工具。 */
|
|
347
|
+
async function maybeLaunchNow(tool, profile) {
|
|
348
|
+
const launch = await p.confirm({
|
|
349
|
+
message: `是否现在启动 ${tool}?`,
|
|
350
|
+
initialValue: true,
|
|
351
|
+
});
|
|
352
|
+
if (p.isCancel(launch)) {
|
|
353
|
+
p.cancel("已取消");
|
|
354
|
+
process.exit(0);
|
|
355
|
+
}
|
|
356
|
+
if (!launch)
|
|
357
|
+
return;
|
|
358
|
+
try {
|
|
359
|
+
await launchTool({ tool, profile: profile.name });
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
363
|
+
p.log.error(msg);
|
|
364
|
+
p.log.warn(`未能启动。稍后可用:llms launch ${tool},或设置 ${tool.toUpperCase()}_BIN 指定可执行文件。`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
292
367
|
async function configureProfileModels(tool, profile) {
|
|
293
368
|
p.log.step(`配置 ${profile.displayName || profile.name} 的模型`);
|
|
294
369
|
const resolved = await resolveModelsInteractive({
|
|
@@ -318,8 +393,7 @@ async function configureProfileModels(tool, profile) {
|
|
|
318
393
|
}
|
|
319
394
|
async function resolveProfileName(tool, name) {
|
|
320
395
|
if (name) {
|
|
321
|
-
|
|
322
|
-
return name;
|
|
396
|
+
return resolveProfileOrThrow(tool, name).name;
|
|
323
397
|
}
|
|
324
398
|
ensureDefaultProvider(tool);
|
|
325
399
|
const profiles = listProfiles(tool);
|
|
@@ -332,7 +406,7 @@ async function resolveProfileName(tool, name) {
|
|
|
332
406
|
message: `选择 ${tool} 供应商`,
|
|
333
407
|
options: profiles.map((profile) => ({
|
|
334
408
|
value: profile.name,
|
|
335
|
-
label:
|
|
409
|
+
label: formatProfileListLabel(profile, {
|
|
336
410
|
defaultName,
|
|
337
411
|
activeName: active,
|
|
338
412
|
}),
|
|
@@ -343,16 +417,6 @@ async function resolveProfileName(tool, name) {
|
|
|
343
417
|
exitOnCancel(selected);
|
|
344
418
|
return selected;
|
|
345
419
|
}
|
|
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
420
|
function registerModelCommands(parent, tool) {
|
|
357
421
|
parent
|
|
358
422
|
.command("model")
|
|
@@ -362,7 +426,7 @@ function registerModelCommands(parent, tool) {
|
|
|
362
426
|
.action(async (opts) => {
|
|
363
427
|
p.intro(`配置 ${tool} 模型`);
|
|
364
428
|
const profile = opts.profile
|
|
365
|
-
?
|
|
429
|
+
? resolveProfileOrThrow(tool, opts.profile)
|
|
366
430
|
: requireProfile(tool, await resolveProfileName(tool));
|
|
367
431
|
await configureProfileModels(tool, profile);
|
|
368
432
|
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);
|