oh-pi 0.1.80 → 0.1.82

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as p from "@clack/prompts";
1
+ import chalk from "chalk";
2
2
  import { selectLanguage, getLocale } from "./i18n.js";
3
3
  import { t } from "./i18n.js";
4
4
  import { welcome } from "./tui/welcome.js";
@@ -9,8 +9,10 @@ import { selectTheme } from "./tui/theme-select.js";
9
9
  import { selectKeybindings } from "./tui/keybinding-select.js";
10
10
  import { selectExtensions } from "./tui/extension-select.js";
11
11
  import { selectAgents } from "./tui/agents-select.js";
12
+ import { runHorizontalTabs } from "./tui/horizontal-tabs.js";
12
13
  import { confirmApply } from "./tui/confirm-apply.js";
13
14
  import { detectEnv } from "./utils/detect.js";
15
+ import { EXTENSIONS } from "./registry.js";
14
16
  /**
15
17
  * 主入口函数。检测环境、选择语言、展示欢迎界面,根据用户选择的模式执行对应配置流程,最终确认并应用配置。
16
18
  */
@@ -56,8 +58,7 @@ async function quickFlow(env) {
56
58
  */
57
59
  async function presetFlow(env) {
58
60
  const preset = await selectPreset();
59
- const providerSetup = await setupProviders(env);
60
- return { ...preset, ...providerSetup };
61
+ return runTabbedFlow(env, preset);
61
62
  }
62
63
  /**
63
64
  * 自定义配置流程。用户逐项选择主题、快捷键、扩展、代理等,并可配置高级选项(如自动压缩阈值)。
@@ -65,27 +66,122 @@ async function presetFlow(env) {
65
66
  * @returns 生成的配置对象
66
67
  */
67
68
  async function customFlow(env) {
68
- const providerSetup = await setupProviders(env);
69
- const theme = await selectTheme();
70
- const keybindings = await selectKeybindings();
71
- const extensions = await selectExtensions();
72
- const agents = await selectAgents();
73
- // Advanced: auto-compaction is now automatic based on model context window
74
- const wantAdvanced = await p.confirm({
75
- message: t("advanced.configure"),
76
- initialValue: false,
69
+ const defaultExtensions = EXTENSIONS.filter(e => e.default).map(e => e.name);
70
+ return runTabbedFlow(env, {
71
+ theme: "dark",
72
+ keybindings: "default",
73
+ extensions: defaultExtensions,
74
+ prompts: ["review", "fix", "explain", "commit", "test", "refactor", "optimize", "security", "document", "pr"],
75
+ agents: "general-developer",
76
+ thinking: "medium",
77
77
  });
78
- if (p.isCancel(wantAdvanced)) {
79
- p.cancel(t("cancelled"));
80
- process.exit(0);
78
+ }
79
+ async function runTabbedFlow(env, initial) {
80
+ const defaultExtensions = EXTENSIONS.filter(e => e.default).map(e => e.name);
81
+ let providerSetup = null;
82
+ let theme = initial.theme;
83
+ let keybindings = initial.keybindings;
84
+ let extensions = initial.extensions.length > 0 ? [...initial.extensions] : defaultExtensions;
85
+ let agents = initial.agents;
86
+ await runHorizontalTabs({
87
+ title: t("custom.tabHeader"),
88
+ canFinish: () => !!providerSetup,
89
+ finishBlockedMessage: () => t("custom.needProviders"),
90
+ tabs: [
91
+ {
92
+ label: t("custom.tabProviders"),
93
+ summary: () => summarizeProviders(providerSetup),
94
+ details: () => providerDetails(providerSetup),
95
+ edit: async () => {
96
+ providerSetup = await setupProviders(env);
97
+ },
98
+ },
99
+ {
100
+ label: t("custom.tabAppearance"),
101
+ summary: () => `${t("confirm.theme")} ${theme} · ${t("confirm.keybindings")} ${keybindings}`,
102
+ details: () => [
103
+ `${chalk.dim(t("confirm.theme"))} ${chalk.cyan(theme)}`,
104
+ `${chalk.dim(t("confirm.keybindings"))} ${chalk.cyan(keybindings)}`,
105
+ ],
106
+ edit: async () => {
107
+ theme = await selectTheme();
108
+ keybindings = await selectKeybindings();
109
+ },
110
+ },
111
+ {
112
+ label: t("custom.tabFeatures"),
113
+ summary: () => t("custom.tabFeaturesHint", { count: extensions.length }),
114
+ details: () => [
115
+ chalk.dim(t("confirm.extensions")),
116
+ extensions.length > 0 ? ` ${extensions.join(", ")}` : ` ${t("confirm.none")}`,
117
+ ],
118
+ edit: async () => {
119
+ extensions = await selectExtensions();
120
+ },
121
+ },
122
+ {
123
+ label: t("custom.tabAgents"),
124
+ summary: () => `${t("confirm.agents")} ${agents}`,
125
+ details: () => [
126
+ `${chalk.dim(t("confirm.agents"))} ${chalk.cyan(agents)}`,
127
+ `${chalk.dim(t("confirm.thinking"))} ${chalk.cyan(initial.thinking)}`,
128
+ ],
129
+ edit: async () => {
130
+ agents = await selectAgents();
131
+ },
132
+ },
133
+ {
134
+ label: t("custom.tabFinish"),
135
+ summary: () => t("custom.tabFinishHint"),
136
+ details: () => [
137
+ chalk.dim(t("custom.tabFinishHelp")),
138
+ ],
139
+ edit: async () => {
140
+ // Finish tab is read-only; use key F to complete.
141
+ },
142
+ },
143
+ ],
144
+ });
145
+ if (!providerSetup) {
146
+ throw new Error("Provider setup is required before finishing tabbed flow");
81
147
  }
148
+ const finalProviderSetup = providerSetup;
82
149
  return {
83
- ...providerSetup,
150
+ providers: finalProviderSetup.providers,
151
+ providerStrategy: finalProviderSetup.providerStrategy,
84
152
  theme,
85
153
  keybindings,
86
154
  extensions,
87
- prompts: ["review", "fix", "explain", "commit", "test", "refactor", "optimize", "security", "document", "pr"],
155
+ prompts: initial.prompts,
88
156
  agents,
89
- thinking: "medium",
157
+ thinking: initial.thinking,
90
158
  };
91
159
  }
160
+ function summarizeProviders(setup) {
161
+ if (!setup)
162
+ return t("custom.providersUnset");
163
+ if (setup.providerStrategy === "keep")
164
+ return t("confirm.providerStrategyKeep");
165
+ if (setup.providerStrategy === "add") {
166
+ return setup.providers.length > 0
167
+ ? t("custom.providersAdd", { list: setup.providers.map(p => p.name).join(", ") })
168
+ : t("confirm.providerStrategyAdd");
169
+ }
170
+ if (setup.providers.length === 0)
171
+ return t("confirm.providerStrategyReplace");
172
+ return t("custom.providersReplace", { list: setup.providers.map(p => p.name).join(", ") });
173
+ }
174
+ function providerDetails(setup) {
175
+ if (!setup)
176
+ return [chalk.dim(t("custom.needProviders"))];
177
+ if (setup.providerStrategy === "keep")
178
+ return [chalk.dim(t("confirm.providerStrategyKeep"))];
179
+ if (setup.providers.length === 0)
180
+ return [chalk.dim(t("confirm.none"))];
181
+ const primary = setup.providers[0];
182
+ return [
183
+ `${chalk.dim(t("confirm.providerStrategy"))} ${chalk.cyan(setup.providerStrategy)}`,
184
+ `${chalk.dim(t("confirm.providers"))} ${chalk.cyan(setup.providers.map(p => p.name).join(", "))}`,
185
+ `${chalk.dim(t("confirm.model"))} ${chalk.cyan(primary?.defaultModel ?? t("confirm.none"))}`,
186
+ ];
187
+ }
package/dist/locales.js CHANGED
@@ -18,6 +18,21 @@ export const messages = {
18
18
  "mode.presetHint": "Choose a pre-made configuration",
19
19
  "mode.custom": "🎛️ Custom",
20
20
  "mode.customHint": "Pick everything yourself",
21
+ "custom.tabHeader": "Custom Setup Tabs",
22
+ "custom.tabPrompt": "Select a tab to configure",
23
+ "custom.tabProviders": "Providers",
24
+ "custom.tabAppearance": "Appearance",
25
+ "custom.tabFeatures": "Features",
26
+ "custom.tabFeaturesHint": "{count} extensions enabled",
27
+ "custom.tabAgents": "Agent Profile",
28
+ "custom.tabFinish": "Finish",
29
+ "custom.tabFinishHint": "Review in next step and apply",
30
+ "custom.tabFinishHelp": "Press F to finish. Press Enter on other tabs to edit.",
31
+ "custom.tabControls": "Controls: ←/→ switch tabs · Enter edit tab · 1-5 jump · F finish · Ctrl+C cancel",
32
+ "custom.needProviders": "Please configure Providers first.",
33
+ "custom.providersUnset": "Providers: not configured",
34
+ "custom.providersAdd": "Add fallback providers: {list}",
35
+ "custom.providersReplace": "Replace providers with: {list}",
21
36
  // provider
22
37
  "provider.select": "Select API providers",
23
38
  "provider.selectPrimary": "Select your primary API provider",
@@ -28,16 +43,16 @@ export const messages = {
28
43
  "provider.customHint": "Ollama, vLLM, LiteLLM, any OpenAI-compatible",
29
44
  "provider.foundEnv": "Found {env} in environment. Use it?",
30
45
  "provider.customEndpoint": "Custom endpoint for {label}? (proxy, Azure, etc.)",
31
- "provider.baseUrl": "Base URL for {label}:",
46
+ "provider.baseUrl": "Base URL for {label} (usually without /v1):",
32
47
  "provider.useCustomUrl": "Use a custom endpoint for {label}? (proxy, self-hosted, etc.)",
33
- "provider.baseUrlPlaceholder": "https://your-proxy.example.com",
34
- "provider.baseUrlValidation": "Must be a valid URL",
48
+ "provider.baseUrlPlaceholder": "https://api.openai.com or https://your-proxy.example.com",
49
+ "provider.baseUrlValidation": "Must be a valid URL (tip: usually omit /v1)",
35
50
  "provider.configured": "{label} configured",
36
51
  "provider.name": "Provider name:",
37
52
  "provider.namePlaceholder": "ollama",
38
53
  "provider.nameRequired": "Name required",
39
- "provider.baseUrlCustom": "Base URL:",
40
- "provider.baseUrlCustomPlaceholder": "http://localhost:11434",
54
+ "provider.baseUrlCustom": "Base URL (usually without /v1):",
55
+ "provider.baseUrlCustomPlaceholder": "http://localhost:11434 (add /v1 only if your gateway requires it)",
41
56
  "provider.needsKey": "Requires API key?",
42
57
  "provider.apiKey": "API key for {label}:",
43
58
  "provider.apiKeyRequired": "API key cannot be empty",
@@ -57,6 +72,8 @@ export const messages = {
57
72
  "provider.apiModeResponsesHint": "Modern OpenAI API surface",
58
73
  "provider.apiModeCompletions": "Chat Completions API",
59
74
  "provider.apiModeCompletionsHint": "Legacy-compatible endpoint",
75
+ "provider.apiModeNext": "Next Step",
76
+ "provider.apiModeIntro": "After selecting a model, choose API mode: Responses or Chat Completions.",
60
77
  "provider.configureCaps": "Configure model capabilities? (context window, multimodal, reasoning)",
61
78
  "provider.contextWindow": "Context window size (tokens):",
62
79
  "provider.contextWindowValidation": "Must be a number ≥ 1024",
@@ -168,6 +185,21 @@ export const messages = {
168
185
  "mode.presetHint": "选择预制配置",
169
186
  "mode.custom": "🎛️ 自定义",
170
187
  "mode.customHint": "逐项自选",
188
+ "custom.tabHeader": "自定义配置 Tabs",
189
+ "custom.tabPrompt": "选择一个页面进行配置",
190
+ "custom.tabProviders": "Providers",
191
+ "custom.tabAppearance": "外观",
192
+ "custom.tabFeatures": "功能",
193
+ "custom.tabFeaturesHint": "已启用 {count} 个扩展",
194
+ "custom.tabAgents": "Agent 模板",
195
+ "custom.tabFinish": "完成",
196
+ "custom.tabFinishHint": "下一步可预览并应用",
197
+ "custom.tabFinishHelp": "按 F 完成;在其他页面按 Enter 进入编辑。",
198
+ "custom.tabControls": "操作:←/→ 切换标签 · Enter 编辑当前标签 · 1-5 快速跳转 · F 完成 · Ctrl+C 取消",
199
+ "custom.needProviders": "请先配置 Providers。",
200
+ "custom.providersUnset": "Providers:未配置",
201
+ "custom.providersAdd": "新增备用 Providers:{list}",
202
+ "custom.providersReplace": "替换为 Providers:{list}",
171
203
  "provider.select": "选择 API 提供商",
172
204
  "provider.selectPrimary": "选择主 Provider",
173
205
  "provider.selectAdditional": "选择新增 Provider",
@@ -177,16 +209,16 @@ export const messages = {
177
209
  "provider.customHint": "Ollama、vLLM、LiteLLM 等 OpenAI 兼容接口",
178
210
  "provider.foundEnv": "在环境变量中找到 {env},是否使用?",
179
211
  "provider.customEndpoint": "为 {label} 设置自定义端点?(代理、Azure 等)",
180
- "provider.baseUrl": "{label} 的 Base URL",
212
+ "provider.baseUrl": "{label} 的 Base URL(通常不要带 /v1):",
181
213
  "provider.useCustomUrl": "是否使用自定义端点?(代理、自托管等)",
182
- "provider.baseUrlPlaceholder": "https://your-proxy.example.com",
183
- "provider.baseUrlValidation": "必须是有效的 URL",
214
+ "provider.baseUrlPlaceholder": "https://api.openai.com 或 https://your-proxy.example.com",
215
+ "provider.baseUrlValidation": "必须是有效 URL(通常不需要 /v1)",
184
216
  "provider.configured": "{label} 配置完成",
185
217
  "provider.name": "提供商名称:",
186
218
  "provider.namePlaceholder": "ollama",
187
219
  "provider.nameRequired": "名称不能为空",
188
- "provider.baseUrlCustom": "Base URL",
189
- "provider.baseUrlCustomPlaceholder": "http://localhost:11434",
220
+ "provider.baseUrlCustom": "Base URL(通常不要带 /v1):",
221
+ "provider.baseUrlCustomPlaceholder": "http://localhost:11434(仅当网关要求时再加 /v1)",
190
222
  "provider.needsKey": "需要 API 密钥?",
191
223
  "provider.apiKey": "{label} 的 API 密钥:",
192
224
  "provider.apiKeyRequired": "API 密钥不能为空",
@@ -206,6 +238,8 @@ export const messages = {
206
238
  "provider.apiModeResponsesHint": "OpenAI 新版 API",
207
239
  "provider.apiModeCompletions": "Chat Completions API",
208
240
  "provider.apiModeCompletionsHint": "兼容旧接口",
241
+ "provider.apiModeNext": "下一步",
242
+ "provider.apiModeIntro": "选完模型后,需要再选择 API 模式:Responses 或 Chat Completions。",
209
243
  "provider.configureCaps": "配置模型能力?(上下文窗口、多模态、推理)",
210
244
  "provider.contextWindow": "上下文窗口大小(tokens):",
211
245
  "provider.contextWindowValidation": "必须是 ≥ 1024 的数字",
@@ -309,6 +343,21 @@ export const messages = {
309
343
  "mode.presetHint": "Choisir une configuration prédéfinie",
310
344
  "mode.custom": "🎛️ Personnalisé",
311
345
  "mode.customHint": "Tout choisir soi-même",
346
+ "custom.tabHeader": "Onglets de configuration personnalisée",
347
+ "custom.tabPrompt": "Choisissez un onglet à configurer",
348
+ "custom.tabProviders": "Fournisseurs",
349
+ "custom.tabAppearance": "Apparence",
350
+ "custom.tabFeatures": "Fonctionnalités",
351
+ "custom.tabFeaturesHint": "{count} extensions activées",
352
+ "custom.tabAgents": "Profil Agent",
353
+ "custom.tabFinish": "Terminer",
354
+ "custom.tabFinishHint": "Prévisualiser puis appliquer à l'étape suivante",
355
+ "custom.tabFinishHelp": "Appuyez sur F pour terminer ; appuyez sur Entrée sur les autres onglets pour éditer.",
356
+ "custom.tabControls": "Contrôles : ←/→ changer d'onglet · Entrée éditer · 1-5 accès rapide · F terminer · Ctrl+C annuler",
357
+ "custom.needProviders": "Veuillez d'abord configurer les fournisseurs.",
358
+ "custom.providersUnset": "Fournisseurs : non configurés",
359
+ "custom.providersAdd": "Ajouter des fournisseurs de secours : {list}",
360
+ "custom.providersReplace": "Remplacer par : {list}",
312
361
  "provider.select": "Sélectionner les fournisseurs API",
313
362
  "provider.selectPrimary": "Sélectionner le fournisseur API principal",
314
363
  "provider.selectAdditional": "Sélectionner un fournisseur supplémentaire",
@@ -318,16 +367,16 @@ export const messages = {
318
367
  "provider.customHint": "Ollama, vLLM, LiteLLM, tout compatible OpenAI",
319
368
  "provider.foundEnv": "{env} trouvé dans l'environnement. L'utiliser ?",
320
369
  "provider.customEndpoint": "Point d'accès personnalisé pour {label} ? (proxy, Azure, etc.)",
321
- "provider.baseUrl": "URL de base pour {label} :",
370
+ "provider.baseUrl": "URL de base pour {label} (généralement sans /v1) :",
322
371
  "provider.useCustomUrl": "Utiliser un endpoint personnalisé pour {label} ? (proxy, auto-hébergé, etc.)",
323
- "provider.baseUrlPlaceholder": "https://your-proxy.example.com",
324
- "provider.baseUrlValidation": "Doit être une URL valide",
372
+ "provider.baseUrlPlaceholder": "https://api.openai.com ou https://your-proxy.example.com",
373
+ "provider.baseUrlValidation": "Doit être une URL valide (astuce : sans /v1 dans la plupart des cas)",
325
374
  "provider.configured": "{label} configuré",
326
375
  "provider.name": "Nom du fournisseur :",
327
376
  "provider.namePlaceholder": "ollama",
328
377
  "provider.nameRequired": "Nom requis",
329
- "provider.baseUrlCustom": "URL de base :",
330
- "provider.baseUrlCustomPlaceholder": "http://localhost:11434",
378
+ "provider.baseUrlCustom": "URL de base (généralement sans /v1) :",
379
+ "provider.baseUrlCustomPlaceholder": "http://localhost:11434 (ajoutez /v1 seulement si votre passerelle l'exige)",
331
380
  "provider.needsKey": "Nécessite une clé API ?",
332
381
  "provider.apiKey": "Clé API pour {label} :",
333
382
  "provider.apiKeyRequired": "La clé API ne peut pas être vide",
@@ -347,6 +396,8 @@ export const messages = {
347
396
  "provider.apiModeResponsesHint": "Surface API OpenAI moderne",
348
397
  "provider.apiModeCompletions": "API Chat Completions",
349
398
  "provider.apiModeCompletionsHint": "Endpoint compatible historique",
399
+ "provider.apiModeNext": "Étape suivante",
400
+ "provider.apiModeIntro": "Après le choix du modèle, choisissez le mode API : Responses ou Chat Completions.",
350
401
  "provider.configureCaps": "Configurer les capacités du modèle ? (fenêtre de contexte, multimodal, raisonnement)",
351
402
  "provider.contextWindow": "Taille de la fenêtre de contexte (tokens) :",
352
403
  "provider.contextWindowValidation": "Doit être un nombre ≥ 1024",
@@ -0,0 +1,14 @@
1
+ export interface HorizontalTabItem {
2
+ label: string;
3
+ summary: () => string;
4
+ details?: () => string[];
5
+ edit: () => Promise<void>;
6
+ }
7
+ interface HorizontalTabsOptions {
8
+ title: string;
9
+ tabs: HorizontalTabItem[];
10
+ canFinish: () => boolean;
11
+ finishBlockedMessage?: () => string;
12
+ }
13
+ export declare function runHorizontalTabs(opts: HorizontalTabsOptions): Promise<void>;
14
+ export {};
@@ -0,0 +1,106 @@
1
+ import * as p from "@clack/prompts";
2
+ import chalk from "chalk";
3
+ import { emitKeypressEvents } from "node:readline";
4
+ import { t } from "../i18n.js";
5
+ function clearScreen() {
6
+ process.stdout.write("\x1b[2J\x1b[H");
7
+ }
8
+ function waitForAction(tabCount) {
9
+ return new Promise((resolve) => {
10
+ const stdin = process.stdin;
11
+ const isRawCapable = !!stdin.isTTY && typeof stdin.setRawMode === "function";
12
+ emitKeypressEvents(stdin);
13
+ if (isRawCapable)
14
+ stdin.setRawMode(true);
15
+ const done = (action) => {
16
+ stdin.off("keypress", onKeypress);
17
+ if (isRawCapable)
18
+ stdin.setRawMode(false);
19
+ resolve(action);
20
+ };
21
+ const onKeypress = (str, key) => {
22
+ if (key.ctrl && key.name === "c")
23
+ return done("cancel");
24
+ if (key.name === "left")
25
+ return done("left");
26
+ if (key.name === "right")
27
+ return done("right");
28
+ if (key.name === "return" || key.name === "space" || key.name === "e")
29
+ return done("edit");
30
+ if (key.name === "f")
31
+ return done("finish");
32
+ if (/^[1-9]$/.test(str)) {
33
+ const idx = Number(str) - 1;
34
+ if (idx >= 0 && idx < tabCount)
35
+ return done({ jump: idx });
36
+ }
37
+ };
38
+ stdin.on("keypress", onKeypress);
39
+ if (!stdin.isTTY) {
40
+ done("cancel");
41
+ }
42
+ });
43
+ }
44
+ function renderTabs(title, tabs, activeIndex, notice) {
45
+ clearScreen();
46
+ console.log(chalk.bold(title));
47
+ console.log();
48
+ const tabLine = tabs
49
+ .map((tab, i) => {
50
+ const label = `${i + 1}. ${tab.label}`;
51
+ return i === activeIndex
52
+ ? chalk.bgCyan.black(` ${label} `)
53
+ : chalk.gray(` ${label} `);
54
+ })
55
+ .join(chalk.gray(" | "));
56
+ console.log(tabLine);
57
+ console.log(chalk.dim(t("custom.tabControls")));
58
+ console.log();
59
+ const active = tabs[activeIndex];
60
+ console.log(chalk.cyan(active.summary()));
61
+ const details = active.details?.() ?? [];
62
+ for (const line of details)
63
+ console.log(line);
64
+ if (notice) {
65
+ console.log();
66
+ console.log(chalk.yellow(notice));
67
+ }
68
+ }
69
+ export async function runHorizontalTabs(opts) {
70
+ const tabs = opts.tabs;
71
+ let activeIndex = 0;
72
+ let notice;
73
+ while (true) {
74
+ renderTabs(opts.title, tabs, activeIndex, notice);
75
+ notice = undefined;
76
+ const action = await waitForAction(tabs.length);
77
+ if (action === "cancel") {
78
+ p.cancel(t("cancelled"));
79
+ process.exit(0);
80
+ }
81
+ if (action === "left") {
82
+ activeIndex = (activeIndex - 1 + tabs.length) % tabs.length;
83
+ continue;
84
+ }
85
+ if (action === "right") {
86
+ activeIndex = (activeIndex + 1) % tabs.length;
87
+ continue;
88
+ }
89
+ if (action === "finish") {
90
+ if (opts.canFinish()) {
91
+ clearScreen();
92
+ return;
93
+ }
94
+ notice = opts.finishBlockedMessage?.() ?? t("custom.needProviders");
95
+ continue;
96
+ }
97
+ if (action === "edit") {
98
+ await tabs[activeIndex].edit();
99
+ continue;
100
+ }
101
+ if (typeof action === "object" && "jump" in action) {
102
+ activeIndex = action.jump;
103
+ continue;
104
+ }
105
+ }
106
+ }
@@ -1,5 +1,10 @@
1
1
  import type { ProviderConfig, ProviderSetupStrategy } from "../types.js";
2
2
  import type { EnvInfo } from "../utils/detect.js";
3
+ /**
4
+ * Normalize user-entered base URL for model discovery probes.
5
+ * Discovery always calls `${base}/v1/models`, so strip trailing `/v1` to avoid `/v1/v1/models`.
6
+ */
7
+ export declare function normalizeDiscoveryBaseUrl(baseUrl: string): string;
3
8
  /** Block internal/private IPs to prevent SSRF */
4
9
  export declare function isUnsafeUrl(urlStr: string): boolean;
5
10
  type OpenAIApiMode = "auto" | "openai-responses" | "openai-completions";
@@ -8,6 +13,7 @@ export interface ProviderSetupResult {
8
13
  providerStrategy: ProviderSetupStrategy;
9
14
  }
10
15
  export declare function resolveOpenAIApiMode(mode: OpenAIApiMode, modelId: string): "openai-responses" | "openai-completions";
16
+ export declare function isOpenAICompatibleApi(api?: string): boolean;
11
17
  /**
12
18
  * Interactively configure API providers with an explicit strategy.
13
19
  * Default flow: one primary provider. Advanced flow: optional fallback providers.
@@ -12,6 +12,14 @@ const PROVIDER_API_URLS = {
12
12
  xai: "https://api.x.ai",
13
13
  mistral: "https://api.mistral.ai",
14
14
  };
15
+ /**
16
+ * Normalize user-entered base URL for model discovery probes.
17
+ * Discovery always calls `${base}/v1/models`, so strip trailing `/v1` to avoid `/v1/v1/models`.
18
+ */
19
+ export function normalizeDiscoveryBaseUrl(baseUrl) {
20
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
21
+ return trimmed.replace(/\/v1$/i, "");
22
+ }
15
23
  /** Block internal/private IPs to prevent SSRF */
16
24
  export function isUnsafeUrl(urlStr) {
17
25
  try {
@@ -50,6 +58,9 @@ export function resolveOpenAIApiMode(mode, modelId) {
50
58
  return "openai-responses";
51
59
  return "openai-completions";
52
60
  }
61
+ export function isOpenAICompatibleApi(api) {
62
+ return !api || api === "openai-completions" || api === "openai-responses";
63
+ }
53
64
  /**
54
65
  * 动态获取模型列表,依次尝试 Anthropic、Google、OpenAI 兼容 API 风格。
55
66
  * @param provider - 提供商名称
@@ -58,7 +69,7 @@ export function resolveOpenAIApiMode(mode, modelId) {
58
69
  * @returns 发现的模型列表及检测到的 API 类型
59
70
  */
60
71
  async function fetchModels(provider, baseUrl, apiKey) {
61
- const base = baseUrl.replace(/\/+$/, "");
72
+ const base = normalizeDiscoveryBaseUrl(baseUrl);
62
73
  const resolvedKey = process.env[apiKey] ?? apiKey;
63
74
  // Try Anthropic-style first (for known anthropic or any provider)
64
75
  try {
@@ -274,7 +285,7 @@ async function setupProviderChoice(choice) {
274
285
  if (useCustomUrl) {
275
286
  const url = await p.text({
276
287
  message: t("provider.baseUrl", { label: info.label }),
277
- placeholder: "https://proxy.example.com",
288
+ placeholder: t("provider.baseUrlPlaceholder"),
278
289
  validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
279
290
  });
280
291
  if (p.isCancel(url)) {
@@ -299,7 +310,7 @@ async function setupProviderChoice(choice) {
299
310
  const { defaultModel, discoveredModels, api } = await selectModelWithMeta(name, info.label, info.models, fetchUrl, apiKey);
300
311
  let finalApi = api;
301
312
  if (name === "openai") {
302
- finalApi = await selectOpenAIApiMode(info.label, defaultModel);
313
+ finalApi = await selectOpenAIApiModeWithHint(info.label, defaultModel);
303
314
  }
304
315
  p.log.success(t("provider.configured", { label: info.label }));
305
316
  return { name, apiKey, defaultModel, baseUrl, api: finalApi, discoveredModels };
@@ -319,6 +330,10 @@ async function selectOpenAIApiMode(label, defaultModel) {
319
330
  }
320
331
  return resolveOpenAIApiMode(selected, defaultModel);
321
332
  }
333
+ async function selectOpenAIApiModeWithHint(label, defaultModel) {
334
+ p.note(t("provider.apiModeIntro"), t("provider.apiModeNext"));
335
+ return selectOpenAIApiMode(label, defaultModel);
336
+ }
322
337
  /**
323
338
  * Interactively configure a custom provider (Ollama, vLLM, or other OpenAI-compatible endpoints).
324
339
  * @returns Custom provider config, or null if cancelled
@@ -352,8 +367,11 @@ async function setupCustomProvider() {
352
367
  apiKey = await promptKey(name);
353
368
  }
354
369
  const { defaultModel, discoveredModels, api } = await selectModelWithMeta(name, name, [], baseUrl, apiKey);
370
+ const finalApi = isOpenAICompatibleApi(api)
371
+ ? await selectOpenAIApiModeWithHint(name, defaultModel)
372
+ : api;
355
373
  p.log.success(t("provider.customConfigured", { name, url: baseUrl }));
356
- return { name, apiKey, defaultModel, baseUrl, api, discoveredModels };
374
+ return { name, apiKey, defaultModel, baseUrl, api: finalApi, discoveredModels };
357
375
  }
358
376
  /**
359
377
  * Select a default model by dynamically fetching available models, falling back to a static list or manual input.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { isUnsafeUrl, resolveOpenAIApiMode } from "./provider-setup.js";
2
+ import { isOpenAICompatibleApi, isUnsafeUrl, normalizeDiscoveryBaseUrl, resolveOpenAIApiMode } from "./provider-setup.js";
3
3
  describe("isUnsafeUrl", () => {
4
4
  it("https remote is safe", () => {
5
5
  expect(isUnsafeUrl("https://api.example.com")).toBe(false);
@@ -52,3 +52,31 @@ describe("resolveOpenAIApiMode", () => {
52
52
  expect(resolveOpenAIApiMode("auto", "gpt-4o")).toBe("openai-completions");
53
53
  });
54
54
  });
55
+ describe("normalizeDiscoveryBaseUrl", () => {
56
+ it("keeps regular host URL", () => {
57
+ expect(normalizeDiscoveryBaseUrl("https://api.openai.com")).toBe("https://api.openai.com");
58
+ });
59
+ it("strips trailing slash", () => {
60
+ expect(normalizeDiscoveryBaseUrl("https://api.openai.com/")).toBe("https://api.openai.com");
61
+ });
62
+ it("strips trailing /v1 to avoid /v1/v1 probe", () => {
63
+ expect(normalizeDiscoveryBaseUrl("https://proxy.example.com/v1")).toBe("https://proxy.example.com");
64
+ });
65
+ it("strips trailing /v1/ to avoid /v1/v1 probe", () => {
66
+ expect(normalizeDiscoveryBaseUrl("http://localhost:11434/v1/")).toBe("http://localhost:11434");
67
+ });
68
+ });
69
+ describe("isOpenAICompatibleApi", () => {
70
+ it("treats undefined as openai-compatible", () => {
71
+ expect(isOpenAICompatibleApi(undefined)).toBe(true);
72
+ });
73
+ it("treats openai-completions as openai-compatible", () => {
74
+ expect(isOpenAICompatibleApi("openai-completions")).toBe(true);
75
+ });
76
+ it("treats openai-responses as openai-compatible", () => {
77
+ expect(isOpenAICompatibleApi("openai-responses")).toBe(true);
78
+ });
79
+ it("treats anthropic api as non-openai-compatible", () => {
80
+ expect(isOpenAICompatibleApi("anthropic-messages")).toBe(false);
81
+ });
82
+ });
@@ -11,6 +11,10 @@ export declare function syncDir(src: string, dest: string): void;
11
11
  * 应用 OhP 配置,生成并写入 ~/.pi/agent/ 下的所有配置文件
12
12
  */
13
13
  export declare function applyConfig(config: OhPConfig): void;
14
+ /**
15
+ * Remove all files/dirs managed by oh-pi before strict replace apply.
16
+ */
17
+ export declare function cleanupManagedConfig(agentDir: string): void;
14
18
  /**
15
19
  * 全局安装 pi-coding-agent,安装失败时抛出异常
16
20
  */
@@ -3,6 +3,17 @@ import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { execSync } from "node:child_process";
5
5
  import { writeProviderEnv, writeModelConfig, writeKeybindings, writeAgents, writeExtensions, writePrompts, writeSkills, writeTheme } from "./writers.js";
6
+ const MANAGED_CONFIG_ENTRIES = [
7
+ "auth.json",
8
+ "settings.json",
9
+ "models.json",
10
+ "keybindings.json",
11
+ "AGENTS.md",
12
+ "extensions",
13
+ "prompts",
14
+ "skills",
15
+ "themes",
16
+ ];
6
17
  /**
7
18
  * 确保目录存在,若不存在则递归创建
8
19
  */
@@ -62,6 +73,9 @@ function copyDir(src, dest) {
62
73
  export function applyConfig(config) {
63
74
  const agentDir = join(homedir(), ".pi", "agent");
64
75
  ensureDir(agentDir);
76
+ if ((config.providerStrategy ?? "replace") === "replace") {
77
+ cleanupManagedConfig(agentDir);
78
+ }
65
79
  writeProviderEnv(agentDir, config);
66
80
  writeModelConfig(agentDir, config);
67
81
  writeKeybindings(agentDir, config);
@@ -71,6 +85,14 @@ export function applyConfig(config) {
71
85
  writeSkills(agentDir, config);
72
86
  writeTheme(agentDir, config);
73
87
  }
88
+ /**
89
+ * Remove all files/dirs managed by oh-pi before strict replace apply.
90
+ */
91
+ export function cleanupManagedConfig(agentDir) {
92
+ for (const entry of MANAGED_CONFIG_ENTRIES) {
93
+ rmSync(join(agentDir, entry), { recursive: true, force: true });
94
+ }
95
+ }
74
96
  /**
75
97
  * 全局安装 pi-coding-agent,安装失败时抛出异常
76
98
  */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { cleanupManagedConfig } from "./install.js";
6
+ const tempDirs = [];
7
+ function makeTempDir() {
8
+ const dir = mkdtempSync(join(tmpdir(), "oh-pi-install-"));
9
+ tempDirs.push(dir);
10
+ return dir;
11
+ }
12
+ afterEach(() => {
13
+ for (const dir of tempDirs.splice(0)) {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ }
16
+ });
17
+ describe("cleanupManagedConfig", () => {
18
+ it("removes managed files and directories while preserving unmanaged data", () => {
19
+ const dir = makeTempDir();
20
+ writeFileSync(join(dir, "auth.json"), "{}");
21
+ writeFileSync(join(dir, "settings.json"), "{}");
22
+ writeFileSync(join(dir, "models.json"), "{}");
23
+ writeFileSync(join(dir, "keybindings.json"), "{}");
24
+ writeFileSync(join(dir, "AGENTS.md"), "# test");
25
+ mkdirSync(join(dir, "extensions"), { recursive: true });
26
+ writeFileSync(join(dir, "extensions", "x.ts"), "export default {}");
27
+ mkdirSync(join(dir, "prompts"), { recursive: true });
28
+ writeFileSync(join(dir, "prompts", "x.md"), "prompt");
29
+ mkdirSync(join(dir, "skills"), { recursive: true });
30
+ writeFileSync(join(dir, "skills", "x.md"), "skill");
31
+ mkdirSync(join(dir, "themes"), { recursive: true });
32
+ writeFileSync(join(dir, "themes", "x.json"), "{}");
33
+ mkdirSync(join(dir, "sessions"), { recursive: true });
34
+ writeFileSync(join(dir, "sessions", "keep.json"), "{}");
35
+ writeFileSync(join(dir, "pi-crash.log"), "keep");
36
+ cleanupManagedConfig(dir);
37
+ expect(existsSync(join(dir, "auth.json"))).toBe(false);
38
+ expect(existsSync(join(dir, "settings.json"))).toBe(false);
39
+ expect(existsSync(join(dir, "models.json"))).toBe(false);
40
+ expect(existsSync(join(dir, "keybindings.json"))).toBe(false);
41
+ expect(existsSync(join(dir, "AGENTS.md"))).toBe(false);
42
+ expect(existsSync(join(dir, "extensions"))).toBe(false);
43
+ expect(existsSync(join(dir, "prompts"))).toBe(false);
44
+ expect(existsSync(join(dir, "skills"))).toBe(false);
45
+ expect(existsSync(join(dir, "themes"))).toBe(false);
46
+ expect(existsSync(join(dir, "sessions", "keep.json"))).toBe(true);
47
+ expect(existsSync(join(dir, "pi-crash.log"))).toBe(true);
48
+ });
49
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {