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.

- Install with Homebrew CI status 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. +## 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); + }); +});