@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.
@@ -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 { assertValidProfileName, profileExists, saveProfile, } from "../store/profiles.js";
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
- const v = await p.text({
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" ? name : preset.displayName,
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 listing can go through the same upstream proxy.
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: "选择要保存到 profile 的模型(空格选择,回车确认)",
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
+ }
@@ -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 { exitOnCancel, promptEditProfile, promptProfileDraft, resolveModelsInteractive, } from "./prompts.js";
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
- const profile = requireProfile(tool, profileName);
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: formatProviderListLabel(profile, {
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
- continue;
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
- const enable = await p.confirm({
135
- message: `是否立即启用「${created.name}」?`,
136
- initialValue: true,
137
- });
138
- if (p.isCancel(enable)) {
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
- requireProfile(tool, name);
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: formatProviderListLabel(profile, {
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
- ? requireProfile(tool, opts.profile)
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
@@ -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);