Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): 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);
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 7 additions & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface Profile {
env_file?: string | string[];
env?: Record<string, string>;
launch?: string;
codex?: {
base_url: string;
env_key: string;
model?: string;
};
}

export interface HatsConfig {
Expand Down Expand Up @@ -47,7 +52,7 @@ export function loadConfig(warnUnknown: boolean | string = true): HatsConfig {
const pr = (raw.profiles ?? {}) as Record<string, object>;
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`);
}
}
Expand Down Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down