@koreanhole/opencodex 2.98.0 → 3.0.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,222 @@
1
+ #!/usr/bin/env bun
2
+ import { randomBytes } from "node:crypto";
3
+ import { rmSync } from "node:fs";
4
+ import {
5
+ ADAPTER_DEFAULT_PORT,
6
+ ADAPTER_PROVIDER_IDS,
7
+ ADAPTER_TOKEN_ENV,
8
+ AdapterConfigError,
9
+ adapterRuntimePath,
10
+ discoverManagedProviderMetadata,
11
+ readAdapterRuntime,
12
+ readUpstreamConfig,
13
+ removeAdapterRuntimeIfPid,
14
+ restoreManagedProviderRows,
15
+ setAdapterDefaultProvider,
16
+ syncManagedProviderRows,
17
+ writeAdapterRuntime,
18
+ type AdapterProviderId,
19
+ } from "../adapter-runtime/config";
20
+ import { startAdapterGateway, type AdapterGateway } from "../adapter-runtime/gateway";
21
+ import { ConfigError } from "../config";
22
+ import { UpstreamPackageError, resolveUpstreamPackage } from "../upstream/package";
23
+ import { loadUpstreamCli } from "../upstream/cli";
24
+ import { runAdapterCli } from "./index";
25
+
26
+ type AdapterCli = (argv: string[]) => Promise<number>;
27
+ type UpstreamLoader = () => Promise<void>;
28
+ type StartPreparer = (env: NodeJS.ProcessEnv) => Promise<unknown>;
29
+
30
+ export interface EntryDeps {
31
+ adapterCli?: AdapterCli;
32
+ loadUpstream?: UpstreamLoader;
33
+ prepareStart?: StartPreparer;
34
+ }
35
+
36
+ const DISTRIBUTION_PACKAGE = "@koreanhole/opencodex";
37
+
38
+ function isAdapterId(value: string | undefined): value is AdapterProviderId {
39
+ return value === "gemini-cli" || value === "cline-cli";
40
+ }
41
+
42
+ export function isAdapterOwnedCommand(argv: readonly string[]): boolean {
43
+ const command = argv[0];
44
+ if (command === "__cli-agent-mcp") return true;
45
+ if (command === "update") return true;
46
+ if (command === "system" && argv.slice(1).includes("update")) return true;
47
+ if (command === "login" && (argv[1] === "cline-cli" || argv[1] === "gemini-cli")) return true;
48
+ if (command === "adapter" && argv[1] === "detach") return true;
49
+ if (command !== "provider") return false;
50
+ if (argv[1] === "add" && isAdapterId(argv[2])) return true;
51
+ if (argv[1] === "use" && isAdapterId(argv[2])) return true;
52
+ return false;
53
+ }
54
+
55
+ function takeFlag(args: string[], flag: string): boolean {
56
+ const index = args.indexOf(flag);
57
+ if (index < 0) return false;
58
+ args.splice(index, 1);
59
+ return true;
60
+ }
61
+
62
+ async function adapterProviderCommand(args: string[], env: NodeJS.ProcessEnv): Promise<number> {
63
+ const sub = args.shift();
64
+ if (sub !== "add" && sub !== "use") throw new ConfigError("Unknown provider command: " + String(sub ?? "(missing)"));
65
+ const provider = args.shift();
66
+ if (!isAdapterId(provider)) throw new ConfigError("Only gemini-cli and cline-cli are handled for provider " + sub + ".");
67
+ if (sub === "add") {
68
+ const setDefault = takeFlag(args, "--set-default");
69
+ if (args.length > 0) throw new ConfigError("Unknown provider add option: " + args[0]);
70
+ const runtime = await readAdapterRuntime(env);
71
+ await syncManagedProviderRows({
72
+ env,
73
+ gatewayPort: runtime?.port ?? ADAPTER_DEFAULT_PORT,
74
+ ...(setDefault ? { defaultProvider: provider } : {}),
75
+ });
76
+ console.log("Configured " + provider + ".");
77
+ return 0;
78
+ }
79
+ if (args.length > 0) throw new ConfigError("Unknown provider use option: " + args[0]);
80
+ await setAdapterDefaultProvider(provider, env);
81
+ console.log("Default provider: " + provider);
82
+ return 0;
83
+ }
84
+
85
+ async function detachAdapter(env: NodeJS.ProcessEnv): Promise<number> {
86
+ const result = await restoreManagedProviderRows(env);
87
+ if (!result.restored) {
88
+ console.log("CLI adapters are not attached.");
89
+ return 0;
90
+ }
91
+ if (result.preservedUserRows.length > 0) {
92
+ console.log("Preserved user-owned provider rows: " + result.preservedUserRows.join(", "));
93
+ }
94
+ console.log("CLI adapter provider rows detached.");
95
+ return 0;
96
+ }
97
+
98
+ function printDistributionUpdate(argv: readonly string[]): number {
99
+ const command = "npm install -g " + DISTRIBUTION_PACKAGE + "@latest";
100
+ if (argv.includes("--json")) {
101
+ console.log(JSON.stringify({
102
+ ok: false,
103
+ reason: "distribution_managed",
104
+ package: DISTRIBUTION_PACKAGE,
105
+ command,
106
+ }));
107
+ } else {
108
+ console.error("ocx: updates are managed by " + DISTRIBUTION_PACKAGE + ".");
109
+ console.error("Run: " + command);
110
+ }
111
+ return 2;
112
+ }
113
+
114
+ async function runAdapterCommand(argv: string[], env: NodeJS.ProcessEnv, adapterCli: AdapterCli): Promise<number> {
115
+ if (argv[0] === "__cli-agent-mcp") return adapterCli(argv);
116
+ if (argv[0] === "login") return adapterCli(argv);
117
+ if (argv[0] === "update" || (argv[0] === "system" && argv.slice(1).includes("update"))) {
118
+ return printDistributionUpdate(argv);
119
+ }
120
+ if (argv[0] === "adapter" && argv[1] === "detach") return detachAdapter(env);
121
+ if (argv[0] === "provider") return adapterProviderCommand(argv.slice(1), env);
122
+ throw new ConfigError("Internal adapter dispatch error.");
123
+ }
124
+
125
+ function pidAlive(pid: number): boolean {
126
+ try {
127
+ process.kill(pid, 0);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** Attach the two managed rows and keep the gateway in the serving process. */
135
+ export async function prepareAdapterStart(
136
+ env: NodeJS.ProcessEnv = process.env,
137
+ deps: {
138
+ discoverMetadata?: typeof discoverManagedProviderMetadata;
139
+ startGateway?: typeof startAdapterGateway;
140
+ syncRows?: typeof syncManagedProviderRows;
141
+ } = {},
142
+ ): Promise<AdapterGateway | undefined> {
143
+ const existing = await readAdapterRuntime(env);
144
+ const inheritedToken = env[ADAPTER_TOKEN_ENV]?.trim();
145
+ if (existing && existing.pid !== process.pid && pidAlive(existing.pid) && !inheritedToken) {
146
+ throw new AdapterConfigError(
147
+ "The CLI adapter gateway is already running in another process; restart it from that process or stop it first.",
148
+ );
149
+ }
150
+ const token = inheritedToken || randomBytes(32).toString("base64url");
151
+ env[ADAPTER_TOKEN_ENV] = token;
152
+ let gateway: AdapterGateway | undefined;
153
+ let gatewayPort: number;
154
+ if (existing && existing.pid !== process.pid && pidAlive(existing.pid)) {
155
+ gatewayPort = existing.port;
156
+ } else {
157
+ if (existing) await removeAdapterRuntimeIfPid(existing.pid, env);
158
+ gateway = (deps.startGateway ?? startAdapterGateway)({ token, env });
159
+ gatewayPort = gateway.port;
160
+ await writeAdapterRuntime({
161
+ version: 1,
162
+ pid: process.pid,
163
+ host: "127.0.0.1",
164
+ port: gatewayPort,
165
+ tokenEnv: ADAPTER_TOKEN_ENV,
166
+ providers: [...ADAPTER_PROVIDER_IDS],
167
+ startedAt: new Date().toISOString(),
168
+ }, env);
169
+ process.once("exit", () => {
170
+ try {
171
+ rmSync(adapterRuntimePath(env), { force: true });
172
+ } catch {
173
+ // best effort
174
+ }
175
+ });
176
+ }
177
+
178
+ try {
179
+ const metadata = await (deps.discoverMetadata ?? discoverManagedProviderMetadata)(env).catch(() => undefined);
180
+ const synced = await (deps.syncRows ?? syncManagedProviderRows)({
181
+ env,
182
+ gatewayPort,
183
+ ...(metadata ? { metadata } : {}),
184
+ });
185
+ if (synced.providerEnvToken) env.OPENCODEX_API_AUTH_TOKEN ??= synced.providerEnvToken;
186
+ return gateway;
187
+ } catch (error) {
188
+ await gateway?.stop().catch(() => undefined);
189
+ await removeAdapterRuntimeIfPid(process.pid, env).catch(() => undefined);
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ export async function runEntry(argv: string[], deps: EntryDeps = {}): Promise<void> {
195
+ const env = process.env;
196
+ env.OCX_DISABLE_UPDATE_CHECK ??= "1";
197
+ if (isAdapterOwnedCommand(argv)) {
198
+ process.exitCode = await runAdapterCommand(argv, env, deps.adapterCli ?? runAdapterCli);
199
+ return;
200
+ }
201
+ if (argv[0] === "start") {
202
+ await (deps.prepareStart ?? prepareAdapterStart)(env);
203
+ }
204
+ const entry = process.argv[1] ?? "ocx";
205
+ const executable = process.argv[0] ?? "bun";
206
+ process.argv = [executable, entry, ...argv];
207
+ await (deps.loadUpstream ?? (() => loadUpstreamCli()))();
208
+ }
209
+
210
+ function printError(error: unknown): number {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ console.error("ocx: " + message);
213
+ return error instanceof ConfigError || error instanceof AdapterConfigError || error instanceof UpstreamPackageError ? 2 : 1;
214
+ }
215
+
216
+ if (import.meta.main) {
217
+ try {
218
+ await runEntry(process.argv.slice(2));
219
+ } catch (error) {
220
+ process.exitCode = printError(error);
221
+ }
222
+ }
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ import { startServer } from "../server";
20
20
  import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
21
21
  import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
22
22
  import { syncCodexCatalog } from "../lib/codex-catalog";
23
+ import { injectCodexConfig, recordCodexCatalogState, restoreCodexConfig } from "../lib/codex-inject";
23
24
 
24
25
  const here = dirname(fileURLToPath(import.meta.url));
25
26
 
@@ -61,7 +62,10 @@ function help(): string {
61
62
  " provider list [--json] Show supported providers",
62
63
  " start [--port <port>] [--host <host>] Start the proxy",
63
64
  " status [--json] Check the running proxy",
64
- " stop [--json] Stop the running proxy",
65
+ " stop [--json] Stop the proxy and restore native Codex",
66
+ " restore [--json] Restore native Codex without stopping",
67
+ " restore back [--json] Re-point Codex at the running proxy",
68
+ " eject Alias of restore",
65
69
  " codex sync [--json] Refresh the Codex model catalog",
66
70
  " version Print the package version",
67
71
  " help Show this help",
@@ -179,21 +183,27 @@ async function startCommand(args: string[]): Promise<number> {
179
183
  config.port = parsed;
180
184
  }
181
185
  if (host !== undefined) config.host = host;
186
+ const running = startServer(config, { env });
187
+ try {
188
+ await injectCodexConfig({ env, endpoint: { host: running.host, port: running.port }, config });
189
+ } catch (error) {
190
+ await running.stop(true).catch(() => undefined);
191
+ throw error;
192
+ }
182
193
  try {
183
194
  const catalog = await syncCodexCatalog({ env });
184
195
  if (catalog.ok && catalog.written) {
196
+ await recordCodexCatalogState(env).catch(error => {
197
+ console.warn(`[opencodex] Codex catalog state was not journaled: ${error instanceof Error ? error.message : String(error)}`);
198
+ });
185
199
  console.log(`Codex catalog refreshed: ${catalog.path}`);
186
200
  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
201
  } else if (!catalog.ok) {
191
202
  console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
192
203
  }
193
204
  } catch (error) {
194
205
  console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
195
206
  }
196
- const running = startServer(config, { env });
197
207
  await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
198
208
  console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
199
209
  console.log(`Health: http://${running.host}:${running.port}/healthz`);
@@ -202,10 +212,22 @@ async function startCommand(args: string[]): Promise<number> {
202
212
  console.log(`\nStopping proxy (${signal})...`);
203
213
  await running.stop(true);
204
214
  await removeRuntimeStateIfPid(process.pid, env);
205
- process.exit(0);
215
+ let exitCode = 0;
216
+ try {
217
+ const restored = await restoreCodexConfig({ env });
218
+ if (!restored.success) {
219
+ exitCode = 1;
220
+ console.error(`ocx: Codex restore did not complete: ${restored.catalog.message}`);
221
+ }
222
+ } catch (error) {
223
+ exitCode = 1;
224
+ console.error(`ocx: Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
225
+ }
226
+ process.exit(exitCode);
206
227
  };
207
228
  process.once("SIGINT", () => void shutdown("SIGINT"));
208
229
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
230
+ process.once("SIGHUP", () => void shutdown("SIGHUP"));
209
231
  await new Promise<never>(() => undefined);
210
232
  return 0;
211
233
  }
@@ -232,9 +254,42 @@ async function codexCommand(args: string[]): Promise<number> {
232
254
  }
233
255
  console.log("Codex reload required: restart the Codex app or app-server model picker.");
234
256
  }
257
+ if (result.ok) await recordCodexCatalogState(process.env).catch(() => undefined);
235
258
  return 0;
236
259
  }
237
260
 
261
+ async function restoreCommand(args: string[]): Promise<number> {
262
+ const env = process.env;
263
+ const back = args[0] === "back";
264
+ if (back) args.shift();
265
+ const json = takeFlag(args, "--json");
266
+ const removeProviderTable = takeFlag(args, "--remove-codex-provider-table");
267
+ if (args.length > 0) throw new ConfigError(`Unknown restore option: ${args[0]}`);
268
+ let result;
269
+ if (back) {
270
+ const state = await readRuntimeState(env);
271
+ if (!state) throw new ConfigError("Proxy is not running; start it before `ocx restore back`.");
272
+ const config = await loadConfig(env);
273
+ if (!isAlive(state.pid) || !await health(state.host, state.port, config, env)) {
274
+ throw new ConfigError("The recorded proxy is not healthy; refusing to restore Codex routing.");
275
+ }
276
+ result = await injectCodexConfig({
277
+ env,
278
+ endpoint: { host: state.host, port: state.port },
279
+ config,
280
+ });
281
+ result = { ...result, action: "restore-back" as const };
282
+ } else {
283
+ result = await restoreCodexConfig({ env, removeProviderTable });
284
+ }
285
+ if (json) console.log(JSON.stringify(result));
286
+ else {
287
+ console.log(result.config.message);
288
+ console.log(result.catalog.message);
289
+ }
290
+ return result.success ? 0 : 1;
291
+ }
292
+
238
293
  async function statusCommand(args: string[]): Promise<number> {
239
294
  const json = takeFlag(args, "--json");
240
295
  if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
@@ -257,16 +312,24 @@ async function stopCommand(args: string[]): Promise<number> {
257
312
  if (args.length > 0) throw new ConfigError(`Unknown stop option: ${args[0]}`);
258
313
  const state = await readRuntimeState();
259
314
  if (!state) {
260
- if (json) console.log(JSON.stringify({ stopped: false, reason: "not-running" }));
261
- else console.log("Proxy is not running.");
315
+ const restored = await restoreCodexConfig({ env: process.env });
316
+ if (json) console.log(JSON.stringify({ stopped: false, reason: "not-running", restore: restored }));
317
+ else {
318
+ console.log("Proxy is not running.");
319
+ console.log(restored.config.message);
320
+ }
262
321
  return 1;
263
322
  }
264
323
  const config = await loadConfig();
265
324
  if (!isAlive(state.pid)) {
266
325
  await removeRuntimeStateIfPid(state.pid);
267
- if (json) console.log(JSON.stringify({ stopped: true, stale: true }));
268
- else console.log("Removed stale proxy state.");
269
- return 0;
326
+ const restored = await restoreCodexConfig({ env: process.env });
327
+ if (json) console.log(JSON.stringify({ stopped: true, stale: true, restore: restored }));
328
+ else {
329
+ console.log("Removed stale proxy state.");
330
+ console.log(restored.config.message);
331
+ }
332
+ return restored.success ? 0 : 1;
270
333
  }
271
334
  if (!await health(state.host, state.port, config, process.env)) {
272
335
  throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the opencodex proxy recorded by this home.`);
@@ -276,12 +339,16 @@ async function stopCommand(args: string[]): Promise<number> {
276
339
  while (isAlive(state.pid) && Date.now() < deadline) await Bun.sleep(50);
277
340
  if (isAlive(state.pid)) process.kill(state.pid, "SIGKILL");
278
341
  await removeRuntimeStateIfPid(state.pid);
279
- if (json) console.log(JSON.stringify({ stopped: true, pid: state.pid }));
280
- else console.log(`Stopped proxy (pid ${state.pid}).`);
281
- return 0;
342
+ const restored = await restoreCodexConfig({ env: process.env });
343
+ if (json) console.log(JSON.stringify({ stopped: true, pid: state.pid, restore: restored }));
344
+ else {
345
+ console.log(`Stopped proxy (pid ${state.pid}).`);
346
+ console.log(restored.config.message);
347
+ }
348
+ return restored.success ? 0 : 1;
282
349
  }
283
350
 
284
- async function main(argv: string[]): Promise<number> {
351
+ export async function runAdapterCli(argv: string[]): Promise<number> {
285
352
  if (argv[0] === "__cli-agent-mcp") {
286
353
  await runCodingAgentMcpServer(argv[1] ?? "", argv[2]);
287
354
  return 0;
@@ -301,11 +368,14 @@ async function main(argv: string[]): Promise<number> {
301
368
  if (command === "start") return await startCommand(argv);
302
369
  if (command === "status") return await statusCommand(argv);
303
370
  if (command === "stop") return await stopCommand(argv);
304
- throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
371
+ if (command === "restore" || command === "eject") return await restoreCommand(argv);
372
+ throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, restore, eject, version, or help.`);
305
373
  }
306
374
 
307
- try {
308
- process.exitCode = await main(process.argv.slice(2));
309
- } catch (error) {
310
- process.exitCode = printError(error);
375
+ if (import.meta.main) {
376
+ try {
377
+ process.exitCode = await runAdapterCli(process.argv.slice(2));
378
+ } catch (error) {
379
+ process.exitCode = printError(error);
380
+ }
311
381
  }
@@ -257,6 +257,67 @@ function readRootTomlString(text: string, key: string): string | undefined {
257
257
  return match?.[1]?.trim() || undefined;
258
258
  }
259
259
 
260
+ export function codexHome(env: NodeJS.ProcessEnv = process.env): string {
261
+ return env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".codex");
262
+ }
263
+
264
+ /** Resolve the catalog only when opencodex is allowed to manage that file. */
265
+ export async function resolveCodexCatalogPath(
266
+ env: NodeJS.ProcessEnv = process.env,
267
+ ): Promise<string | undefined> {
268
+ const home = codexHome(env);
269
+ let configured: string | undefined;
270
+ try {
271
+ configured = readRootTomlString(await readFile(join(home, "config.toml"), "utf8"), "model_catalog_json");
272
+ } catch {
273
+ configured = undefined;
274
+ }
275
+ if (configured && basename(configured) !== "opencodex-catalog.json") return undefined;
276
+ return configured ? resolve(home, configured) : join(home, "opencodex-catalog.json");
277
+ }
278
+
279
+ export function isManagedCodexCatalogSlug(slug: unknown): boolean {
280
+ return managedSlug(slug);
281
+ }
282
+
283
+ /** Remove only opencodex-routed rows; preserve native/user rows. */
284
+ export async function removeManagedCodexCatalogRows(path: string): Promise<{
285
+ changed: boolean;
286
+ removed: number;
287
+ message: string;
288
+ }> {
289
+ let raw: string;
290
+ try {
291
+ raw = await readFile(path, "utf8");
292
+ } catch (error) {
293
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
294
+ return { changed: false, removed: 0, message: "No opencodex catalog file was present." };
295
+ }
296
+ throw error;
297
+ }
298
+ let parsed: Record<string, unknown>;
299
+ try {
300
+ const value = asRecord(JSON.parse(raw));
301
+ if (!value) throw new Error("catalog is not an object");
302
+ parsed = value;
303
+ } catch {
304
+ throw new Error("The opencodex catalog is malformed; it was preserved.");
305
+ }
306
+ const models = Array.isArray(parsed.models) ? parsed.models : undefined;
307
+ if (!models || models.some(model => !asRecord(model))) {
308
+ throw new Error("The opencodex catalog has an invalid models field; it was preserved.");
309
+ }
310
+ const kept = models.filter(model => !managedSlug(asRecord(model)?.slug));
311
+ const removed = models.length - kept.length;
312
+ if (removed === 0) return { changed: false, removed: 0, message: "No managed catalog rows were present." };
313
+ if (kept.length === 0 && basename(path) === "opencodex-catalog.json") {
314
+ await rm(path, { force: true });
315
+ return { changed: true, removed, message: `Removed ${removed} managed catalog row(s) and the empty opencodex catalog.` };
316
+ }
317
+ await writeCodexCatalog(path, { ...parsed, models: kept } as CodexCatalogDocument);
318
+ return { changed: true, removed, message: `Removed ${removed} managed catalog row(s) from ${path}.` };
319
+ }
320
+
260
321
  export async function writeCodexCatalog(
261
322
  path: string,
262
323
  document: CodexCatalogDocument,