Skip to content

Commit 060f51d

Browse files
author
Codex
committed
Fix Codex catalog injection on Windows
1 parent fb29df5 commit 060f51d

4 files changed

Lines changed: 172 additions & 22 deletions

File tree

src/cli.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ async function syncModelsToCodex(port?: number) {
3737
const { injectCodexConfig } = await import("./codex-inject");
3838
const result = await injectCodexConfig(p, config);
3939
try {
40-
const { syncCatalogModels } = await import("./codex-catalog");
40+
const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog");
4141
const cat = await syncCatalogModels(config);
42-
if (cat.added > 0) console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
42+
if (cat.added > 0) {
43+
invalidateCodexModelsCache();
44+
console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
45+
}
4346
} catch (e) {
4447
console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
4548
}

src/codex-catalog.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFil
22
import { homedir } from "node:os";
33
import { join } from "node:path";
44
import { atomicWriteFile } from "./config";
5+
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
56
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
67
import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
78
import type { OcxConfig, OcxProviderConfig } from "./types";
89

9-
const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
10-
const DEFAULT_CATALOG_PATH = join(homedir(), ".codex", "opencodex-catalog.json");
1110
const OCX_DIR = join(homedir(), ".opencodex");
1211
const CATALOG_BACKUP_PATH = join(OCX_DIR, "catalog-backup.json");
1312

@@ -40,8 +39,8 @@ export function readCodexCatalogPath(): string {
4039
try {
4140
if (existsSync(CODEX_CONFIG_PATH)) {
4241
const toml = readFileSync(CODEX_CONFIG_PATH, "utf-8");
43-
const m = toml.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"/m);
44-
if (m) return m[1];
42+
const path = readRootTomlString(toml, "model_catalog_json");
43+
if (path) return resolveCodexConfigPath(path);
4544
}
4645
} catch { /* ignore */ }
4746
return DEFAULT_CATALOG_PATH;
@@ -55,13 +54,36 @@ function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown
5554
} catch { return null; }
5655
}
5756

57+
function normalizeServiceTiers(entry: RawEntry): RawEntry {
58+
if (entry.service_tier === "priority") entry.service_tier = "fast";
59+
if (Array.isArray(entry.service_tiers)) {
60+
entry.service_tiers = entry.service_tiers.map(tier => {
61+
if (tier && typeof tier === "object" && "id" in tier && tier.id === "priority") {
62+
return { ...tier, id: "fast" };
63+
}
64+
return tier;
65+
});
66+
}
67+
return entry;
68+
}
69+
70+
function loadCatalogForSync(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
71+
const catalog = readCatalog(path);
72+
if (catalog) return catalog;
73+
return readCatalog(CODEX_MODELS_CACHE_PATH);
74+
}
75+
76+
function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknown } | null {
77+
return readCatalog(readCodexCatalogPath()) ?? readCatalog(CODEX_MODELS_CACHE_PATH);
78+
}
79+
5880
/**
5981
* A full native entry from the on-disk catalog, used as a clone template so injected
6082
* entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
6183
* Returns a deep copy, or null if no catalog/native entry exists.
6284
*/
6385
export function loadCatalogTemplate(): RawEntry | null {
64-
const cat = readCatalog(readCodexCatalogPath());
86+
const cat = readCurrentCatalogOrCache();
6587
const native = cat?.models?.find(
6688
m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
6789
);
@@ -109,16 +131,16 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
109131
e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
110132
e.default_reasoning_level = "medium";
111133
}
112-
return e;
134+
return normalizeServiceTiers(e);
113135
}
114136
// Fallback when no template is available (best-effort; strict parser may need more).
115-
return {
137+
return normalizeServiceTiers({
116138
slug, display_name: slug, description: desc,
117139
default_reasoning_level: "medium",
118140
supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
119141
shell_type: "shell_command", visibility: "list", supported_in_api: true,
120142
priority, base_instructions: "You are a helpful coding assistant.",
121-
};
143+
});
122144
}
123145

124146
/**
@@ -149,7 +171,7 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
149171

150172
/** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
151173
export function listCatalogNativeSlugs(): string[] {
152-
const cat = readCatalog(readCodexCatalogPath());
174+
const cat = readCurrentCatalogOrCache();
153175
return (cat?.models ?? [])
154176
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list")
155177
.map(m => m.slug as string);
@@ -244,7 +266,7 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
244266
*/
245267
export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
246268
const catalogPath = readCodexCatalogPath();
247-
const catalog = readCatalog(catalogPath);
269+
const catalog = loadCatalogForSync(catalogPath);
248270
if (!catalog) return { added: 0, path: catalogPath };
249271

250272
const template = (catalog.models ?? []).find(
@@ -272,9 +294,9 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
272294
.map(m => {
273295
const slug = m.slug as string;
274296
const priority = rank.has(slug) ? rank.get(slug)! : (baseline.get(slug) ?? (m.priority as number));
275-
return { ...m, priority };
297+
return normalizeServiceTiers({ ...m, priority });
276298
});
277-
catalog.models = [...native, ...goEntries];
299+
catalog.models = [...native, ...goEntries].map(m => normalizeServiceTiers(m));
278300

279301
try {
280302
if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });

src/codex-inject.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2-
import { homedir } from "node:os";
3-
import { join } from "node:path";
42
import { atomicWriteFile } from "./config";
53
import { restoreCodexCatalog } from "./codex-catalog";
4+
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, tomlString } from "./codex-paths";
65
import type { OcxConfig } from "./types";
76

8-
const CODEX_HOME = join(homedir(), ".codex");
9-
const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
10-
const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
11-
127
const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
138

149
/**
@@ -31,6 +26,15 @@ function buildProviderTableBlock(port: number): string {
3126
return lines.join("\n") + "\n";
3227
}
3328

29+
function buildProfileTableBlock(catalogPath: string): string {
30+
return [
31+
"",
32+
"[profiles.opencodex]",
33+
'model_provider = "opencodex"',
34+
`model_catalog_json = ${tomlString(catalogPath)}`,
35+
].join("\n") + "\n";
36+
}
37+
3438
/**
3539
* Strip every existing `model_provider` line that we must not duplicate: any line set to
3640
* "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any
@@ -70,11 +74,65 @@ function setRootModelProvider(content: string): string {
7074
return lines.join("\n");
7175
}
7276

73-
function buildProfileFile(port: number): string {
77+
function readRootModelCatalogPath(content: string): string | null {
78+
return readRootTomlString(content, "model_catalog_json");
79+
}
80+
81+
function setRootModelCatalogPath(content: string, catalogPath: string): string {
82+
if (readRootModelCatalogPath(content)) return content;
83+
const lines = content.split("\n");
84+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
85+
const key = `model_catalog_json = ${tomlString(catalogPath)}`;
86+
if (firstTable === -1) {
87+
return content.replace(/\n+$/, "") + "\n" + key + "\n";
88+
}
89+
let insertAt = firstTable;
90+
while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
91+
lines.splice(insertAt, 0, key);
92+
return lines.join("\n");
93+
}
94+
95+
function removeProfileSection(content: string): string {
96+
const lines = content.split("\n");
97+
const filtered: string[] = [];
98+
let inProfile = false;
99+
for (const line of lines) {
100+
if (line.trim() === "[profiles.opencodex]") {
101+
inProfile = true;
102+
continue;
103+
}
104+
if (inProfile) {
105+
if (line.startsWith("[") && line.trim() !== "[profiles.opencodex]") {
106+
inProfile = false;
107+
filtered.push(line);
108+
}
109+
continue;
110+
}
111+
filtered.push(line);
112+
}
113+
return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
114+
}
115+
116+
function normalizeServiceTier(content: string): string {
117+
return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"');
118+
}
119+
120+
function stripDefaultCatalogPath(content: string): string {
121+
return content
122+
.split("\n")
123+
.filter(line => {
124+
const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
125+
return !m || parseTomlString(m[1]) !== DEFAULT_CATALOG_PATH;
126+
})
127+
.join("\n");
128+
}
129+
130+
function buildProfileFile(port: number, catalogPath: string): string {
74131
return [
75132
"# OpenCodex proxy profile — use with: codex --profile opencodex",
76133
`# Routes all model requests through the opencodex proxy at localhost:${port}`,
77134
'model_provider = "opencodex"',
135+
`model_catalog_json = ${tomlString(catalogPath)}`,
78136
"",
79137
].join("\n");
80138
}
@@ -92,15 +150,21 @@ export async function injectCodexConfig(port: number, _config?: OcxConfig): Prom
92150
if (content.includes("[model_providers.opencodex]")) {
93151
content = removeOcxSection(content);
94152
}
153+
content = removeProfileSection(content);
95154
content = stripExistingModelProvider(content);
155+
content = normalizeServiceTier(content);
156+
157+
const catalogPath = readRootModelCatalogPath(content) ?? DEFAULT_CATALOG_PATH;
158+
content = setRootModelCatalogPath(content, catalogPath);
96159

97160
// 1) Root key BEFORE the first table header (must be a global, not nested under a table).
98161
content = setRootModelProvider(content);
99162
// 2) Provider table appended at EOF (position-independent).
100163
content = content.trimEnd() + "\n" + buildProviderTableBlock(port);
164+
content = content.trimEnd() + "\n" + buildProfileTableBlock(catalogPath);
101165

102166
writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
103-
writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port), "utf-8");
167+
writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
104168

105169
return {
106170
success: true,
@@ -141,9 +205,11 @@ export function stripOpencodexConfig(content: string): string {
141205
if (out.includes("[model_providers.opencodex]")) {
142206
out = removeOcxSection(out);
143207
}
208+
out = removeProfileSection(out);
144209
// Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
145210
// must match the detection regex above, or a detected line could survive un-removed.
146211
out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
212+
out = stripDefaultCatalogPath(out);
147213
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
148214
}
149215

src/codex-paths.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { realpathSync, statSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { isAbsolute, join, resolve } from "node:path";
4+
5+
function resolveCodexHome(): string {
6+
const raw = process.env.CODEX_HOME?.trim();
7+
if (raw) {
8+
const path = resolve(raw);
9+
let stat;
10+
try {
11+
stat = statSync(path);
12+
} catch (err) {
13+
const message = err instanceof Error ? err.message : String(err);
14+
throw new Error(`CODEX_HOME points to ${raw}, but that path could not be read: ${message}`);
15+
}
16+
if (!stat.isDirectory()) {
17+
throw new Error(`CODEX_HOME points to ${raw}, but that path is not a directory`);
18+
}
19+
return realpathSync.native(path);
20+
}
21+
22+
return join(homedir(), ".codex");
23+
}
24+
25+
export const CODEX_HOME = resolveCodexHome();
26+
export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
27+
export const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
28+
export const DEFAULT_CATALOG_PATH = join(CODEX_HOME, "opencodex-catalog.json");
29+
export const CODEX_MODELS_CACHE_PATH = join(CODEX_HOME, "models_cache.json");
30+
31+
export function tomlString(value: string): string {
32+
return JSON.stringify(value);
33+
}
34+
35+
export function parseTomlString(raw: string): string {
36+
if (raw.startsWith("\"")) {
37+
try {
38+
return JSON.parse(raw) as string;
39+
} catch {
40+
return raw.slice(1, -1);
41+
}
42+
}
43+
return raw.slice(1, -1);
44+
}
45+
46+
export function readRootTomlString(content: string, key: string): string | null {
47+
const lines = content.split("\n");
48+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
49+
const rootLines = firstTable === -1 ? lines : lines.slice(0, firstTable);
50+
for (const line of rootLines) {
51+
const m = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(\"(?:\\\\.|[^\"])*\"|'[^']*')`));
52+
if (m) return parseTomlString(m[1]);
53+
}
54+
return null;
55+
}
56+
57+
export function resolveCodexConfigPath(path: string): string {
58+
return isAbsolute(path) ? path : join(CODEX_HOME, path);
59+
}

0 commit comments

Comments
 (0)