From 3baa1d1be53c841bb1779d13f414b213402c28ce Mon Sep 17 00:00:00 2001 From: Yilin Wang Date: Tue, 18 Aug 2026 16:05:10 +0800 Subject: [PATCH] feat: inject Codex provider from hats --- docs/advanced.md | 15 +++++++++++ src/commands/run.ts | 38 +++++++++++++++++++++++++++ src/core/config.ts | 9 +++++-- test/integration.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 71a39b6..ae78727 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -3,6 +3,21 @@ Use these options for company gateways, local models, shared environment files, or profiles that need separate CLI homes. +## Codex provider + +A hat can fix the Codex provider and optionally supply its default model: + +```toml +[profiles.company-codex] +launch = "codex" +codex = { base_url = "https://gateway.example/v1", env_key = "OPENAI_API_KEY", model = "gpt-5.6" } +env = { OPENAI_API_KEY = "env:COMPANY_OPENAI_API_KEY" } +``` + +`base_url` and `env_key` are required. `model` is optional, and Codex `-m` or +`--model` can override it for one run. Hats refuses provider/profile overrides for +this hat. + ## Company gateway Create the hat, add the gateway variables, and run it: diff --git a/src/commands/run.ts b/src/commands/run.ts index 8d7f1e5..4c2890d 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -6,6 +6,43 @@ import { getProfile } from "../core/profile.js"; import { assembleEnv } from "../core/env.js"; import { parseLaunch, runChild } from "../core/spawn.js"; +function injectCodexProvider(profile: Profile, argv: string[], env: Record): string[] { + if (argv[0] !== "codex" || !profile.codex) return argv; + + const { base_url, env_key, model } = profile.codex; + if (typeof base_url !== "string" || !base_url || typeof env_key !== "string" || !env_key) { + throw new Error(`hat "${profile.name}" codex requires string base_url and env_key values`); + } + if (model !== undefined && (typeof model !== "string" || !model)) { + throw new Error(`hat "${profile.name}" codex model must be a non-empty string`); + } + if (!env[env_key]?.trim()) { + throw new Error(`hat "${profile.name}" codex credential ${env_key} is missing or empty`); + } + + for (let i = 1; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--") break; + if (arg === "--profile" || arg === "-p" || arg.startsWith("--profile=") || arg.startsWith("-p=")) { + throw new Error("Codex profile is managed by the selected hat"); + } + const config = arg === "-c" || arg === "--config" ? argv[++i] : arg.match(/^(?:-c|--config)=(.*)$/)?.[1]; + const key = config?.split("=", 1)[0].trim(); + if (key === "model_provider" || key === "model_providers" || key?.startsWith("model_providers.")) { + throw new Error(`Codex config ${key} is managed by the selected hat`); + } + } + + const injected = [ + "-c", 'model_provider="hats"', + "-c", 'model_providers.hats.name="Hats"', + "-c", `model_providers.hats.base_url=${JSON.stringify(base_url)}`, + "-c", `model_providers.hats.env_key=${JSON.stringify(env_key)}`, + ]; + if (model) injected.push("-c", `model=${JSON.stringify(model)}`); + return [argv[0], ...injected, ...argv.slice(1)]; +} + function banner(profile: Profile, env: { configDir?: string; stripped: string[] }): void { const parts: string[] = [`🎩 ${profile.name}`]; if (profile.desc) parts.push(profile.desc); @@ -77,6 +114,7 @@ async function launch( throw new Error(`hat "${profile.name}" has no launch command`); } argv = [...argv, ...extraArgs]; + argv = injectCodexProvider(profile, argv, env); const run = () => runChild(argv, { env }); return override ? run() : withHerdrHat(profile.name, () => withTmuxHat(profile.name, run)); diff --git a/src/core/config.ts b/src/core/config.ts index 24cc056..0faf1b4 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,6 +18,11 @@ export interface Profile { env_file?: string | string[]; env?: Record; launch?: string; + codex?: { + base_url: string; + env_key: string; + model?: string; + }; } export interface HatsConfig { @@ -47,7 +52,7 @@ export function loadConfig(warnUnknown: boolean | string = true): HatsConfig { const pr = (raw.profiles ?? {}) as Record; for (const [name, v] of Object.entries(pr)) { for (const key of Object.keys(v)) { - if ((warnUnknown === true || warnUnknown === name) && !["desc", "env_file", "env", "launch"].includes(key)) { + if ((warnUnknown === true || warnUnknown === name) && !["desc", "env_file", "env", "launch", "codex"].includes(key)) { console.error(`warning: profiles.${name}.${key} is unknown and will be ignored`); } } @@ -88,7 +93,7 @@ function tomlValue(value: unknown): string { function profileSection(profile: Profile): string { const lines = [`[profiles.${profile.name}]`]; - for (const key of ["desc", "env_file", "launch"] as const) { + for (const key of ["desc", "env_file", "launch", "codex"] as const) { if (profile[key] !== undefined) lines.push(`${key} = ${tomlValue(profile[key])}`); } if (profile.env) { diff --git a/test/integration.test.ts b/test/integration.test.ts index 51a2410..7087493 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -411,6 +411,63 @@ describe("integration: tmux active hat metadata", () => { }); describe("integration: hats exec through the real CLI", () => { + test("injects a Codex provider and rejects user provider overrides", async () => { + const home = mkdtempSync(join(tmpdir(), "hats-codex-provider-")); + const bin = join(home, "bin"); + const log = join(home, "args.json"); + mkdirSync(bin); + writeFileSync( + join(bin, "codex"), + `#!/usr/bin/env node\nrequire('node:fs').writeFileSync(process.env.ARGS_LOG, JSON.stringify(process.argv.slice(2)))\n`, + { mode: 0o755 }, + ); + writeFileSync( + join(home, "config.toml"), + '[profiles.work]\nlaunch = "codex"\ncodex = { base_url = "https://gateway.example/v1", env_key = "OPENAI_API_KEY", model = "hat-default" }\nenv = { OPENAI_API_KEY = "secret" }\n', + ); + + try { + const env = childEnv({ HATS_HOME: home, ARGS_LOG: log, PATH: `${bin}:${process.env.PATH ?? ""}` }); + const ok = await runCli(["work", "-m", "cli-override"], env); + assert.equal(ok.code, 0, ok.stderr); + assert.deepEqual(JSON.parse(readFileSync(log, "utf8")), [ + "-c", 'model_provider="hats"', + "-c", 'model_providers.hats.name="Hats"', + "-c", 'model_providers.hats.base_url="https://gateway.example/v1"', + "-c", 'model_providers.hats.env_key="OPENAI_API_KEY"', + "-c", 'model="hat-default"', + "-m", "cli-override", + ]); + + for (const args of [ + ["work", "--profile=other"], + ["work", "-c", 'model_provider="other"'], + ["work", "--config=model_providers.other.base_url=\"https://other\""], + ]) { + const blocked = await runCli(args, env); + assert.notEqual(blocked.code, 0); + assert.match(blocked.stderr, /managed by the selected hat/); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("does not start Codex when its configured credential is empty", async () => { + const home = mkdtempSync(join(tmpdir(), "hats-codex-credential-")); + try { + writeFileSync( + join(home, "config.toml"), + '[profiles.work]\nlaunch = "codex"\ncodex = { base_url = "https://gateway.example/v1", env_key = "PRIVATE_TOKEN" }\nenv = { PRIVATE_TOKEN = "" }\n', + ); + const r = await runCli(["work"], childEnv({ HATS_HOME: home })); + assert.notEqual(r.code, 0); + assert.match(r.stderr, /PRIVATE_TOKEN is missing or empty/); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("HATS_PROFILE identifies the selected hat and cannot be overridden by profile env", async () => { const home = mkdtempSync(join(tmpdir(), "hats-profile-env-")); try {