Skip to content
Merged
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
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,24 @@ the selected hat.
See [Advanced configuration](docs/advanced.md) for env references, local models, and
manual configuration.

## Share an environment across CLIs
## Use one environment across CLIs

Point multiple hats at the same environment file when Claude and Codex use the same
company gateway:
Give the hat a default command, then replace it after `--` when you want another CLI:

```toml
[profiles.write]
[profiles.work]
launch = "claude"
env_file = "~/.config/company-ai.env"

[profiles.review]
launch = "codex"
env_file = "~/.config/company-ai.env"
```

```bash
hats write
hats review # run in another terminal
hats work
hats work -- codex
```

The `--` boundary must immediately follow the hat name. Without it, trailing arguments
are appended to the default command.

## Optional: isolate CLI state

Provider environments are process-local by default, but hats for the same CLI still
Expand All @@ -103,6 +101,7 @@ the underlying CLI.
```text
hats add [<name> <command...>] create a hat
hats <hat> [args...] launch a hat (same as hats run <hat>)
hats <hat> -- <command...> replace the hat's default command
hats edit open the config in $EDITOR
hats ls list hats
```
Expand Down
17 changes: 8 additions & 9 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,23 @@ hats 不修改全局配置。每个 hat 只作用于它启动的进程,因此

环境变量引用、本地模型和手动配置等进阶用法,请参阅[高级配置](docs/advanced.md)。

## 在多个 CLI 之间共享环境
## 让多个 CLI 使用同一个环境

如果 Claude 和 Codex 都连接同一个公司网关,可以让多个 hat 共用一份环境变量文件
为 hat 设置默认命令;需要换用另一个 CLI 时,在 `--` 后提供替代命令

```toml
[profiles.write]
[profiles.work]
launch = "claude"
env_file = "~/.config/company-ai.env"

[profiles.review]
launch = "codex"
env_file = "~/.config/company-ai.env"
```

```bash
hats write
hats review # 在另一个终端中运行
hats work
hats work -- codex
```

`--` 必须紧跟 hat 名称。没有 `--` 时,后续参数仍会追加到默认命令。

## 可选:隔离 CLI 状态

provider 环境默认按进程隔离,但同一个 CLI 的多个 hat 仍会共用其默认配置目录。如果还需要隔离设置、插件和历史记录,请添加 `--isolated`:
Expand All @@ -92,6 +90,7 @@ hats add personal codex --isolated
```text
hats add [<名称> <命令...>] 创建一个 hat
hats <hat> [参数...] 启动一个 hat(等同于 hats run <hat>)
hats <hat> -- <命令...> 替换 hat 的默认启动命令
hats edit 在 $EDITOR 中打开配置文件
hats ls 列出所有 hat
```
Expand Down
14 changes: 14 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ env = {
COMPANY_AI_URL=https://gateway.example
```

When the command that Hats actually launches is Codex, a non-empty
`OPENAI_BASE_URL` and `OPENAI_API_KEY` make the Hat the provider source. Hats passes
process-local Codex `-c` overrides for `model_provider`, `base_url`, and `env_key`.
It does not write a profile file, and the secret stays in the process environment. The
endpoint must support the Responses API.

If a Hat contains provider credentials but lacks either required Codex value, Hats
stops before launch instead of inheriting the global Codex provider. An explicit Codex
`--profile` or `-c model_provider=...` is treated as a user override.

## Local AI model

Run a local Claude-compatible CLI without inheriting a company gateway:
Expand Down Expand Up @@ -174,3 +184,7 @@ profile needs its own supported config home:
This separates local CLI state; it does not guarantee that multiple OAuth subscriptions
can coexist. `--isolated` only infers from a bare `codex` or `claude` first token. Set
the config-home environment variable by hand for wrappers and custom launchers.

For `hats <hat> -- <command>`, hats selects the isolated config-home variable from the
replacement command. An unknown or unsupported command still receives the hat's
process-local environment, while the launch banner reports `config: (environment only)`.
26 changes: 17 additions & 9 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { loadConfig, type Profile } from "../core/config.js";
import { getProfile } from "../core/profile.js";
import { assembleEnv } from "../core/env.js";
import { parseLaunch, runChild } from "../core/spawn.js";
import { adaptCodex } from "../core/codex.js";

function banner(profile: Profile, env: { configDir?: string; stripped: string[] }): void {
function banner(profile: Profile, env: { configDir?: string; environmentOnly?: boolean; stripped: string[] }): void {
const parts: string[] = [`🎩 ${profile.name}`];
if (profile.desc) parts.push(profile.desc);
parts.push(`config: ${env.configDir ?? "(default)"}`);
parts.push(`config: ${env.environmentOnly ? "(environment only)" : env.configDir ?? "(default)"}`);
if (env.stripped.length) parts.push(`stripped ${env.stripped.length}`);
// eslint-disable-next-line no-console
console.error(`\x1b[36m${parts.join(" · ")}\x1b[0m`);
Expand Down Expand Up @@ -63,11 +64,8 @@ async function launch(
profile: Profile,
extraArgs: string[],
override?: string[],
track = true,
): Promise<number> {
const { env, ...summary } = await assembleEnv(profile);
if (summary.configDir) mkdirSync(summary.configDir, { recursive: true });
banner(profile, { configDir: summary.configDir, stripped: summary.stripped });

let argv: string[];
if (override && override.length) {
argv = override;
Expand All @@ -78,8 +76,13 @@ async function launch(
}
argv = [...argv, ...extraArgs];

const { env, ...summary } = await assembleEnv(profile, override && track ? argv[0] : undefined);
if (track) argv = adaptCodex(profile.name, argv, env);
if (summary.configDir) mkdirSync(summary.configDir, { recursive: true });
banner(profile, summary);

const run = () => runChild(argv, { env });
return override ? run() : withHerdrHat(profile.name, () => withTmuxHat(profile.name, run));
return track ? withHerdrHat(profile.name, () => withTmuxHat(profile.name, run)) : run();
}

export const runCommand = new Command("run")
Expand All @@ -90,7 +93,12 @@ export const runCommand = new Command("run")
.action(async (name: string, args: string[]) => {
const cfg = loadConfig(name);
const profile = getProfile(cfg, name);
const code = await launch(profile, args);
const boundary = process.argv.indexOf("--", 2);
const hatIndex = process.argv[2] === "run" ? 3 : 2;
if (boundary > hatIndex + 1) throw new Error("-- must immediately follow the hat name");
const override = boundary < 0 ? undefined : process.argv.slice(boundary + 1);
if (override?.length === 0) throw new Error("replacement command is empty");
const code = await launch(profile, override ? [] : args, override);
process.exit(code);
});

Expand All @@ -107,6 +115,6 @@ export const execCommand = new Command("exec")
console.error("exec requires a command. Usage: hats exec <hat> -- <cmd> [args...]");
process.exit(2);
}
const code = await launch(profile, [], args);
const code = await launch(profile, [], args, false);
process.exit(code);
});
60 changes: 60 additions & 0 deletions src/core/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createHash } from "node:crypto";
import { basename } from "node:path";

const PROVIDER_ENV = [
"OPENAI_BASE_URL",
"OPENAI_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_VERTEX_BASE_URL",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
];

function hasProviderOverride(argv: string[]): boolean {
for (let i = 1; i < argv.length && argv[i] !== "--"; i++) {
const arg = argv[i];
if (arg === "-p" || arg === "--profile" || arg.startsWith("-p=") || arg.startsWith("--profile=")) return true;
const config =
arg === "-c" || arg === "--config"
? argv[++i]
: arg.startsWith("-c=") || arg.startsWith("--config=")
? arg.slice(arg.indexOf("=") + 1)
: undefined;
if (config && /^\s*(model_provider|profile)\s*=/.test(config)) return true;
}
return false;
}

/** Inject a process-local provider only when this Hat owns enough Codex provider data. */
export function adaptCodex(profileName: string, argv: string[], env: Record<string, string>): string[] {
if (basename(argv[0]) !== "codex" || hasProviderOverride(argv)) return argv;
if (!PROVIDER_ENV.some((key) => env[key] !== undefined)) return argv;

const missing = ["OPENAI_BASE_URL", "OPENAI_API_KEY"].filter((key) => !env[key]?.trim());
if (missing.length) {
throw new Error(`Codex provider for hat "${profileName}" is incomplete; missing ${missing.join(", ")}`);
}
try {
const url = new URL(env.OPENAI_BASE_URL);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error();
} catch {
throw new Error(`Codex provider for hat "${profileName}" has invalid OPENAI_BASE_URL`);
}

const id = `hats-${createHash("sha256").update(profileName).digest("hex")}`;
const injected = [
"-c",
`model_provider=${JSON.stringify(id)}`,
"-c",
`model_providers.${id}.name="Hats"`,
"-c",
`model_providers.${id}.base_url=${JSON.stringify(env.OPENAI_BASE_URL)}`,
"-c",
`model_providers.${id}.env_key="OPENAI_API_KEY"`,
];
return [argv[0], ...injected, ...argv.slice(1)];
}
25 changes: 22 additions & 3 deletions src/core/env.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readFileSync, existsSync } from "node:fs";
import { basename } from "node:path";
import { parse as parseDotenv } from "dotenv";
import type { Profile } from "./config.js";
import { expandTilde, resolveForRun, refKind } from "./resolve.js";
import { TOOL_CREDENTIAL_ENV, TOOL_HOME_VARS } from "./tools.js";
import { CredentialStorage, TOOLS, TOOL_CREDENTIAL_ENV, TOOL_HOME_VARS } from "./tools.js";

/**
* Provider / config prefixes stripped from the *inherited* environment so a
Expand Down Expand Up @@ -38,6 +39,8 @@ export interface AssembledEnv {
env: Record<string, string>;
/** Resolved config-home var (CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME), if any. */
configDir?: string;
/** An isolated hat launched a command whose CLI state Hats cannot safely isolate. */
environmentOnly?: boolean;
/** Names of inherited provider vars that were stripped (for visibility). */
stripped: string[];
}
Expand All @@ -49,7 +52,7 @@ export interface AssembledEnv {
* references (env:/file:/cmd:) are used verbatim (no re-expansion, so a token
* containing `$` is not mangled).
*/
export async function assembleEnv(profile: Profile): Promise<AssembledEnv> {
export async function assembleEnv(profile: Profile, command?: string): Promise<AssembledEnv> {
const env: Record<string, string> = {};
const stripped: string[] = [];
for (const [k, v] of Object.entries(process.env)) {
Expand Down Expand Up @@ -99,9 +102,25 @@ export async function assembleEnv(profile: Profile): Promise<AssembledEnv> {
env[k] = expandVars(expandTilde(env[k]), env);
}

const configuredHomeVar = Object.keys(env).find((key) => TOOL_HOME_VARS.has(key));
const configuredHome = configuredHomeVar ? env[configuredHomeVar] : undefined;
const tool = command ? TOOLS[basename(command)] : undefined;
let environmentOnly = false;
if (configuredHome && command) {
for (const key of TOOL_HOME_VARS) delete env[key];
if (
tool?.homeVar &&
[CredentialStorage.ConfigHome, CredentialStorage.DirectoryKeychain].includes(tool.credentialStorage)
) {
env[tool.homeVar] = configuredHome;
} else {
environmentOnly = true;
}
}

env.HATS_PROFILE = profile.name;

const configDir = Object.keys(env).find((key) => TOOL_HOME_VARS.has(key));
const resolvedConfigDir = configDir ? env[configDir] : undefined;
return { env, configDir: resolvedConfigDir, stripped };
return { env, configDir: resolvedConfigDir, environmentOnly, stripped };
}
66 changes: 66 additions & 0 deletions test/codex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { adaptCodex } from "../src/core/codex.js";

function fixture() {
const home = mkdtempSync(join(tmpdir(), "hats-codex-"));
return {
home,
env: {
CODEX_HOME: home,
OPENAI_BASE_URL: "https://gateway.example/v1",
OPENAI_API_KEY: "secret",
},
};
}

describe("Codex provider adapter", () => {
test("injects a process-local provider and preserves model arguments", () => {
const { home, env } = fixture();
try {
const argv = adaptCodex("café.hat", ["/opt/bin/codex", "-m", "gpt-test"], env);
const id = "hats-3f515fc9f389a544c7e8be985c393a83d688d1a0f270af8162f1910fec19be37";
assert.deepEqual(argv, [
"/opt/bin/codex",
"-c", `model_provider=${JSON.stringify(id)}`,
"-c", `model_providers.${id}.name="Hats"`,
"-c", `model_providers.${id}.base_url="https://gateway.example/v1"`,
"-c", `model_providers.${id}.env_key="OPENAI_API_KEY"`,
"-m", "gpt-test",
]);
assert.equal(argv.includes("secret"), false);
assert.deepEqual(readdirSync(home), []);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("fails before launch when Hat provider data is incomplete", () => {
const { home, env } = fixture();
try {
delete (env as Partial<typeof env>).OPENAI_BASE_URL;
assert.throws(() => adaptCodex("rc", ["codex"], env), /missing OPENAI_BASE_URL/);
assert.deepEqual(readdirSync(home), []);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("leaves explicit provider selection and non-Codex commands alone", () => {
const { home, env } = fixture();
try {
const explicit = ["codex", "-c", "model_provider=4ai"];
assert.equal(adaptCodex("4ai", explicit, env), explicit);
const profile = ["codex", "-p=personal"];
assert.equal(adaptCodex("personal", profile, env), profile);
const ollama = ["ollama", "launch", "codex"];
assert.equal(adaptCodex("local", ollama, env), ollama);
assert.deepEqual(readdirSync(home), []);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
7 changes: 7 additions & 0 deletions test/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ describe("isolation / provider-prefix strip", () => {
assert.notEqual(a.configDir, b.configDir);
});

test("recognizes a replacement CLI by executable basename", async () => {
const home = join(tmpdir(), "hats-codex-home");
const { env } = await assembleEnv({ name: "a", env: { CLAUDE_CONFIG_DIR: home } }, "/opt/bin/codex");
assert.equal(env.CODEX_HOME, home);
assert.equal(env.CLAUDE_CONFIG_DIR, undefined);
});

test("${VAR} expansion resolves against the assembled env", async () => {
const profile: Profile = {
name: "r",
Expand Down
Loading