Skip to content

Commit 70e6c9f

Browse files
lidge-junclaude
andcommitted
feat(cli): ocx init exposes the full provider catalog (GUI parity)
init.ts shipped 7 hardcoded presets; the GUI exposed 30 API-key providers + OAuth + local. Refactor init to build its menu from the SAME registries (OAUTH_PROVIDERS + KEY_LOGIN_PROVIDERS) + ChatGPT-forward + non-catalog key providers + local — a testable buildInitProviders(). Per kind: forward saves authMode:forward; oauth saves the entry + points to `ocx login <id>`; key shows the provider's dashboardUrl, collects the key, and runs enrichProviderFromCatalog (same models/vision classification the GUI applies); local defaults to a blank key. Closes the goal's "GUI+CLI 양쪽" CLI gap. Verified: menu=42 entries, all 30 catalog + 3 oauth + 3 local present (0 missing), tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f3707cc commit 70e6c9f

2 files changed

Lines changed: 133 additions & 71 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Plan — CLI `ocx init` provider-catalog parity with the GUI
2+
3+
## Goal gap (from the active goal's last checkpoint)
4+
The GUI exposes the full provider set (30 KEY_LOGIN API-key providers via `/api/key-providers`
5+
+ 3 OAuth + local), but the CLI `ocx init` ships **7 hardcoded `PRESETS`** that don't reference the
6+
registries. So the goal's "GUI(10100)+CLI 양쪽" is unmet on the CLI side: a CLI user can't pick
7+
deepseek/mistral/kilo/minimax/etc. without "custom".
8+
9+
## Change
10+
Refactor `src/init.ts` to build its provider menu from the authoritative registries instead of a
11+
private list:
12+
- `OAUTH_PROVIDERS` (oauth/index) → account-login entries (xai/anthropic/kimi).
13+
- `KEY_LOGIN_PROVIDERS` (oauth/key-providers) → the full API-key catalog (30).
14+
- A few non-catalog key providers (OpenAI API-key, OpenRouter, Groq, Google, Azure) + ChatGPT-forward.
15+
- Local (ollama/vllm/lm-studio).
16+
17+
Wizard behavior per kind:
18+
- **forward** (ChatGPT login): save `authMode:"forward"`, no key.
19+
- **oauth**: save the provider entry, then instruct `ocx login <id>` (the existing OAuth CLI flow).
20+
- **key**: show the provider's `dashboardUrl` ("get your key here"), collect the key (or `${ENV}`),
21+
and `enrichProviderFromCatalog` so the catalog's models/noVisionModels classification is applied
22+
(same enrichment the GUI POST does).
23+
- **local**: blank key by default.
24+
25+
Add a testable `buildInitProviders()` exported for verification.
26+
27+
## Files
28+
- MODIFY `src/init.ts` — registry-driven menu + per-kind handling + catalog enrichment.
29+
30+
## Verify
31+
- `bun x tsc --noEmit` clean.
32+
- Probe `buildInitProviders()`: asserts the menu contains the full KEY_LOGIN catalog (all 30 ids),
33+
the 3 OAuth ids, and the 3 local ids — i.e., CLI now reaches the same providers as the GUI.

src/init.ts

Lines changed: 100 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as readline from "node:readline";
22
import { injectCodexConfig } from "./codex-inject";
33
import { getDefaultConfig, saveConfig } from "./config";
4+
import { KEY_LOGIN_PROVIDERS, enrichProviderFromCatalog } from "./oauth/key-providers";
5+
import { OAUTH_PROVIDERS } from "./oauth";
46
import type { OcxConfig, OcxProviderConfig } from "./types";
57

68
function createPrompt(): { ask(question: string): Promise<string>; close(): void } {
@@ -13,94 +15,120 @@ function createPrompt(): { ask(question: string): Promise<string>; close(): void
1315
};
1416
}
1517

16-
const PRESETS: Record<string, { adapter: string; baseUrl: string; envKey: string; models: string[] }> = {
17-
"opencode-go": {
18-
adapter: "openai-chat",
19-
baseUrl: "https://opencode.ai/zen/go/v1",
20-
envKey: "OPENCODE_API_KEY",
21-
models: ["kimi-k2.5", "kimi-k2.6", "deepseek-v4-flash", "qwen3.5-plus"],
22-
},
23-
"anthropic": {
24-
adapter: "anthropic",
25-
baseUrl: "https://api.anthropic.com",
26-
envKey: "ANTHROPIC_API_KEY",
27-
models: ["claude-sonnet-4-20250514", "claude-opus-4-20250916"],
28-
},
29-
"openai": {
30-
adapter: "openai-responses",
31-
baseUrl: "https://api.openai.com",
32-
envKey: "OPENAI_API_KEY",
33-
models: ["gpt-5.5", "o3-pro"],
34-
},
35-
"openrouter": {
36-
adapter: "openai-chat",
37-
baseUrl: "https://openrouter.ai/api/v1",
38-
envKey: "OPENROUTER_API_KEY",
39-
models: ["anthropic/claude-sonnet-4", "google/gemini-3-pro"],
40-
},
41-
"groq": {
42-
adapter: "openai-chat",
43-
baseUrl: "https://api.groq.com/openai/v1",
44-
envKey: "GROQ_API_KEY",
45-
models: ["llama-4-scout-17b", "llama-4-maverick-17b"],
46-
},
47-
"google": {
48-
adapter: "google",
49-
baseUrl: "https://generativelanguage.googleapis.com",
50-
envKey: "GOOGLE_API_KEY",
51-
models: ["gemini-3-pro", "gemini-3-flash"],
52-
},
53-
"azure-openai": {
54-
adapter: "azure-openai",
55-
baseUrl: "https://{your-resource}.openai.azure.com/openai/deployments/{deployment}",
56-
envKey: "AZURE_OPENAI_API_KEY",
57-
models: ["gpt-5.5"],
58-
},
18+
type InitKind = "forward" | "oauth" | "key" | "local";
19+
export interface InitProvider {
20+
id: string;
21+
label: string;
22+
adapter: string;
23+
baseUrl: string;
24+
kind: InitKind;
25+
dashboardUrl?: string;
26+
defaultModel?: string;
27+
}
28+
29+
const OAUTH_LABELS: Record<string, string> = {
30+
xai: "xAI (Grok)", anthropic: "Anthropic (Claude)", kimi: "Kimi (Moonshot)",
5931
};
6032

33+
/**
34+
* The full CLI provider menu, built from the SAME registries the GUI uses (OAUTH_PROVIDERS +
35+
* KEY_LOGIN_PROVIDERS) plus the ChatGPT-forward, a few non-catalog key providers, and local servers —
36+
* so `ocx init` reaches provider parity with the GUI. Exported for verification.
37+
*/
38+
export function buildInitProviders(): InitProvider[] {
39+
const out: InitProvider[] = [];
40+
// ChatGPT login (no key) — the default forward provider.
41+
out.push({ id: "openai", label: "OpenAI — ChatGPT login (no key)", adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", kind: "forward" });
42+
// Real account logins (OAuth).
43+
for (const id of Object.keys(OAUTH_PROVIDERS)) {
44+
const pc = OAUTH_PROVIDERS[id].providerConfig;
45+
out.push({ id, label: `${OAUTH_LABELS[id] ?? id} — account login`, adapter: pc.adapter, baseUrl: pc.baseUrl, kind: "oauth", defaultModel: pc.defaultModel });
46+
}
47+
// Key providers not in the catalog (native adapters / well-known endpoints).
48+
out.push({ id: "openai-apikey", label: "OpenAI (API key)", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", kind: "key", dashboardUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-5.5" });
49+
out.push({ id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", kind: "key", dashboardUrl: "https://openrouter.ai/keys" });
50+
out.push({ id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", kind: "key", dashboardUrl: "https://console.groq.com/keys" });
51+
out.push({ id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", kind: "key", dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro" });
52+
out.push({ id: "azure-openai", label: "Azure OpenAI", adapter: "azure", baseUrl: "https://{resource}.openai.azure.com/openai/deployments/{deployment}", kind: "key", dashboardUrl: "https://portal.azure.com" });
53+
// The full API-key catalog (deepseek, mistral, kilo, minimax, … — same set the GUI shows).
54+
for (const [id, p] of Object.entries(KEY_LOGIN_PROVIDERS)) {
55+
out.push({ id, label: p.label, adapter: p.adapter, baseUrl: p.baseUrl, kind: "key", dashboardUrl: p.dashboardUrl, defaultModel: p.defaultModel });
56+
}
57+
// Local servers (usually no key).
58+
out.push({ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", kind: "local" });
59+
out.push({ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", kind: "local" });
60+
out.push({ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", kind: "local" });
61+
return out;
62+
}
63+
64+
const KIND_HEADING: Record<InitKind, string> = {
65+
forward: "ChatGPT login",
66+
oauth: "Account login (OAuth — then run: ocx login <id>)",
67+
key: "API key (paste a key from the provider's dashboard)",
68+
local: "Local servers (usually no key)",
69+
};
70+
71+
function printMenu(providers: InitProvider[]): void {
72+
console.log("Available providers:");
73+
let lastKind: InitKind | null = null;
74+
providers.forEach((p, i) => {
75+
if (p.kind !== lastKind) { console.log(`\n ${KIND_HEADING[p.kind]}:`); lastKind = p.kind; }
76+
console.log(` ${String(i + 1).padStart(2)}. ${p.label}`);
77+
});
78+
console.log(`\n ${providers.length + 1}. custom (enter URL manually)`);
79+
}
80+
81+
const envKeyFor = (id: string) => `${id.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
82+
6183
export async function runInit(): Promise<void> {
6284
const prompt = createPrompt();
63-
6485
console.log("\n🔧 opencodex (ocx) setup\n");
6586

66-
const presetNames = Object.keys(PRESETS);
67-
console.log("Available providers:");
68-
presetNames.forEach((name, i) => console.log(` ${i + 1}. ${name}`));
69-
console.log(` ${presetNames.length + 1}. custom (enter URL manually)`);
87+
const providers = buildInitProviders();
88+
printMenu(providers);
7089

7190
const choice = await prompt.ask("\nSelect provider (number): ");
7291
const idx = parseInt(choice, 10) - 1;
7392

7493
let providerName: string;
7594
let providerConfig: OcxProviderConfig;
76-
77-
if (idx >= 0 && idx < presetNames.length) {
78-
providerName = presetNames[idx];
79-
const preset = PRESETS[providerName];
80-
81-
console.log(`\n📡 ${providerName} selected`);
82-
console.log(` Base URL: ${preset.baseUrl}`);
83-
console.log(` Models: ${preset.models.join(", ")}`);
84-
85-
const apiKey = await prompt.ask(`\nAPI key (or env var ${preset.envKey}): `);
86-
const resolvedKey = apiKey.trim() || `\${${preset.envKey}}`;
87-
88-
const modelChoice = await prompt.ask(`Default model [${preset.models[0]}]: `);
89-
const defaultModel = modelChoice.trim() || preset.models[0];
90-
91-
providerConfig = {
92-
adapter: preset.adapter,
93-
baseUrl: preset.baseUrl,
94-
apiKey: resolvedKey,
95-
defaultModel,
96-
};
95+
let oauthHint = false;
96+
97+
if (idx >= 0 && idx < providers.length) {
98+
const p = providers[idx];
99+
providerName = p.id;
100+
console.log(`\n📡 ${p.label}`);
101+
console.log(` Base URL: ${p.baseUrl}`);
102+
103+
if (p.kind === "forward") {
104+
providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
105+
console.log(" No API key needed — forwards your existing `codex login`.");
106+
} else if (p.kind === "oauth") {
107+
providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
108+
oauthHint = true;
109+
} else {
110+
// key + local: collect a key (local usually blank).
111+
if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
112+
const env = envKeyFor(p.id);
113+
const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
114+
const apiKey = (await prompt.ask(`\n${hint}`)).trim();
115+
const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
116+
const defaultModel = modelChoice || p.defaultModel;
117+
providerConfig = {
118+
adapter: p.adapter,
119+
baseUrl: p.baseUrl,
120+
...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
121+
...(defaultModel ? { defaultModel } : {}),
122+
};
123+
// Apply the catalog's models / vision classification (same enrichment as the GUI).
124+
enrichProviderFromCatalog(p.id, providerConfig);
125+
}
97126
} else {
98127
providerName = await prompt.ask("Provider name: ");
99128
const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
100129
const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
101130
const apiKey = await prompt.ask("API key (optional): ");
102131
const defaultModel = await prompt.ask("Default model: ");
103-
104132
providerConfig = {
105133
adapter: adapter.trim(),
106134
baseUrl: baseUrl.trim(),
@@ -109,7 +137,7 @@ export async function runInit(): Promise<void> {
109137
};
110138
}
111139

112-
const portStr = await prompt.ask("Proxy port [10100]: ");
140+
const portStr = await prompt.ask("\nProxy port [10100]: ");
113141
const port = parseInt(portStr, 10) || 10100;
114142

115143
const config: OcxConfig = {
@@ -121,6 +149,7 @@ export async function runInit(): Promise<void> {
121149

122150
saveConfig(config);
123151
console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
152+
if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
124153

125154
const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
126155
if (injectAnswer.trim().toLowerCase() !== "n") {

0 commit comments

Comments
 (0)