import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { agentPath } from "../shared/paths.ts"; declare const require: any; declare const process: { env: Record }; type OAuthCredential = { type: "oauth"; access: string; refresh: string; expires: number; accountId?: string; }; type CodexCliAuth = { auth_mode?: string; OPENAI_API_KEY?: string | null; tokens?: { id_token?: string; access_token?: string; refresh_token?: string; account_id?: string; }; last_refresh?: string; [key: string]: unknown; }; type CodexProfile = { name: string; updatedAt: string; accountId?: string; piAuth: OAuthCredential; codexCliAuth?: CodexCliAuth; }; type ProfileFile = { active?: string; profiles: Record; }; const fs = require("node:fs") as { existsSync: (path: string) => boolean; readFileSync: (path: string, encoding: string) => string; writeFileSync: (path: string, data: string, encoding: string) => void; mkdirSync: ( path: string, options?: { recursive?: boolean; mode?: number }, ) => void; chmodSync: (path: string, mode: number) => void; }; const os = require("node:os") as { homedir: () => string }; const path = require("node:path") as { join: (...parts: string[]) => string; dirname: (path: string) => string; }; const PROVIDER = "openai-codex"; const PROFILES_PATH = agentPath("codex-accounts.json"); const CODEX_AUTH_PATH = path.join( process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "auth.json", ); function normalizeName(name: string) { return name.trim().toLowerCase().replace(/\s+/g, "-"); } function ensureParent(file: string) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); } function readJson(file: string, fallback: T): T { try { if (!fs.existsSync(file)) return fallback; return JSON.parse(fs.readFileSync(file, "utf8")) as T; } catch { return fallback; } } function writeJson(file: string, value: unknown, mode = 0o600) { ensureParent(file); fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); try { fs.chmodSync(file, mode); } catch { // Best effort: Windows may ignore chmod-style permissions. } } function readProfiles(): ProfileFile { const parsed = readJson(PROFILES_PATH, { profiles: {} }); return { active: parsed.active, profiles: parsed.profiles && typeof parsed.profiles === "object" ? parsed.profiles : {}, }; } function writeProfiles(value: ProfileFile) { writeJson(PROFILES_PATH, value); } function getPiAuthCredential(ctx: any): OAuthCredential | undefined { ctx.modelRegistry?.authStorage?.reload?.(); const credential = ctx.modelRegistry?.authStorage?.get?.(PROVIDER) as | Partial | undefined; if ( !credential || credential.type !== "oauth" || typeof credential.access !== "string" || typeof credential.refresh !== "string" || typeof credential.expires !== "number" ) return undefined; return credential as OAuthCredential; } function readCodexCliAuth(): CodexCliAuth | undefined { if (!fs.existsSync(CODEX_AUTH_PATH)) return undefined; const auth = readJson(CODEX_AUTH_PATH, undefined); return auth && typeof auth === "object" ? auth : undefined; } function accountIdFrom(profile: CodexProfile | undefined) { return profile?.piAuth.accountId ?? profile?.codexCliAuth?.tokens?.account_id; } function currentAccountId(ctx: any) { return ( getPiAuthCredential(ctx)?.accountId ?? readCodexCliAuth()?.tokens?.account_id ); } function findProfile(input: string, profiles: Record) { const key = normalizeName(input); return ( profiles[key] ?? Object.values(profiles).find( (profile) => normalizeName(profile.name) === key || profile.name === input, ) ); } function saveCurrentProfile(nameInput: string, ctx: any) { const name = normalizeName(nameInput); if (!name) throw new Error("Account name is required."); const piAuth = getPiAuthCredential(ctx); if (!piAuth) throw new Error( `No ${PROVIDER} OAuth credentials found. Run /login ${PROVIDER} first, then /codex save ${name}.`, ); const profile: CodexProfile = { name, updatedAt: new Date().toISOString(), accountId: piAuth.accountId ?? readCodexCliAuth()?.tokens?.account_id, piAuth, codexCliAuth: readCodexCliAuth(), }; const file = readProfiles(); file.profiles[name] = profile; file.active = name; writeProfiles(file); return profile; } function writePiCredential(profile: CodexProfile, ctx: any) { const authStorage = ctx.modelRegistry?.authStorage; if (!authStorage?.set) throw new Error( "Pi auth storage is unavailable; cannot switch Codex account in this session.", ); authStorage.set(PROVIDER, profile.piAuth); authStorage.reload?.(); } function writeCodexCliCredential(profile: CodexProfile) { if (!profile.codexCliAuth) return false; writeJson(CODEX_AUTH_PATH, profile.codexCliAuth); return true; } function switchToProfile(nameInput: string, ctx: any) { const file = readProfiles(); const profile = findProfile(nameInput, file.profiles); if (!profile) throw new Error( `Unknown Codex account '${nameInput}'. Saved accounts: ${Object.keys(file.profiles).join(", ") || "none"}`, ); writePiCredential(profile, ctx); const wroteCodexCli = writeCodexCliCredential(profile); file.active = profile.name; writeProfiles(file); ctx.modelRegistry?.refresh?.(); return { profile, wroteCodexCli }; } function formatProfile( profile: CodexProfile, currentId?: string, active?: string, ) { const id = accountIdFrom(profile); const current = id && id === currentId ? " current" : ""; const activeMark = active === profile.name ? " active" : ""; const suffix = [current, activeMark].filter(Boolean).join(","); return `${profile.name}${suffix ? ` (${suffix.trim()})` : ""}${id ? ` — ${id.slice(0, 8)}…` : ""} — saved ${profile.updatedAt.slice(0, 10)}`; } async function chooseProfile(ctx: any) { const file = readProfiles(); const profiles = Object.values(file.profiles).sort((a, b) => a.name.localeCompare(b.name), ); if (profiles.length === 0) { ctx.ui.notify( `No saved Codex accounts. Log in, then run /codex save .`, "warning", ); return; } const currentId = currentAccountId(ctx); const labels = profiles.map( (profile, index) => `${index + 1}. ${formatProfile(profile, currentId, file.active)}`, ); const selected = await ctx.ui.select("Switch Codex account", labels); if (!selected) return; const profile = profiles[labels.indexOf(selected)]; if (!profile) return; const result = switchToProfile(profile.name, ctx); ctx.ui.notify( `Codex account: ${result.profile.name}${result.wroteCodexCli ? " (Pi + Codex CLI)" : " (Pi only)"}`, "info", ); } export default function codexSwitcher(pi: ExtensionAPI) { pi.registerCommand("codex", { description: "Switch saved ChatGPT/Codex accounts: /codex, /codex save , /codex use ", getArgumentCompletions: (prefix: string) => { const query = prefix.trim().toLowerCase(); const commands = [ "list", "current", "save", "use", "delete", "login-help", ]; const profiles = Object.keys(readProfiles().profiles); const items = [...commands, ...profiles] .filter((value) => !query || value.toLowerCase().includes(query)) .map((value) => ({ value, label: value, description: profiles.includes(value) ? "saved Codex account" : `/codex ${value}`, })); return items.length ? items : null; }, handler: async (args: string, ctx: any) => { const input = args.trim(); const [command = "", ...restParts] = input.split(/\s+/); const rest = restParts.join(" ").trim(); const lower = command.toLowerCase(); try { if (!input) { await chooseProfile(ctx); return; } if (lower === "list" || lower === "ls") { const file = readProfiles(); const profiles = Object.values(file.profiles).sort((a, b) => a.name.localeCompare(b.name), ); const currentId = currentAccountId(ctx); if (profiles.length === 0) ctx.ui.notify("No saved Codex accounts.", "info"); else ctx.ui.notify( profiles .map((profile) => formatProfile(profile, currentId, file.active), ) .join("\n"), "info", ); return; } if (lower === "current") { const id = currentAccountId(ctx); const file = readProfiles(); const matching = Object.values(file.profiles).find( (profile) => accountIdFrom(profile) === id, ); ctx.ui.notify( id ? `Current Codex account: ${matching?.name ?? "unsaved"} (${id.slice(0, 8)}…)` : "No current Codex credentials found.", "info", ); return; } if (lower === "save") { const name = rest || (ctx.hasUI ? await ctx.ui.input( "Save current Codex account as", "personal/work", ) : undefined); if (!name) return; const profile = saveCurrentProfile(name, ctx); ctx.modelRegistry?.authStorage?.reload?.(); ctx.modelRegistry?.refresh?.(); ctx.ui.notify(`Saved Codex account '${profile.name}'.`, "info"); return; } if (lower === "use" || lower === "switch") { const name = rest || (ctx.hasUI ? await ctx.ui.input("Switch Codex account", "account name") : undefined); if (!name) return; const result = switchToProfile(name, ctx); ctx.ui.notify( `Codex account: ${result.profile.name}${result.wroteCodexCli ? " (Pi + Codex CLI)" : " (Pi only)"}`, "info", ); return; } if (lower === "delete" || lower === "rm") { const name = rest; if (!name) { ctx.ui.notify("Usage: /codex delete ", "warning"); return; } const file = readProfiles(); const profile = findProfile(name, file.profiles); if (!profile) { ctx.ui.notify(`No saved Codex account named '${name}'.`, "warning"); return; } delete file.profiles[profile.name]; if (file.active === profile.name) file.active = undefined; writeProfiles(file); ctx.ui.notify(`Deleted Codex account '${profile.name}'.`, "info"); return; } if (lower === "login-help" || lower === "help") { ctx.ui.notify( [ "Codex account switcher workflow:", "1. /login openai-codex, authenticate account A", "2. /codex save personal", "3. /login openai-codex, authenticate account B", "4. /codex save work", "5. /codex or /codex use to switch", ].join("\n"), "info", ); return; } const result = switchToProfile(input, ctx); ctx.ui.notify( `Codex account: ${result.profile.name}${result.wroteCodexCli ? " (Pi + Codex CLI)" : " (Pi only)"}`, "info", ); } catch (error) { ctx.ui.notify( error instanceof Error ? error.message : String(error), "error", ); } }, }); }