diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 454834a..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:
@@ -79,6 +79,7 @@ jobs:
- name: Package tarball
run: |
(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
@@ -170,6 +171,7 @@ jobs:
def install
bin.install "hats"
+ generate_completions_from_executable(bin/"hats", "completion")
end
test do
@@ -189,4 +191,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/README.md b/README.md
index f847903..60e66f1 100644
--- a/README.md
+++ b/README.md
@@ -4,40 +4,70 @@
hats
-Run Claude Code, Codex, and Gemini profiles side by side.
+Run multiple AI CLI setups side by side — one hat per terminal, zero shell pollution.
-
-`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.
+## Install
```bash
-hats run work # one terminal
-hats run personal # another terminal
+curl -fsSL https://raw.githubusercontent.com/Colafornia/hats/main/install.sh | sh
```
-## Install
+No Node.js, Bun, `sudo`, or shell startup-file changes required.
+
+Prefer Homebrew?
```bash
brew install colafornia/tap/hats
```
-hats ships as a standalone binary. No Node.js or Bun required.
+## Quick start
+
+```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.
+
+Create a hat by naming it and the CLI it should launch:
-## Quick Start
+```bash
+hats add personal codex
+hats personal
+```
-Create your first hat with the interactive setup:
+To feed one env file to different CLIs, create both hats and point them at the same file
+with `hats edit`:
```bash
-hats add
+hats add write claude
+hats add review codex
+hats edit
```
-Then run the name you chose with `hats run `.
+```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
@@ -45,78 +75,119 @@ Most AI CLI switchers mutate global state: they export provider env vars, rewrit
tool config, or silently change what every terminal will use next. That breaks down when
you need more than one setup open at once.
-`hats` makes the switch explicit:
+`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.
-```bash
-hats run company-claude
-hats run personal-codex
-hats run local-claude
-```
+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.
-Each command creates one isolated child process. The parent shell keeps none of the
-profile's credentials, `ANTHROPIC_*`, `OPENAI_*`, or `CODEX_*` values.
+## Run multiple subscriptions side by side
-Use hats when you want to:
+Multiple subscriptions are optional. The simple `hats ` workflow above does not
+require isolated CLI homes.
-- 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 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.
-## Common Setups
+### Two Codex accounts
-### Codex official subscription
+```bash
+hats add codex-work codex --isolated
+hats add codex-personal codex --isolated
-Use your normal `~/.codex` login:
+hats codex-work login
+hats codex-personal login
-```bash
-hats add codex codex
-hats run codex
+hats codex-work
+# In another terminal:
+hats codex-personal
```
-### Multiple Codex accounts
-
-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.
-See [Advanced configuration](docs/advanced.md) for gateways, local models, shared env
-files, and hand-written config.
+### How isolation works
-## Commands
+`--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.
-### Start here
+## Commands
```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 --home
-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 add --isolated
+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
```
-`hats add` without arguments opens a short wizard: name, launch command, and whether
-this hat needs a separate login.
+## 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
@@ -125,7 +196,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
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..c34e21d
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,43 @@
+#!/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"
+echo "Tip: enable Tab completion: https://github.com/Colafornia/hats#shell-completion"
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/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..5569fbc
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,46 @@
+import { Command } from "commander";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+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();
+
+// 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)
+ .version(pkgVersion, "-v");
+
+for (const command of COMMANDS) program.addCommand(command);
+
+const argv = process.argv.slice();
+const first = argv[2];
+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) => {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`hats: ${msg}`);
+ process.exit(1);
+});
diff --git a/src/commands/add.ts b/src/commands/add.ts
index f916737..3aa1601 100644
--- a/src/commands/add.ts
+++ b/src/commands/add.ts
@@ -1,32 +1,44 @@
-import { Command } from "commander";
+import { Command, Option } 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 { addProfile, loadConfig, type Profile } from "../core/config.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 {
- if (!command.length) {
- p.log.error("launch command required. Usage: hats add [--home]");
+/** 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}`);
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);
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 };
}
const cfg = loadConfig();
if (cfg.profiles[name]) {
- p.log.error(`profile "${name}" already exists`);
+ p.log.error(`hat "${name}" already exists`);
process.exit(1);
}
- cfg.profiles[name] = profile;
- saveConfig(cfg);
- p.log.success(`created profile "${name}"${opts.home ? ` · config: ${profile.env && Object.values(profile.env)[0]}` : ""}`);
+ addProfile(profile);
+ 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". */
@@ -40,7 +52,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");
@@ -53,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");
@@ -71,9 +83,8 @@ 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);
- p.log.success(`created profile "${profile.name}"`);
+ addProfile(profile);
+ 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;
@@ -81,12 +92,13 @@ 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...] [--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);
- });
\ No newline at end of file
+ });
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/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..d90df84 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 add [--isolated]")} 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/index.ts b/src/commands/index.ts
new file mode 100644
index 0000000..0e6f88c
--- /dev/null
+++ b/src/commands/index.ts
@@ -0,0 +1,22 @@
+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";
+import { completionCommand } from "./completion.js";
+
+export const COMMANDS = [
+ runCommand,
+ execCommand,
+ whichCommand,
+ lsCommand,
+ addCommand,
+ setenvCommand,
+ initCommand,
+ rmCommand,
+ editCommand,
+ completionCommand,
+];
diff --git a/src/commands/init.ts b/src/commands/init.ts
index 5706f9e..1cb7238 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
@@ -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]
@@ -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 d501482..d67f372 100644
--- a/src/commands/rm.ts
+++ b/src/commands/rm.ts
@@ -1,25 +1,23 @@
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)")
- .argument("", "profile name")
- .action((name: string) => {
+ .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 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 hat "${name}"? (referenced files are kept)`, initialValue: false });
+ if (!isRemovalConfirmed(confirmed)) return p.cancel("cancelled");
+ removeProfile(name);
+ p.log.success(`deleted hat "${name}"`);
+ });
diff --git a/src/commands/run.ts b/src/commands/run.ts
index 766b8c0..086d938 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,30 +35,30 @@ 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[]) => {
- const cfg = loadConfig();
+ const cfg = loadConfig(name);
const profile = getProfile(cfg, name);
const code = await launch(profile, args);
process.exit(code);
});
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[]) => {
- const cfg = loadConfig();
+ const cfg = loadConfig(name);
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 2f2ac9b..64eecd9 100644
--- a/src/commands/setenv.ts
+++ b/src/commands/setenv.ts
@@ -1,24 +1,26 @@
-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";
-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";
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("--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("--launch ", "also set the hat's launch command")
+ .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");
} 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 {
@@ -34,12 +36,13 @@ 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;
}
const cfg = loadConfig();
+ const existed = Boolean(cfg.profiles[name]);
let profile: Profile = cfg.profiles[name];
if (!profile) {
profile = { name };
@@ -52,12 +55,13 @@ 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}`);
}
- 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/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/builtins.ts b/src/core/builtins.ts
new file mode 100644
index 0000000..1c3d35a
--- /dev/null
+++ b/src/core/builtins.ts
@@ -0,0 +1,21 @@
+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: {},
+ completion: {},
+};
+
+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/config.ts b/src/core/config.ts
index 23151f4..24cc056 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -1,7 +1,16 @@
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";
export interface Profile {
name: string;
@@ -16,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");
}
@@ -28,13 +39,18 @@ 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;
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 ((warnUnknown === true || warnUnknown === name) && !["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 +66,97 @@ export function saveConfig(cfg: HatsConfig): void {
profiles: {} as Record,
};
for (const [name, p] of Object.entries(cfg.profiles)) {
+ warnPlaintextSecrets(p);
const { name: _omit, ...rest } = p;
void _omit;
(out.profiles as Record)[name] = rest;
}
- writeFileSync(configPath(), stringify(out));
-}
\ No newline at end of file
+ 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/src/core/env.ts b/src/core/env.ts
index 15473ae..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
@@ -20,18 +21,17 @@ export const STRIP_PREFIXES = [
"CODEX_",
"OPENAI_",
"GEMINI_",
- "GOOGLE_",
];
-
function shouldStrip(name: string, prefixes: string[]): boolean {
- return 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. */
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 +62,44 @@ 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
+ 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 1a0a0da..44d0bab 100644
--- a/src/core/profile.ts
+++ b/src/core/profile.ts
@@ -1,15 +1,15 @@
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 {}
-/** 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",
-};
+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`;
+}
/** First whitespace-delimited token of a launch string (no shell parsing). */
export function launchFirstToken(launch: string | undefined): string | undefined {
@@ -34,27 +34,35 @@ 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.
+ * 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);
- 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.credentialStorage === CredentialStorage.FixedKeychain) {
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 in a fixed keychain entry; isolate manually with " +
+ 'env = { GEMINI_CLI_HOME = "...", GEMINI_FORCE_FILE_STORAGE = "true" }',
);
}
- return { varName, path: tildeify(join(hatsHome(), "homes", name)) };
+ 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",
+ );
+ }
+ return { varName: tool.homeVar as string, path: tildeify(join(hatsHome(), "homes", name)) };
}
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;
}
@@ -71,4 +79,4 @@ export function validateProfile(p: Profile): string[] {
}
}
return errs;
-}
\ 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/src/core/tools.ts b/src/core/tools.ts
new file mode 100644
index 0000000..8aabc92
--- /dev/null
+++ b/src/core/tools.ts
@@ -0,0 +1,37 @@
+export enum CredentialStorage {
+ ConfigHome = "config-home",
+ DirectoryKeychain = "directory-keychain",
+ FixedKeychain = "fixed-keychain",
+ ExternalData = "external-data",
+}
+
+export interface ToolEntry {
+ credentialStorage: CredentialStorage;
+ homeVar?: string;
+ credEnv?: string[];
+}
+
+export const TOOLS: Record = {
+ codex: {
+ credentialStorage: CredentialStorage.ConfigHome,
+ homeVar: "CODEX_HOME",
+ credEnv: ["OPENAI_API_KEY"],
+ },
+ claude: {
+ credentialStorage: CredentialStorage.DirectoryKeychain,
+ homeVar: "CLAUDE_CONFIG_DIR",
+ credEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
+ },
+ gemini: {
+ credentialStorage: CredentialStorage.FixedKeychain,
+ homeVar: "GEMINI_CLI_HOME",
+ credEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
+ },
+ opencode: { credentialStorage: CredentialStorage.ExternalData },
+};
+
+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/src/index.ts b/src/index.ts
index 0262bb1..80954ec 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,57 +1,24 @@
#!/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";
+import { BUILTIN_COMMANDS, BUILTIN_NAMES, COMPLETE_COMMAND } from "./core/builtins.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 {
+if (process.argv[2] === COMPLETE_COMMAND) {
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);
+ const command = BUILTIN_COMMANDS[process.argv[4]];
+ let candidates =
+ position === 0
+ ? [...BUILTIN_NAMES, ...profiles]
+ : position === 1 && command?.completesHat
+ ? profiles
+ : [];
+ 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));
}
+ 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)
- .action(() => {
- // bare `hats`: non-interactive summary / first-run hint (no picker).
- friendlyHint();
- });
-
-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);
-program.parseAsync(process.argv).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
+await import("./cli.js");
diff --git a/test/add.test.ts b/test/add.test.ts
index c4e9199..028d5d0 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";
@@ -72,16 +72,28 @@ 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 ` 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 () => {
@@ -91,6 +103,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 +120,13 @@ 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
+
+ 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/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(),
+ );
+});
diff --git a/test/config.test.ts b/test/config.test.ts
index 1829a2d..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 } from "node:fs";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@@ -56,6 +56,38 @@ 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);
+ });
+
+ 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", () => {
@@ -74,4 +106,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/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 });
+ }
+});
diff --git a/test/env.test.ts b/test/env.test.ts
index 0a3294d..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",
@@ -86,6 +92,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 +138,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");
+ });
+ });
+});
diff --git a/test/integration.test.ts b/test/integration.test.ts
index f9459e3..f8946e3 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.2.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}`);
@@ -90,4 +96,126 @@ 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("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);
+ 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/);
+ });
+});
+
+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());
+
+ 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 3485be5..f20a563 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, validateProfileName } from "../src/core/profile.js";
+import { CredentialStorage, 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,20 @@ 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",
- });
+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(
+ Object.fromEntries(Object.entries(TOOLS).map(([name, tool]) => [name, tool.credentialStorage])),
+ {
+ codex: CredentialStorage.ConfigHome,
+ claude: CredentialStorage.DirectoryKeychain,
+ gemini: CredentialStorage.FixedKeychain,
+ opencode: CredentialStorage.ExternalData,
+ },
+ );
});
-});
\ No newline at end of file
+});
diff --git a/test/rm.test.ts b/test/rm.test.ts
new file mode 100644
index 0000000..8a11c94
--- /dev/null
+++ b/test/rm.test.ts
@@ -0,0 +1,11 @@
+import { describe, test } from "node:test";
+import assert from "node:assert/strict";
+import { isRemovalConfirmed } from "../src/commands/rm.js";
+
+describe("rm", () => {
+ 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);
+ });
+});