@koreanhole/opencodex 2.95.10 → 2.97.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.
@@ -0,0 +1,291 @@
1
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import type { OcxProviderModel, OcxReasoningLevel } from "../../../types";
5
+
6
+ const DEFAULT_METADATA_TTL_MS = 5 * 60_000;
7
+
8
+ export interface GeminiThinkingConfig {
9
+ thinkingLevel?: string;
10
+ thinkingBudget?: number;
11
+ includeThoughts?: boolean;
12
+ }
13
+
14
+ export interface GeminiReasoningLevel extends OcxReasoningLevel {
15
+ thinkingConfig: GeminiThinkingConfig;
16
+ originalEffort: string;
17
+ }
18
+
19
+ export interface GeminiModelCapability {
20
+ thinkingSupported: boolean;
21
+ reasoningLevels: GeminiReasoningLevel[];
22
+ defaultReasoningLevel?: string;
23
+ }
24
+
25
+ export interface GeminiMetadataResult {
26
+ packageRoot?: string;
27
+ settingsPath?: string;
28
+ capabilities: Record<string, GeminiModelCapability>;
29
+ warning?: string;
30
+ }
31
+
32
+ export interface GeminiMetadataOptions {
33
+ binary: string;
34
+ env?: NodeJS.ProcessEnv;
35
+ packageRoot?: string;
36
+ ttlMs?: number;
37
+ now?: () => number;
38
+ }
39
+
40
+ interface CachedMetadata extends GeminiMetadataResult {
41
+ fetchedAt: number;
42
+ }
43
+
44
+ const metadataCache = new Map<string, CachedMetadata>();
45
+
46
+ export function resetGeminiMetadataCacheForTests(): void {
47
+ metadataCache.clear();
48
+ }
49
+
50
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
51
+ return value && typeof value === "object" && !Array.isArray(value)
52
+ ? value as Record<string, unknown>
53
+ : undefined;
54
+ }
55
+
56
+ function stringValue(value: unknown): string | undefined {
57
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
58
+ }
59
+
60
+ function extractDefaultJsonValue(markdown: string, property: string): unknown {
61
+ const marker = `- **\`modelConfigs.${property}\`**`;
62
+ const start = markdown.indexOf(marker);
63
+ if (start < 0) return undefined;
64
+ const fenceStart = markdown.indexOf("```json", start);
65
+ if (fenceStart < 0) return undefined;
66
+ const bodyStart = markdown.indexOf("\n", fenceStart) + 1;
67
+ const fenceEnd = markdown.indexOf("```", bodyStart);
68
+ if (bodyStart <= 0 || fenceEnd < 0) return undefined;
69
+ try {
70
+ return JSON.parse(markdown.slice(bodyStart, fenceEnd).trim()) as unknown;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function extractDefaultJson(markdown: string, property: string): Record<string, unknown> | undefined {
77
+ return asRecord(extractDefaultJsonValue(markdown, property));
78
+ }
79
+
80
+ async function findPackageRoot(binary: string): Promise<string | undefined> {
81
+ let current: string;
82
+ try {
83
+ current = dirname(await realpath(binary));
84
+ } catch {
85
+ current = dirname(resolve(binary));
86
+ }
87
+ for (let depth = 0; depth < 10; depth++) {
88
+ try {
89
+ const manifest = JSON.parse(await readFile(join(current, "package.json"), "utf8")) as { name?: unknown };
90
+ if (manifest.name === "@google/gemini-cli") return current;
91
+ } catch {
92
+ // Keep walking toward the package root.
93
+ }
94
+ const parent = dirname(current);
95
+ if (parent === current) break;
96
+ current = parent;
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ async function readThinkingLevels(packageRoot: string): Promise<Array<{ effort: string; original: string }>> {
102
+ let files: string[];
103
+ try {
104
+ files = (await readdir(join(packageRoot, "bundle"))).filter(name => name.endsWith(".js"));
105
+ } catch {
106
+ return [];
107
+ }
108
+ const candidates = await Promise.all(files.map(async name => {
109
+ const path = join(packageRoot, "bundle", name);
110
+ const info = await stat(path).catch(() => undefined);
111
+ return info ? { path, size: info.size } : undefined;
112
+ }));
113
+ candidates.sort((left, right) => (left?.size ?? Infinity) - (right?.size ?? Infinity));
114
+ for (const candidate of candidates) {
115
+ if (!candidate) continue;
116
+ const text = await readFile(candidate.path, "utf8").catch(() => "");
117
+ if (!text.includes("ThinkingLevel2[\"LOW\"]")) continue;
118
+ const levels = [...text.matchAll(/ThinkingLevel2\["([A-Z0-9_]+)"\]/g)]
119
+ .map(match => match[1]!)
120
+ .filter(level => level !== "THINKING_LEVEL_UNSPECIFIED");
121
+ return [...new Set(levels)].map(level => ({ effort: level.toLowerCase(), original: level }));
122
+ }
123
+ return [];
124
+ }
125
+
126
+ function mergeConfig(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
127
+ const out = { ...target };
128
+ for (const [key, value] of Object.entries(source)) {
129
+ const targetRecord = asRecord(out[key]);
130
+ const sourceRecord = asRecord(value);
131
+ out[key] = targetRecord && sourceRecord ? mergeConfig(targetRecord, sourceRecord) : value;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ function resolveAlias(
137
+ id: string,
138
+ aliases: Record<string, unknown>,
139
+ seen = new Set<string>(),
140
+ ): Record<string, unknown> {
141
+ if (seen.has(id)) return {};
142
+ seen.add(id);
143
+ const alias = asRecord(aliases[id]);
144
+ if (!alias) return {};
145
+ const parent = stringValue(alias.extends);
146
+ const inherited = parent ? resolveAlias(parent, aliases, seen) : {};
147
+ return mergeConfig(inherited, asRecord(alias.modelConfig) ?? {});
148
+ }
149
+
150
+ function capabilitiesFromMetadata(
151
+ definitions: Record<string, unknown>,
152
+ aliases: Record<string, unknown>,
153
+ levels: Array<{ effort: string; original: string }>,
154
+ overrides: readonly unknown[],
155
+ ): Record<string, GeminiModelCapability> {
156
+ const capabilities: Record<string, GeminiModelCapability> = {};
157
+ for (const [modelId, rawDefinition] of Object.entries(definitions)) {
158
+ const definition = asRecord(rawDefinition);
159
+ const features = asRecord(definition?.features);
160
+ const thinkingSupported = features?.thinking === true;
161
+ if (!thinkingSupported) {
162
+ capabilities[modelId] = { thinkingSupported: false, reasoningLevels: [] };
163
+ continue;
164
+ }
165
+ let generateContentConfig = asRecord(resolveAlias(modelId, aliases).generateContentConfig) ?? {};
166
+ for (const rawOverride of overrides) {
167
+ const override = asRecord(rawOverride);
168
+ if (stringValue(asRecord(override?.match)?.model) !== modelId) continue;
169
+ generateContentConfig = mergeConfig(generateContentConfig, asRecord(asRecord(override?.modelConfig)?.generateContentConfig) ?? {});
170
+ }
171
+ const config = asRecord(generateContentConfig.thinkingConfig);
172
+ const configuredLevel = stringValue(config?.thinkingLevel)?.toLowerCase();
173
+ const includeThoughts = config?.includeThoughts === true;
174
+ const reasoningLevels = levels.map(level => ({
175
+ effort: level.effort,
176
+ originalEffort: level.original,
177
+ description: `Gemini CLI ${level.original} thinking level`,
178
+ thinkingConfig: {
179
+ thinkingLevel: level.original,
180
+ ...(includeThoughts ? { includeThoughts: true } : {}),
181
+ },
182
+ }));
183
+ const defaultReasoningLevel = reasoningLevels.some(level => level.effort === configuredLevel)
184
+ ? configuredLevel
185
+ : reasoningLevels.find(level => level.effort === "high")?.effort ?? reasoningLevels[0]?.effort;
186
+ capabilities[modelId] = {
187
+ thinkingSupported: true,
188
+ reasoningLevels,
189
+ ...(defaultReasoningLevel ? { defaultReasoningLevel } : {}),
190
+ };
191
+ }
192
+ return capabilities;
193
+ }
194
+
195
+ async function loadMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
196
+ const env = options.env ?? process.env;
197
+ const packageRoot = options.packageRoot ?? await findPackageRoot(options.binary);
198
+ if (!packageRoot) return { capabilities: {}, warning: "Gemini CLI package metadata was not found." };
199
+ const docsPath = join(packageRoot, "bundle", "docs", "reference", "configuration.md");
200
+ let markdown: string;
201
+ try {
202
+ markdown = await readFile(docsPath, "utf8");
203
+ } catch {
204
+ return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration documentation was not found." };
205
+ }
206
+ const defaultAliases = extractDefaultJson(markdown, "aliases");
207
+ const defaultDefinitions = extractDefaultJson(markdown, "modelDefinitions");
208
+ const defaultOverrides = extractDefaultJsonValue(markdown, "overrides");
209
+ if (!defaultAliases || !defaultDefinitions) {
210
+ return { packageRoot, capabilities: {}, warning: "Gemini CLI model configuration metadata could not be parsed." };
211
+ }
212
+
213
+ const home = stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir();
214
+ const settingsPath = join(home, ".gemini", "settings.json");
215
+ let userSettings: Record<string, unknown> = {};
216
+ try {
217
+ userSettings = asRecord(JSON.parse(await readFile(settingsPath, "utf8"))) ?? {};
218
+ } catch {
219
+ // User settings are optional; absence is not a metadata failure.
220
+ }
221
+ const modelConfigs = asRecord(userSettings.modelConfigs) ?? {};
222
+ const aliases = mergeConfig(
223
+ mergeConfig(defaultAliases, asRecord(modelConfigs.customAliases) ?? {}),
224
+ asRecord(modelConfigs.aliases) ?? {},
225
+ );
226
+ const definitions = mergeConfig(defaultDefinitions, asRecord(modelConfigs.modelDefinitions) ?? {});
227
+ const overrides = [
228
+ ...(Array.isArray(defaultOverrides) ? defaultOverrides : []),
229
+ ...(Array.isArray(modelConfigs.overrides) ? modelConfigs.overrides : []),
230
+ ...(Array.isArray(modelConfigs.customOverrides) ? modelConfigs.customOverrides : []),
231
+ ];
232
+ const levels = await readThinkingLevels(packageRoot);
233
+ if (levels.length === 0) {
234
+ return { packageRoot, settingsPath, capabilities: {}, warning: "Gemini CLI thinking-level metadata was not found." };
235
+ }
236
+ return {
237
+ packageRoot,
238
+ settingsPath,
239
+ capabilities: capabilitiesFromMetadata(definitions, aliases, levels, overrides),
240
+ };
241
+ }
242
+
243
+ export async function discoverGeminiModelMetadata(options: GeminiMetadataOptions): Promise<GeminiMetadataResult> {
244
+ const env = options.env ?? process.env;
245
+ const key = [
246
+ options.packageRoot ?? "auto",
247
+ resolve(options.binary),
248
+ stringValue(env.GEMINI_CLI_HOME) ?? stringValue(env.HOME) ?? stringValue(env.USERPROFILE) ?? homedir(),
249
+ ].join("\n");
250
+ const now = options.now ?? Date.now;
251
+ const ttlMs = options.ttlMs ?? DEFAULT_METADATA_TTL_MS;
252
+ const cached = metadataCache.get(key);
253
+ if (cached && now() - cached.fetchedAt < ttlMs) return cached;
254
+ const result = await loadMetadata(options);
255
+ const entry: CachedMetadata = { ...result, fetchedAt: now() };
256
+ metadataCache.set(key, entry);
257
+ return entry;
258
+ }
259
+
260
+ export function mergeGeminiModelDescriptors(
261
+ advertised: readonly { modelId: string; name?: string; description?: string }[],
262
+ metadata: GeminiMetadataResult,
263
+ ): OcxProviderModel[] {
264
+ return advertised.map(model => {
265
+ const capability = metadata.capabilities[model.modelId];
266
+ const reasoningLevels = capability?.reasoningLevels.map(level => ({
267
+ effort: level.effort,
268
+ description: level.description,
269
+ })) ?? [];
270
+ return {
271
+ id: model.modelId,
272
+ ...(model.name ? { name: model.name } : {}),
273
+ ...(model.description ? { description: model.description } : {}),
274
+ supportedReasoningLevels: reasoningLevels,
275
+ ...(capability?.defaultReasoningLevel ? { defaultReasoningLevel: capability.defaultReasoningLevel } : {}),
276
+ };
277
+ });
278
+ }
279
+
280
+ export function reasoningLevelForEffort(
281
+ capability: GeminiModelCapability | undefined,
282
+ effort: string | undefined,
283
+ ): GeminiReasoningLevel | undefined {
284
+ if (!capability?.thinkingSupported || capability.reasoningLevels.length === 0) return undefined;
285
+ const wanted = effort?.trim().toLowerCase();
286
+ if (!wanted) {
287
+ return capability.reasoningLevels.find(level => level.effort === capability.defaultReasoningLevel)
288
+ ?? capability.reasoningLevels[0];
289
+ }
290
+ return capability.reasoningLevels.find(level => level.effort === wanted);
291
+ }
@@ -1,4 +1,4 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { appendFile, readFile } from "node:fs/promises";
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -9,21 +9,46 @@ export interface CodingAgentMcpTool {
9
9
  inputSchema: Record<string, unknown>;
10
10
  }
11
11
 
12
- export async function serveCodingAgentMcpTools(tools: CodingAgentMcpTool[], serverName: string): Promise<void> {
12
+ const MAX_CAPTURE_CALLS = 16;
13
+ const MAX_CAPTURE_ARGUMENT_BYTES = 2 * 1024 * 1024;
14
+
15
+ export async function serveCodingAgentMcpTools(
16
+ tools: CodingAgentMcpTool[],
17
+ serverName: string,
18
+ capturePath?: string,
19
+ ): Promise<void> {
13
20
  const exitOnStdinClose = (): void => process.exit(0);
14
21
  process.stdin.on("end", exitOnStdinClose);
15
22
  process.stdin.on("close", exitOnStdinClose);
16
23
  const names = new Set(tools.map(tool => tool.name));
17
24
  const server = new Server({ name: serverName, version: "1.0.0" }, { capabilities: { tools: {} } });
25
+ let capturedCalls = 0;
18
26
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
19
27
  server.setRequestHandler(CallToolRequestSchema, async request => {
20
28
  if (!names.has(request.params.name)) throw new Error("unknown isolated tool");
29
+ if (capturePath) {
30
+ if (++capturedCalls > MAX_CAPTURE_CALLS) throw new Error("isolated tool capture limit exceeded");
31
+ const argumentsValue = request.params.arguments ?? {};
32
+ const serialized = JSON.stringify(argumentsValue);
33
+ if (Buffer.byteLength(serialized) > MAX_CAPTURE_ARGUMENT_BYTES) {
34
+ throw new Error("isolated tool arguments exceed the capture limit");
35
+ }
36
+ await appendFile(capturePath, `${JSON.stringify({
37
+ id: `gemini-tool-${crypto.randomUUID()}`,
38
+ name: request.params.name,
39
+ arguments: argumentsValue,
40
+ })}\n`, { encoding: "utf8", mode: 0o600 });
41
+ }
21
42
  return new Promise<never>(() => undefined);
22
43
  });
23
44
  await server.connect(new StdioServerTransport());
24
45
  }
25
46
 
26
- export async function runCodingAgentMcpServer(catalogPath: string): Promise<void> {
47
+ export async function runCodingAgentMcpServer(catalogPath: string, capturePath?: string): Promise<void> {
27
48
  const tools = JSON.parse(await readFile(catalogPath, "utf8")) as CodingAgentMcpTool[];
28
- await serveCodingAgentMcpTools(tools, "opencodex-coding-agent-tools");
49
+ await serveCodingAgentMcpTools(tools, "opencodex-coding-agent-tools", capturePath);
50
+ }
51
+
52
+ if (import.meta.main) {
53
+ await runCodingAgentMcpServer(process.argv[2] ?? "", process.argv[3]);
29
54
  }
@@ -0,0 +1,71 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { AdapterEvent } from "../../types";
3
+ import type { CliAgentStreamState } from "./types";
4
+
5
+ const MAX_TOOL_ARGUMENT_BYTES = 2 * 1024 * 1024;
6
+
7
+ export class ToolIntentError extends Error {
8
+ readonly code = "cli_protocol_error";
9
+ readonly status = 502;
10
+
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "ToolIntentError";
14
+ }
15
+ }
16
+
17
+ export interface ToolIntentOptions {
18
+ toolNames: ReadonlyMap<string, string>;
19
+ maxToolCalls?: number;
20
+ }
21
+
22
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
23
+ return value && typeof value === "object" && !Array.isArray(value)
24
+ ? value as Record<string, unknown>
25
+ : undefined;
26
+ }
27
+
28
+ function toolArguments(input: unknown, label: string): string {
29
+ let value = input;
30
+ if (typeof value === "string") {
31
+ try {
32
+ value = JSON.parse(value);
33
+ } catch {
34
+ throw new ToolIntentError(`${label} returned a tool call with invalid JSON arguments.`);
35
+ }
36
+ }
37
+ const record = asRecord(value ?? {});
38
+ if (!record) throw new ToolIntentError(`${label} returned non-object tool arguments.`);
39
+ const serialized = JSON.stringify(record);
40
+ if (Buffer.byteLength(serialized) > MAX_TOOL_ARGUMENT_BYTES) {
41
+ throw new ToolIntentError(`${label} returned tool arguments above the byte limit.`);
42
+ }
43
+ return serialized;
44
+ }
45
+
46
+ /** Convert one validated vendor tool request into events owned by Codex. */
47
+ export function mapToolIntent(
48
+ label: string,
49
+ vendorName: string,
50
+ toolCallId: string | undefined,
51
+ input: unknown,
52
+ state: CliAgentStreamState,
53
+ options: ToolIntentOptions,
54
+ ): AdapterEvent[] {
55
+ if (!vendorName) throw new ToolIntentError(`${label} returned a tool call without a name.`);
56
+ const wireName = options.toolNames.get(vendorName);
57
+ if (!wireName) throw new ToolIntentError(`${label} returned an undeclared tool: ${vendorName.slice(0, 128)}`);
58
+ const maxToolCalls = options.maxToolCalls ?? 16;
59
+ if (state.toolCalls >= maxToolCalls) {
60
+ throw new ToolIntentError(`${label} exceeded the per-turn tool-call limit.`);
61
+ }
62
+ const ordinal = ++state.nextToolOrdinal;
63
+ const id = toolCallId?.trim() || `${label.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-tool-${ordinal}`;
64
+ const argumentsJson = toolArguments(input, label);
65
+ state.toolCalls++;
66
+ return [
67
+ { type: "tool_call_start", id, name: wireName },
68
+ { type: "tool_call_delta", arguments: argumentsJson },
69
+ { type: "tool_call_end" },
70
+ ];
71
+ }
package/src/cli/index.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import {
6
6
  CLINE_PROVIDER_ID,
7
+ GEMINI_PROVIDER_ID,
7
8
  ConfigError,
8
9
  apiToken,
9
10
  defaultConfig,
@@ -13,10 +14,12 @@ import {
13
14
  removeRuntimeStateIfPid,
14
15
  saveConfig,
15
16
  writeRuntimeState,
17
+ type ProviderId,
16
18
  } from "../config";
17
19
  import { startServer } from "../server";
18
20
  import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
19
21
  import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
22
+ import { syncCodexCatalog } from "../lib/codex-catalog";
20
23
 
21
24
  const here = dirname(fileURLToPath(import.meta.url));
22
25
 
@@ -47,20 +50,24 @@ async function version(): Promise<string> {
47
50
 
48
51
  function help(): string {
49
52
  return [
50
- "opencodex Cline-only proxy",
53
+ "opencodex local CLI proxy",
51
54
  "",
52
55
  "Usage: ocx <command>",
53
56
  "",
54
57
  "Commands:",
55
58
  " login cline-cli [--json] [--no-wait] Authenticate the local Cline CLI",
56
- " provider add cline-cli [--set-default] Configure the Cline CLI transport",
57
- " provider use cline-cli Select Cline CLI as the default provider",
58
- " provider list [--json] Show the supported provider",
59
+ " provider add <provider> [--set-default] Configure a CLI transport",
60
+ " provider use <provider> Select the default provider",
61
+ " provider list [--json] Show supported providers",
59
62
  " start [--port <port>] [--host <host>] Start the proxy",
60
63
  " status [--json] Check the running proxy",
61
64
  " stop [--json] Stop the running proxy",
65
+ " codex sync [--json] Refresh the Codex model catalog",
62
66
  " version Print the package version",
63
67
  " help Show this help",
68
+ "",
69
+ "Providers: cline-cli, gemini-cli",
70
+ "Gemini authentication is managed by Gemini CLI (`gemini` or GEMINI_API_KEY).",
64
71
  ].join("\n");
65
72
  }
66
73
 
@@ -88,7 +95,7 @@ async function health(host: string, port: number, config: Awaited<ReturnType<typ
88
95
  });
89
96
  if (!response.ok) return false;
90
97
  const body = await response.json() as { service?: string; ok?: boolean };
91
- return body.ok === true && body.service === "opencodex-cline";
98
+ return body.ok === true && body.service === "opencodex";
92
99
  } catch {
93
100
  return false;
94
101
  }
@@ -100,31 +107,46 @@ async function providerCommand(args: string[]): Promise<number> {
100
107
  if (sub === "list") {
101
108
  const json = takeFlag(args, "--json");
102
109
  if (args.length > 0) throw new ConfigError(`Unknown provider list option: ${args[0]}`);
103
- if (json) console.log(JSON.stringify({ defaultProvider: CLINE_PROVIDER_ID, providers: config.providers }, null, 2));
104
- else console.log(`${CLINE_PROVIDER_ID}${config.defaultProvider === CLINE_PROVIDER_ID ? " (default)" : ""}`);
110
+ if (json) console.log(JSON.stringify({ defaultProvider: config.defaultProvider, providers: config.providers }, null, 2));
111
+ else {
112
+ for (const id of [CLINE_PROVIDER_ID, GEMINI_PROVIDER_ID]) {
113
+ console.log(`${id}${config.defaultProvider === id ? " (default)" : ""}`);
114
+ }
115
+ }
105
116
  return 0;
106
117
  }
107
118
  if (sub === "add") {
108
- if (args.shift() !== CLINE_PROVIDER_ID) throw new ConfigError("Only `provider add cline-cli` is supported.");
119
+ const requested = args.shift();
120
+ if (requested !== CLINE_PROVIDER_ID && requested !== GEMINI_PROVIDER_ID) {
121
+ throw new ConfigError("Only `provider add cline-cli` or `provider add gemini-cli` is supported.");
122
+ }
123
+ const id = requested as ProviderId;
109
124
  const setDefault = takeFlag(args, "--set-default");
110
125
  if (args.length > 0) throw new ConfigError(`Unknown provider add option: ${args[0]}`);
111
- const next = { ...config, providers: { [CLINE_PROVIDER_ID]: defaultConfig().providers[CLINE_PROVIDER_ID] } };
112
- if (setDefault) next.defaultProvider = CLINE_PROVIDER_ID;
126
+ const next = { ...config, providers: { ...config.providers, [id]: defaultConfig().providers[id] } };
127
+ if (setDefault) next.defaultProvider = id;
113
128
  await saveConfig(next);
114
- console.log(`Configured ${CLINE_PROVIDER_ID} at ${getConfigPath()}`);
129
+ console.log(`Configured ${id} at ${getConfigPath()}`);
115
130
  return 0;
116
131
  }
117
132
  if (sub === "use") {
118
- if (args.shift() !== CLINE_PROVIDER_ID) throw new ConfigError("Only `provider use cline-cli` is supported.");
119
- await saveConfig({ ...config, defaultProvider: CLINE_PROVIDER_ID });
120
- console.log(`Default provider: ${CLINE_PROVIDER_ID}`);
133
+ const requested = args.shift();
134
+ if (requested !== CLINE_PROVIDER_ID && requested !== GEMINI_PROVIDER_ID) {
135
+ throw new ConfigError("Only `provider use cline-cli` or `provider use gemini-cli` is supported.");
136
+ }
137
+ await saveConfig({ ...config, defaultProvider: requested });
138
+ console.log(`Default provider: ${requested}`);
121
139
  return 0;
122
140
  }
123
141
  throw new ConfigError(`Unknown provider command: ${sub ?? "(missing)"}`);
124
142
  }
125
143
 
126
144
  async function loginCommand(args: string[]): Promise<number> {
127
- if (args.shift() !== CLINE_PROVIDER_ID) throw new ConfigError("Only `login cline-cli` is supported.");
145
+ const provider = args.shift();
146
+ if (provider === GEMINI_PROVIDER_ID) {
147
+ throw new ConfigError("Gemini authentication is managed by Gemini CLI. Run `gemini` to log in or set GEMINI_API_KEY for the CLI.");
148
+ }
149
+ if (provider !== CLINE_PROVIDER_ID) throw new ConfigError("Only `login cline-cli` is supported; authenticate Gemini through Gemini CLI.");
128
150
  const json = takeFlag(args, "--json");
129
151
  const noWait = takeFlag(args, "--no-wait");
130
152
  if (args.length > 0) throw new ConfigError(`Unknown login option: ${args[0]}`);
@@ -157,9 +179,23 @@ async function startCommand(args: string[]): Promise<number> {
157
179
  config.port = parsed;
158
180
  }
159
181
  if (host !== undefined) config.host = host;
182
+ try {
183
+ const catalog = await syncCodexCatalog({ env });
184
+ if (catalog.ok && catalog.written) {
185
+ console.log(`Codex catalog refreshed: ${catalog.path}`);
186
+ console.log("Restart the Codex app/app-server to reload the model picker.");
187
+ if (!catalog.configured) {
188
+ console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
189
+ }
190
+ } else if (!catalog.ok) {
191
+ console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
192
+ }
193
+ } catch (error) {
194
+ console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
160
196
  const running = startServer(config, { env });
161
197
  await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
162
- console.log(`Cline-only proxy listening on http://${running.host}:${running.port}/v1`);
198
+ console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
163
199
  console.log(`Health: http://${running.host}:${running.port}/healthz`);
164
200
 
165
201
  const shutdown = async (signal: string): Promise<void> => {
@@ -174,6 +210,31 @@ async function startCommand(args: string[]): Promise<number> {
174
210
  return 0;
175
211
  }
176
212
 
213
+ async function codexCommand(args: string[]): Promise<number> {
214
+ const sub = args.shift();
215
+ if (sub !== "sync") throw new ConfigError(`Unknown codex command: ${sub ?? "(missing)"}. Expected sync.`);
216
+ const json = takeFlag(args, "--json");
217
+ if (args.length > 0) throw new ConfigError(`Unknown codex sync option: ${args[0]}`);
218
+ const result = await syncCodexCatalog({ env: process.env });
219
+ if (!result.ok) {
220
+ if (json) console.log(JSON.stringify({ ok: false, path: result.path, reason: result.reason, message: result.message }));
221
+ else console.error(`ocx: ${result.message}`);
222
+ return 1;
223
+ }
224
+ if (json) {
225
+ console.log(JSON.stringify(result));
226
+ } else {
227
+ console.log(`Catalog: ${result.path}`);
228
+ console.log(`Models: ${result.modelCount} (Gemini ${result.geminiModelCount}, reasoning-enabled ${result.reasoningModelCount})`);
229
+ for (const warning of result.warnings) console.warn(`[opencodex] ${warning}`);
230
+ if (!result.configured) {
231
+ console.log(`Activate it with: model_catalog_json = "${result.path}"`);
232
+ }
233
+ console.log("Codex reload required: restart the Codex app or app-server model picker.");
234
+ }
235
+ return 0;
236
+ }
237
+
177
238
  async function statusCommand(args: string[]): Promise<number> {
178
239
  const json = takeFlag(args, "--json");
179
240
  if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
@@ -208,7 +269,7 @@ async function stopCommand(args: string[]): Promise<number> {
208
269
  return 0;
209
270
  }
210
271
  if (!await health(state.host, state.port, config, process.env)) {
211
- throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the Cline-only proxy recorded by this home.`);
272
+ throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the opencodex proxy recorded by this home.`);
212
273
  }
213
274
  process.kill(state.pid, "SIGTERM");
214
275
  const deadline = Date.now() + 5_000;
@@ -222,7 +283,7 @@ async function stopCommand(args: string[]): Promise<number> {
222
283
 
223
284
  async function main(argv: string[]): Promise<number> {
224
285
  if (argv[0] === "__cli-agent-mcp") {
225
- await runCodingAgentMcpServer(argv[1] ?? "");
286
+ await runCodingAgentMcpServer(argv[1] ?? "", argv[2]);
226
287
  return 0;
227
288
  }
228
289
  const command = argv.shift();
@@ -236,10 +297,11 @@ async function main(argv: string[]): Promise<number> {
236
297
  }
237
298
  if (command === "login") return await loginCommand(argv);
238
299
  if (command === "provider") return await providerCommand(argv);
300
+ if (command === "codex") return await codexCommand(argv);
239
301
  if (command === "start") return await startCommand(argv);
240
302
  if (command === "status") return await statusCommand(argv);
241
303
  if (command === "stop") return await stopCommand(argv);
242
- throw new ConfigError(`Unknown command: ${command}. Expected login, provider, start, status, stop, version, or help.`);
304
+ throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
243
305
  }
244
306
 
245
307
  try {