From 5f08d4cbfa85edda109bf33c32922b071f12bbfe Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 16:40:22 +0800 Subject: [PATCH 01/16] fix: make env resolution safe and explicit --- src/core/env.ts | 37 ++++++++++++++++++++++++++----------- src/core/resolve.ts | 30 ++++++++++++++++++------------ test/env.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/core/env.ts b/src/core/env.ts index 15473ae..d8ef6c9 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -20,18 +20,19 @@ export const STRIP_PREFIXES = [ "CODEX_", "OPENAI_", "GEMINI_", - "GOOGLE_", ]; +const STRIP_EXACT = new Set(["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"]); function shouldStrip(name: string, prefixes: string[]): boolean { - return prefixes.some((p) => name.startsWith(p)); + return STRIP_EXACT.has(name) || prefixes.some((p) => name.startsWith(p)); } /** Expand ${VAR} and $VAR references using the assembled env. */ function expandVars(s: string, env: Record): string { return s - .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => env[n] ?? "") - .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, n) => env[n] ?? ""); + .replace(/(? env[n] ?? "") + .replace(/(? env[n] ?? "") + .replace(/\\\$/g, "$"); } export interface AssembledEnv { @@ -62,29 +63,43 @@ export async function assembleEnv(profile: Profile): Promise { } const noExpand = new Set(); + const expand = new Set(); if (profile.env_file) { const files = Array.isArray(profile.env_file) ? profile.env_file : [profile.env_file]; for (const f of files) { const p = expandTilde(f); - if (!existsSync(p)) continue; - Object.assign(env, parseDotenv(readFileSync(p, "utf8"))); + if (!existsSync(p)) { + console.error(`warning: env_file not found: ${p}`); + continue; + } + const parsed = parseDotenv(readFileSync(p, "utf8")); + Object.assign(env, parsed); + Object.keys(parsed).forEach((k) => expand.add(k)); } } if (profile.env) { for (const [k, v] of Object.entries(profile.env)) { - const knd = refKind(v); - env[k] = resolveForRun(v); + // TOML parses unquoted scalars as native types (`true` -> boolean, `42` -> + // number). Env vars must be strings, so coerce anything non-string; refKind / + // resolveForRun / expandTilde all assume strings and would crash otherwise. + const sv = typeof v === "string" ? v : String(v); + const knd = refKind(sv); + if (knd === "env" && process.env[sv.slice(4)] === undefined) { + console.error(`warning: env reference ${sv.slice(4)} is not set`); + } + env[k] = resolveForRun(sv); if (knd !== "plain") noExpand.add(k); + else expand.add(k); } } - for (const [k, v] of Object.entries(env)) { + for (const k of expand) { if (noExpand.has(k)) continue; - env[k] = expandVars(expandTilde(v), env); + env[k] = expandVars(expandTilde(env[k]), env); } const configDir = env.CLAUDE_CONFIG_DIR || env.CODEX_HOME || env.GEMINI_CLI_HOME; return { env, configDir, stripped }; -} \ No newline at end of file +} diff --git a/src/core/resolve.ts b/src/core/resolve.ts index 59a25df..73c371c 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -13,10 +13,14 @@ export function looksSecret(key: string): boolean { return SECRET_KEY_RE.test(key); } -export function refKind(v: string): RefKind { - if (v.startsWith("env:")) return "env"; - if (v.startsWith("file:")) return "file"; - if (v.startsWith("cmd:")) return "cmd"; +export function refKind(v: unknown): RefKind { + // TOML parses unquoted scalars as native types (e.g. `requires_openai_auth = true` + // -> boolean). Coerce so callers that pass a raw profile env value don't crash on + // `v.startsWith`; a boolean/number is never a `env:`/`file:`/`cmd:` reference. + const s = typeof v === "string" ? v : String(v); + if (s.startsWith("env:")) return "env"; + if (s.startsWith("file:")) return "file"; + if (s.startsWith("cmd:")) return "cmd"; return "plain"; } @@ -39,10 +43,11 @@ function refArg(v: string, kind: RefKind): string { * - file:path: file contents, trimmed * - cmd:: stdout of the command, trimmed (run with current env, e.g. for `op read`) */ -export function resolveForRun(v: string): string { - const k = refKind(v); - if (k === "plain") return v; - const arg = refArg(v, k); +export function resolveForRun(v: unknown): string { + const s = typeof v === "string" ? v : String(v); + const k = refKind(s); + if (k === "plain") return s; + const arg = refArg(s, k); if (k === "env") return process.env[arg] ?? ""; if (k === "file") return readFileSync(arg, "utf8").trim(); return execSync(arg, { encoding: "utf8" }).trim(); @@ -63,14 +68,15 @@ export interface DisplayValue { * A plaintext value is masked too when its key name looks like a secret * (pass `key` for that check); otherwise shown raw (e.g. ANTHROPIC_BASE_URL). */ -export function describeForDisplay(v: string, key?: string): DisplayValue { - const k = refKind(v); +export function describeForDisplay(v: unknown, key?: string): DisplayValue { + const s = typeof v === "string" ? v : String(v); + const k = refKind(s); if (k === "plain") { if (key && looksSecret(key)) { return { display: "****", source: "plain (secret)", isRef: false }; } - return { display: v, source: "plain", isRef: false }; + return { display: s, source: "plain", isRef: false }; } - const arg = v.slice(k.length + 1); + const arg = s.slice(k.length + 1); return { display: "****", source: `${k}:${arg}`, isRef: true }; } \ No newline at end of file diff --git a/test/env.test.ts b/test/env.test.ts index 0a3294d..6a3d940 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -86,6 +86,37 @@ describe("isolation / provider-prefix strip", () => { assert.equal(env.ANTHROPIC_API_PATH, "https://relay/v1"); }); + test("inherited values containing $ are left unchanged", async () => { + await withEnv("HATS_LITERAL_DOLLAR", "abc$def", async () => { + const { env } = await assembleEnv({ name: "r" }); + assert.equal(env.HATS_LITERAL_DOLLAR, "abc$def"); + }); + }); + + test("plain profile values support an escaped dollar", async () => { + const { env } = await assembleEnv({ name: "r", env: { TOKEN: String.raw`abc\$def` } }); + assert.equal(env.TOKEN, "abc$def"); + }); + + test("TOML scalar env values are coerced to strings", async () => { + const { env } = await assembleEnv({ name: "r", env: { FLAG: true, COUNT: 2 } as never }); + assert.equal(env.FLAG, "true"); + assert.equal(env.COUNT, "2"); + }); + + test("missing env_file and env: references warn", async () => { + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => errors.push(args.join(" ")); + try { + await assembleEnv({ name: "r", env_file: "/definitely/missing", env: { TOKEN: "env:HATS_MISSING" } }); + } finally { + console.error = original; + } + assert.match(errors.join("\n"), /env_file.*not found/); + assert.match(errors.join("\n"), /HATS_MISSING.*not set/); + }); + test("reference values are used verbatim — a token containing $ is not re-expanded", async () => { const dir = mkdtempSync(join(tmpdir(), "hats-env-")); try { @@ -101,8 +132,15 @@ describe("isolation / provider-prefix strip", () => { describe("STRIP_PREFIXES", () => { test("covers the popular coding CLIs", () => { - for (const p of ["ANTHROPIC_", "CLAUDE_", "CODEX_", "OPENAI_", "GEMINI_", "GOOGLE_"]) { + for (const p of ["ANTHROPIC_", "CLAUDE_", "CODEX_", "OPENAI_", "GEMINI_"]) { assert.ok(STRIP_PREFIXES.includes(p), `${p} in default strip set`); } }); -}); \ No newline at end of file + + test("does not strip unrelated GOOGLE_ variables", async () => { + await withEnv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/gcp.json", async () => { + const { env } = await assembleEnv({ name: "gcp" }); + assert.equal(env.GOOGLE_APPLICATION_CREDENTIALS, "/tmp/gcp.json"); + }); + }); +}); From 80de94fb7b1c90db83db91fe542b394d2bbfb631 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 16:44:25 +0800 Subject: [PATCH 02/16] fix: validate hats before destructive actions --- src/commands/add.ts | 11 ++++++++--- src/commands/rm.ts | 19 +++++++------------ src/core/config.ts | 13 ++++++++++++- src/core/profile.ts | 9 ++++++++- test/add.test.ts | 10 +++++++++- test/config.test.ts | 29 +++++++++++++++++++++++++++-- test/rm.test.ts | 35 +++++++++++++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 test/rm.test.ts diff --git a/src/commands/add.ts b/src/commands/add.ts index f916737..3c80a03 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -2,11 +2,16 @@ import { Command } from "commander"; import * as p from "@clack/prompts"; import { quote } from "shell-quote"; import { loadConfig, saveConfig, type Profile } from "../core/config.js"; -import { profileNames, resolveConfigHome, ProfileError } from "../core/profile.js"; +import { profileNames, resolveConfigHome, ProfileError, validateProfileName } from "../core/profile.js"; import { openConfigEditor } from "./edit.js"; /** Non-interactive: `hats add [--home]`. */ function addPositional(name: string, command: string[], opts: { home?: boolean }): void { + const nameError = validateProfileName(name); + if (nameError) { + p.log.error(`invalid hat name: ${nameError}`); + process.exit(1); + } if (!command.length) { p.log.error("launch command required. Usage: hats add [--home]"); process.exit(1); @@ -40,7 +45,7 @@ async function addInteractive(): Promise { const t = v.trim(); if (!t) return "required"; if (existing.has(t)) return "already exists"; - if (!/^[A-Za-z0-9_-]+$/.test(t)) return "letters, digits, _ or - only"; + return validateProfileName(t); }, }); if (p.isCancel(name)) return p.cancel("cancelled"); @@ -89,4 +94,4 @@ export const addCommand = new Command("add") .action(async (name: string | undefined, command: string[], opts: { home?: boolean }) => { if (name === undefined) await addInteractive(); else addPositional(name, command, opts); - }); \ No newline at end of file + }); diff --git a/src/commands/rm.ts b/src/commands/rm.ts index d501482..aaf7ef7 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -5,21 +5,16 @@ import { loadConfig, saveConfig } from "../core/config.js"; export const rmCommand = new Command("rm") .description("delete a profile (referenced .env / files are left untouched)") .argument("", "profile name") - .action((name: string) => { + .action(async (name: string) => { const cfg = loadConfig(); if (!cfg.profiles[name]) { // eslint-disable-next-line no-console console.error(`profile "${name}" not found`); process.exit(1); } - const ok = p.confirm({ message: `Delete profile "${name}"? (referenced files are kept)`, initialValue: false }); - void ok.then((confirmed) => { - if (p.isCancel(ok) || !confirmed) { - p.cancel("cancelled"); - return; - } - delete cfg.profiles[name]; - saveConfig(cfg); - p.log.success(`deleted profile "${name}"`); - }); - }); \ No newline at end of file + const confirmed = await p.confirm({ message: `Delete profile "${name}"? (referenced files are kept)`, initialValue: false }); + if (p.isCancel(confirmed) || !confirmed) return p.cancel("cancelled"); + delete cfg.profiles[name]; + saveConfig(cfg); + p.log.success(`deleted profile "${name}"`); + }); diff --git a/src/core/config.ts b/src/core/config.ts index 23151f4..0764445 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,6 +2,7 @@ import { parse, stringify } from "smol-toml"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { looksSecret, refKind } from "./resolve.js"; export interface Profile { name: string; @@ -35,6 +36,11 @@ export function loadConfig(): HatsConfig { const profiles: Record = {}; const pr = (raw.profiles ?? {}) as Record; for (const [name, v] of Object.entries(pr)) { + for (const key of Object.keys(v)) { + if (!["desc", "env_file", "env", "launch"].includes(key)) { + console.error(`warning: profiles.${name}.${key} is unknown and will be ignored`); + } + } profiles[name] = { name, ...(v as Partial) }; } return { @@ -50,9 +56,14 @@ export function saveConfig(cfg: HatsConfig): void { profiles: {} as Record, }; for (const [name, p] of Object.entries(cfg.profiles)) { + for (const [key, value] of Object.entries(p.env ?? {})) { + if (looksSecret(key) && refKind(value) === "plain") { + console.error(`warning: ${key} contains a plaintext secret; prefer cmd:op read ...`); + } + } const { name: _omit, ...rest } = p; void _omit; (out.profiles as Record)[name] = rest; } writeFileSync(configPath(), stringify(out)); -} \ No newline at end of file +} diff --git a/src/core/profile.ts b/src/core/profile.ts index 1a0a0da..9ed46bc 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -4,6 +4,13 @@ import { hatsHome, type HatsConfig, type Profile } from "./config.js"; export class ProfileError extends Error {} +export const RESERVED_NAMES = new Set(["run", "exec", "which", "ls", "add", "setenv", "init", "rm", "edit"]); + +export function validateProfileName(name: string): string | undefined { + if (!/^[A-Za-z0-9_-]+$/.test(name)) return "letters, digits, _ or - only"; + if (RESERVED_NAMES.has(name)) return `"${name}" is a reserved command name`; +} + /** Which env var carries a tool's isolated config home, keyed by launch first-token. */ export const CONFIG_HOME_BY_TOOL: Record = { codex: "CODEX_HOME", @@ -71,4 +78,4 @@ export function validateProfile(p: Profile): string[] { } } return errs; -} \ No newline at end of file +} diff --git a/test/add.test.ts b/test/add.test.ts index c4e9199..745c2ba 100644 --- a/test/add.test.ts +++ b/test/add.test.ts @@ -91,6 +91,14 @@ describe("add (positional)", () => { assert.match(r.out, /already exists/); }); + test("invalid and built-in names are rejected", async () => { + for (const name of ["bad.name", "run"]) { + const r = await runAdd([name, "codex"]); + assert.notEqual(r.code, 0); + assert.match(r.out, /name|reserved/i); + } + }); + test("a quoted arg with a space round-trips through the stored launch (Q1)", async () => { // `hats add q codex --model "gpt 5"` — the shell already split this into argv, // so commander receives ["codex","--model","gpt 5"]. The stored launch must @@ -100,4 +108,4 @@ describe("add (positional)", () => { const launch = profiles().q.launch ?? ""; assert.deepEqual(parseLaunch(launch), ["codex", "--model", "gpt 5"], "launch round-trips to the same argv"); }); -}); \ No newline at end of file +}); diff --git a/test/config.test.ts b/test/config.test.ts index 1829a2d..34430e8 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,6 +1,6 @@ import { describe, test, before, after } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -56,6 +56,31 @@ describe("config", () => { test("configPath respects $HATS_HOME", () => { assert.equal(configPath(), join(tmpHome, "config.toml")); }); + + test("load warns about unknown profile fields", () => { + writeFileSync(configPath(), 'version = 1\n[profiles.old]\nlaunch = "codex"\nkind = "legacy"\n'); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => errors.push(args.join(" ")); + try { + loadConfig(); + } finally { + console.error = original; + } + assert.match(errors.join("\n"), /profiles\.old\.kind.*unknown/i); + }); + + test("save warns when a plaintext secret is written", () => { + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => errors.push(args.join(" ")); + try { + saveConfig({ version: 1, profiles: { x: { name: "x", env: { API_TOKEN: "plaintext" } } } }); + } finally { + console.error = original; + } + assert.match(errors.join("\n"), /API_TOKEN.*plaintext.*cmd:/i); + }); }); describe("profile", () => { @@ -74,4 +99,4 @@ describe("profile", () => { const errs = validateProfile({ name: "x", launch: "claude" }); assert.equal(errs.length, 0); }); -}); \ No newline at end of file +}); diff --git a/test/rm.test.ts b/test/rm.test.ts new file mode 100644 index 0000000..32e4f49 --- /dev/null +++ b/test/rm.test.ts @@ -0,0 +1,35 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("rm", () => { + test("Ctrl+C cancellation does not delete the profile", async () => { + const home = mkdtempSync(join(tmpdir(), "hats-rm-")); + const path = join(home, "config.toml"); + const original = 'version = 1\n[profiles.keep]\nlaunch = "codex"\n'; + writeFileSync(path, original); + try { + await new Promise((resolve, reject) => { + const child = spawn(join(root, "node_modules/.bin/tsx"), [join(root, "src/index.ts"), "rm", "keep"], { + env: { ...process.env, HATS_HOME: home }, + stdio: ["pipe", "ignore", "ignore"], + }); + const timer = setTimeout(() => child.stdin.write("\x03"), 100); + child.on("error", reject); + child.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + assert.equal(readFileSync(path, "utf8"), original); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); From 10c1c824e249f71f3326ac5a3ca4c325fcb59ea6 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 17:05:55 +0800 Subject: [PATCH 03/16] fix: preserve recoverable config writes --- src/commands/add.ts | 8 ++- src/commands/rm.ts | 11 +++-- src/commands/setenv.ts | 8 +-- src/core/config.ts | 107 ++++++++++++++++++++++++++++++++++++++--- test/add.test.ts | 11 ++++- test/config.test.ts | 9 +++- test/rm.test.ts | 34 ++----------- test/setenv.test.ts | 26 +++++++++- 8 files changed, 162 insertions(+), 52 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 3c80a03..8cabc98 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import * as p from "@clack/prompts"; import { quote } from "shell-quote"; -import { loadConfig, saveConfig, type Profile } from "../core/config.js"; +import { addProfile, loadConfig, type Profile } from "../core/config.js"; import { profileNames, resolveConfigHome, ProfileError, validateProfileName } from "../core/profile.js"; import { openConfigEditor } from "./edit.js"; @@ -29,8 +29,7 @@ function addPositional(name: string, command: string[], opts: { home?: boolean } p.log.error(`profile "${name}" already exists`); process.exit(1); } - cfg.profiles[name] = profile; - saveConfig(cfg); + addProfile(profile); p.log.success(`created profile "${name}"${opts.home ? ` · config: ${profile.env && Object.values(profile.env)[0]}` : ""}`); } @@ -76,8 +75,7 @@ async function addInteractive(): Promise { const { varName, path } = resolveConfigHome(name as string, launch as string); profile.env = { [varName]: path }; } - cfg.profiles[profile.name] = profile; - saveConfig(cfg); + addProfile(profile); p.log.success(`created profile "${profile.name}"`); const editNow = await p.confirm({ message: "Open config to add env vars now?", initialValue: false }); diff --git a/src/commands/rm.ts b/src/commands/rm.ts index aaf7ef7..1ac156f 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -1,6 +1,10 @@ import { Command } from "commander"; import * as p from "@clack/prompts"; -import { loadConfig, saveConfig } from "../core/config.js"; +import { loadConfig, removeProfile } from "../core/config.js"; + +export function isRemovalConfirmed(value: unknown): value is true { + return !p.isCancel(value) && value === true; +} export const rmCommand = new Command("rm") .description("delete a profile (referenced .env / files are left untouched)") @@ -13,8 +17,7 @@ export const rmCommand = new Command("rm") process.exit(1); } const confirmed = await p.confirm({ message: `Delete profile "${name}"? (referenced files are kept)`, initialValue: false }); - if (p.isCancel(confirmed) || !confirmed) return p.cancel("cancelled"); - delete cfg.profiles[name]; - saveConfig(cfg); + if (!isRemovalConfirmed(confirmed)) return p.cancel("cancelled"); + removeProfile(name); p.log.success(`deleted profile "${name}"`); }); diff --git a/src/commands/setenv.ts b/src/commands/setenv.ts index 2f2ac9b..561c890 100644 --- a/src/commands/setenv.ts +++ b/src/commands/setenv.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import * as p from "@clack/prompts"; import { readFileSync } from "node:fs"; import { parse as parseDotenv } from "dotenv"; -import { loadConfig, saveConfig, type Profile } from "../core/config.js"; +import { addProfile, loadConfig, updateProfile, type Profile } from "../core/config.js"; import { resolveConfigHome } from "../core/profile.js"; import { describeForDisplay } from "../core/resolve.js"; @@ -40,6 +40,7 @@ export const setenvCommand = new Command("setenv") } const cfg = loadConfig(); + const existed = Boolean(cfg.profiles[name]); let profile: Profile = cfg.profiles[name]; if (!profile) { profile = { name }; @@ -57,7 +58,8 @@ export const setenvCommand = new Command("setenv") profile.env = { ...(profile.env ?? {}), [varName]: path }; merged.push(` ${varName} = ${path}`); } - saveConfig(cfg); + if (existed) updateProfile(profile); + else addProfile(profile); p.log.success(`updated "${name}"${opts.launch ? ` (launch=${opts.launch})` : ""}:\n${merged.join("\n")}`); - }); \ No newline at end of file + }); diff --git a/src/core/config.ts b/src/core/config.ts index 0764445..15fecca 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,5 +1,13 @@ import { parse, stringify } from "smol-toml"; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { looksSecret, refKind } from "./resolve.js"; @@ -17,6 +25,8 @@ export interface HatsConfig { profiles: Record; } +export class ConfigWriteError extends Error {} + export function hatsHome(): string { return process.env.HATS_HOME || join(homedir(), ".config", "hats"); } @@ -56,14 +66,97 @@ export function saveConfig(cfg: HatsConfig): void { profiles: {} as Record, }; for (const [name, p] of Object.entries(cfg.profiles)) { - for (const [key, value] of Object.entries(p.env ?? {})) { - if (looksSecret(key) && refKind(value) === "plain") { - console.error(`warning: ${key} contains a plaintext secret; prefer cmd:op read ...`); - } - } + warnPlaintextSecrets(p); const { name: _omit, ...rest } = p; void _omit; (out.profiles as Record)[name] = rest; } - writeFileSync(configPath(), stringify(out)); + atomicWrite(stringify(out), () => undefined); +} + +function warnPlaintextSecrets(profile: Profile): void { + for (const [key, value] of Object.entries(profile.env ?? {})) { + if (looksSecret(key) && refKind(value) === "plain") { + console.error(`warning: ${key} contains a plaintext secret; prefer cmd:op read ...`); + } + } +} + +function tomlValue(value: unknown): string { + return stringify({ value }).trim().slice("value = ".length); +} + +function profileSection(profile: Profile): string { + const lines = [`[profiles.${profile.name}]`]; + for (const key of ["desc", "env_file", "launch"] as const) { + if (profile[key] !== undefined) lines.push(`${key} = ${tomlValue(profile[key])}`); + } + if (profile.env) { + const entries = Object.entries(profile.env).map(([key, value]) => `${tomlValue(key)} = ${tomlValue(value)}`); + lines.push(`env = { ${entries.join(", ")} }`); + } + return `${lines.join("\n")}\n`; +} + +function sectionRange(raw: string, name: string): [number, number] { + const lines = raw.split(/(?<=\n)/); + const header = `[profiles.${name}]`; + const start = lines.findIndex((line) => line.trim() === header); + const ambiguous = lines.some((line) => line.trim().startsWith(`[profiles.${name}.`)); + if (start < 0 || ambiguous) { + throw new ConfigWriteError(`cannot safely modify hat "${name}"; use hats edit`); + } + let end = start + 1; + while (end < lines.length && !lines[end].trimStart().startsWith("[")) end++; + while (end > start + 1 && /^(\s*|\s*#.*)$/.test(lines[end - 1].trimEnd())) end--; + return [lines.slice(0, start).join("").length, lines.slice(0, end).join("").length]; +} + +function atomicWrite(candidate: string, assertCandidate: (raw: Record) => void): void { + mkdirSync(hatsHome(), { recursive: true }); + const path = configPath(); + const temp = `${path}.tmp-${process.pid}`; + try { + writeFileSync(temp, candidate); + const parsed = parse(readFileSync(temp, "utf8")) as Record; + assertCandidate(parsed); + if (existsSync(path)) copyFileSync(path, `${path}.bak`); + renameSync(temp, path); + } catch (error) { + if (existsSync(temp)) unlinkSync(temp); + throw error; + } +} + +function hasProfile(raw: Record, name: string): boolean { + return Object.hasOwn((raw.profiles ?? {}) as object, name); +} + +export function addProfile(profile: Profile): void { + warnPlaintextSecrets(profile); + const path = configPath(); + const raw = existsSync(path) ? readFileSync(path, "utf8") : stringify({ version: 1 }); + const candidate = `${raw.trimEnd()}\n\n${profileSection(profile)}`; + atomicWrite(candidate, (parsed) => { + if (!hasProfile(parsed, profile.name)) throw new ConfigWriteError("added hat failed validation"); + }); +} + +export function updateProfile(profile: Profile): void { + warnPlaintextSecrets(profile); + const raw = readFileSync(configPath(), "utf8"); + const [start, end] = sectionRange(raw, profile.name); + const candidate = raw.slice(0, start) + profileSection(profile) + raw.slice(end); + atomicWrite(candidate, (parsed) => { + if (!hasProfile(parsed, profile.name)) throw new ConfigWriteError("updated hat failed validation"); + }); +} + +export function removeProfile(name: string): void { + const raw = readFileSync(configPath(), "utf8"); + const [start, end] = sectionRange(raw, name); + const candidate = raw.slice(0, start) + raw.slice(end); + atomicWrite(candidate, (parsed) => { + if (hasProfile(parsed, name)) throw new ConfigWriteError("removed hat failed validation"); + }); } diff --git a/test/add.test.ts b/test/add.test.ts index 745c2ba..432ac10 100644 --- a/test/add.test.ts +++ b/test/add.test.ts @@ -1,7 +1,7 @@ import { describe, test, before, after } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -108,4 +108,13 @@ describe("add (positional)", () => { const launch = profiles().q.launch ?? ""; assert.deepEqual(parseLaunch(launch), ["codex", "--model", "gpt 5"], "launch round-trips to the same argv"); }); + + test("add appends without removing existing comments", async () => { + const path = join(tmpHome, "config.toml"); + writeFileSync(path, '# keep this comment\nversion = 1\n\n[profiles.old]\nlaunch = "claude"\n'); + const r = await runAdd(["fresh", "codex"]); + assert.equal(r.code, 0, r.out); + assert.match(readFileSync(path, "utf8"), /# keep this comment/); + assert.equal(profiles().fresh.launch, "codex"); + }); }); diff --git a/test/config.test.ts b/test/config.test.ts index 34430e8..8b2bec9 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,6 +1,6 @@ import { describe, test, before, after } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -81,6 +81,13 @@ describe("config", () => { } assert.match(errors.join("\n"), /API_TOKEN.*plaintext.*cmd:/i); }); + + test("replacing an existing config keeps one recoverable .bak", () => { + saveConfig({ version: 1, profiles: { before: { name: "before", launch: "codex" } } }); + const before = readFileSync(configPath(), "utf8"); + saveConfig({ version: 1, profiles: { after: { name: "after", launch: "claude" } } }); + assert.equal(readFileSync(`${configPath()}.bak`, "utf8"), before); + }); }); describe("profile", () => { diff --git a/test/rm.test.ts b/test/rm.test.ts index 32e4f49..8a11c94 100644 --- a/test/rm.test.ts +++ b/test/rm.test.ts @@ -1,35 +1,11 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +import { isRemovalConfirmed } from "../src/commands/rm.js"; describe("rm", () => { - test("Ctrl+C cancellation does not delete the profile", async () => { - const home = mkdtempSync(join(tmpdir(), "hats-rm-")); - const path = join(home, "config.toml"); - const original = 'version = 1\n[profiles.keep]\nlaunch = "codex"\n'; - writeFileSync(path, original); - try { - await new Promise((resolve, reject) => { - const child = spawn(join(root, "node_modules/.bin/tsx"), [join(root, "src/index.ts"), "rm", "keep"], { - env: { ...process.env, HATS_HOME: home }, - stdio: ["pipe", "ignore", "ignore"], - }); - const timer = setTimeout(() => child.stdin.write("\x03"), 100); - child.on("error", reject); - child.on("exit", () => { - clearTimeout(timer); - resolve(); - }); - }); - assert.equal(readFileSync(path, "utf8"), original); - } finally { - rmSync(home, { recursive: true, force: true }); - } + test("only an explicit yes permits deletion", () => { + assert.equal(isRemovalConfirmed(true), true); + assert.equal(isRemovalConfirmed(false), false); + assert.equal(isRemovalConfirmed(Symbol("cancel")), false); }); }); diff --git a/test/setenv.test.ts b/test/setenv.test.ts index b8c4afd..79f82af 100644 --- a/test/setenv.test.ts +++ b/test/setenv.test.ts @@ -1,7 +1,7 @@ import { describe, test, before, after } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -106,4 +106,26 @@ describe("setenv (batch merge env)", () => { await runSetenv(["r", "--launch", "codex"], "OPENAI_API_KEY=file:~/k\n"); assert.equal(profiles().r.env?.OPENAI_API_KEY, "file:~/k", "file: ref stored verbatim, not read"); }); -}); \ No newline at end of file + + test("rewrites only the target section and creates a backup", async () => { + const path = join(tmpHome, "config.toml"); + const original = '# top\nversion = 1\n\n[profiles.target]\nlaunch = "codex"\n\n# other comment\n[profiles.other]\nlaunch = "claude"\n'; + writeFileSync(path, original); + const r = await runSetenv(["target"], "A=1\n"); + assert.equal(r.code, 0, r.stderr); + const updated = readFileSync(path, "utf8"); + assert.match(updated, /# top/); + assert.match(updated, /# other comment/); + assert.equal(readFileSync(`${path}.bak`, "utf8"), original); + }); + + test("refuses an ambiguous nested target without changing the config", async () => { + const path = join(tmpHome, "config.toml"); + const original = 'version = 1\n[profiles.complex]\nlaunch = "codex"\n[profiles.complex.env]\nA = "1"\n'; + writeFileSync(path, original); + const r = await runSetenv(["complex"], "B=2\n"); + assert.notEqual(r.code, 0); + assert.match(r.stderr, /hats edit/); + assert.equal(readFileSync(path, "utf8"), original); + }); +}); From c9ed5b748315d5a39ce9b5012c7d9c1218cd22fe Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 17:24:26 +0800 Subject: [PATCH 04/16] feat: make hats the default run command --- src/index.ts | 16 +++++++++------- test/integration.test.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0262bb1..7d71550 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { editCommand } from "./commands/edit.js"; import { initCommand } from "./commands/init.js"; import { setenvCommand } from "./commands/setenv.js"; import { friendlyHint } from "./commands/hint.js"; +import { loadConfig } from "./core/config.js"; const program = new Command(); @@ -33,11 +34,7 @@ const pkgVersion = readVersion(); program .name("hats") .description("per-terminal / per-process config isolator for Claude Code, Codex, and any CLI") - .version(pkgVersion) - .action(() => { - // bare `hats`: non-interactive summary / first-run hint (no picker). - friendlyHint(); - }); + .version(pkgVersion); program.addCommand(runCommand); program.addCommand(execCommand); @@ -49,9 +46,14 @@ program.addCommand(initCommand); program.addCommand(rmCommand); program.addCommand(editCommand); -program.parseAsync(process.argv).catch((err: unknown) => { +const argv = process.argv.slice(); +const first = argv[2]; +if (first && !first.startsWith("-") && loadConfig().profiles[first]) argv.splice(2, 0, "run"); + +const parse = argv.length === 2 ? (friendlyHint(), Promise.resolve()) : program.parseAsync(argv); +parse.catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console console.error(`hats: ${msg}`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index f9459e3..e668dcf 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -90,4 +90,32 @@ describe("integration: hats exec through the real CLI", () => { ); assert.equal(r.stdout, "", "dirty ANTHROPIC_* must be stripped before reaching the child"); }); -}); \ No newline at end of file +}); + +describe("integration: hat shorthand", () => { + test("`hats ` runs the hat and preserves trailing args", async () => { + const script = join(tmpHome, "argv.mjs"); + writeFileSync(script, "console.log(process.argv.slice(2).join('|'))\n"); + writeFileSync( + join(tmpHome, "config.toml"), + `${CONFIG_TOML}\n[profiles.work]\nlaunch = "node ${script}"\n`, + ); + + const r = await runCli(["work", "--model", "gpt 5"], childEnv()); + + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.equal(r.stdout.trim(), "--model|gpt 5"); + }); + + test("an unknown word stays an unknown command", async () => { + const r = await runCli(["not-a-hat"], childEnv()); + assert.notEqual(r.code, 0); + assert.match(r.stderr, /unknown command 'not-a-hat'/); + }); + + test("a mistyped built-in gets Commander's real suggestion", async () => { + const r = await runCli(["rn", "work"], childEnv()); + assert.notEqual(r.code, 0); + assert.match(r.stderr, /Did you mean.*run/); + }); +}); From be0a9a0d988dd70ffb19de2717bec55ca4dca33e Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 17:28:45 +0800 Subject: [PATCH 05/16] feat: simplify first-run commands --- src/commands/add.ts | 15 ++++++--------- src/commands/edit.ts | 6 ++++-- src/commands/hint.ts | 10 +++++----- src/commands/init.ts | 8 ++++---- src/commands/ls.ts | 6 +++--- src/commands/rm.ts | 10 +++++----- src/commands/run.ts | 14 +++++++------- src/commands/setenv.ts | 8 ++++---- src/commands/which.ts | 10 +++++----- src/core/profile.ts | 2 +- test/add.test.ts | 6 +++--- test/edit.test.ts | 23 +++++++++++++++++++++++ 12 files changed, 70 insertions(+), 48 deletions(-) create mode 100644 test/edit.test.ts diff --git a/src/commands/add.ts b/src/commands/add.ts index 8cabc98..cfb64df 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -12,10 +12,7 @@ function addPositional(name: string, command: string[], opts: { home?: boolean } p.log.error(`invalid hat name: ${nameError}`); process.exit(1); } - if (!command.length) { - p.log.error("launch command required. Usage: hats add [--home]"); - process.exit(1); - } + if (!command.length) command = [name]; // Serialize argv back to a single launch string, re-quoting tokens that need it // (e.g. `--model "gpt 5"`) so parseLaunch() round-trips it to the same argv later. const launch = quote(command); @@ -26,11 +23,11 @@ function addPositional(name: string, command: string[], opts: { home?: boolean } } const cfg = loadConfig(); if (cfg.profiles[name]) { - p.log.error(`profile "${name}" already exists`); + p.log.error(`hat "${name}" already exists`); process.exit(1); } addProfile(profile); - p.log.success(`created profile "${name}"${opts.home ? ` · config: ${profile.env && Object.values(profile.env)[0]}` : ""}`); + p.log.success(`created hat "${name}"${opts.home ? ` · config: ${profile.env && Object.values(profile.env)[0]}` : ""}`); } /** Thin interactive wizard: 3 questions + optional "open editor to add env". */ @@ -76,7 +73,7 @@ async function addInteractive(): Promise { profile.env = { [varName]: path }; } addProfile(profile); - p.log.success(`created profile "${profile.name}"`); + p.log.success(`created hat "${profile.name}"`); const editNow = await p.confirm({ message: "Open config to add env vars now?", initialValue: false }); if (p.isCancel(editNow)) return; @@ -84,8 +81,8 @@ async function addInteractive(): Promise { } export const addCommand = new Command("add") - .description("create a hat: `hats add [--home]` (or bare `hats add` for a thin wizard)") - .argument("[name]", "profile name") + .description("create a hat: `hats add [command...] [--home]` (or bare `hats add` for a thin wizard)") + .argument("[name]", "hat name") .argument("[command...]", "launch command (variadic)") .option("--home", "isolate this hat's config home (infer CODEX_HOME/CLAUDE_CONFIG_DIR/GEMINI_CLI_HOME)") .allowUnknownOption() // let launch flags (e.g. --model) pass through into the variadic command diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 0cd4b14..c6a3c91 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -1,9 +1,11 @@ import { Command } from "commander"; import { spawnSync } from "node:child_process"; -import { configPath } from "../core/config.js"; +import { existsSync } from "node:fs"; +import { configPath, defaultConfig, saveConfig } from "../core/config.js"; /** Open the hats config in $EDITOR (inherit stdio). Exits the process with the editor's status. */ export function openConfigEditor(): void { + if (!existsSync(configPath())) saveConfig(defaultConfig()); const editor = process.env.EDITOR || "vi"; const result = spawnSync(editor, [configPath()], { stdio: "inherit" }); process.exit(result.status ?? 0); @@ -13,4 +15,4 @@ export const editCommand = new Command("edit") .description("open the hats config in $EDITOR") .action(() => { openConfigEditor(); - }); \ No newline at end of file + }); diff --git a/src/commands/hint.ts b/src/commands/hint.ts index bc100f3..489136c 100644 --- a/src/commands/hint.ts +++ b/src/commands/hint.ts @@ -16,24 +16,24 @@ export function friendlyHint(): void { lines.push(""); if (names.length === 0) { - lines.push(`No profiles yet. Create one:`); + lines.push(`No hats yet. Create one:`); lines.push(` ${c("hats add")} thin interactive wizard`); lines.push(` ${c("hats add [--home]")} non-interactive`); lines.push(` ${c("hats init")} write an example config to copy from`); } else { - lines.push(`${c("Your profiles:")}`); + lines.push(`${c("Your hats:")}`); for (const n of names) { const p = cfg.profiles[n]; const d = p.desc ? ` — ${p.desc}` : ""; lines.push(` ${n}${d}`); } lines.push(""); - lines.push(`${c("Run one:")} hats run `); - lines.push(`${c("Inspect:")} hats which · ${c("list:")} hats ls`); + lines.push(`${c("Run one:")} hats `); + lines.push(`${c("Inspect:")} hats which · ${c("list:")} hats ls`); } lines.push(""); lines.push(`Full command list: ${c("hats -h")}`); // eslint-disable-next-line no-console console.log(lines.join("\n")); -} \ No newline at end of file +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 5706f9e..65efb49 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -4,8 +4,8 @@ import { writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { hatsHome, configPath, saveConfig, defaultConfig } from "../core/config.js"; -export const EXAMPLE = `# hats example config — copy a [profiles.] block into config.toml and edit. -# These are generic shapes; rename profiles to whatever you like. +export const EXAMPLE = `# hats example config — copy a hat block into config.toml and edit. +# These are generic shapes; rename hats to whatever you like. version = 1 @@ -55,5 +55,5 @@ export const initCommand = new Command("init") writeFileSync(examplePath, EXAMPLE); p.log.success(`example written: ${examplePath}`); p.log.info(`your config: ${configPath()}`); - p.log.info("copy a profile block from the example into your config, then `hats edit`."); - }); \ No newline at end of file + p.log.info("copy a hat block from the example into your config, then `hats edit`."); + }); diff --git a/src/commands/ls.ts b/src/commands/ls.ts index 35c6d20..b4ab715 100644 --- a/src/commands/ls.ts +++ b/src/commands/ls.ts @@ -3,13 +3,13 @@ import Table from "cli-table3"; import { loadConfig } from "../core/config.js"; export const lsCommand = new Command("ls") - .description("list all profiles") + .description("list all hats") .action(() => { const cfg = loadConfig(); const entries = Object.values(cfg.profiles); if (entries.length === 0) { // eslint-disable-next-line no-console - console.log("No profiles yet. Run `hats add` to create one."); + console.log("No hats yet. Run `hats add` to create one."); return; } entries.sort((a, b) => a.name.localeCompare(b.name)); @@ -35,4 +35,4 @@ export const lsCommand = new Command("ls") } // eslint-disable-next-line no-console console.log(table.toString()); - }); \ No newline at end of file + }); diff --git a/src/commands/rm.ts b/src/commands/rm.ts index 1ac156f..d67f372 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -7,17 +7,17 @@ export function isRemovalConfirmed(value: unknown): value is true { } export const rmCommand = new Command("rm") - .description("delete a profile (referenced .env / files are left untouched)") - .argument("", "profile name") + .description("delete a hat (referenced .env / files are left untouched)") + .argument("", "hat name") .action(async (name: string) => { const cfg = loadConfig(); if (!cfg.profiles[name]) { // eslint-disable-next-line no-console - console.error(`profile "${name}" not found`); + console.error(`hat "${name}" not found`); process.exit(1); } - const confirmed = await p.confirm({ message: `Delete profile "${name}"? (referenced files are kept)`, initialValue: false }); + const confirmed = await p.confirm({ message: `Delete hat "${name}"? (referenced files are kept)`, initialValue: false }); if (!isRemovalConfirmed(confirmed)) return p.cancel("cancelled"); removeProfile(name); - p.log.success(`deleted profile "${name}"`); + p.log.success(`deleted hat "${name}"`); }); diff --git a/src/commands/run.ts b/src/commands/run.ts index 766b8c0..e32bda9 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -27,7 +27,7 @@ async function launch( } else if (profile.launch) { argv = parseLaunch(profile.launch); } else { - throw new Error(`profile "${profile.name}" has no launch command`); + throw new Error(`hat "${profile.name}" has no launch command`); } argv = [...argv, ...extraArgs]; @@ -35,8 +35,8 @@ async function launch( } export const runCommand = new Command("run") - .description("isolate-launch a profile's command") - .argument("", "profile name") + .description("isolate-launch a hat's command") + .argument("", "hat name") .argument("[args...]", "extra args appended to the launch command") .allowUnknownOption() .action(async (name: string, args: string[]) => { @@ -47,8 +47,8 @@ export const runCommand = new Command("run") }); export const execCommand = new Command("exec") - .description("run an arbitrary command with a profile's env (ignores launch)") - .argument("", "profile name") + .description("run an arbitrary command with a hat's env (ignores launch)") + .argument("", "hat name") .argument("[args...]", "command and its args (after --)") .allowUnknownOption() .action(async (name: string, args: string[]) => { @@ -56,9 +56,9 @@ export const execCommand = new Command("exec") const profile = getProfile(cfg, name); if (!args.length) { // eslint-disable-next-line no-console - console.error("exec requires a command. Usage: hats exec -- [args...]"); + console.error("exec requires a command. Usage: hats exec -- [args...]"); process.exit(2); } const code = await launch(profile, [], args); process.exit(code); - }); \ No newline at end of file + }); diff --git a/src/commands/setenv.ts b/src/commands/setenv.ts index 561c890..1f52cb0 100644 --- a/src/commands/setenv.ts +++ b/src/commands/setenv.ts @@ -7,10 +7,10 @@ import { resolveConfigHome } from "../core/profile.js"; import { describeForDisplay } from "../core/resolve.js"; export const setenvCommand = new Command("setenv") - .description("batch-merge env keys for a profile from KEY=value lines (stdin or --file)") - .argument("", "profile name (created if missing)") + .description("batch-merge env keys for a hat from KEY=value lines (stdin or --file)") + .argument("", "hat name (created if missing)") .option("-f, --file ", "read KEY=value lines from this file instead of stdin") - .option("--launch ", "also set the profile's launch command") + .option("--launch ", "also set the hat's launch command") .option("--home", "also inject the inferred config-home var (CODEX_HOME/CLAUDE_CONFIG_DIR/GEMINI_CLI_HOME)") .action((name: string, opts: { file?: string; launch?: string; home?: boolean }) => { let content: string; @@ -18,7 +18,7 @@ export const setenvCommand = new Command("setenv") content = readFileSync(opts.file, "utf8"); } else if (process.stdin.isTTY) { p.log.error( - "No env input. Use:\n" + " hats setenv --file .env\n" + "or:\n" + " hats edit", + "No env input. Use:\n" + " hats setenv --file .env\n" + "or:\n" + " hats edit", ); process.exit(1); } else { diff --git a/src/commands/which.ts b/src/commands/which.ts index 03322f3..8646f5f 100644 --- a/src/commands/which.ts +++ b/src/commands/which.ts @@ -5,14 +5,14 @@ import { describeForDisplay } from "../core/resolve.js"; import { STRIP_PREFIXES } from "../core/env.js"; export const whichCommand = new Command("which") - .description("show what a profile would inject (secrets masked, cmd: not executed)") - .argument("", "profile name") + .description("show what a hat would inject (secrets masked, cmd: not executed)") + .argument("", "hat name") .action((name: string) => { const cfg = loadConfig(); const profile = getProfile(cfg, name); const lines: string[] = []; - lines.push(`profile: ${name}`); + lines.push(`hat: ${name}`); if (profile.desc) lines.push(`desc: ${profile.desc}`); if (profile.launch) lines.push(`launch: ${profile.launch}`); lines.push(`strips: ${STRIP_PREFIXES.join(", ")} (from inherited env)`); @@ -33,9 +33,9 @@ export const whichCommand = new Command("which") } else if (profile.env_file) { lines.push("env: (no inline keys — values come from env_file above)"); } else { - lines.push("env: (none — zero-injection profile)"); + lines.push("env: (none — zero-injection hat)"); } // eslint-disable-next-line no-console console.log(lines.join("\n")); - }); \ No newline at end of file + }); diff --git a/src/core/profile.ts b/src/core/profile.ts index 9ed46bc..b177d9e 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -61,7 +61,7 @@ export function resolveConfigHome(name: string, launch: string | undefined): Con export function getProfile(cfg: HatsConfig, name: string): Profile { const p = cfg.profiles[name]; - if (!p) throw new ProfileError(`profile "${name}" not found`); + if (!p) throw new ProfileError(`hat "${name}" not found`); return p; } diff --git a/test/add.test.ts b/test/add.test.ts index 432ac10..96bd7b2 100644 --- a/test/add.test.ts +++ b/test/add.test.ts @@ -78,10 +78,10 @@ describe("add (positional)", () => { assert.match(r.out, /--home only works when launch starts with codex, claude, or gemini/); }); - test("`add ` with no launch command fails", async () => { + test("`add ` defaults launch to the hat name", async () => { const r = await runAdd(["lonely"]); - assert.notEqual(r.code, 0); - assert.match(r.out, /launch command required/); + assert.equal(r.code, 0, r.out); + assert.equal(profiles().lonely.launch, "lonely"); }); test("duplicate name is rejected", async () => { diff --git a/test/edit.test.ts b/test/edit.test.ts new file mode 100644 index 0000000..0f3db5d --- /dev/null +++ b/test/edit.test.ts @@ -0,0 +1,23 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "smol-toml"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +test("edit creates a minimal config before opening the editor", () => { + const home = mkdtempSync(join(tmpdir(), "hats-edit-")); + try { + const result = spawnSync(join(repoRoot, "node_modules", ".bin", "tsx"), [join(repoRoot, "src", "index.ts"), "edit"], { + env: { ...process.env, HATS_HOME: home, EDITOR: "/usr/bin/true" }, + }); + assert.equal(result.status, 0, result.stderr.toString()); + assert.deepEqual(parse(readFileSync(join(home, "config.toml"), "utf8")), { version: 1, profiles: {} }); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); From d327c0f1b46a397080b229c226bf648fb798a843 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 17:44:34 +0800 Subject: [PATCH 06/16] feat: make isolated subscriptions honest --- src/commands/add.ts | 32 ++++++++++++++++++++++---------- src/commands/hint.ts | 2 +- src/commands/init.ts | 2 +- src/commands/setenv.ts | 12 +++++++----- src/core/env.ts | 10 +++++----- src/core/profile.ts | 36 +++++++++++++++++++----------------- src/core/tools.ts | 28 ++++++++++++++++++++++++++++ test/add.test.ts | 14 +++++++++++++- test/env.test.ts | 6 ++++++ test/profile.test.ts | 32 +++++++++++++++++++++----------- 10 files changed, 123 insertions(+), 51 deletions(-) create mode 100644 src/core/tools.ts diff --git a/src/commands/add.ts b/src/commands/add.ts index cfb64df..3aa1601 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,12 +1,18 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import * as p from "@clack/prompts"; import { quote } from "shell-quote"; import { addProfile, loadConfig, type Profile } from "../core/config.js"; -import { profileNames, resolveConfigHome, ProfileError, validateProfileName } from "../core/profile.js"; +import { + launchFirstToken, + profileNames, + resolveConfigHome, + ProfileError, + validateProfileName, +} from "../core/profile.js"; import { openConfigEditor } from "./edit.js"; -/** Non-interactive: `hats add [--home]`. */ -function addPositional(name: string, command: string[], opts: { home?: boolean }): void { +/** Non-interactive: `hats add [--isolated]`. */ +function addPositional(name: string, command: string[], opts: { isolated?: boolean; home?: boolean }): void { const nameError = validateProfileName(name); if (nameError) { p.log.error(`invalid hat name: ${nameError}`); @@ -17,7 +23,8 @@ function addPositional(name: string, command: string[], opts: { home?: boolean } // (e.g. `--model "gpt 5"`) so parseLaunch() round-trips it to the same argv later. const launch = quote(command); const profile: Profile = { name, launch }; - if (opts.home) { + const isolated = opts.isolated || opts.home; + if (isolated) { const { varName, path } = resolveConfigHome(name, launch); profile.env = { [varName]: path }; } @@ -27,7 +34,11 @@ function addPositional(name: string, command: string[], opts: { home?: boolean } process.exit(1); } addProfile(profile); - p.log.success(`created hat "${name}"${opts.home ? ` · config: ${profile.env && Object.values(profile.env)[0]}` : ""}`); + p.log.success(`created hat "${name}"${isolated ? ` · config: ${profile.env && Object.values(profile.env)[0]}` : ""}`); + if (isolated && launchFirstToken(launch) === "claude") { + p.log.warn("Claude isolation requires a recent version with per-directory keychain credentials; upgrade if unsure"); + } + p.log.info(`next: hats ${name}`); } /** Thin interactive wizard: 3 questions + optional "open editor to add env". */ @@ -54,7 +65,7 @@ async function addInteractive(): Promise { let useHome = false; try { - // Probe inference first so we can warn early if --home won't work for this launch. + // Probe inference first so we can warn early if isolation won't work for this launch. resolveConfigHome(name as string, launch as string); const ans = await p.confirm({ message: "Use separate login for this hat?", initialValue: false }); if (p.isCancel(ans)) return p.cancel("cancelled"); @@ -81,12 +92,13 @@ async function addInteractive(): Promise { } export const addCommand = new Command("add") - .description("create a hat: `hats add [command...] [--home]` (or bare `hats add` for a thin wizard)") + .description("create a hat: `hats add [command...] [--isolated]` (or bare `hats add` for a thin wizard)") .argument("[name]", "hat name") .argument("[command...]", "launch command (variadic)") - .option("--home", "isolate this hat's config home (infer CODEX_HOME/CLAUDE_CONFIG_DIR/GEMINI_CLI_HOME)") + .option("--isolated", "give this hat its own supported CLI config home") + .addOption(new Option("--home", "alias for --isolated").hideHelp()) .allowUnknownOption() // let launch flags (e.g. --model) pass through into the variadic command - .action(async (name: string | undefined, command: string[], opts: { home?: boolean }) => { + .action(async (name: string | undefined, command: string[], opts: { isolated?: boolean; home?: boolean }) => { if (name === undefined) await addInteractive(); else addPositional(name, command, opts); }); diff --git a/src/commands/hint.ts b/src/commands/hint.ts index 489136c..d90df84 100644 --- a/src/commands/hint.ts +++ b/src/commands/hint.ts @@ -18,7 +18,7 @@ export function friendlyHint(): void { if (names.length === 0) { lines.push(`No hats yet. Create one:`); lines.push(` ${c("hats add")} thin interactive wizard`); - lines.push(` ${c("hats add [--home]")} non-interactive`); + lines.push(` ${c("hats add [--isolated]")} non-interactive`); lines.push(` ${c("hats init")} write an example config to copy from`); } else { lines.push(`${c("Your hats:")}`); diff --git a/src/commands/init.ts b/src/commands/init.ts index 65efb49..1cb7238 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,7 +15,7 @@ version = 1 launch = "codex" # Official Codex, isolated login/config home (separate OAuth account). -# Created with: hats add codex-personal codex --home +# Created with: hats add codex-personal codex --isolated # hats infers CODEX_HOME from the launch first token; the path lives under # ~/.config/hats/homes/ (i.e. $HATS_HOME/homes/). [profiles.codex-personal] diff --git a/src/commands/setenv.ts b/src/commands/setenv.ts index 1f52cb0..64eecd9 100644 --- a/src/commands/setenv.ts +++ b/src/commands/setenv.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import * as p from "@clack/prompts"; import { readFileSync } from "node:fs"; import { parse as parseDotenv } from "dotenv"; @@ -11,8 +11,10 @@ export const setenvCommand = new Command("setenv") .argument("", "hat name (created if missing)") .option("-f, --file ", "read KEY=value lines from this file instead of stdin") .option("--launch ", "also set the hat's launch command") - .option("--home", "also inject the inferred config-home var (CODEX_HOME/CLAUDE_CONFIG_DIR/GEMINI_CLI_HOME)") - .action((name: string, opts: { file?: string; launch?: string; home?: boolean }) => { + .option("--isolated", "also inject the supported CLI's isolated config home") + .addOption(new Option("--home", "alias for --isolated").hideHelp()) + .action((name: string, opts: { file?: string; launch?: string; isolated?: boolean; home?: boolean }) => { + const isolated = opts.isolated || opts.home; let content: string; if (opts.file) { content = readFileSync(opts.file, "utf8"); @@ -34,7 +36,7 @@ export const setenvCommand = new Command("setenv") const parsed = parseDotenv(content) as Record; // `--home` alone (no KEY=value lines) is allowed; otherwise need at least one key. const hasInput = Object.keys(parsed).length > 0; - if (!hasInput && !opts.home && !opts.launch) { + if (!hasInput && !isolated && !opts.launch) { p.log.warn("no KEY=value lines found"); return; } @@ -53,7 +55,7 @@ export const setenvCommand = new Command("setenv") profile.env = { ...(profile.env ?? {}), ...parsed }; merged.push(...Object.keys(parsed).map((k) => ` ${k} = ${describeForDisplay(parsed[k], k).display}`)); } - if (opts.home) { + if (isolated) { const { varName, path } = resolveConfigHome(name, profile.launch); profile.env = { ...(profile.env ?? {}), [varName]: path }; merged.push(` ${varName} = ${path}`); diff --git a/src/core/env.ts b/src/core/env.ts index d8ef6c9..0240985 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "node:fs"; 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"; /** * Provider / config prefixes stripped from the *inherited* environment so a @@ -21,10 +22,8 @@ export const STRIP_PREFIXES = [ "OPENAI_", "GEMINI_", ]; -const STRIP_EXACT = new Set(["GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"]); - function shouldStrip(name: string, prefixes: string[]): boolean { - return STRIP_EXACT.has(name) || prefixes.some((p) => name.startsWith(p)); + return TOOL_CREDENTIAL_ENV.has(name) || prefixes.some((p) => name.startsWith(p)); } /** Expand ${VAR} and $VAR references using the assembled env. */ @@ -100,6 +99,7 @@ export async function assembleEnv(profile: Profile): Promise { env[k] = expandVars(expandTilde(env[k]), env); } - const configDir = env.CLAUDE_CONFIG_DIR || env.CODEX_HOME || env.GEMINI_CLI_HOME; - return { env, configDir, stripped }; + const configDir = Object.keys(env).find((key) => TOOL_HOME_VARS.has(key)); + const resolvedConfigDir = configDir ? env[configDir] : undefined; + return { env, configDir: resolvedConfigDir, stripped }; } diff --git a/src/core/profile.ts b/src/core/profile.ts index b177d9e..5814abe 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { hatsHome, type HatsConfig, type Profile } from "./config.js"; +import { TOOLS } from "./tools.js"; export class ProfileError extends Error {} @@ -11,13 +12,6 @@ export function validateProfileName(name: string): string | undefined { if (RESERVED_NAMES.has(name)) return `"${name}" is a reserved command name`; } -/** Which env var carries a tool's isolated config home, keyed by launch first-token. */ -export const CONFIG_HOME_BY_TOOL: Record = { - codex: "CODEX_HOME", - claude: "CLAUDE_CONFIG_DIR", - gemini: "GEMINI_CLI_HOME", -}; - /** First whitespace-delimited token of a launch string (no shell parsing). */ export function launchFirstToken(launch: string | undefined): string | undefined { if (!launch) return undefined; @@ -41,22 +35,30 @@ export interface ConfigHome { } /** - * Resolve the isolated config home for a profile: var name inferred from the - * launch command's first token (codex/claude/gemini), path under HATS_HOME/homes/. - * Throws ProfileError if the first token isn't a known tool — caller should let the user - * set the env var manually instead of guessing. + * Resolve a supported isolated config home from the launch command's first token. + * Forms C/D fail with an honest manual recipe instead of pretending credentials move. */ export function resolveConfigHome(name: string, launch: string | undefined): ConfigHome { const first = launchFirstToken(launch); - const varName = first ? CONFIG_HOME_BY_TOOL[first] : undefined; - if (!varName) { + const tool = first ? TOOLS[first] : undefined; + if (!tool) { + throw new ProfileError( + `--isolated only works for a known tool (got "${first ?? "(none)"}"). ` + + `Use env injection for other CLIs.`, + ); + } + if (tool.form === "C") { + throw new ProfileError( + "credentials live in a fixed keychain entry; isolate manually with " + + 'env = { GEMINI_CLI_HOME = "...", GEMINI_FORCE_FILE_STORAGE = "true" }', + ); + } + if (tool.form === "D") { throw new ProfileError( - `--home only works when launch starts with codex, claude, or gemini ` + - `(got "${first ?? "(none)"}"). Set it manually, e.g. ` + - `env = { CODEX_HOME = "~/.config/hats/homes/${name}" }`, + "credentials live outside the config home (XDG data); use env injection instead — provider keys are env-driven", ); } - return { varName, path: tildeify(join(hatsHome(), "homes", name)) }; + return { varName: tool.homeVar as string, path: tildeify(join(hatsHome(), "homes", name)) }; } export function getProfile(cfg: HatsConfig, name: string): Profile { diff --git a/src/core/tools.ts b/src/core/tools.ts new file mode 100644 index 0000000..e968502 --- /dev/null +++ b/src/core/tools.ts @@ -0,0 +1,28 @@ +export type CredentialForm = "A" | "B" | "C" | "D"; + +export interface ToolEntry { + form: CredentialForm; + homeVar?: string; + credEnv?: string[]; +} + +export const TOOLS: Record = { + codex: { form: "A", homeVar: "CODEX_HOME", credEnv: ["OPENAI_API_KEY"] }, + claude: { + form: "B", + homeVar: "CLAUDE_CONFIG_DIR", + credEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + }, + gemini: { + form: "C", + homeVar: "GEMINI_CLI_HOME", + credEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"], + }, + opencode: { form: "D" }, +}; + +export const TOOL_HOME_VARS = new Set( + Object.values(TOOLS).flatMap((tool) => (tool.homeVar ? [tool.homeVar] : [])), +); + +export const TOOL_CREDENTIAL_ENV = new Set(Object.values(TOOLS).flatMap((tool) => tool.credEnv ?? [])); diff --git a/test/add.test.ts b/test/add.test.ts index 96bd7b2..028d5d0 100644 --- a/test/add.test.ts +++ b/test/add.test.ts @@ -72,10 +72,22 @@ describe("add (positional)", () => { assert.equal(p.env?.CODEX_HOME, join(tmpHome, "homes", "codex-personal")); }); + test("`--isolated` is the primary spelling and `--home` remains compatible", async () => { + const r = await runAdd(["claude-work", "claude", "--isolated"]); + assert.equal(r.code, 0, `out: ${r.out}`); + assert.equal(profiles()["claude-work"].env?.CLAUDE_CONFIG_DIR, join(tmpHome, "homes", "claude-work")); + }); + + test("`--isolated` rejects tools that cannot isolate credentials by config home", async () => { + const r = await runAdd(["gemini-work", "gemini", "--isolated"]); + assert.notEqual(r.code, 0); + assert.match(r.out, /fixed keychain.*GEMINI_FORCE_FILE_STORAGE/s); + }); + test("`add --home` with an uninferrable launch fails (no guessing)", async () => { const r = await runAdd(["local", "ollama", "launch", "claude", "--home"]); assert.notEqual(r.code, 0, "should error when --home can't be inferred"); - assert.match(r.out, /--home only works when launch starts with codex, claude, or gemini/); + assert.match(r.out, /--isolated only works for a known tool/); }); test("`add ` defaults launch to the hat name", async () => { diff --git a/test/env.test.ts b/test/env.test.ts index 6a3d940..ce2285a 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -77,6 +77,12 @@ describe("isolation / provider-prefix strip", () => { assert.notEqual(ea.configDir, eb.configDir); }); + test("codex hats also resolve distinct config homes through the registry", async () => { + const a = await assembleEnv({ name: "a", env: { CODEX_HOME: "~/.codex-a" } }); + const b = await assembleEnv({ name: "b", env: { CODEX_HOME: "~/.codex-b" } }); + assert.notEqual(a.configDir, b.configDir); + }); + test("${VAR} expansion resolves against the assembled env", async () => { const profile: Profile = { name: "r", diff --git a/test/profile.test.ts b/test/profile.test.ts index 3485be5..a014758 100644 --- a/test/profile.test.ts +++ b/test/profile.test.ts @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; -import { resolveConfigHome, launchFirstToken, CONFIG_HOME_BY_TOOL, ProfileError } from "../src/core/profile.js"; +import { resolveConfigHome, launchFirstToken, ProfileError } from "../src/core/profile.js"; +import { TOOLS } from "../src/core/tools.js"; let tmpHome: string; let prevHome: string | undefined; @@ -31,8 +32,18 @@ describe("resolveConfigHome (--home inference)", () => { assert.equal(resolveConfigHome("c", "claude").varName, "CLAUDE_CONFIG_DIR"); }); - test("gemini launch → GEMINI_CLI_HOME", () => { - assert.equal(resolveConfigHome("g", "gemini").varName, "GEMINI_CLI_HOME"); + test("fixed-keychain tools refuse to pretend they are isolated", () => { + assert.throws( + () => resolveConfigHome("g", "gemini"), + (error: unknown) => error instanceof ProfileError && /fixed keychain.*GEMINI_FORCE_FILE_STORAGE/s.test(error.message), + ); + }); + + test("tools whose credentials live outside config home point to env injection", () => { + assert.throws( + () => resolveConfigHome("o", "opencode"), + (error: unknown) => error instanceof ProfileError && /outside the config home.*env injection/s.test(error.message), + ); }); test("infers from the FIRST launch token only (ollama launch claude → not claude)", () => { @@ -72,12 +83,11 @@ describe("launchFirstToken", () => { }); }); -describe("CONFIG_HOME_BY_TOOL", () => { - test("covers the three supported tools", () => { - assert.deepEqual(CONFIG_HOME_BY_TOOL, { - codex: "CODEX_HOME", - claude: "CLAUDE_CONFIG_DIR", - gemini: "GEMINI_CLI_HOME", - }); +describe("TOOLS", () => { + test("enumerates credential forms used by isolation decisions", () => { + assert.deepEqual( + Object.fromEntries(Object.entries(TOOLS).map(([name, tool]) => [name, tool.form])), + { codex: "A", claude: "B", gemini: "C", opencode: "D" }, + ); }); -}); \ No newline at end of file +}); From 5664aa83d79795c4f108bcd2f0529c2d1610bac5 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 17:52:51 +0800 Subject: [PATCH 07/16] docs: explain multiple subscriptions separately --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f847903..baaa2d1 100644 --- a/README.md +++ b/README.md @@ -63,28 +63,66 @@ Use hats when you want to: - replace global switchers that rewrite shared `~/.claude` or `~/.codex` config - launch any CLI with one profile and zero residue in the current shell -## Common Setups +## Run multiple subscriptions side by side -### Codex official subscription +hats supports fully isolated accounts for **Codex** and **Claude Code**. Each hat gets +its own login, config, history, and settings, so work and personal subscriptions can +run at the same time without overwriting each other. -Use your normal `~/.codex` login: +### Two Codex accounts ```bash -hats add codex codex -hats run codex -``` +hats add codex-work codex --isolated +hats add codex-personal codex --isolated + +hats codex-work login +hats codex-personal login -### Multiple Codex accounts +hats codex-work +# In another terminal: +hats codex-personal +``` -Use a second Codex or ChatGPT account without touching the default `~/.codex`: +### Two Claude Code accounts ```bash -hats add codex-personal codex --home -hats run codex-personal login -hats run codex-personal +hats add claude-work claude --isolated +hats add claude-personal claude --isolated + +hats claude-work +# In another terminal: +hats claude-personal ``` -`--home` gives this hat its own CLI home under `~/.config/hats/homes/`. +Codex opens `login`; Claude Code opens its onboarding flow on first run. Complete each +login once, then launch that account anytime with its hat name. + +### How isolation works + +`--isolated` creates a dedicated CLI home under +`~/.config/hats/homes/`. hats also removes inherited provider credentials from +the child process, preventing a shell-level API key from silently overriding the +selected OAuth account. Add a key explicitly to the hat only when that override is +intentional. + +Use a recent Claude Code release: isolated Claude accounts rely on its per-directory +keychain storage. + +### Other CLIs + +hats can launch any CLI with per-process env and config. Credential-home isolation is +currently available for Codex and Claude Code. For tools with shared credential +storage, hats fails clearly instead of claiming the accounts are separated: + +- Gemini uses a fixed keychain entry. Use explicit env configuration with + `GEMINI_CLI_HOME` and `GEMINI_FORCE_FILE_STORAGE=true` if you accept that manual + setup. +- OpenCode stores credentials outside its config home. Use provider keys through the + hat's `env` or `env_file`; redirecting `XDG_DATA_HOME` would affect every XDG app in + the child process and is not recommended. + +hats does not manage OAuth or report login state. The underlying CLI remains +responsible for login and token refresh. See [Advanced configuration](docs/advanced.md) for gateways, local models, shared env files, and hand-written config. @@ -106,7 +144,7 @@ hats ls list profiles ```text hats show profiles and first-run hints hats init write an example config -hats add --home +hats add --isolated hats exec -- run another command with the profile env hats which inspect a profile, with secrets masked hats setenv --file .env merge env vars from KEY=value lines From cca4ebe00c15b552a70acbf48a67b62e86384ac3 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sat, 18 Jul 2026 18:05:50 +0800 Subject: [PATCH 08/16] refactor: name credential storage modes --- src/core/profile.ts | 8 ++++---- src/core/tools.ts | 21 +++++++++++++++------ test/profile.test.ts | 13 +++++++++---- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/core/profile.ts b/src/core/profile.ts index 5814abe..be99c65 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { hatsHome, type HatsConfig, type Profile } from "./config.js"; -import { TOOLS } from "./tools.js"; +import { CredentialStorage, TOOLS } from "./tools.js"; export class ProfileError extends Error {} @@ -36,7 +36,7 @@ export interface ConfigHome { /** * Resolve a supported isolated config home from the launch command's first token. - * Forms C/D fail with an honest manual recipe instead of pretending credentials move. + * Shared credential stores fail with a manual recipe instead of pretending credentials move. */ export function resolveConfigHome(name: string, launch: string | undefined): ConfigHome { const first = launchFirstToken(launch); @@ -47,13 +47,13 @@ export function resolveConfigHome(name: string, launch: string | undefined): Con `Use env injection for other CLIs.`, ); } - if (tool.form === "C") { + if (tool.credentialStorage === CredentialStorage.FixedKeychain) { throw new ProfileError( "credentials live in a fixed keychain entry; isolate manually with " + 'env = { GEMINI_CLI_HOME = "...", GEMINI_FORCE_FILE_STORAGE = "true" }', ); } - if (tool.form === "D") { + if (tool.credentialStorage === CredentialStorage.ExternalData) { throw new ProfileError( "credentials live outside the config home (XDG data); use env injection instead — provider keys are env-driven", ); diff --git a/src/core/tools.ts b/src/core/tools.ts index e968502..8aabc92 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -1,24 +1,33 @@ -export type CredentialForm = "A" | "B" | "C" | "D"; +export enum CredentialStorage { + ConfigHome = "config-home", + DirectoryKeychain = "directory-keychain", + FixedKeychain = "fixed-keychain", + ExternalData = "external-data", +} export interface ToolEntry { - form: CredentialForm; + credentialStorage: CredentialStorage; homeVar?: string; credEnv?: string[]; } export const TOOLS: Record = { - codex: { form: "A", homeVar: "CODEX_HOME", credEnv: ["OPENAI_API_KEY"] }, + codex: { + credentialStorage: CredentialStorage.ConfigHome, + homeVar: "CODEX_HOME", + credEnv: ["OPENAI_API_KEY"], + }, claude: { - form: "B", + credentialStorage: CredentialStorage.DirectoryKeychain, homeVar: "CLAUDE_CONFIG_DIR", credEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], }, gemini: { - form: "C", + credentialStorage: CredentialStorage.FixedKeychain, homeVar: "GEMINI_CLI_HOME", credEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"], }, - opencode: { form: "D" }, + opencode: { credentialStorage: CredentialStorage.ExternalData }, }; export const TOOL_HOME_VARS = new Set( diff --git a/test/profile.test.ts b/test/profile.test.ts index a014758..3019bb5 100644 --- a/test/profile.test.ts +++ b/test/profile.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; import { resolveConfigHome, launchFirstToken, ProfileError } from "../src/core/profile.js"; -import { TOOLS } from "../src/core/tools.js"; +import { CredentialStorage, TOOLS } from "../src/core/tools.js"; let tmpHome: string; let prevHome: string | undefined; @@ -84,10 +84,15 @@ describe("launchFirstToken", () => { }); describe("TOOLS", () => { - test("enumerates credential forms used by isolation decisions", () => { + test("describes credential storage used by isolation decisions", () => { assert.deepEqual( - Object.fromEntries(Object.entries(TOOLS).map(([name, tool]) => [name, tool.form])), - { codex: "A", claude: "B", gemini: "C", opencode: "D" }, + Object.fromEntries(Object.entries(TOOLS).map(([name, tool]) => [name, tool.credentialStorage])), + { + codex: CredentialStorage.ConfigHome, + claude: CredentialStorage.DirectoryKeychain, + gemini: CredentialStorage.FixedKeychain, + opencode: CredentialStorage.ExternalData, + }, ); }); }); From 7f0ca59e4e7f0c00351b3bd99b38cebbf33f17b6 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sun, 19 Jul 2026 00:49:58 +0800 Subject: [PATCH 09/16] feat: ship completion and verified curl install --- .github/workflows/release.yml | 11 +++++- completions/_hats | 12 ++++++ completions/hats.bash | 10 +++++ completions/hats.fish | 7 ++++ install.sh | 42 ++++++++++++++++++++ src/cli.ts | 57 +++++++++++++++++++++++++++ src/core/profile.ts | 13 ++++++- src/index.ts | 72 ++++++++++------------------------- test/integration.test.ts | 48 +++++++++++++++++++++++ test/profile.test.ts | 6 ++- 10 files changed, 223 insertions(+), 55 deletions(-) create mode 100644 completions/_hats create mode 100644 completions/hats.bash create mode 100644 completions/hats.fish create mode 100755 install.sh create mode 100644 src/cli.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 454834a..3082c19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,11 @@ jobs: - name: Package tarball run: | - (cd build && tar czf ../hats-${{ matrix.target }}.tar.gz hats) + cp -R completions build/ + (cd build && tar czf ../hats-${{ matrix.target }}.tar.gz hats completions) + for member in hats completions/hats.bash completions/_hats completions/hats.fish; do + tar tzf hats-${{ matrix.target }}.tar.gz "$member" >/dev/null + done # `shasum -a 256` (Perl script) is present on both macOS and Ubuntu; # `sha256sum` is coreutils-only and missing on macOS runners. shasum -a 256 hats-${{ matrix.target }}.tar.gz | tee hats-${{ matrix.target }}.tar.gz.sha256 @@ -170,6 +174,9 @@ jobs: def install bin.install "hats" + bash_completion.install "completions/hats.bash" => "hats" + zsh_completion.install "completions/_hats" + fish_completion.install "completions/hats.fish" end test do @@ -189,4 +196,4 @@ jobs: git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" git add Formula/hats.rb git commit -m "hats ${VERSION}" - git push origin HEAD:main \ No newline at end of file + git push origin HEAD:main diff --git a/completions/_hats b/completions/_hats new file mode 100644 index 0000000..ac5771d --- /dev/null +++ b/completions/_hats @@ -0,0 +1,12 @@ +#compdef hats + +_hats() { + local index=$((CURRENT - 2)) + local -a candidates + local output=$(hats __complete "$index" "${words[@]:1}" 2>/dev/null) + [[ -n $output ]] || return 0 + candidates=("${(@f)output}") + compadd -- "${candidates[@]}" +} + +_hats "$@" diff --git a/completions/hats.bash b/completions/hats.bash new file mode 100644 index 0000000..5e9b8f3 --- /dev/null +++ b/completions/hats.bash @@ -0,0 +1,10 @@ +_hats() { + local index=$((COMP_CWORD - 1)) + local cur=${COMP_WORDS[COMP_CWORD]} + local candidates + candidates=$(hats __complete "$index" "${COMP_WORDS[@]:1}" 2>/dev/null) + # shellcheck disable=SC2207 + COMPREPLY=($(compgen -W "$candidates" -- "$cur")) +} + +complete -F _hats hats diff --git a/completions/hats.fish b/completions/hats.fish new file mode 100644 index 0000000..642fb62 --- /dev/null +++ b/completions/hats.fish @@ -0,0 +1,7 @@ +function __hats_complete + set -l words (commandline -poc) + set -e words[1] + hats __complete (count $words) $words 2>/dev/null +end + +complete -c hats -f -a '(__hats_complete)' diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ed6a78e --- /dev/null +++ b/install.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + *) echo "hats: unsupported OS: $(uname -s)" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=x64 ;; + *) echo "hats: unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +asset="hats-$os-$arch.tar.gz" +if [ -n "${HATS_VERSION:-}" ]; then + version=${HATS_VERSION#v} + base="https://github.com/Colafornia/hats/releases/download/v$version" +else + base="https://github.com/Colafornia/hats/releases/latest/download" +fi + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +curl -fsSL "$base/$asset" -o "$tmp/$asset" +curl -fsSL "$base/SHA256SUMS" -o "$tmp/SHA256SUMS" + +expected=$(awk -v file="$asset" '$2 == file { print $1 }' "$tmp/SHA256SUMS") +[ -n "$expected" ] || { echo "hats: checksum missing for $asset" >&2; exit 1; } +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp/$asset" | awk '{ print $1 }') +else + actual=$(shasum -a 256 "$tmp/$asset" | awk '{ print $1 }') +fi +[ "$actual" = "$expected" ] || { echo "hats: checksum verification failed" >&2; exit 1; } + +tar xzf "$tmp/$asset" -C "$tmp" hats +install_dir=${HATS_INSTALL_DIR:-"$HOME/.local/bin"} +mkdir -p "$install_dir" +install -m 755 "$tmp/hats" "$install_dir/hats" +echo "installed hats to $install_dir/hats" diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..64e2a35 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,57 @@ +import { Command } from "commander"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { runCommand, execCommand } from "./commands/run.js"; +import { whichCommand } from "./commands/which.js"; +import { lsCommand } from "./commands/ls.js"; +import { addCommand } from "./commands/add.js"; +import { rmCommand } from "./commands/rm.js"; +import { editCommand } from "./commands/edit.js"; +import { initCommand } from "./commands/init.js"; +import { setenvCommand } from "./commands/setenv.js"; +import { friendlyHint } from "./commands/hint.js"; +import { loadConfig } from "./core/config.js"; + +const program = new Command(); + +// Version: read from the adjacent package.json when available (works under +// tsx and the dist/ bundle). Under a Bun `--compile` binary there is no +// package.json next to the executable, so fall back to HATS_VERSION, which +// the release workflow bakes in at compile time via `--define`. +function readVersion(): string { + try { + return JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"), + ).version as string; + } catch { + return process.env.HATS_VERSION ?? "0.0.0-dev"; + } +} +const pkgVersion = readVersion(); + +program + .name("hats") + .description("per-terminal / per-process config isolator for Claude Code, Codex, and any CLI") + .version(pkgVersion); + +program.addCommand(runCommand); +program.addCommand(execCommand); +program.addCommand(whichCommand); +program.addCommand(lsCommand); +program.addCommand(addCommand); +program.addCommand(setenvCommand); +program.addCommand(initCommand); +program.addCommand(rmCommand); +program.addCommand(editCommand); + +const argv = process.argv.slice(); +const first = argv[2]; +if (first && !first.startsWith("-") && loadConfig().profiles[first]) argv.splice(2, 0, "run"); + +const parse = argv.length === 2 ? (friendlyHint(), Promise.resolve()) : program.parseAsync(argv); +parse.catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`hats: ${msg}`); + process.exit(1); +}); diff --git a/src/core/profile.ts b/src/core/profile.ts index be99c65..e0c128d 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -5,7 +5,18 @@ import { CredentialStorage, TOOLS } from "./tools.js"; export class ProfileError extends Error {} -export const RESERVED_NAMES = new Set(["run", "exec", "which", "ls", "add", "setenv", "init", "rm", "edit"]); +export const RESERVED_NAMES = new Set([ + "run", + "exec", + "which", + "ls", + "add", + "setenv", + "init", + "rm", + "edit", + "__complete", +]); export function validateProfileName(name: string): string | undefined { if (!/^[A-Za-z0-9_-]+$/.test(name)) return "letters, digits, _ or - only"; diff --git a/src/index.ts b/src/index.ts index 7d71550..a940eaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,59 +1,29 @@ #!/usr/bin/env node -import { Command } from "commander"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { runCommand, execCommand } from "./commands/run.js"; -import { whichCommand } from "./commands/which.js"; -import { lsCommand } from "./commands/ls.js"; -import { addCommand } from "./commands/add.js"; -import { rmCommand } from "./commands/rm.js"; -import { editCommand } from "./commands/edit.js"; -import { initCommand } from "./commands/init.js"; -import { setenvCommand } from "./commands/setenv.js"; -import { friendlyHint } from "./commands/hint.js"; import { loadConfig } from "./core/config.js"; -const program = new Command(); +const BUILTINS = ["run", "exec", "which", "ls", "add", "setenv", "init", "rm", "edit"]; +const FLAGS: Record = { + add: ["--isolated"], + setenv: ["--file", "--launch", "--isolated"], +}; -// Version: read from the adjacent package.json when available (works under -// tsx and the dist/ bundle). Under a Bun `--compile` binary there is no -// package.json next to the executable, so fall back to HATS_VERSION, which -// the release workflow bakes in at compile time via `--define`. -function readVersion(): string { +if (process.argv[2] === "__complete") { try { - return JSON.parse( - readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"), - ).version as string; - } catch { - return process.env.HATS_VERSION ?? "0.0.0-dev"; + const position = Number(process.argv[3]); + const profiles = Object.keys(loadConfig().profiles); + let candidates = + position === 0 + ? [...BUILTINS, ...profiles] + : position === 1 && ["run", "exec", "which", "rm", "setenv"].includes(process.argv[4]) + ? profiles + : []; + const command = process.argv[4]; + if (position > 0 && FLAGS[command]) candidates = [...candidates, ...FLAGS[command]]; + if (candidates.length) process.stdout.write(candidates.join("\n") + "\n"); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); } + process.exit(0); } -const pkgVersion = readVersion(); -program - .name("hats") - .description("per-terminal / per-process config isolator for Claude Code, Codex, and any CLI") - .version(pkgVersion); - -program.addCommand(runCommand); -program.addCommand(execCommand); -program.addCommand(whichCommand); -program.addCommand(lsCommand); -program.addCommand(addCommand); -program.addCommand(setenvCommand); -program.addCommand(initCommand); -program.addCommand(rmCommand); -program.addCommand(editCommand); - -const argv = process.argv.slice(); -const first = argv[2]; -if (first && !first.startsWith("-") && loadConfig().profiles[first]) argv.splice(2, 0, "run"); - -const parse = argv.length === 2 ? (friendlyHint(), Promise.resolve()) : program.parseAsync(argv); -parse.catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - // eslint-disable-next-line no-console - console.error(`hats: ${msg}`); - process.exit(1); -}); +await import("./cli.js"); diff --git a/test/integration.test.ts b/test/integration.test.ts index e668dcf..6ecf39c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -119,3 +119,51 @@ describe("integration: hat shorthand", () => { assert.match(r.stderr, /Did you mean.*run/); }); }); + +describe("integration: shell completion", () => { + test("top-level completion lists built-ins and configured hats", async () => { + const r = await runCli(["__complete", "0"], childEnv()); + + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + const candidates = r.stdout.trim().split("\n"); + assert.ok(candidates.includes("run")); + assert.ok(candidates.includes("relay")); + assert.ok(candidates.includes("local")); + }); + + test("hat-taking commands complete configured hats for their first argument", async () => { + for (const command of ["run", "exec", "which", "rm", "setenv"]) { + const r = await runCli(["__complete", "1", command], childEnv()); + assert.equal(r.code, 0, `${command}: ${r.stderr}`); + assert.match(r.stdout, /^relay$/m, command); + assert.match(r.stdout, /^local$/m, command); + } + }); + + test("command options are offered only where hats still owns the arguments", async () => { + const add = await runCli(["__complete", "1", "add"], childEnv()); + assert.match(add.stdout, /^--isolated$/m); + + const setenv = await runCli(["__complete", "2", "setenv", "relay"], childEnv()); + assert.match(setenv.stdout, /^--file$/m); + assert.match(setenv.stdout, /^--launch$/m); + assert.match(setenv.stdout, /^--isolated$/m); + + const run = await runCli(["__complete", "2", "run", "relay"], childEnv()); + assert.equal(run.stdout, ""); + }); + + test("completion errors stay off stdout and exit successfully", async () => { + const invalidHome = mkdtempSync(join(tmpdir(), "hats-complete-invalid-")); + try { + writeFileSync(join(invalidHome, "config.toml"), "not valid toml = ["); + const r = await runCli(["__complete", "0"], childEnv({ HATS_HOME: invalidHome })); + + assert.equal(r.code, 0); + assert.equal(r.stdout, ""); + assert.notEqual(r.stderr, ""); + } finally { + rmSync(invalidHome, { recursive: true, force: true }); + } + }); +}); diff --git a/test/profile.test.ts b/test/profile.test.ts index 3019bb5..f20a563 100644 --- a/test/profile.test.ts +++ b/test/profile.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; -import { resolveConfigHome, launchFirstToken, ProfileError } from "../src/core/profile.js"; +import { resolveConfigHome, launchFirstToken, ProfileError, validateProfileName } from "../src/core/profile.js"; import { CredentialStorage, TOOLS } from "../src/core/tools.js"; let tmpHome: string; @@ -83,6 +83,10 @@ describe("launchFirstToken", () => { }); }); +test("the hidden completion command is reserved from hat names", () => { + assert.match(validateProfileName("__complete") ?? "", /reserved/); +}); + describe("TOOLS", () => { test("describes credential storage used by isolation decisions", () => { assert.deepEqual( From cb3d0db5098319944129ca63089907cc4748f989 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sun, 19 Jul 2026 00:50:18 +0800 Subject: [PATCH 10/16] docs: lead with the simple hats experience --- README.md | 168 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 114 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index baaa2d1..c298f62 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

hats

-

Run Claude Code, Codex, and Gemini profiles side by side.

+

Run any AI CLI with the right config — one hat per terminal, zero shell pollution.

Install with Homebrew @@ -12,61 +12,130 @@ MIT license

-`hats` runs each profile in a clean child process. Use a company gateway in one -terminal, a personal subscription in another, and a local model in a third. It does -not switch global providers, pollute your shell, or copy credentials. - ```bash -hats run work # one terminal -hats run personal # another terminal +hats add work claude +hats work ``` +That is the whole default workflow. A hat starts its CLI in a clean child process with +the config you chose. Your current shell and other terminals stay unchanged. + ## Install +### Homebrew + ```bash brew install colafornia/tap/hats ``` -hats ships as a standalone binary. No Node.js or Bun required. +### curl + +```bash +curl -fsSL https://raw.githubusercontent.com/Colafornia/hats/main/install.sh | sh +``` -## Quick Start +The installer verifies the release checksum and puts the standalone binary in +`~/.local/bin` without `sudo` or shell startup-file changes. Pin a release with +`HATS_VERSION=v0.1.0`; override the destination with `HATS_INSTALL_DIR`. -Create your first hat with the interactive setup: +To inspect the installer first: ```bash -hats add +curl -fsSLO https://raw.githubusercontent.com/Colafornia/hats/main/install.sh +less install.sh +sh install.sh ``` -Then run the name you chose with `hats run `. +### Manual install -## Why +Download `hats--.tar.gz` and `SHA256SUMS` from the matching +[GitHub Release](https://github.com/Colafornia/hats/releases), then replace the +placeholder below with your asset name: -Most AI CLI switchers mutate global state: they export provider env vars, rewrite shared -tool config, or silently change what every terminal will use next. That breaks down when -you need more than one setup open at once. +```bash +grep ' hats--.tar.gz$' SHA256SUMS | shasum -a 256 -c - +tar xzf hats--.tar.gz +mkdir -p ~/.local/bin +install -m 755 hats ~/.local/bin/hats +``` + +Release tarballs include Bash, Zsh, and Fish completion files. Homebrew installs them +automatically. With curl, download and extract the matching release tarball to get its +`completions` directory. Keep that directory and add the matching setup to your shell +config: + +```bash +# Bash +source /path/to/completions/hats.bash + +# Zsh +fpath=(/path/to/completions $fpath) +autoload -Uz compinit && compinit + +# Fish +source /path/to/completions/hats.fish +``` + +No Node.js or Bun is required at runtime. + +## Quick start -`hats` makes the switch explicit: +Create a hat by naming it and the CLI it should launch: ```bash -hats run company-claude -hats run personal-codex -hats run local-claude +hats add personal codex +hats personal ``` -Each command creates one isolated child process. The parent shell keeps none of the -profile's credentials, `ANTHROPIC_*`, `OPENAI_*`, or `CODEX_*` values. +To feed one env file to different CLIs, create both hats and point them at the same file +with `hats edit`: -Use hats when you want to: +```bash +hats add write claude +hats add review codex +hats edit +``` + +```toml +[profiles.write] +launch = "claude" +env_file = "~/.config/company-ai.env" + +[profiles.review] +launch = "codex" +env_file = "~/.config/company-ai.env" +``` + +```bash +hats write +hats review # another terminal, same env file, independent process +``` + +`hats add` without arguments opens a short wizard. Use `hats edit` for hand-written +config and advanced env references. + +## Why + +Most AI CLI switchers mutate global state: they export provider env vars, rewrite shared +tool config, or silently change what every terminal will use next. That breaks down when +you need more than one setup open at once. -- run multiple AI coding subscriptions or OAuth accounts at the same time -- keep company gateways, personal accounts, and local models from leaking into each other -- replace global switchers that rewrite shared `~/.claude` or `~/.codex` config -- launch any CLI with one profile and zero residue in the current shell +`hats` makes each launch explicit and local to one child process. It also removes +inherited provider credentials such as `ANTHROPIC_*`, `OPENAI_*`, and `CODEX_*` unless +the selected hat adds them back intentionally. + +Use hats for company gateways, personal subscriptions, local models, or any CLI that +needs a repeatable per-process environment. See +[Advanced configuration](docs/advanced.md) for env references, shared env files, local +models, and hand-written config. ## Run multiple subscriptions side by side -hats supports fully isolated accounts for **Codex** and **Claude Code**. Each hat gets -its own login, config, history, and settings, so work and personal subscriptions can +Multiple subscriptions are optional. The simple `hats ` workflow above does not +require isolated CLI homes. + +hats supports isolated accounts for **Codex** and **Claude Code**. Each isolated hat +gets its own login, config, history, and settings, so work and personal subscriptions can run at the same time without overwriting each other. ### Two Codex accounts @@ -99,11 +168,10 @@ login once, then launch that account anytime with its hat name. ### How isolation works -`--isolated` creates a dedicated CLI home under -`~/.config/hats/homes/`. hats also removes inherited provider credentials from -the child process, preventing a shell-level API key from silently overriding the -selected OAuth account. Add a key explicitly to the hat only when that override is -intentional. +`--isolated` creates a dedicated CLI home under `~/.config/hats/homes/`. hats +also removes inherited provider credentials from the child process, preventing a +shell-level API key from silently overriding the selected OAuth account. Add a key +explicitly to the hat only when that override is intentional. Use a recent Claude Code release: isolated Claude accounts rely on its per-directory keychain storage. @@ -111,8 +179,8 @@ keychain storage. ### Other CLIs hats can launch any CLI with per-process env and config. Credential-home isolation is -currently available for Codex and Claude Code. For tools with shared credential -storage, hats fails clearly instead of claiming the accounts are separated: +currently available for Codex and Claude Code. For tools with shared credential storage, +hats fails clearly instead of claiming the accounts are separated: - Gemini uses a fixed keychain entry. Use explicit env configuration with `GEMINI_CLI_HOME` and `GEMINI_FORCE_FILE_STORAGE=true` if you accept that manual @@ -121,41 +189,33 @@ storage, hats fails clearly instead of claiming the accounts are separated: hat's `env` or `env_file`; redirecting `XDG_DATA_HOME` would affect every XDG app in the child process and is not recommended. -hats does not manage OAuth or report login state. The underlying CLI remains -responsible for login and token refresh. - -See [Advanced configuration](docs/advanced.md) for gateways, local models, shared env -files, and hand-written config. +hats does not manage OAuth or report login state. The underlying CLI remains responsible +for login and token refresh. ## Commands -### Start here - ```text hats add [ ] create a hat -hats run [args...] run the profile's launch command +hats [args...] launch a hat (same as hats run ) hats edit open the config in $EDITOR -hats ls list profiles +hats ls list hats ```
More ```text -hats show profiles and first-run hints +hats show hats and first-run hints hats init write an example config hats add --isolated -hats exec -- run another command with the profile env -hats which inspect a profile, with secrets masked -hats setenv --file .env merge env vars from KEY=value lines -hats rm delete a profile entry +hats exec -- run another command with the hat's env +hats which inspect a hat, with secrets masked +hats setenv --file .env merge env vars from KEY=value lines +hats rm delete a hat ```
-`hats add` without arguments opens a short wizard: name, launch command, and whether -this hat needs a separate login. - ## Non-goals - No global provider switching. @@ -163,7 +223,7 @@ this hat needs a separate login. - No OAuth management. The underlying CLI still owns login and refresh. - No automatic `.zshrc` migration. - No GUI desktop app launching in v0.1. -- No interactive profile picker. Switching stays explicit: `hats run `. +- No interactive hat picker. Switching stays explicit: `hats `. ## License From 10d3ec28604c6006302ff8879e0c8de25a0b6f41 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sun, 19 Jul 2026 13:35:48 +0800 Subject: [PATCH 11/16] refactor: centralize built-in command metadata --- src/core/builtins.ts | 20 ++++++++++++++++++++ src/core/profile.ts | 14 +------------- src/index.ts | 17 ++++++----------- 3 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 src/core/builtins.ts diff --git a/src/core/builtins.ts b/src/core/builtins.ts new file mode 100644 index 0000000..f25f92a --- /dev/null +++ b/src/core/builtins.ts @@ -0,0 +1,20 @@ +interface BuiltinCommand { + completesHat?: boolean; + flags?: readonly string[]; +} + +export const BUILTIN_COMMANDS: Record = { + run: { completesHat: true }, + exec: { completesHat: true }, + which: { completesHat: true }, + ls: {}, + add: { flags: ["--isolated"] }, + setenv: { completesHat: true, flags: ["--file", "--launch", "--isolated"] }, + init: {}, + rm: { completesHat: true }, + edit: {}, +}; + +export const BUILTIN_NAMES = Object.keys(BUILTIN_COMMANDS); +export const COMPLETE_COMMAND = "__complete"; +export const RESERVED_NAMES = new Set([...BUILTIN_NAMES, COMPLETE_COMMAND]); diff --git a/src/core/profile.ts b/src/core/profile.ts index e0c128d..44d0bab 100644 --- a/src/core/profile.ts +++ b/src/core/profile.ts @@ -2,22 +2,10 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { hatsHome, type HatsConfig, type Profile } from "./config.js"; import { CredentialStorage, TOOLS } from "./tools.js"; +import { RESERVED_NAMES } from "./builtins.js"; export class ProfileError extends Error {} -export const RESERVED_NAMES = new Set([ - "run", - "exec", - "which", - "ls", - "add", - "setenv", - "init", - "rm", - "edit", - "__complete", -]); - export function validateProfileName(name: string): string | undefined { if (!/^[A-Za-z0-9_-]+$/.test(name)) return "letters, digits, _ or - only"; if (RESERVED_NAMES.has(name)) return `"${name}" is a reserved command name`; diff --git a/src/index.ts b/src/index.ts index a940eaf..80954ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,19 @@ #!/usr/bin/env node import { loadConfig } from "./core/config.js"; +import { BUILTIN_COMMANDS, BUILTIN_NAMES, COMPLETE_COMMAND } from "./core/builtins.js"; -const BUILTINS = ["run", "exec", "which", "ls", "add", "setenv", "init", "rm", "edit"]; -const FLAGS: Record = { - add: ["--isolated"], - setenv: ["--file", "--launch", "--isolated"], -}; - -if (process.argv[2] === "__complete") { +if (process.argv[2] === COMPLETE_COMMAND) { try { const position = Number(process.argv[3]); const profiles = Object.keys(loadConfig().profiles); + const command = BUILTIN_COMMANDS[process.argv[4]]; let candidates = position === 0 - ? [...BUILTINS, ...profiles] - : position === 1 && ["run", "exec", "which", "rm", "setenv"].includes(process.argv[4]) + ? [...BUILTIN_NAMES, ...profiles] + : position === 1 && command?.completesHat ? profiles : []; - const command = process.argv[4]; - if (position > 0 && FLAGS[command]) candidates = [...candidates, ...FLAGS[command]]; + if (position > 0 && command?.flags) candidates = [...candidates, ...command.flags]; if (candidates.length) process.stdout.write(candidates.join("\n") + "\n"); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); From 833d9c57068b517d4fa9c48c1ec24f4a52e64241 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sun, 19 Jul 2026 14:38:50 +0800 Subject: [PATCH 12/16] refactor: keep command registries in sync --- src/cli.ts | 19 ++----------------- src/commands/index.ts | 20 ++++++++++++++++++++ test/builtins.test.ts | 11 +++++++++++ 3 files changed, 33 insertions(+), 17 deletions(-) create mode 100644 src/commands/index.ts create mode 100644 test/builtins.test.ts diff --git a/src/cli.ts b/src/cli.ts index 64e2a35..f619922 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,14 +2,7 @@ import { Command } from "commander"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { runCommand, execCommand } from "./commands/run.js"; -import { whichCommand } from "./commands/which.js"; -import { lsCommand } from "./commands/ls.js"; -import { addCommand } from "./commands/add.js"; -import { rmCommand } from "./commands/rm.js"; -import { editCommand } from "./commands/edit.js"; -import { initCommand } from "./commands/init.js"; -import { setenvCommand } from "./commands/setenv.js"; +import { COMMANDS } from "./commands/index.js"; import { friendlyHint } from "./commands/hint.js"; import { loadConfig } from "./core/config.js"; @@ -35,15 +28,7 @@ program .description("per-terminal / per-process config isolator for Claude Code, Codex, and any CLI") .version(pkgVersion); -program.addCommand(runCommand); -program.addCommand(execCommand); -program.addCommand(whichCommand); -program.addCommand(lsCommand); -program.addCommand(addCommand); -program.addCommand(setenvCommand); -program.addCommand(initCommand); -program.addCommand(rmCommand); -program.addCommand(editCommand); +for (const command of COMMANDS) program.addCommand(command); const argv = process.argv.slice(); const first = argv[2]; diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..b2e17cd --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,20 @@ +import { runCommand, execCommand } from "./run.js"; +import { whichCommand } from "./which.js"; +import { lsCommand } from "./ls.js"; +import { addCommand } from "./add.js"; +import { rmCommand } from "./rm.js"; +import { editCommand } from "./edit.js"; +import { initCommand } from "./init.js"; +import { setenvCommand } from "./setenv.js"; + +export const COMMANDS = [ + runCommand, + execCommand, + whichCommand, + lsCommand, + addCommand, + setenvCommand, + initCommand, + rmCommand, + editCommand, +]; diff --git a/test/builtins.test.ts b/test/builtins.test.ts new file mode 100644 index 0000000..16bc933 --- /dev/null +++ b/test/builtins.test.ts @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { COMMANDS } from "../src/commands/index.js"; +import { BUILTIN_NAMES } from "../src/core/builtins.js"; + +test("registered commands match completion metadata", () => { + assert.deepEqual( + COMMANDS.map((command) => command.name()).sort(), + [...BUILTIN_NAMES].sort(), + ); +}); From db40ca9780218e83857181c2318dda4b034f02f9 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sun, 19 Jul 2026 15:26:20 +0800 Subject: [PATCH 13/16] fix: keep common CLI paths quiet --- src/cli.ts | 5 +++-- src/commands/run.ts | 4 ++-- src/core/config.ts | 4 ++-- test/integration.test.ts | 20 ++++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f619922..85de80a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,13 +26,14 @@ const pkgVersion = readVersion(); program .name("hats") .description("per-terminal / per-process config isolator for Claude Code, Codex, and any CLI") - .version(pkgVersion); + .version(pkgVersion) + .version(pkgVersion, "-v"); for (const command of COMMANDS) program.addCommand(command); const argv = process.argv.slice(); const first = argv[2]; -if (first && !first.startsWith("-") && loadConfig().profiles[first]) argv.splice(2, 0, "run"); +if (first && !first.startsWith("-") && loadConfig(false).profiles[first]) argv.splice(2, 0, "run"); const parse = argv.length === 2 ? (friendlyHint(), Promise.resolve()) : program.parseAsync(argv); parse.catch((err: unknown) => { diff --git a/src/commands/run.ts b/src/commands/run.ts index e32bda9..086d938 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -40,7 +40,7 @@ export const runCommand = new Command("run") .argument("[args...]", "extra args appended to the launch command") .allowUnknownOption() .action(async (name: string, args: string[]) => { - const cfg = loadConfig(); + const cfg = loadConfig(name); const profile = getProfile(cfg, name); const code = await launch(profile, args); process.exit(code); @@ -52,7 +52,7 @@ export const execCommand = new Command("exec") .argument("[args...]", "command and its args (after --)") .allowUnknownOption() .action(async (name: string, args: string[]) => { - const cfg = loadConfig(); + const cfg = loadConfig(name); const profile = getProfile(cfg, name); if (!args.length) { // eslint-disable-next-line no-console diff --git a/src/core/config.ts b/src/core/config.ts index 15fecca..24cc056 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -39,7 +39,7 @@ export function defaultConfig(): HatsConfig { return { version: 1, profiles: {} }; } -export function loadConfig(): HatsConfig { +export function loadConfig(warnUnknown: boolean | string = true): HatsConfig { const p = configPath(); if (!existsSync(p)) return defaultConfig(); const raw = parse(readFileSync(p, "utf8")) as Record; @@ -47,7 +47,7 @@ export function loadConfig(): HatsConfig { const pr = (raw.profiles ?? {}) as Record; for (const [name, v] of Object.entries(pr)) { for (const key of Object.keys(v)) { - if (!["desc", "env_file", "env", "launch"].includes(key)) { + if ((warnUnknown === true || warnUnknown === name) && !["desc", "env_file", "env", "launch"].includes(key)) { console.error(`warning: profiles.${name}.${key} is unknown and will be ignored`); } } diff --git a/test/integration.test.ts b/test/integration.test.ts index 6ecf39c..e1be1fa 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -71,6 +71,12 @@ function runCli(args: string[], env: Record): Promise } describe("integration: hats exec through the real CLI", () => { + test("-v prints the version", async () => { + const r = await runCli(["-v"], childEnv()); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.equal(r.stdout.trim(), "0.1.0"); + }); + test("exit code is passed through (E1)", async () => { const r = await runCli(["exec", "relay", "--", "node", "-e", "process.exit(3)"], childEnv()); assert.equal(r.code, 3, `stderr: ${r.stderr}`); @@ -107,6 +113,20 @@ describe("integration: hat shorthand", () => { assert.equal(r.stdout.trim(), "--model|gpt 5"); }); + test("shorthand does not warn about unrelated hats", async () => { + const script = join(tmpHome, "noop.mjs"); + writeFileSync(script, ""); + writeFileSync( + join(tmpHome, "config.toml"), + `${CONFIG_TOML}\n[profiles.work]\nlaunch = "node ${script}"\n\n[profiles.legacy]\nlaunch = "claude"\nkind = "legacy"\n`, + ); + + const r = await runCli(["work"], childEnv()); + + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.doesNotMatch(r.stderr, /profiles\.legacy\.kind/); + }); + test("an unknown word stays an unknown command", async () => { const r = await runCli(["not-a-hat"], childEnv()); assert.notEqual(r.code, 0); From c66c2f955e73ad71091fcdbc923504df930cd764 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Sun, 19 Jul 2026 15:26:29 +0800 Subject: [PATCH 14/16] docs: put the shortest install path first --- README.md | 63 ++++++++----------------------------------------------- 1 file changed, 9 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index c298f62..4fa8a88 100644 --- a/README.md +++ b/README.md @@ -4,81 +4,36 @@

hats

-

Run any AI CLI with the right config — one hat per terminal, zero shell pollution.

+

Run multiple AI CLI setups side by side — one hat per terminal, zero shell pollution.

- Install with Homebrew CI status MIT license

-```bash -hats add work claude -hats work -``` - -That is the whole default workflow. A hat starts its CLI in a clean child process with -the config you chose. Your current shell and other terminals stay unchanged. - ## Install -### Homebrew - -```bash -brew install colafornia/tap/hats -``` - -### curl - ```bash curl -fsSL https://raw.githubusercontent.com/Colafornia/hats/main/install.sh | sh ``` -The installer verifies the release checksum and puts the standalone binary in -`~/.local/bin` without `sudo` or shell startup-file changes. Pin a release with -`HATS_VERSION=v0.1.0`; override the destination with `HATS_INSTALL_DIR`. +No Node.js, Bun, `sudo`, or shell startup-file changes required. -To inspect the installer first: +Prefer Homebrew? ```bash -curl -fsSLO https://raw.githubusercontent.com/Colafornia/hats/main/install.sh -less install.sh -sh install.sh -``` - -### Manual install - -Download `hats--.tar.gz` and `SHA256SUMS` from the matching -[GitHub Release](https://github.com/Colafornia/hats/releases), then replace the -placeholder below with your asset name: - -```bash -grep ' hats--.tar.gz$' SHA256SUMS | shasum -a 256 -c - -tar xzf hats--.tar.gz -mkdir -p ~/.local/bin -install -m 755 hats ~/.local/bin/hats +brew install colafornia/tap/hats ``` -Release tarballs include Bash, Zsh, and Fish completion files. Homebrew installs them -automatically. With curl, download and extract the matching release tarball to get its -`completions` directory. Keep that directory and add the matching setup to your shell -config: +## Quick start ```bash -# Bash -source /path/to/completions/hats.bash - -# Zsh -fpath=(/path/to/completions $fpath) -autoload -Uz compinit && compinit - -# Fish -source /path/to/completions/hats.fish +hats add work claude +hats work ``` -No Node.js or Bun is required at runtime. - -## Quick start +That is the whole default workflow. A hat starts its CLI in a clean child process with +the config you chose. Your current shell and other terminals stay unchanged. Create a hat by naming it and the CLI it should launch: From facb027a1a0fb3fe5b0af6f7388fbfd3a7b5a8c2 Mon Sep 17 00:00:00 2001 From: Colafornia Date: Mon, 20 Jul 2026 10:03:25 +0800 Subject: [PATCH 15/16] feat: expose shell completion setup --- .github/workflows/release.yml | 11 +++------ README.md | 18 ++++++++++++++ completions/_hats | 12 ---------- completions/hats.bash | 10 -------- completions/hats.fish | 7 ------ install.sh | 1 + src/cli.ts | 5 +++- src/commands/completion.ts | 45 +++++++++++++++++++++++++++++++++++ src/commands/index.ts | 2 ++ src/core/builtins.ts | 1 + test/integration.test.ts | 32 +++++++++++++++++++++++++ 11 files changed, 106 insertions(+), 38 deletions(-) delete mode 100644 completions/_hats delete mode 100644 completions/hats.bash delete mode 100644 completions/hats.fish create mode 100644 src/commands/completion.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3082c19..775f23b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,11 +78,8 @@ jobs: - name: Package tarball run: | - cp -R completions build/ - (cd build && tar czf ../hats-${{ matrix.target }}.tar.gz hats completions) - for member in hats completions/hats.bash completions/_hats completions/hats.fish; do - tar tzf hats-${{ matrix.target }}.tar.gz "$member" >/dev/null - done + (cd build && tar czf ../hats-${{ matrix.target }}.tar.gz hats) + tar tzf hats-${{ matrix.target }}.tar.gz hats >/dev/null # `shasum -a 256` (Perl script) is present on both macOS and Ubuntu; # `sha256sum` is coreutils-only and missing on macOS runners. shasum -a 256 hats-${{ matrix.target }}.tar.gz | tee hats-${{ matrix.target }}.tar.gz.sha256 @@ -174,9 +171,7 @@ jobs: def install bin.install "hats" - bash_completion.install "completions/hats.bash" => "hats" - zsh_completion.install "completions/_hats" - fish_completion.install "completions/hats.fish" + generate_completions_from_executable(bin/"hats", "completion") end test do diff --git a/README.md b/README.md index 4fa8a88..60e66f1 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,28 @@ hats exec -- run another command with the hat's env hats which inspect a hat, with secrets masked hats setenv --file .env merge env vars from KEY=value lines hats rm delete a hat +hats completion output Bash, Zsh, or Fish completion code ``` +## Shell completion + +Homebrew enables completion automatically. For other installs, add the command for +your shell to its startup file: + +```zsh +eval "$(hats completion zsh)" +``` + +```bash +eval "$(hats completion bash)" +``` + +```fish +hats completion fish | source +``` + ## Non-goals - No global provider switching. diff --git a/completions/_hats b/completions/_hats deleted file mode 100644 index ac5771d..0000000 --- a/completions/_hats +++ /dev/null @@ -1,12 +0,0 @@ -#compdef hats - -_hats() { - local index=$((CURRENT - 2)) - local -a candidates - local output=$(hats __complete "$index" "${words[@]:1}" 2>/dev/null) - [[ -n $output ]] || return 0 - candidates=("${(@f)output}") - compadd -- "${candidates[@]}" -} - -_hats "$@" diff --git a/completions/hats.bash b/completions/hats.bash deleted file mode 100644 index 5e9b8f3..0000000 --- a/completions/hats.bash +++ /dev/null @@ -1,10 +0,0 @@ -_hats() { - local index=$((COMP_CWORD - 1)) - local cur=${COMP_WORDS[COMP_CWORD]} - local candidates - candidates=$(hats __complete "$index" "${COMP_WORDS[@]:1}" 2>/dev/null) - # shellcheck disable=SC2207 - COMPREPLY=($(compgen -W "$candidates" -- "$cur")) -} - -complete -F _hats hats diff --git a/completions/hats.fish b/completions/hats.fish deleted file mode 100644 index 642fb62..0000000 --- a/completions/hats.fish +++ /dev/null @@ -1,7 +0,0 @@ -function __hats_complete - set -l words (commandline -poc) - set -e words[1] - hats __complete (count $words) $words 2>/dev/null -end - -complete -c hats -f -a '(__hats_complete)' diff --git a/install.sh b/install.sh index ed6a78e..c34e21d 100755 --- a/install.sh +++ b/install.sh @@ -40,3 +40,4 @@ install_dir=${HATS_INSTALL_DIR:-"$HOME/.local/bin"} mkdir -p "$install_dir" install -m 755 "$tmp/hats" "$install_dir/hats" echo "installed hats to $install_dir/hats" +echo "Tip: enable Tab completion: https://github.com/Colafornia/hats#shell-completion" diff --git a/src/cli.ts b/src/cli.ts index 85de80a..5569fbc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { COMMANDS } from "./commands/index.js"; import { friendlyHint } from "./commands/hint.js"; import { loadConfig } from "./core/config.js"; +import { BUILTIN_NAMES } from "./core/builtins.js"; const program = new Command(); @@ -33,7 +34,9 @@ for (const command of COMMANDS) program.addCommand(command); const argv = process.argv.slice(); const first = argv[2]; -if (first && !first.startsWith("-") && loadConfig(false).profiles[first]) argv.splice(2, 0, "run"); +if (first && !first.startsWith("-") && !BUILTIN_NAMES.includes(first) && loadConfig(false).profiles[first]) { + argv.splice(2, 0, "run"); +} const parse = argv.length === 2 ? (friendlyHint(), Promise.resolve()) : program.parseAsync(argv); parse.catch((err: unknown) => { diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..fb9ad04 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; + +const scripts: Record = { + bash: `_hats() { + local index=$((COMP_CWORD - 1)) + local cur=\${COMP_WORDS[COMP_CWORD]} + local candidates + candidates=$(hats __complete "$index" "\${COMP_WORDS[@]:1}" 2>/dev/null) + # shellcheck disable=SC2207 + COMPREPLY=($(compgen -W "$candidates" -- "$cur")) +} + +complete -F _hats hats +`, + zsh: `#compdef hats + +_hats() { + local index=$((CURRENT - 2)) + local -a candidates + local output=$(hats __complete "$index" "\${words[@]:1}" 2>/dev/null) + [[ -n $output ]] || return 0 + candidates=("\${(@f)output}") + compadd -- "\${candidates[@]}" +} + +compdef _hats hats +`, + fish: `function __hats_complete + set -l words (commandline -poc) + set -e words[1] + hats __complete (count $words) $words 2>/dev/null +end + +complete -c hats -f -a '(__hats_complete)' +`, +}; + +export const completionCommand = new Command("completion") + .description("output shell completion code") + .argument("", "bash, zsh, or fish") + .action((shell: string) => { + const script = scripts[shell]; + if (!script) throw new Error(`unsupported shell "${shell}" (expected bash, zsh, or fish)`); + process.stdout.write(script); + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index b2e17cd..0e6f88c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,6 +6,7 @@ import { rmCommand } from "./rm.js"; import { editCommand } from "./edit.js"; import { initCommand } from "./init.js"; import { setenvCommand } from "./setenv.js"; +import { completionCommand } from "./completion.js"; export const COMMANDS = [ runCommand, @@ -17,4 +18,5 @@ export const COMMANDS = [ initCommand, rmCommand, editCommand, + completionCommand, ]; diff --git a/src/core/builtins.ts b/src/core/builtins.ts index f25f92a..1c3d35a 100644 --- a/src/core/builtins.ts +++ b/src/core/builtins.ts @@ -13,6 +13,7 @@ export const BUILTIN_COMMANDS: Record = { init: {}, rm: { completesHat: true }, edit: {}, + completion: {}, }; export const BUILTIN_NAMES = Object.keys(BUILTIN_COMMANDS); diff --git a/test/integration.test.ts b/test/integration.test.ts index e1be1fa..b1abfc3 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -141,6 +141,38 @@ describe("integration: hat shorthand", () => { }); describe("integration: shell completion", () => { + test("the public command emits sourceable adapters for supported shells", async () => { + const registrations = { + bash: "complete -F _hats hats", + zsh: "compdef _hats hats", + fish: "complete -c hats", + }; + + for (const [shell, registration] of Object.entries(registrations)) { + const r = await runCli(["completion", shell], childEnv()); + assert.equal(r.code, 0, `${shell}: ${r.stderr}`); + assert.match(r.stdout, /hats __complete/, shell); + assert.match(r.stdout, new RegExp(registration), shell); + } + + const unsupported = await runCli(["completion", "powershell"], childEnv()); + assert.notEqual(unsupported.code, 0); + assert.match(unsupported.stderr, /unsupported shell/); + }); + + test("generating an adapter does not depend on the hats config", async () => { + const invalidHome = mkdtempSync(join(tmpdir(), "hats-completion-invalid-")); + try { + writeFileSync(join(invalidHome, "config.toml"), "not valid toml = ["); + const r = await runCli(["completion", "zsh"], childEnv({ HATS_HOME: invalidHome })); + + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /compdef _hats hats/); + } finally { + rmSync(invalidHome, { recursive: true, force: true }); + } + }); + test("top-level completion lists built-ins and configured hats", async () => { const r = await runCli(["__complete", "0"], childEnv()); From 66fe2e602fa40caa101819d63a6b122b3e43e9db Mon Sep 17 00:00:00 2001 From: Colafornia Date: Mon, 20 Jul 2026 13:32:11 +0800 Subject: [PATCH 16/16] chore: bump version to 0.2.0 --- .github/workflows/release.yml | 2 +- package-lock.json | 4 ++-- package.json | 4 ++-- test/integration.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 775f23b..b10699c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ name: release # regenerates a Homebrew tap formula (if HOMEBREW_TAP_TOKEN is set). # # Local smoke test before pushing a tag: -# bun build --compile --define 'process.env.HATS_VERSION="0.1.0"' src/index.ts --outfile hats +# bun build --compile --define 'process.env.HATS_VERSION="0.2.0"' src/index.ts --outfile hats # ./hats --version && ./hats ls && ./hats which rc on: diff --git a/package-lock.json b/package-lock.json index 2e2a93a..b46e736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hats", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hats", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", diff --git a/package.json b/package.json index 5a57f45..72af77d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hats", - "version": "0.1.0", + "version": "0.2.0", "description": "Per-terminal / per-process config isolator for Claude Code, Codex, and any CLI — switch hats without polluting your shell.", "type": "module", "bin": { @@ -34,4 +34,4 @@ "tsx": "^4.19.0", "typescript": "^5.6.0" } -} \ No newline at end of file +} diff --git a/test/integration.test.ts b/test/integration.test.ts index b1abfc3..f8946e3 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -74,7 +74,7 @@ describe("integration: hats exec through the real CLI", () => { test("-v prints the version", async () => { const r = await runCli(["-v"], childEnv()); assert.equal(r.code, 0, `stderr: ${r.stderr}`); - assert.equal(r.stdout.trim(), "0.1.0"); + assert.equal(r.stdout.trim(), "0.2.0"); }); test("exit code is passed through (E1)", async () => {