import { spawn } from "node:child_process";import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";import { homedir } from "node:os";import path from "node:path"; export type CodexMarketplace = { name: string; source?: string; sourceType?: string; lastUpdated?: string;}; export function defaultCodexConfigPath() { return path.join(homedir(), ".codex", "config.toml");} export function pluginConfigKey(name: string, source?: string) { const cleanName = name.trim(); const cleanSource = source?.trim(); return cleanSource ? `${cleanName}@${cleanSource}` : cleanName;} export function setPluginEnabledInConfig( rawConfig: string, key: string, enabled: boolean,): string { const header = `[plugins."${escapeTomlKey(key)}"]`; const enabledLine = `enabled = ${enabled ? "true" : "false"}`; const sectionStart = rawConfig.indexOf(header); if (sectionStart === -1) { const prefix = rawConfig.trimEnd(); return `${prefix}${prefix ? "\n\n" : ""}${header}\n${enabledLine}\n`; } const tailStart = sectionStart + header.length; const nextSection = rawConfig.slice(tailStart).search(/\n\[/); const sectionEnd = nextSection === -1 ? rawConfig.length : tailStart + nextSection; const before = rawConfig.slice(0, sectionStart); const section = rawConfig.slice(sectionStart, sectionEnd); const after = rawConfig.slice(sectionEnd); if (/^\s*enabled\s*=\s*(true|false)\s*$/m.test(section)) { return before + section.replace(/^\s*enabled\s*=\s*(true|false)\s*$/m, enabledLine) + after; } return before + `${section.trimEnd()}\n${enabledLine}\n` + after;} export function readCodexMarketplaces(configPath = defaultCodexConfigPath()): CodexMarketplace[] { const raw = safeRead(configPath); const rows: CodexMarketplace[] = []; let current: CodexMarketplace | null = null; for (const line of raw.split(/\r?\n/)) { const header = /^\[marketplaces\.([^\]]+)\]\s*$/.exec(line); if (header) { current = { name: header[1].replaceAll('"', "") }; rows.push(current); continue; } if (!current) continue; if (/^\[/.test(line)) { current = null; continue; } const value = /^\s*([A-Za-z_]+)\s*=\s*"([^"]*)"\s*$/.exec(line); if (!value) continue; if (value[1] === "source") current.source = value[2]; if (value[1] === "source_type") current.sourceType = value[2]; if (value[1] === "last_updated") current.lastUpdated = value[2]; } return rows;} export function upgradeCodexMarketplace(name?: string, timeoutMs = 120_000) { return runCodexPluginCommand( ["plugin", "marketplace", "upgrade", ...(name?.trim() ? [name.trim()] : [])], timeoutMs, );} export function addCodexMarketplace(source: string, timeoutMs = 120_000) { return runCodexPluginCommand(["plugin", "marketplace", "add", source.trim()], timeoutMs);} export function setCodexPluginEnabled({ name, source, enabled, configPath = defaultCodexConfigPath(),}: { name: string; source?: string; enabled: boolean; configPath?: string;}) { const key = pluginConfigKey(name, source); const current = safeRead(configPath); const next = setPluginEnabledInConfig(current, key, enabled); if (next === current) return { key, changed: false }; mkdirSync(path.dirname(configPath), { recursive: true }); const tmp = `${configPath}.${process.pid}.tmp`; writeFileSync(tmp, next); renameSync(tmp, configPath); return { key, changed: true };} function escapeTomlKey(value: string) { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');} function safeRead(filePath: string) { try { return readFileSync(filePath, "utf8"); } catch { return ""; }} function runCodexPluginCommand(args: string[], timeoutMs: number) { const command = process.env.CODEX_BIN || "codex"; return new Promise<{ ok: boolean; stdout: string; stderr: string }>((resolve) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; const limit = 20_000; const timer = setTimeout(() => { child.kill("SIGTERM"); stderr += `\nTimed out after ${timeoutMs}ms.`; }, timeoutMs); child.stdout.on("data", (chunk: Buffer) => { stdout = (stdout + chunk.toString()).slice(-limit); }); child.stderr.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-limit); }); child.on("error", (error) => { clearTimeout(timer); resolve({ ok: false, stdout, stderr: stderr || error.message }); }); child.on("exit", (code) => { clearTimeout(timer); resolve({ ok: code === 0, stdout, stderr }); }); });}
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
export type CodexMarketplace = {
name: string;
source?: string;
sourceType?: string;
lastUpdated?: string;
};
export function defaultCodexConfigPath() {
return path.join(homedir(), ".codex", "config.toml");
}
export function pluginConfigKey(name: string, source?: string) {
const cleanName = name.trim();
const cleanSource = source?.trim();
return cleanSource ? `${cleanName}@${cleanSource}` : cleanName;
}
export function setPluginEnabledInConfig(
rawConfig: string,
key: string,
enabled: boolean,
): string {
const header = `[plugins."${escapeTomlKey(key)}"]`;
const enabledLine = `enabled = ${enabled ? "true" : "false"}`;
const sectionStart = rawConfig.indexOf(header);
if (sectionStart === -1) {
const prefix = rawConfig.trimEnd();
return `${prefix}${prefix ? "\n\n" : ""}${header}\n${enabledLine}\n`;
}
const tailStart = sectionStart + header.length;
const nextSection = rawConfig.slice(tailStart).search(/\n\[/);
const sectionEnd =
nextSection === -1 ? rawConfig.length : tailStart + nextSection;
const before = rawConfig.slice(0, sectionStart);
const section = rawConfig.slice(sectionStart, sectionEnd);
const after = rawConfig.slice(sectionEnd);
if (/^\s*enabled\s*=\s*(true|false)\s*$/m.test(section)) {
return before + section.replace(/^\s*enabled\s*=\s*(true|false)\s*$/m, enabledLine) + after;
}
return before + `${section.trimEnd()}\n${enabledLine}\n` + after;
}
export function readCodexMarketplaces(configPath = defaultCodexConfigPath()): CodexMarketplace[] {
const raw = safeRead(configPath);
const rows: CodexMarketplace[] = [];
let current: CodexMarketplace | null = null;
for (const line of raw.split(/\r?\n/)) {
const header = /^\[marketplaces\.([^\]]+)\]\s*$/.exec(line);
if (header) {
current = { name: header[1].replaceAll('"', "") };
rows.push(current);
continue;
}
if (!current) continue;
if (/^\[/.test(line)) {
current = null;
continue;
}
const value = /^\s*([A-Za-z_]+)\s*=\s*"([^"]*)"\s*$/.exec(line);
if (!value) continue;
if (value[1] === "source") current.source = value[2];
if (value[1] === "source_type") current.sourceType = value[2];
if (value[1] === "last_updated") current.lastUpdated = value[2];
}
return rows;
}
export function upgradeCodexMarketplace(name?: string, timeoutMs = 120_000) {
return runCodexPluginCommand(
["plugin", "marketplace", "upgrade", ...(name?.trim() ? [name.trim()] : [])],
timeoutMs,
);
}
export function addCodexMarketplace(source: string, timeoutMs = 120_000) {
return runCodexPluginCommand(["plugin", "marketplace", "add", source.trim()], timeoutMs);
}
export function setCodexPluginEnabled({
name,
source,
enabled,
configPath = defaultCodexConfigPath(),
}: {
name: string;
source?: string;
enabled: boolean;
configPath?: string;
}) {
const key = pluginConfigKey(name, source);
const current = safeRead(configPath);
const next = setPluginEnabledInConfig(current, key, enabled);
if (next === current) return { key, changed: false };
mkdirSync(path.dirname(configPath), { recursive: true });
const tmp = `${configPath}.${process.pid}.tmp`;
writeFileSync(tmp, next);
renameSync(tmp, configPath);
return { key, changed: true };
}
function escapeTomlKey(value: string) {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function safeRead(filePath: string) {
try {
return readFileSync(filePath, "utf8");
} catch {
return "";
}
}
function runCodexPluginCommand(args: string[], timeoutMs: number) {
const command = process.env.CODEX_BIN || "codex";
return new Promise<{ ok: boolean; stdout: string; stderr: string }>((resolve) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const limit = 20_000;
const timer = setTimeout(() => {
child.kill("SIGTERM");
stderr += `\nTimed out after ${timeoutMs}ms.`;
}, timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
stdout = (stdout + chunk.toString()).slice(-limit);
});
child.stderr.on("data", (chunk: Buffer) => {
stderr = (stderr + chunk.toString()).slice(-limit);
});
child.on("error", (error) => {
clearTimeout(timer);
resolve({ ok: false, stdout, stderr: stderr || error.message });
});
child.on("exit", (code) => {
clearTimeout(timer);
resolve({ ok: code === 0, stdout, stderr });
});
});
}