diff --git a/CHANGELOG.md b/CHANGELOG.md index a66115d..72d35be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] — 2026-06-14 + +The vertical-orchestration step: clikae grows the muscle for directing a fleet of +AI CLIs that each burn their own subscription — and a sibling Claude Code skill +(`conductor`) stands on it. All additive; the existing command surface is unchanged. + +### Added + +- **`clikae conduct` (BETA)** — fan ONE prompt across N accounts **in parallel**, + each running headless **read-only** on its own tank (its own quota), then collect + every leg's full output and print a captured/dry table. clikae does **not** judge — + it hands you N result files and an honest table; you (or a session model acting as + conductor) pick the winner. Read-only by design so legs can't clobber a shared + tree. New optional adapter hook `adapter_audit_flags` (claude, codex). +- **`clikae git-id --name … --email …`** (issue #22) — give a tank + an optional git commit identity. When set, `clikae env` also exports + `GIT_AUTHOR_*` / `GIT_COMMITTER_*`, so commits made in that shell are stamped with + the identity you intended — not the engine's account email (the HANDOFF §13 + mis-attribution incident). git env vars beat `git config`; honest limits (loses to + an explicit `git -c user.email=…`, per-shell only, future commits only) are + documented in the command's help and `docs/grammar.md`. +- **`clikae burn --prompt-file ` / `--prompt ` / `--add-dir `** (issue + #24) — the easy way to burn a write-task: clikae fills each engine's headless-write + flags from a new optional adapter hook `adapter_burn_flags` (claude, codex), so you + no longer hand-assemble `-p … --dangerously-skip-permissions` / `exec -s + workspace-write …`. A cross-engine `--to` reroute now regenerates the flags for the + new engine (previously it shipped the wrong engine's flags). The explicit `-- ` + form keeps working unchanged. + +### Fixed + +- The new `adapter_burn_flags` / `adapter_audit_flags` recipes are **NUL-separated**, + not newline-separated, so a multi-line prompt survives as a single argv item (a + newline framing shattered a prompt that itself contained newlines). +- `clikae conduct` classifies a leg by its captured output, not the result file's + size, so an empty (failed) leg is reported as a real failure rather than a false + "captured" (an empty `printf` still writes a trailing newline). + ## [0.5.14] — 2026-06-07 ### Changed diff --git a/README.md b/README.md index dd313c4..40cf521 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # clikae -> **Your starting point for working with AI coding CLIs.** Type `clikae` and land on everything you were just doing — your recent sessions across every account and every engine (Claude Code, Codex, Antigravity), each with a one-line recap of where you left off. Pick one and pick up where you were. +> **Your starting point for working with AI coding CLIs — and the cost-aware control plane for a fleet of them.** Type `clikae` and land on everything you were just doing — your recent sessions across every account and every engine (Claude Code, Codex, Antigravity), each with a one-line recap of where you left off. Pick one and pick up where you were. When a tank runs dry mid-task, carry the same session onward — or fan a headless job out across accounts and engines, each burning its own subscription, none eating your main budget. > > *"Kirikae" (切り替え, ki-ri-ka-e) is Japanese for "switching".* [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Status](https://img.shields.io/badge/status-v0.5.14-blue.svg)](CHANGELOG.md) +[![Status](https://img.shields.io/badge/status-v0.6.0-blue.svg)](CHANGELOG.md) 🌐 [日本語](https://cver.net/ja-jp/oss/clikae) · [한국어](https://cver.net/ko-kr/oss/clikae) · [繁體中文](https://cver.net/zh-tw/oss/clikae) @@ -35,6 +35,40 @@ It: It works for any CLI that selects its config via an environment variable (or a flag), ships with built-in adapters for **Claude Code, OpenAI Codex, GitHub CLI, gcloud, Docker, Helm, kubectl, AWS, Azure CLI, npm, Terraform, Pulumi, and Vercel** (plus real **per-account multi-tank for Antigravity / agy** — each tank carries its own Google login via the macOS Keychain), and adding a new one is ~10 lines of bash. No daemons, no global state, and exactly one opt-out network call (a throttled update check — `CLIKAE_NO_UPDATE_CHECK=1` silences it) — every line is auditable. +## Command your fleet — cost-aware, across vendors + +Once you have more than one account on more than one engine, the home board +stops being a switcher and starts being a **control plane**: one person directing +a fleet of AI coding CLIs, each burning its **own** subscription quota, none of +them quietly eating the budget you're using for your main session. clikae knows +where each engine keeps its config and transcripts, how each one signals a usage +limit, and which tank still has fuel — so the arbitrage is two commands, not a +bag of `--resume` flags and environment-variable juggling: + +- **Hit a wall, keep going — `clikae to`.** When the tank you're on runs dry, + carry the *same live conversation* onto another tank's quota (a real + `--resume`), or hand it across vendors as a written brief — Claude → Codex → + Antigravity. The cross-vendor brief is summarized **on-device** by a local + model when you have one (`apfel`, `ollama`, or `llm`), so the session never + leaves your machine, costs nothing, and works offline. + +- **Fan the grunt work out — `clikae burn`.** Run a long, headless task on a + tank and verify it by the **artifact it produces** — never the exit code + (`codex exec` exits `0` even when it hit its limit). When that tank runs dry, + clikae **re-fires the same task on your next reserve tank automatically**, + skipping any account that shares a dried login and never reaching for the tank + your interactive session is live on. The expensive supervisor stays asleep; + the cheap workers burn whichever account still has gas. + +- **Spot a wall before it's a wall — `clikae watch`.** Tail a running engine, + notice when it's about to go dry, and offer (or auto-carry) the session onward + to the next tank in your burn order. + +Both `to` and `burn` follow one rule — *aggregate, never mutate the source.* A +session or a memory slice is carried as a **copy**; the tank you came from is +left exactly as it was. No proxy, no daemon, no traffic interception — clikae +reshapes *where your state lives*, it never sits in the middle of your requests. + ## Install **Homebrew** (macOS / Linux): diff --git a/bin/clikae b/bin/clikae index b5eafe9..8c4c80a 100755 --- a/bin/clikae +++ b/bin/clikae @@ -5,7 +5,7 @@ set -eo pipefail -CLIKAE_VERSION="0.5.14" +CLIKAE_VERSION="0.6.0" # Resolve install root (handles symlink chains, e.g., via Homebrew) __resolve_self() { @@ -75,7 +75,7 @@ raw="${1:-home}" __clikae_is_reserved() { case "$1" in - home|dashboard|doctor|demo|init|app|tanks|list|ls|status|remove|rm|run|burn|to|continue|relay|handoff|watch|auto|antigravity|agy|rename|alias|migrate|env|info|adapters|lang|help|--help|-h|version|--version|-v) return 0 ;; + home|dashboard|doctor|demo|init|app|tanks|list|ls|status|remove|rm|run|burn|conduct|to|continue|relay|handoff|watch|auto|antigravity|agy|rename|alias|migrate|env|git-id|info|adapters|lang|help|--help|-h|version|--version|-v) return 0 ;; *) return 1 ;; esac } @@ -88,6 +88,7 @@ if __clikae_is_reserved "$raw"; then rm) cmd="remove" ;; dashboard) cmd="home" ;; agy) cmd="antigravity" ;; + git-id) cmd="git_id" ;; # hyphen → underscore (file git_id.sh, fn cmd_git_id) --help|-h) cmd="help" ;; --version|-v) cmd="version" ;; esac diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md new file mode 100644 index 0000000..6898b88 --- /dev/null +++ b/docs/DEVLOG.md @@ -0,0 +1,238 @@ +# Devlog + +A narrative history of clikae, from the first commit to the deliberate park. +For the precise, per-release record see [CHANGELOG.md](../CHANGELOG.md) — this is +the story around it: the itch, the wrong turns, and the lessons that made each +version what it is. Dates are the real tag dates (JST); claims map to the +changelog. Nothing here is roadmap or aspiration — only what actually shipped. + +## The itch + +Two Claude subscriptions, because one Max plan kept running dry mid-task. A Codex +login. Antigravity. All in different terminals, on different projects, half of +them unfinished. The recurring small pain wasn't dramatic — it was the daily +*"which account was that in, and what was I even doing?"*, followed by a `/clear` +and re-explaining the whole project to a fresh session on a fresh quota. + +clikae started as the dumbest possible fix for that: give each account its own +config directory, write a shell alias, done. The interesting part — carrying a +*live* session onto another account so the conversation survives the quota wall — +came later, and turned out to be the actual point. + +## The timeline + +### v0.1.0 — 2026-05-28 · the scaffold + +The first commit (untagged; the public tags begin at v0.2.0). A pure-bash CLI +dispatcher with a small modular `lib/`, and exactly the verbs you'd expect: +`init` a profile, write an `alias`, generate a double-click `.app` launcher, +`run` a CLI under a profile, plus `list` / `remove`. One built-in adapter — +Claude Code, via `CLAUDE_CONFIG_DIR` — and a template for adding more. MIT, and +the pitch was already "every line is auditable." + +### v0.2.0 — 2026-05-28 · six more adapters, and `migrate` + +Same evening, six more built-in adapters (gh, gcloud, docker, helm, kubectl, +aws), and `clikae migrate` to *adopt* the hand-rolled `~/.claude-acct-{a,b}` +setup people already had instead of asking them to start over. Also a bats suite +and the first real bug caught by it: `clikae app` had been generating an +uncompilable AppleScript the whole of v0.1, because BSD `sed` strips backslashes +from the replacement string. A path that's never exercised is a path that's +quietly broken. + +### v0.3.0 — 2026-05-29 · Homebrew, and the Keychain footgun + +`brew install CVERInc/clikae/clikae` started working. And the first piece of +hard-won macOS knowledge got encoded: Claude Code keys its OAuth token in the +login Keychain by the *config-dir path*, so moving a profile forces a re-login — +unless `migrate --keep-login` carries the token across. The token never leaves +the Keychain; clikae just teaches it the new address. + +### v0.4.0 — 2026-05-30 · breadth (az, npm, terraform, pulumi) and Windows + +Four more adapters (eleven total) and a native PowerShell port for people who +don't have bash. A `migrate` in-use guard, too — refusing to pull a config +directory out from under a live session. (The Windows port later got demoted; see +v0.5.0. It was a good-faith experiment that the maintained grammar outgrew.) + +### v0.5.0 — 2026-06-01 · clikae becomes a verb + +The pivot. The name is 切り替え — *switching* — so the headline action stopped +carrying a verb of its own: `clikae ` switches and runs. One +`clikae to ` carries your current session onward — same engine resumes, +a different engine gets a written brief. The vocabulary settled on **engine** and +**tank** and **fuel/dry**. This release also brought `status`, `relay`, `handoff` +(a portable, vendor-neutral session brief for when the next tank starts blind), +`watch` (ambient "notice a dry tank, switch"), `--ephemeral` throwaway memory, +`--json` for the planned GUI, and a first interactive home board. + +It also brought the honesty that became a house rule. `watch`'s limit-detection +shipped with a frank caveat *in the code and the `--help`*: an interactive CLI +hitting its limit fires no hook and returns no exit code, so the only signal is +what the limit writes into the transcript — and the exact marker was *not yet +confirmed against a real limit event*. Better to ship the caveat than to ship a +promise the platform can't keep. + +Windows support was reframed here as community/unsupported — it's bash, and +that's the pitch. + +### v0.5.1 — 2026-06-01 · a logo that reflows + +A responsive welcome screen: on a wide terminal the copy sits beside the logo, on +a narrow one it stacks, measured with `stty size` (because `tput cols` lies +inside a command substitution). Small, but it's the first thing a newcomer sees. + +### v0.5.2 — 2026-06-02 · the board leads with "continue" + +The release that quietly changed what clikae *is* — from an account switcher into +a continuity dashboard. The board now opens with your most recent sessions across +all tanks, each titled by Claude's own AI-generated name, each with a one-line +**recap** of where you left off — read for free from Claude's own session +summary. And when you carry a session across engines, the brief is written by a +**local** model already on your machine (apfel, ollama, llm) if one's there: +private, free, offline. Your session content — which may include source or +secrets — never leaves the machine to make the handoff. + +### v0.5.3 — 2026-06-02 · one burn order, and i18n + +Your tanks became a single flat **burn order**, and the board *is* that order — +rearrange in place with `[` / `]`, and `clikae ` switches by name alone. +The separate, undiscoverable "fuel pool" concept was deleted outright (you +couldn't set it from the board, and it duplicated the tanks clikae already knew). +A supervised auto-carry landed as BETA — start claude *through* clikae and it +carries you onward when you hit the wall, in the same terminal. No daemon: it only +acts on a session you launched through it. Deliberate. And interface localisation +arrived — en-US / ja-JP / zh-TW — with the katakana wordmark キリカエ kept only in +Japanese. + +### v0.5.4 — 2026-06-03 · the dot becomes a fuel gauge + +The board's status dot used to mean "you are here" — except it meant a global +symlink for agy and a per-shell env var for claude, which is exactly the +switcher-thinking clikae had stopped being. So it became a fuel gauge, one axis, +like a traffic light: red dry, green ready, ○ no reading (honestly blank for +engines clikae can't read off disk — never a guessed green). "Which tank am I on" +moved to where it belongs: the cursor. The yellow weekly-usage caution dot +shipped here too, as BETA — it captures Claude's "used N% of your weekly limit" +notice *verbatim* (disk has no weekly denominator to compute one), and until that +notice is observed reaching a stream clikae can tail, yellow simply never lights. + +### v0.5.5 — 2026-06-04 · agy goes real multi-account, and `burn` arrives + +Antigravity keeps its Google login in one machine-wide Keychain item, so swapping +the `~/.gemini` symlink alone left every agy tank riding the *same* account. +`clikae agy ` now carries the login *with* the tank, Keychain to Keychain, +the token never touching disk. And `clikae burn` — the headless guarded task +runner — landed: it verifies a task by the **artifact** it must produce, never the +exit code, because `codex exec` cheerfully exits 0 even when it hit its limit and +wrote nothing. (Trusting an exit code here would be the bug; the artifact is the +truth.) + +### v0.5.6 — 2026-06-04 · a one-line fix for a one-line regression + +v0.5.5's new cross-shell process guard leaked `ps`'s exit code, so under +`set -eo pipefail` a `ps` that couldn't run — on a locked-down host or a sandbox — +took the whole command down instead of degrading to "couldn't scan, proceed." The +scan is meant to be best-effort. Now it actually is. With a regression test, so a +deliberately failing `ps` must never again abort a rename. + +### v0.5.7 — 2026-06-04 · the board is fuel tanks only + +Tool-CLI tanks (gh, npm, aws) aren't AI sessions — "launching" one only printed a +usage screen — so they moved off the board into the full `clikae tanks` +inventory. `app --board` shipped: one double-click button for the whole on-ramp. +And a small Ghostty saga got solved: Ghostty pops an "Allow Ghostty to execute…?" +dialog for an injected `-e` command, so a launcher looked like an empty shell +until you clicked Allow. Passing the command through a trusted `--config-file` +(and re-signing the bundle so Apple Silicon doesn't block it) makes the window +just open. + +### v0.5.8 — 2026-06-04 · carry onward from a dry tank + +Pressing Enter on a dry tank's board row used to dead-end on "resume" or "open +fresh" — both of which only put you back on the exhausted quota, which is the one +thing you didn't want. Now a dry row leads with *carry this session onto the next +fuelled tank*. The next-tank selector became a ring: it circles the whole burn +order (a tank *above* you is still a reserve), prefers a fuelled same-engine tank, +and skips any tank whose account is already dry — because a usage limit hits the +whole account, so hopping to a sibling on the same login would just land on the +same empty quota. + +### v0.5.9 — 2026-06-05 · a quiet update notice, and honest snapshot times + +A codex-style "✨ Update available" notice on the board: update now / skip / skip +until next version — throttled to once a day, cached, offline-safe, fully +opt-out (`CLIKAE_NO_UPDATE_CHECK=1`), and shown only when it can name the right +command for your install rather than guessing one. (This is also the one network +call clikae makes; the README later owned up to that.) Plus the ability to carry a +session onward even when the tank *isn't* dry, and a "· seen HH:MM" tag on +snapshot reset times — because codex reports its limit in UTC for a different +window than its own TUI shows, and clikae would rather frame a number as a +snapshot than pretend it's a live countdown. + +### v0.5.10 — 2026-06-05 · the *real* burn fix + +The footgun with a name (燒爆 — "burning yourself up"): `clikae burn claude ` +could reroute its dry-fallthrough onto the very tank an *interactive* session was +live on, silently spending that conversation's quota. A 2026-06-05 dogfood had +declared this fixed — after testing **codex only**. It wasn't; the claude path was +never covered and was confirmed still live. Lesson, now written down: don't clear +a multi-engine bug by testing one engine. `burn` now skips a tank an interactive +session holds, and `--allow-active` overrides if you really mean it. + +### v0.5.11 — 2026-06-05 · the "is this actually a bug?" audit + +A pass that compared what clikae *claimed* against what it *did*, with help from a +few parallel agents. The headline find: `clikae watch` — a headline feature — +shipped broken, calling a helper that was never defined, so it could crash before +tailing anything. No test had covered that line. Fixed, and covered. Out of the +audit came `docs/EXPECTATIONS.md`, an "is this a bug?" guide to the deliberate- +but-surprising behaviours (the fuel dot isn't "you are here", codex resets in UTC, +agy switches globally, limits are account-level, …), and a sweep of doc +corrections — including that the board's language key is `l`, not `h`, which every +doc had managed to get wrong in unison. + +### v0.5.12 — 2026-06-05 · state schema versioning + +Groundwork, invisible in normal use: everything under `$CLIKAE_HOME/` now carries +a `version` marker, so a future change to an on-disk format is safe — clikae reads +it on startup and migrates forward, and *warns* rather than downgrading if a newer +clikae wrote your state. Deliberately minimal: one version file, one migration +runner, no framework. The stamp is written only when state is created, so read +commands stay strictly read-only. This was the last item on the world-class-gaps +punch-list. + +### v0.5.13 — 2026-06-07 · `burn` hardened, agy docs made honest + +Two real dogfood runs surfaced a correctness landmine: a *stale* artifact left +over from a previous run could make a failed task look like it succeeded. Success +is now judged by the artifact appearing *or its timestamp changing* (via the +existing GNU-stat-first mtime helper — a self-authored BSD-first version would +have returned garbage on Linux; review caught it). `--fresh` deletes the artifact +before running; `--timeout` gained a `perl` alarm fallback for stock macOS, which +ships neither `timeout` nor `gtimeout`; and a one-line summary closes each run. +The agy docs got more precise too: its *state* follows `$HOME`, but its *login* is +one global Keychain entry — which is the real reason switching is global. + +### v0.5.14 — 2026-06-07 · the park + +A doc- and comment-only release, cut so the published tarball exactly matches +`main`. It drops a phantom `$CLIKAE_HOME/adapters` TODO that was never implemented +(no-phantom-features, applied to a comment), and marks the world-class-gaps +handoff historical now that its punch-list is cleared. Nothing behaves +differently — this release exists to leave the repo tidy. + +## Where it parks + +clikae is, deliberately, done for now. The strategy is honest about what it is: +a portfolio piece, an on-ramp, and a tip jar — not a revenue product. A pure-bash +CLI on Homebrew has roughly zero convenience moat to charge for, and the niche +turned out to be crowded by mid-2026 (Quotio, Relay, caam, and a graveyard of +auth-switchers). So clikae stops at "complete for this stage" rather than being +pushed uphill as a business. Its real, narrower edge stays sharp: no proxy, no +daemon, no telemetry — every line auditable — plus the one thing none of the +competitors do, carrying an *expensive orchestrator* onto cheap context. + +The bones are good and the punch-list is empty. If a future itch is sharp enough +to earn a v0.6, it'll ship then. Until it earns it, clikae waits — fuelled, ready, +parked. diff --git a/docs/grammar.md b/docs/grammar.md index 27b0b6a..2020429 100644 --- a/docs/grammar.md +++ b/docs/grammar.md @@ -120,12 +120,14 @@ fuel words forced on them. | `clikae init ` | Create a tank. (`--alias` also writes a shell alias.) | | `clikae remove ` | Remove a tank (dir, alias, .app). | | `clikae rename ` | Rename a tank (dir, alias, login carried over). | +| `clikae git-id [--name N --email E \| --unset]` | Give a tank an optional **git commit identity**. When set, `clikae env` also exports `GIT_AUTHOR_*`/`GIT_COMMITTER_*` so commits in that shell are stamped with the identity you meant — not the engine's account email (issue #22 / HANDOFF §13). A plain metadata verb (create/inspect tank state), no fuel metaphor. Honest limit: env vars beat `git config` but not an explicit `git -c user.email=…`; future commits only. | | `clikae tanks` (alias: `clikae list` / `ls`) | List every tank, with the logged-in account. `tanks` is canonical — a **noun query**, like the existing `adapters` command, not a coined verb. `list`/`ls` stay for convention and for the GUI's `list --json`. | | `clikae status [cli]` | Which tank each CLI is on **in this shell**. | | `clikae to [target]` | Carry your session onward; bare = the next tank in your burn order (your tanks are the reserve). | | `clikae auto [ask\|safe\|full]` | (BETA, claude) how much the supervised launch carries on its own on a dry tank. | | `clikae watch [tank]` | Notice a dry tank; offer/auto carry onward to the next tank in the burn order. | -| `clikae burn --artifact -- ` | Run a **headless** task on a tank, verify it by the artifact it must produce (never the exit code — `codex exec` exits 0 even when limited), and re-fire the same task on the next reserve tank if this one runs dry. The headless sibling of the switch: batch/parallelism stays the orchestrator's job; `burn` is the single-task unit it fans out and re-fires. `--to` forces an explicit next hop. | +| `clikae burn --artifact (--prompt-file \| -- )` | Run a **headless** task on a tank, verify it by the artifact it must produce (never the exit code — `codex exec` exits 0 even when limited), and re-fire the same task on the next reserve tank if this one runs dry. Give the task the **easy way** (`--prompt-file`/`--prompt` + `--add-dir`: clikae fills each engine's headless-write flags from its adapter, so a cross-engine reroute stays sound) or the **power-user way** (raw `-- `). The headless sibling of the switch: batch/parallelism stays the orchestrator's job; `burn` is the single-task unit it fans out and re-fires. `--to` forces an explicit next hop. | +| `clikae conduct --leg /… (--prompt-file \| --prompt )` | **(BETA)** The vertical-orchestration primitive: fan ONE prompt across N accounts **in parallel**, each running headless **read-only** on its own tank (its own quota), then collect every leg's full output and print a captured/dry table. clikae does **not** judge — it hands you N result files and an honest table; you (or the session model acting as conductor) pick the winner. Brain/muscle split: clikae is the muscle (accounts, dry-detection, parallel routing), the conductor is the brain. Read-only by design so legs can't clobber a shared tree; write/impl tournaments stay an orchestrator's job. | | `clikae migrate [cli]` | Adopt a hand-rolled config-dir + alias setup. | | `clikae app` / `clikae alias` | Generate a macOS launcher / write a shell alias. | | `clikae lang [en-US\|ja-JP\|zh-TW]` | Show or set the interface language (dashboard + prompts); the board's `l` key opens a language picker. | @@ -138,10 +140,11 @@ fuel words forced on them. `bin/clikae` resolves the first argument in this order: 1. **Reserved command?** (`init`, `remove`, `list`, `tanks`, `status`, `to`, - `watch`, `auto`, `burn`, `rename`, `migrate`, `env`, `app`, `alias`, `lang`, - `run`, `continue`, `relay`, `handoff`, `doctor`, `info`, `adapters`, `demo`, - `home`, `help`, `version`, plus the `agy`/`dashboard`/`ls`/`rm` aliases and - `-h/--help/-v/--version`) → run that command. + `watch`, `auto`, `burn`, `conduct`, `rename`, `git-id`, `migrate`, `env`, `app`, `alias`, + `lang`, `run`, `continue`, `relay`, `handoff`, `doctor`, `info`, `adapters`, + `demo`, `home`, `help`, `version`, plus the `agy`/`dashboard`/`ls`/`rm` aliases + and `-h/--help/-v/--version`) → run that command. (`git-id` routes to the + `git_id.sh` command — the only verb whose file/function uses `_` for the `-`.) 2. **Else, a known CLI?** (an adapter in `lib/adapters/` or a target in `lib/targets/`, e.g. `claude`, `codex`, `agy`) → the **bare switch** of §3.1. 3. **Else** → unknown; show an error + `help`. diff --git a/lib/adapters/_template.sh b/lib/adapters/_template.sh index dede004..91fc461 100644 --- a/lib/adapters/_template.sh +++ b/lib/adapters/_template.sh @@ -48,3 +48,28 @@ adapter_run() { local profile_dir="$1"; shift EXAMPLE_CONFIG_DIR="$profile_dir" exec example "$@" } + +# Optional: how to run this CLI HEADLESS-with-write, so `clikae burn` can take a +# high-level --prompt-file / --prompt + --add-dir instead of making the caller +# hand-assemble each engine's flag dialect. Print the engine argv (AFTER the +# binary), ONE item per NUL (\0); the prompt is passed as data and never re-quoted +# by burn. NUL-separation (not newline) is REQUIRED — it's what lets a multi-line +# prompt survive as one argv item. Define this only for engines burn can drive +# (claude, codex); plain config-switcher adapters (gh, aws…) leave it out, and +# burn then errors clearly, pointing the user at the explicit `-- ` form. +# +# adapter_burn_flags() { +# local prompt="$1"; shift +# printf -- '-p\0%s\0' "$prompt" # the prompt, headless +# local d; for d in "$@"; do printf -- '--add-dir\0%s\0' "$d"; done # writable dirs +# } + +# Optional: the READ-ONLY sibling of adapter_burn_flags, for `clikae conduct`'s +# parallel fan-out (best-of-N audits/analyses). Same NUL-per-item contract, but +# the recipe must NOT grant write access (so N legs can't clobber each other). +# +# adapter_audit_flags() { +# local prompt="$1"; shift +# printf -- '-p\0%s\0' "$prompt" # headless, no write permission +# local d; for d in "$@"; do printf -- '--add-dir\0%s\0' "$d"; done # read roots +# } diff --git a/lib/adapters/claude.sh b/lib/adapters/claude.sh index 0af5f61..bd9207b 100644 --- a/lib/adapters/claude.sh +++ b/lib/adapters/claude.sh @@ -29,6 +29,29 @@ adapter_run() { CLAUDE_CONFIG_DIR="$profile_dir" exec claude "$@" } +# Optional hook: how to run claude HEADLESS-with-write for `clikae burn`'s +# convenience form (--prompt-file / --prompt). Prints the engine argv (after the +# binary), one item per NUL, that runs non-interactively with write +# permission in each . NUL-separation (not newline) is what lets a +# multi-line prompt survive as ONE argv item — a newline framing would shatter a +# prompt that itself contains newlines (independent-audit catch, 2026-06-13). This +# is the principled home for the headless flags a caller would otherwise +# hand-assemble (the 2026-06-06 tugtile burn-writeup friction #1). +adapter_burn_flags() { + local prompt="$1"; shift + printf -- '-p\0%s\0--dangerously-skip-permissions\0' "$prompt" + local d; for d in "$@"; do printf -- '--add-dir\0%s\0' "$d"; done +} + +# Optional hook: how to run claude HEADLESS READ-ONLY for `clikae conduct`'s +# fan-out (no --dangerously-skip-permissions → reads/reasons, edits blocked). The +# prompt is data; are extra read roots (claude reads $PWD by default). +adapter_audit_flags() { + local prompt="$1"; shift + printf -- '-p\0%s\0' "$prompt" + local d; for d in "$@"; do printf -- '--add-dir\0%s\0' "$d"; done +} + # Optional hook: start a fresh session under this profile, seeded with an initial # prompt. Used by `clikae handoff --to` to hand a brief to the next tank. Claude # Code takes an initial prompt as a positional argument. diff --git a/lib/adapters/codex.sh b/lib/adapters/codex.sh index 2f3ac66..a35ceb7 100644 --- a/lib/adapters/codex.sh +++ b/lib/adapters/codex.sh @@ -31,6 +31,31 @@ adapter_run() { CODEX_HOME="$profile_dir" exec codex "$@" } +# Optional hook: how to run codex HEADLESS-with-write for `clikae burn`'s +# convenience form (--prompt-file / --prompt). Codex's headless verb is `exec`, +# its working dir is `-C `, and `-s workspace-write` makes that dir writable. +# Codex takes a SINGLE working dir, so the FIRST --add-dir becomes -C (the rest +# are ignored — codex's writable root is the cwd under workspace-write). The +# prompt is the trailing positional. Items are NUL-separated so a multi-line +# prompt survives as ONE argv item (newline framing would shatter it). +adapter_burn_flags() { + local prompt="$1"; shift + printf 'exec\0' + [ $# -gt 0 ] && printf -- '-C\0%s\0' "$1" + printf -- '-s\0workspace-write\0%s\0' "$prompt" +} + +# Optional hook: how to run codex HEADLESS READ-ONLY for `clikae conduct`'s +# fan-out. `-s read-only` sandboxes it to reads; --skip-git-repo-check lets it run +# outside a repo. First is the cwd (-C); the prompt is the trailing data. +# NUL-separated items (multi-line prompt survives as one argv item). +adapter_audit_flags() { + local prompt="$1"; shift + printf 'exec\0--skip-git-repo-check\0' + [ $# -gt 0 ] && printf -- '-C\0%s\0' "$1" + printf -- '-s\0read-only\0%s\0' "$prompt" +} + # Optional hook: start a session seeded with an initial prompt (for # `clikae handoff --to codex/`). Codex takes a positional prompt. adapter_start_with_prompt() { diff --git a/lib/commands/burn.sh b/lib/commands/burn.sh index e206622..bae5f42 100644 --- a/lib/commands/burn.sh +++ b/lib/commands/burn.sh @@ -15,15 +15,27 @@ _burn_help() { cat <<'EOF' -Usage: clikae burn --artifact [--to ] - [--timeout ] [--no-reroute] [--allow-active] - -- +Usage: clikae burn --artifact + ( --prompt-file | --prompt | -- ) + [--add-dir ]... [--to ] [--timeout ] + [--no-reroute] [--allow-active] [--fresh] -Run a headless engine command on , verify it by the ARTIFACT it should +Run a headless engine task on , verify it by the ARTIFACT it should produce (never the exit code — codex exec exits 0 even when it hit its limit and -wrote nothing), and if the tank ran dry, re-fire the SAME command on the next -tank in your reserve. +wrote nothing), and if the tank ran dry, re-fire the SAME task on the next tank +in your reserve. +Give the task in one of two ways: + • the easy way — --prompt-file / --prompt : clikae fills in each + engine's own headless-write flags (claude's -p / codex's exec …) from its + adapter, so you never hand-assemble them and a cross-engine reroute stays + sound (the flags are regenerated for the new engine). + • the power-user way — -- : pass the raw engine argv yourself. + + --prompt-file read the task prompt from a file (no quoting hell). + --prompt inline prompt, for one-liners. (Mutually exclusive with the above.) + --add-dir a directory the engine may write in. Defaults to the + artifact's parent. Repeatable. (codex uses the first as its cwd.) --artifact the file the task must produce. Success = it appears, or (if it already existed) its timestamp changes — a STALE file from a previous run is NOT counted as success. @@ -46,9 +58,12 @@ no artifact but no limit -> a real task failure (NOT rerouted — it'd fail the on every tank). Examples: + clikae burn claude L --artifact out/core.test.cjs \ + --prompt-file task.txt --add-dir "$PWD" # the easy way + clikae burn codex M --artifact /tmp/out.md \ + --prompt-file task.txt --add-dir /tmp # same task, different engine, no flag changes clikae burn codex M --artifact /tmp/out.md -- exec -C /tmp -s workspace-write \ - "read /tmp/in.txt, write /tmp/out.md" - clikae burn codex M --artifact /tmp/out.md --to codex/H -- exec ... "" + "read /tmp/in.txt, write /tmp/out.md" # the power-user way (raw argv) burn is the headless sibling of the interactive switch: pre-stage inputs to /tmp (never hand a tank slow iCloud-backed I/O), and make tasks idempotent + artifact- @@ -116,15 +131,39 @@ _burn_size() { if [ -e "$1" ]; then wc -c < "$1" 2>/dev/null | tr -d ' '; else printf '?'; fi } +# _burn_compose -- +# Build the full engine argv into the global array BURN_ARGV: the per-engine +# headless-write flags from adapter_burn_flags (which must be defined for the +# CURRENTLY-loaded adapter), followed by any verbatim post-`--` argv. Called once +# per engine so a cross-engine reroute regenerates the flags for the NEW engine +# (fixing the old "ship claude's -p flags to codex" unsoundness). Newline-per-item +# read keeps a multi-line prompt with spaces intact. +_burn_compose() { + local prompt="$1"; shift + local n="$1"; shift + local -a post=(); local i + for ((i=0; i. Usage: clikae burn --artifact -- " + [ -n "$cli" ] || log_fail "Missing . Usage: clikae burn --artifact (--prompt-file | -- )" [ -n "$tank" ] || log_fail "Missing ." [ -n "$artifact" ] || log_fail "Missing --artifact — burn verifies completion by the artifact, never the exit code." - [ "${#cmd[@]}" -ge 1 ] || log_fail "Missing the engine command after -- (e.g. -- exec -C /tmp \"\")." + + # Convenience surface (--prompt / --prompt-file): clikae fills each engine's + # headless-write flags from its adapter, so the task is just "a prompt + the + # file it must produce" (2026-06-06 tugtile burn-writeup friction #1). + [ "$prompt_set" -eq 1 ] && [ -n "$prompt_file" ] \ + && log_fail "Use either --prompt or --prompt-file, not both." + if [ -n "$prompt_file" ]; then + [ -r "$prompt_file" ] || log_fail "--prompt-file not readable: $prompt_file" + prompt="$(cat "$prompt_file")"; prompt_set=1 + fi + if [ "$prompt_set" -eq 1 ]; then + # Default the writable dir to the artifact's parent, so the engine can always + # at least write the file you asked for. + [ "${#add_dirs[@]}" -ge 1 ] || add_dirs=("$(dirname "$artifact")") + else + [ "${#cmd[@]}" -ge 1 ] || log_fail "Give a task: --prompt-file / --prompt , or the explicit -- form." + fi validate_name cli "$cli" validate_name profile "$tank" case "$cli" in agy|antigravity) log_fail "agy is already global/single-account — there's no per-tank headless burn to do. Just use it directly (clikae agy ), or burn a codex/claude tank." ;; esac + # Keep the verbatim post-`--` argv aside; in --prompt mode it's appended after + # the engine's generated flags (an escape hatch for extra per-engine args). + local -a post_cmd=("${cmd[@]}") load_adapter "$cli" local binary; binary="$(adapter_meta_cli_binary)" command -v "$binary" >/dev/null 2>&1 || log_fail "'$binary' is not on PATH." local envvar; envvar="$(adapter_meta_env_var 2>/dev/null || true)" # for the in-use guard + if [ "$prompt_set" -eq 1 ]; then + declare -F adapter_burn_flags >/dev/null \ + || log_fail "$cli has no headless-write recipe (adapter defines no adapter_burn_flags). Use the explicit '-- ' form." + _burn_compose "$prompt" "${#post_cmd[@]}" "${post_cmd[@]}" -- "${add_dirs[@]}" + cmd=("${BURN_ARGV[@]}") + fi # #2 (tugtile dogfood): snapshot the artifact so a STALE file from a prior run # isn't mistaken for success. --fresh clears it; otherwise warn + judge by mtime. @@ -231,10 +295,21 @@ KV *) nx_cli="$cli"; nx_tank="$nxt" ;; esac if [ "$nx_cli" != "$cli" ]; then - log_warn "Cross-engine reroute → $nx_cli: the SAME command runs under $nx_cli (only sound if it's engine-agnostic)." cli="$nx_cli"; load_adapter "$cli"; binary="$(adapter_meta_cli_binary)" envvar="$(adapter_meta_env_var 2>/dev/null || true)" # in-use guard tracks the new engine's var command -v "$binary" >/dev/null 2>&1 || log_fail "Reroute engine '$binary' is not on PATH." + if [ "$prompt_set" -eq 1 ]; then + # Regenerate the headless flags for the NEW engine — a cross-engine reroute + # of a --prompt task is sound (codex's flags differ from claude's, and the + # prompt is engine-agnostic). Without a recipe for the new engine, stop. + declare -F adapter_burn_flags >/dev/null \ + || log_fail "Cross-engine reroute → $nx_cli, which has no headless-write recipe (no adapter_burn_flags)." + _burn_compose "$prompt" "${#post_cmd[@]}" "${post_cmd[@]}" -- "${add_dirs[@]}" + cmd=("${BURN_ARGV[@]}") + log_warn "Cross-engine reroute → $nx_cli: re-running the same prompt under $nx_cli's headless flags." + else + log_warn "Cross-engine reroute → $nx_cli: the SAME command runs under $nx_cli (only sound if it's engine-agnostic)." + fi fi cur="$nx_tank" log_info "Rerouting (dry) → $cli/$cur" diff --git a/lib/commands/conduct.sh b/lib/commands/conduct.sh new file mode 100644 index 0000000..5274a84 --- /dev/null +++ b/lib/commands/conduct.sh @@ -0,0 +1,181 @@ +# shellcheck shell=bash +# lib/commands/conduct.sh — `clikae conduct` (BETA): fan ONE prompt across N +# accounts in parallel, collect each leg's FULL output, and tabulate which tanks +# produced a result vs ran dry. The "vertical orchestration" primitive: a fleet of +# AI CLIs each burning its OWN subscription, none eating your main budget. +# +# What it is NOT: clikae does not JUDGE the outputs — it stays a pure switcher. +# It hands you N full result files (and an honest dry/captured table); you (or the +# session model acting as conductor) pick the winner. That brain/muscle split is +# the point: clikae is the muscle (accounts, dry-detection, parallel routing); the +# conductor (a human, or a Claude/codex session) is the brain. +# +# Read-only by design (each leg runs headless READ-ONLY via adapter_audit_flags), +# so N legs can't clobber a shared working tree — the safe, common best-of-N case +# (audits, analyses, design proposals). Write/impl tournaments that need isolated +# worktrees stay an orchestrator's job (see the conductor skill's Heavy mode). + +# Reuse burn's timeout-tool resolver (timeout/gtimeout/perl-or-warn). +# shellcheck source=./burn.sh +source "$CLIKAE_LIB/commands/burn.sh" + +_conduct_help() { + cat <<'EOF' +Usage: clikae conduct ( --prompt-file | --prompt ) + --leg / [--leg /]... + [--add-dir ]... [--out-dir ] [--timeout ] + +Fan ONE prompt across several accounts IN PARALLEL — each leg runs the prompt +headless and READ-ONLY on its own tank (its own subscription quota) — then +collect every leg's full output and print a captured/dry table. You pick the +winner; clikae never judges. (BETA — the vertical-orchestration primitive.) + + --prompt-file the task prompt (self-contained — each leg is a blind run). + --prompt inline prompt (mutually exclusive with --prompt-file). + --leg / a leg to fan to. Repeatable. Engine must support a + read-only headless recipe (claude, codex). Same prompt, + different account — different perspective / spare quota. + --add-dir extra read root for every leg (default: $PWD). Repeatable. + --out-dir where to collect -.txt results + (default: a fresh mktemp dir, printed at the end). + --timeout bound each leg (coreutils timeout/gtimeout, else a perl alarm). + +Each leg's outcome is judged by its OUTPUT, never the exit code (a headless agent +exits 0 even when it hit its limit). Outcomes per leg: captured / dry (with the +vendor's reset phrase) / empty (a real failure — auth/sandbox/no answer). + +Example — best-of-N audit across three accounts: + clikae conduct --prompt-file review.md \ + --leg codex/H --leg codex/i --leg claude/C --add-dir "$PWD" +EOF +} + +# _conduct_one +# One leg, meant to run in the background. Loads its OWN adapter (a background +# subshell has a private copy of the function table, so parallel legs don't clash), +# runs the read-only headless recipe with the tank's env + stdin closed, captures +# combined output to , and writes a one-word verdict to : +# DRY | CAPTURED | EMPTY | NORECIPE | NOPATH | NOTANK +_conduct_one() { + local engine="$1" tank="$2" prompt="$3" outfile="$4" statusfile="$5" tmo="$6"; shift 6 + local -a add_dirs=("$@") + + load_adapter "$engine" 2>/dev/null || { printf 'NOTANK\n' > "$statusfile"; return 0; } + declare -F adapter_audit_flags >/dev/null || { printf 'NORECIPE\n' > "$statusfile"; return 0; } + local binary; binary="$(adapter_meta_cli_binary)" + command -v "$binary" >/dev/null 2>&1 || { printf 'NOPATH\n' > "$statusfile"; return 0; } + local dir; dir="$(profile_dir "$engine" "$tank")" + [ -d "$dir" ] || { printf 'NOTANK\n' > "$statusfile"; return 0; } + + local -a gen=(); local line + # NUL-delimited read so a multi-line prompt survives as a single argv item. + while IFS= read -r -d '' line; do gen+=("$line"); done < <(adapter_audit_flags "$prompt" "${add_dirs[@]}") + + local -a runner=() + if [ -n "$tmo" ]; then + local tb; tb="$(_burn_timeout_bin)" + case "$tb" in + timeout|gtimeout) runner=("$tb" "$tmo") ;; + perl) runner=(perl -e 'alarm shift; exec @ARGV or exit 127' "$tmo") ;; + esac + fi + + # `|| true`: the engine's exit code is meaningless (a headless agent exits 0 on + # a limit, non-zero on a real failure) — we judge by the OUTPUT. Without this, + # `set -e` (inherited by this background subshell) would abort a non-zero leg + # BEFORE it writes its .status file, dropping a real failure into "unknown" + # instead of EMPTY (independent-audit catch, 2026-06-13). + local out + out="$( + while IFS= read -r kv; do [ -n "$kv" ] && export "${kv%%=*}"="${kv#*=}"; done <&1 + )" || true + printf '%s\n' "$out" > "$outfile" + + # Classify by the captured OUTPUT, not the file size: printf '%s\n' "$out" always + # writes a trailing newline, so an empty leg yields a 1-byte file — `-s` would + # wrongly call that CAPTURED. Key off $out being non-empty instead. + local reset + if reset="$(limit_output_dry "$engine" "$out")"; then + printf 'DRY %s\n' "$reset" > "$statusfile" + elif [ -n "$out" ]; then + printf 'CAPTURED\n' > "$statusfile" + else + printf 'EMPTY\n' > "$statusfile" + fi +} + +cmd_conduct() { + local prompt="" prompt_file="" prompt_set=0 out_dir="" timeout_s="" + local -a legs=() add_dirs=() + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) _conduct_help; return 0 ;; + --prompt) shift; [ $# -gt 0 ] || log_fail "--prompt needs a string"; prompt="$1"; prompt_set=1; shift ;; + --prompt-file) shift; [ $# -gt 0 ] || log_fail "--prompt-file needs a path"; prompt_file="$1"; shift ;; + --leg) shift; [ $# -gt 0 ] || log_fail "--leg needs /"; legs+=("$1"); shift ;; + --add-dir) shift; [ $# -gt 0 ] || log_fail "--add-dir needs a path"; add_dirs+=("$1"); shift ;; + --out-dir) shift; [ $# -gt 0 ] || log_fail "--out-dir needs a path"; out_dir="$1"; shift ;; + --timeout) shift; [ $# -gt 0 ] || log_fail "--timeout needs seconds"; timeout_s="$1"; shift ;; + -*) log_fail "Unknown flag: $1 (try: clikae conduct --help)" ;; + *) log_fail "Unexpected argument: $1 (legs go via --leg /)" ;; + esac + done + + [ "$prompt_set" -eq 1 ] && [ -n "$prompt_file" ] && log_fail "Use either --prompt or --prompt-file, not both." + if [ -n "$prompt_file" ]; then + [ -r "$prompt_file" ] || log_fail "--prompt-file not readable: $prompt_file" + prompt="$(cat "$prompt_file")"; prompt_set=1 + fi + [ "$prompt_set" -eq 1 ] || log_fail "Give a prompt: --prompt-file or --prompt ." + [ "${#legs[@]}" -ge 1 ] || log_fail "Give at least one --leg / to fan to." + [ "${#add_dirs[@]}" -ge 1 ] || add_dirs=("$PWD") + + if [ -z "$out_dir" ]; then + out_dir="$(mktemp -d "${TMPDIR:-/tmp}/clikae-conduct.XXXXXX")" \ + || log_fail "Could not create a temp out-dir; pass --out-dir ." + else + mkdir -p "$out_dir" || log_fail "Could not create --out-dir: $out_dir" + fi + + log_info "conduct: fanning 1 prompt across ${#legs[@]} legs (read-only, parallel) → $out_dir" + + # Launch every leg in the background (each burns its own account's quota). + local -a pids=() tags=() outs=() stats=() + local leg engine tank slug + for leg in "${legs[@]}"; do + case "$leg" in + */*) engine="${leg%%/*}"; tank="${leg#*/}" ;; + *) log_warn "skipping malformed --leg '$leg' (want /)"; continue ;; + esac + slug="${engine}-${tank}" + local of="$out_dir/$slug.txt" sf="$out_dir/$slug.status" + _conduct_one "$engine" "$tank" "$prompt" "$of" "$sf" "$timeout_s" "${add_dirs[@]}" & + pids+=("$!"); tags+=("$engine/$tank"); outs+=("$of"); stats+=("$sf") + log_dim " → leg $engine/$tank launched (pid $!)" + done + [ "${#pids[@]}" -ge 1 ] || log_fail "No valid legs to run." + + local p; for p in "${pids[@]}"; do wait "$p" 2>/dev/null || true; done + + # Tabulate. Outcome is judged by each leg's OUTPUT/status, never its exit code. + local i captured=0 dry=0 other=0 verdict rest + log_info "conduct results ($out_dir):" + for i in "${!tags[@]}"; do + verdict="$(cut -d' ' -f1 < "${stats[$i]}" 2>/dev/null || echo '?')" + rest="$(cut -s -d' ' -f2- < "${stats[$i]}" 2>/dev/null || true)" + case "$verdict" in + CAPTURED) captured=$((captured+1)); log_ok " ✔ ${tags[$i]} — captured ($(_burn_size "${outs[$i]}")B) → ${outs[$i]}" ;; + DRY) dry=$((dry+1)); log_warn " ⛽ ${tags[$i]} — ran dry${rest:+ ($rest)}" ;; + EMPTY) other=$((other+1)); log_err " ✖ ${tags[$i]} — no output (auth / sandbox / no answer)" ;; + NORECIPE) other=$((other+1)); log_err " ✖ ${tags[$i]} — engine has no read-only recipe (adapter_audit_flags)" ;; + NOPATH) other=$((other+1)); log_err " ✖ ${tags[$i]} — engine binary not on PATH" ;; + NOTANK) other=$((other+1)); log_err " ✖ ${tags[$i]} — no such tank (clikae tanks to list)" ;; + *) other=$((other+1)); log_err " ✖ ${tags[$i]} — unknown outcome" ;; + esac + done + log_info "summary: ${captured} captured · ${dry} dry · ${other} other → read them in $out_dir, then pick the winner." + [ "$captured" -ge 1 ] +} diff --git a/lib/commands/env.sh b/lib/commands/env.sh index cc58a89..d600eca 100644 --- a/lib/commands/env.sh +++ b/lib/commands/env.sh @@ -64,6 +64,20 @@ EOF return 1 fi + # issue #22: if this tank carries an intended git identity, also export the four + # standard git env vars so commits made in this shell are stamped with it (git + # env vars beat `git config`, pinning authorship even when the engine commits via + # global config). Absent file ⇒ nothing emitted (safe default). + local gid gname gemail + gid="$(git_identity_read "$cli" "$tank")" + if [ -n "$gid" ]; then + gname="${gid%%$'\t'*}"; gemail="${gid#*$'\t'}" + printf 'export GIT_AUTHOR_NAME=%s\n' "$(_env_shquote "$gname")" + printf 'export GIT_AUTHOR_EMAIL=%s\n' "$(_env_shquote "$gemail")" + printf 'export GIT_COMMITTER_NAME=%s\n' "$(_env_shquote "$gname")" + printf 'export GIT_COMMITTER_EMAIL=%s\n' "$(_env_shquote "$gemail")" + fi + # If stdout is a terminal the user almost certainly forgot the eval — nudge on # stderr so it never pollutes the eval'd output. if [ -t 1 ]; then diff --git a/lib/commands/git_id.sh b/lib/commands/git_id.sh new file mode 100644 index 0000000..4e3fcc0 --- /dev/null +++ b/lib/commands/git_id.sh @@ -0,0 +1,103 @@ +# shellcheck shell=bash +# lib/commands/git_id.sh — `clikae git-id [--name N --email E | --unset]` +# +# Give a tank an OPTIONAL intended git commit identity. Once set, `eval "$(clikae +# env )"` also exports GIT_AUTHOR_* / GIT_COMMITTER_*, so commits +# made in that shell are stamped with the identity you MEANT — not whatever the +# engine's account email happens to be. (issue #22 / HANDOFF §13: a headless run +# once authored 9 commits under the wrong GitHub account; this prevents the NEXT +# mis-stamp.) +# +# Honest limits (kept in the help so we never oversell): +# • git precedence is `-c` flag > GIT_* env > git config. This pins the env-var +# path (the common case) but does NOT beat an engine that commits with an +# explicit `git -c user.email=… commit`. +# • per-shell only (like `env`); you must eval the env into the shell first. +# • it only ever influences FUTURE commits — it cannot fix attribution on +# commits already made (that needs a history rewrite + force-push). + +cmd_git_id() { + local cli="" tank="" name="" email="" do_unset=0 name_set=0 email_set=0 + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + cat <<'EOF' +Usage: + clikae git-id # show this tank's git identity + clikae git-id --name N --email E # set it + clikae git-id --unset # remove it + +Give a tank an optional git commit identity. When set, `eval "$(clikae env + )"` also exports GIT_AUTHOR_NAME/EMAIL + GIT_COMMITTER_NAME/EMAIL, +so commits made in that shell are stamped with the identity you intended — +regardless of what the engine would otherwise inject. + + eval "$(clikae env claude work)" # now this shell is on the tank AND its git id + git commit -m "…" # authored as the tank's name/email + +Honest limits: + • git precedence: `-c` flag > GIT_* env > git config. This wins over the + global-config commit path (the usual case), but NOT over an engine that + commits with an explicit `git -c user.email=… commit`. + • per-shell only — it lives in the shell that eval'd `clikae env`. + • only future commits — it cannot re-map commits already made. +EOF + return 0 ;; + --name) shift; [ $# -gt 0 ] || log_fail "--name needs a value"; name="$1"; name_set=1; shift ;; + --email) shift; [ $# -gt 0 ] || log_fail "--email needs a value"; email="$1"; email_set=1; shift ;; + --unset) do_unset=1; shift ;; + --) shift; break ;; + -*) log_fail "Unknown flag: $1 (try: clikae git-id --help)" ;; + *) if [ -z "$cli" ]; then cli="$1" + elif [ -z "$tank" ]; then tank="$1" + else log_fail "Unexpected argument: $1"; fi + shift ;; + esac + done + + [ -n "$cli" ] || log_fail "Missing . Usage: clikae git-id --name N --email E" + [ -n "$tank" ] || log_fail "Missing ." + validate_name cli "$cli" + validate_name profile "$tank" + # Require the tank to exist (don't silently create metadata for a typo'd tank). + profile_exists "$cli" "$tank" || log_fail "No such tank: $cli/$tank (create it with: clikae init $cli $tank)" + + local f; f="$(git_identity_file "$cli" "$tank")" + + # --unset: remove the identity file. + if [ "$do_unset" -eq 1 ]; then + [ "$name_set" -eq 1 ] || [ "$email_set" -eq 1 ] \ + && log_fail "--unset takes no --name/--email." + if [ -f "$f" ]; then rm -f "$f" && log_ok "Cleared git identity for $cli/$tank." + else log_info "No git identity set for $cli/$tank — nothing to clear."; fi + return 0 + fi + + # Bare form (no --name/--email): show the current value. + if [ "$name_set" -eq 0 ] && [ "$email_set" -eq 0 ]; then + local cur; cur="$(git_identity_read "$cli" "$tank")" + if [ -n "$cur" ]; then + printf '%s <%s>\n' "${cur%%$'\t'*}" "${cur#*$'\t'}" + else + log_info "No git identity set for $cli/$tank." + log_dim "Set one: clikae git-id $cli $tank --name \"Your Name\" --email you@example.com" + fi + return 0 + fi + + # Set form: both fields required (a half identity stamps confusingly). + [ "$name_set" -eq 1 ] || log_fail "Setting a git identity needs --name (and --email)." + [ "$email_set" -eq 1 ] || log_fail "Setting a git identity needs --email (and --name)." + [ -n "$name" ] || log_fail "--name must not be empty." + [ -n "$email" ] || log_fail "--email must not be empty." + # No tabs/newlines — the store is one TAB-separated line. + case "$name$email" in + *$'\t'*|*$'\n'*) log_fail "--name/--email must not contain tabs or newlines." ;; + esac + + mkdir -p "$(dirname "$f")" + printf '%s\t%s\n' "$name" "$email" > "$f" \ + || log_fail "Could not write git identity to $f" + log_ok "Set git identity for $cli/$tank: $name <$email>" + log_dim "Active when you eval \"\$(clikae env $cli $tank)\" (then commits in that shell use it)." +} diff --git a/lib/commands/help.sh b/lib/commands/help.sh index fb7d734..eb391da 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -34,6 +34,7 @@ Make & manage tanks: init create a new tank (--alias adds a shell alias) remove remove a tank (dir, alias, .app) rename rename a tank (login carried over) + git-id set a tank's git commit identity (--name --email) migrate [engine] adopt a hand-rolled config-dir + alias setup Keep burning when a tank runs dry: @@ -43,6 +44,8 @@ Keep burning when a tank runs dry: watch [tank] watch for a dry tank and switch onward burn -- run a headless task on a tank; on a dry tank, re-fire it on the next (verify by --artifact) + conduct --leg /... --prompt-file (BETA) fan ONE prompt across N + accounts in parallel, collect each full result Use & inspect: app generate a macOS launcher .app diff --git a/lib/core/adapter_loader.sh b/lib/core/adapter_loader.sh index 36c6cf5..309fd0b 100644 --- a/lib/core/adapter_loader.sh +++ b/lib/core/adapter_loader.sh @@ -166,7 +166,8 @@ load_adapter() { adapter_export_env adapter_run adapter_init \ adapter_relay adapter_transcript_path adapter_start_with_prompt \ adapter_account_label adapter_migrate_credentials adapter_flag_args \ - adapter_memory_dir adapter_install_hint \ + adapter_memory_dir adapter_install_hint adapter_burn_flags \ + adapter_audit_flags \ 2>/dev/null || true # shellcheck source=/dev/null diff --git a/lib/core/profile_store.sh b/lib/core/profile_store.sh index 89bf28d..4e47afb 100644 --- a/lib/core/profile_store.sh +++ b/lib/core/profile_store.sh @@ -43,6 +43,27 @@ validate_name() { fi } +# --- per-tank git commit identity (issue #22) ------------------------------ +# A tank governs an AI account's auth/fuel/memory; a coding session ALSO emits a +# git author/committer, which the tank does not control today. These helpers let +# a tank carry an OPTIONAL intended git identity, stamped into the shell by +# `clikae env` so commits aren't mis-attributed to the engine's account email. +# Stored as plain text under the tank dir (local-only, auditable): +# clikae-meta/git-identity -> "nameemail" (one line) + +# git_identity_file -> the path to the identity file. +git_identity_file() { + printf '%s/clikae-meta/git-identity\n' "$(profile_dir "$1" "$2")" +} + +# git_identity_read -> echo "nameemail" if set, else nothing. +# Never aborts the caller under `set -eo pipefail` (a missing file is normal). +git_identity_read() { + local f; f="$(git_identity_file "$1" "$2")" + [ -f "$f" ] || return 0 + head -n 1 "$f" 2>/dev/null || true +} + # List every profile as " " lines, sorted. list_all_profiles() { local root diff --git a/tests/bats/burn.bats b/tests/bats/burn.bats index 7704427..54b982d 100644 --- a/tests/bats/burn.bats +++ b/tests/bats/burn.bats @@ -8,24 +8,38 @@ load '../helpers' # Stub `codex` on PATH. Per-tank behaviour keyed off $CODEX_HOME: # a ".dry" marker in the tank dir -> emit the limit line, write nothing (exit 0) -# otherwise, `run ` -> create (the artifact) +# `run ` -> create (the legacy raw-argv form) +# `exec …` (the generated form) -> create $STUB_ARTIFACT, if set # otherwise -> do nothing (a task that fails to produce) +# If $STUB_ARGV_LOG is set, every invocation appends its full argv (one line) there +# so a test can assert the generated flag shape. _stub_codex() { local bin="$BATS_TEST_TMPDIR/bin" mkdir -p "$bin" cat > "$bin/codex" <<'STUB' #!/usr/bin/env bash +[ -n "$STUB_ARGV_LOG" ] && printf '%s\n' "$*" >> "$STUB_ARGV_LOG" +[ -n "$STUB_ARGC_LOG" ] && printf '%s' "$#" > "$STUB_ARGC_LOG" if [ -f "$CODEX_HOME/.dry" ]; then echo "You've hit your usage limit. Try again at Jul 7th, 2026 2:17 PM." exit 0 fi if [ "$1" = "run" ] && [ -n "$2" ]; then : > "$2"; fi +if [ "$1" = "exec" ] && [ -n "$STUB_ARTIFACT" ]; then : > "$STUB_ARTIFACT"; fi exit 0 STUB chmod +x "$bin/codex" PATH="$bin:$PATH"; export PATH } +# A stub `gh` (a real adapter that does NOT define adapter_burn_flags) for the +# "no headless-write recipe" error path. +_stub_gh() { + local bin="$BATS_TEST_TMPDIR/bin"; mkdir -p "$bin" + printf '#!/usr/bin/env bash\nexit 0\n' > "$bin/gh"; chmod +x "$bin/gh" + PATH="$bin:$PATH"; export PATH +} + @test "burn completes on a live tank and verifies by the artifact" { _stub_codex clikae init codex T1 @@ -222,3 +236,164 @@ _seed_email() { printf '{"emailAddress": "%s"}\n' "$3" > "$CLIKAE_HOME/profiles/ local out; out="$(PATH="$BATS_TEST_TMPDIR/perlbin" _burn_timeout_bin)" # only perl, no (g)timeout [ "$out" = "perl" ] } + +# --- issue #24: the convenience surface (--prompt-file / --prompt / --add-dir) --- +# clikae fills each engine's headless-write flags from its adapter, so the caller +# never hand-assembles them (2026-06-06 tugtile burn-writeup friction #1). + +@test "burn --prompt-file builds the engine command via the hook and completes" { + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + export STUB_ARTIFACT="$A" + printf 'write the file\n' > "$BATS_TEST_TMPDIR/task.txt" + run clikae burn codex T1 --artifact "$A" --prompt-file "$BATS_TEST_TMPDIR/task.txt" + [ "$status" -eq 0 ] + [ -f "$A" ] + [[ "$output" == *"Done on codex/T1"* ]] || false +} + +@test "burn --prompt inline is equivalent to --prompt-file" { + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + export STUB_ARTIFACT="$A" + run clikae burn codex T1 --artifact "$A" --prompt "write the file" + [ "$status" -eq 0 ] + [ -f "$A" ] +} + +@test "burn --add-dir defaults to the artifact's parent (codex gets -C dirname)" { + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/sub/out.md"; mkdir -p "$BATS_TEST_TMPDIR/sub" + export STUB_ARTIFACT="$A" STUB_ARGV_LOG="$BATS_TEST_TMPDIR/argv.log" + run clikae burn codex T1 --artifact "$A" --prompt "x" + [ "$status" -eq 0 ] + grep -q -- "exec -C $BATS_TEST_TMPDIR/sub -s workspace-write" "$BATS_TEST_TMPDIR/argv.log" +} + +@test "burn rejects --prompt and --prompt-file together" { + _stub_codex + clikae init codex T1 + printf 'x\n' > "$BATS_TEST_TMPDIR/task.txt" + run clikae burn codex T1 --artifact "$BATS_TEST_TMPDIR/out.md" --prompt x --prompt-file "$BATS_TEST_TMPDIR/task.txt" + [ "$status" -ne 0 ] + [[ "$output" == *"not both"* ]] || false +} + +@test "burn with no prompt and no -- errors and mentions the prompt options" { + _stub_codex + clikae init codex T1 + run clikae burn codex T1 --artifact "$BATS_TEST_TMPDIR/out.md" + [ "$status" -ne 0 ] + [[ "$output" == *"--prompt-file"* ]] || false +} + +@test "burn --prompt on an engine with no adapter_burn_flags errors clearly" { + _stub_gh + clikae init gh T1 + run clikae burn gh T1 --artifact "$BATS_TEST_TMPDIR/out.md" --prompt "x" + [ "$status" -ne 0 ] + [[ "$output" == *"headless-write recipe"* ]] || false + [[ "$output" == *"-- "$CLIKAE_HOME/profiles/codex/T1/.dry" + local A="$BATS_TEST_TMPDIR/out.md" + export STUB_ARTIFACT="$A" STUB_ARGV_LOG="$BATS_TEST_TMPDIR/argv.log" + run clikae burn codex T1 --artifact "$A" --prompt "write it" + [ "$status" -eq 0 ] + [ -f "$A" ] + [[ "$output" == *"codex/T2"* ]] || false + grep -q -- "exec -C .* -s workspace-write" "$BATS_TEST_TMPDIR/argv.log" +} + +# --- issue #24: direct unit tests pinning each engine's flag recipe --- +# A CLI flag rename is caught here, not in the field. + +@test "adapter_burn_flags (claude): exact NUL-per-argv recipe" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/adapters/claude.sh" + local -a got=(); local x + while IFS= read -r -d '' x; do got+=("$x"); done < <(adapter_burn_flags "do a thing" /tmp/wd) + [ "${#got[@]}" -eq 5 ] + [ "${got[0]}" = "-p" ] + [ "${got[1]}" = "do a thing" ] + [ "${got[2]}" = "--dangerously-skip-permissions" ] + [ "${got[3]}" = "--add-dir" ] + [ "${got[4]}" = "/tmp/wd" ] +} + +@test "adapter_burn_flags (codex): exact NUL-per-argv recipe, first add-dir = cwd" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/adapters/codex.sh" + # second add-dir /ignored is dropped — codex's writable root is its cwd (-C). + local -a got=(); local x + while IFS= read -r -d '' x; do got+=("$x"); done < <(adapter_burn_flags "do a thing" /tmp/wd /ignored) + [ "${#got[@]}" -eq 6 ] + [ "${got[0]}" = "exec" ] + [ "${got[1]}" = "-C" ] + [ "${got[2]}" = "/tmp/wd" ] + [ "${got[3]}" = "-s" ] + [ "${got[4]}" = "workspace-write" ] + [ "${got[5]}" = "do a thing" ] +} + +# THE BLIND SPOT (independent-audit catch 2026-06-13): a MULTI-LINE prompt must +# survive as ONE argv item, not be shattered into one item per line. Every other +# burn/conduct test uses a single-line prompt, which hid this. +@test "adapter_burn_flags / adapter_audit_flags do NOT leak across adapters (leak-guard)" { + # The new optional hooks are in adapter_loader's unset list, so an adapter that + # doesn't define them (gh) must not inherit claude's. + _src_burn + load_adapter claude + declare -F adapter_burn_flags >/dev/null # claude HAS it + declare -F adapter_audit_flags >/dev/null + load_adapter gh + ! declare -F adapter_burn_flags >/dev/null # gh must NOT have inherited it + ! declare -F adapter_audit_flags >/dev/null +} + +@test "burn: --prompt with a trailing -- appends the extra argv after the generated flags" { + # Documented escape-hatch combo. Pin the behaviour so it's not a silent surprise: + # generated flags first, post-`--` argv appended verbatim. + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + export STUB_ARTIFACT="$A" STUB_ARGV_LOG="$BATS_TEST_TMPDIR/argv.log" + run clikae burn codex T1 --artifact "$A" --prompt "do it" -- --color never + [ "$status" -eq 0 ] + # exec -C -s workspace-write "do it" --color never (extra appended last) + grep -q -- "workspace-write do it --color never" "$BATS_TEST_TMPDIR/argv.log" +} + +@test "adapter_burn_flags (claude): a multi-line prompt stays ONE argv item" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/adapters/claude.sh" + local ml; ml=$'line one\nline two\nline three' + local -a got=(); local x + while IFS= read -r -d '' x; do got+=("$x"); done < <(adapter_burn_flags "$ml" /tmp/wd) + [ "${#got[@]}" -eq 5 ] # NOT 7 — the 3 prompt lines did not split + [ "${got[1]}" = "$ml" ] # the whole multi-line prompt, intact +} + +@test "burn --prompt-file delivers a MULTI-LINE prompt to codex as one arg" { + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + export STUB_ARTIFACT="$A" STUB_ARGC_LOG="$BATS_TEST_TMPDIR/argc.log" + printf 'first line\nsecond line\nthird line\n' > "$BATS_TEST_TMPDIR/task.txt" + run clikae burn codex T1 --artifact "$A" --prompt-file "$BATS_TEST_TMPDIR/task.txt" + [ "$status" -eq 0 ] + # codex argv must be exactly: exec -C -s workspace-write = 6 args. + # A shattered 3-line prompt would be 8. (The file's trailing newline is part of + # the single prompt arg, not a separate arg.) + [ "$(cat "$BATS_TEST_TMPDIR/argc.log")" = "6" ] +} diff --git a/tests/bats/conduct.bats b/tests/bats/conduct.bats new file mode 100644 index 0000000..0ad3a64 --- /dev/null +++ b/tests/bats/conduct.bats @@ -0,0 +1,190 @@ +#!/usr/bin/env bats +# tests/bats/conduct.bats — `clikae conduct` (BETA): fan ONE prompt across N +# accounts in PARALLEL, collect each leg's full output, tabulate captured/dry. +# clikae never judges. Uses stubbed `codex`/`claude`/`gh` binaries; no real engine. +# (`[[ … ]]` carry `|| false`; see tests/README.md.) + +load '../helpers' + +# Stub `codex`: a ".dry" marker in $CODEX_HOME → emit the limit line (exit 0, the +# headless-exits-0-on-limit trap); otherwise echo a deterministic result so the +# parent can see "captured". +_stub_codex() { + local bin="$BATS_TEST_TMPDIR/bin"; mkdir -p "$bin" + cat > "$bin/codex" <<'STUB' +#!/usr/bin/env bash +if [ -f "$CODEX_HOME/.dry" ]; then + echo "You've hit your usage limit. Try again at Jul 7th, 2026 2:17 PM." + exit 0 +fi +if [ -f "$CODEX_HOME/.fail" ]; then exit 3; fi # a real failure: NON-ZERO, no output +echo "AUDIT from codex @ $CODEX_HOME" +exit 0 +STUB + chmod +x "$bin/codex"; PATH="$bin:$PATH"; export PATH +} + +# Stub `claude`: echo a deterministic read-only result. +_stub_claude() { + local bin="$BATS_TEST_TMPDIR/bin"; mkdir -p "$bin" + cat > "$bin/claude" <<'STUB' +#!/usr/bin/env bash +echo "AUDIT from claude @ $CLAUDE_CONFIG_DIR" +exit 0 +STUB + chmod +x "$bin/claude"; PATH="$bin:$PATH"; export PATH +} + +_stub_gh() { + local bin="$BATS_TEST_TMPDIR/bin"; mkdir -p "$bin" + printf '#!/usr/bin/env bash\nexit 0\n' > "$bin/gh"; chmod +x "$bin/gh" + PATH="$bin:$PATH"; export PATH +} + +@test "conduct fans across two codex tanks; both captured; exit 0" { + _stub_codex + clikae init codex A + clikae init codex B + local D="$BATS_TEST_TMPDIR/out" + run clikae conduct --prompt "audit this" --leg codex/A --leg codex/B --out-dir "$D" + [ "$status" -eq 0 ] + [[ "$output" == *"2 captured"* ]] || false + [ -s "$D/codex-A.txt" ] + [ -s "$D/codex-B.txt" ] + grep -q "AUDIT from codex" "$D/codex-A.txt" +} + +@test "conduct: a dry leg is shown as dry, a live leg still captures (exit 0)" { + _stub_codex + clikae init codex A + clikae init codex B + : > "$CLIKAE_HOME/profiles/codex/A/.dry" # A is dry; B is live + local D="$BATS_TEST_TMPDIR/out" + run clikae conduct --prompt "x" --leg codex/A --leg codex/B --out-dir "$D" + [ "$status" -eq 0 ] + [[ "$output" == *"codex/A — ran dry"* ]] || false + [[ "$output" == *"codex/B — captured"* ]] || false + [[ "$output" == *"1 captured · 1 dry"* ]] || false +} + +@test "conduct: every leg dry → non-zero (nothing captured)" { + _stub_codex + clikae init codex A + clikae init codex B + : > "$CLIKAE_HOME/profiles/codex/A/.dry" + : > "$CLIKAE_HOME/profiles/codex/B/.dry" + run clikae conduct --prompt "x" --leg codex/A --leg codex/B --out-dir "$BATS_TEST_TMPDIR/out" + [ "$status" -ne 0 ] + [[ "$output" == *"0 captured · 2 dry"* ]] || false +} + +@test "conduct fans CROSS-ENGINE (codex + claude), both captured" { + _stub_codex + _stub_claude + clikae init codex A + clikae init claude C + local D="$BATS_TEST_TMPDIR/out" + run clikae conduct --prompt "x" --leg codex/A --leg claude/C --out-dir "$D" + [ "$status" -eq 0 ] + [[ "$output" == *"2 captured"* ]] || false + grep -q "AUDIT from claude" "$D/claude-C.txt" +} + +@test "conduct: a leg whose engine has no read-only recipe is flagged NORECIPE" { + _stub_codex + _stub_gh + clikae init codex A + clikae init gh G + run clikae conduct --prompt "x" --leg codex/A --leg gh/G --out-dir "$BATS_TEST_TMPDIR/out" + [ "$status" -eq 0 ] # codex still captured + [[ "$output" == *"no read-only recipe"* ]] || false +} + +@test "conduct: a leg whose engine exits NON-ZERO with no output is EMPTY, not 'unknown'" { + # Locks the #2 audit fix: under set -e (inherited by the background subshell) the + # out="$(…)" assignment must not abort before the .status file is written. + _stub_codex + clikae init codex A + clikae init codex B + : > "$CLIKAE_HOME/profiles/codex/B/.fail" # B fails hard (exit 3, no output) + local D="$BATS_TEST_TMPDIR/out" + run clikae conduct --prompt "x" --leg codex/A --leg codex/B --out-dir "$D" + [ "$status" -eq 0 ] # A still captured + [[ "$output" == *"codex/B — no output"* ]] || false # classified EMPTY, not unknown + [[ "$output" != *"unknown outcome"* ]] || false + [ "$(head -1 "$D/codex-B.status")" = "EMPTY" ] # the .status file WAS written +} + +@test "conduct: an unknown tank is flagged, not crashed" { + _stub_codex + clikae init codex A + run clikae conduct --prompt "x" --leg codex/A --leg codex/NOPE --out-dir "$BATS_TEST_TMPDIR/out" + [ "$status" -eq 0 ] + [[ "$output" == *"codex/NOPE — no such tank"* ]] || false +} + +@test "conduct requires a prompt" { + run clikae conduct --leg codex/A + [ "$status" -ne 0 ] + [[ "$output" == *"prompt"* ]] || false +} + +@test "conduct requires at least one leg" { + run clikae conduct --prompt "x" + [ "$status" -ne 0 ] + [[ "$output" == *"--leg"* ]] || false +} + +@test "conduct rejects --prompt and --prompt-file together" { + printf 'x\n' > "$BATS_TEST_TMPDIR/p.txt" + run clikae conduct --prompt x --prompt-file "$BATS_TEST_TMPDIR/p.txt" --leg codex/A + [ "$status" -ne 0 ] + [[ "$output" == *"not both"* ]] || false +} + +@test "conduct --prompt-file reads the prompt from a file" { + _stub_codex + clikae init codex A + printf 'audit from a file\n' > "$BATS_TEST_TMPDIR/p.txt" + local D="$BATS_TEST_TMPDIR/out" + run clikae conduct --prompt-file "$BATS_TEST_TMPDIR/p.txt" --leg codex/A --out-dir "$D" + [ "$status" -eq 0 ] + [ -s "$D/codex-A.txt" ] +} + +# Direct unit tests pinning the read-only recipes (a flag rename is caught here). +@test "adapter_audit_flags (codex): NUL read-only recipe, first dir = cwd" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/adapters/codex.sh" + local -a got=(); local x + while IFS= read -r -d '' x; do got+=("$x"); done < <(adapter_audit_flags "do it" /tmp/wd) + [ "${#got[@]}" -eq 7 ] + [ "${got[0]}" = "exec" ] + [ "${got[1]}" = "--skip-git-repo-check" ] + [ "${got[2]}" = "-C" ] + [ "${got[3]}" = "/tmp/wd" ] + [ "${got[4]}" = "-s" ] + [ "${got[5]}" = "read-only" ] + [ "${got[6]}" = "do it" ] +} + +@test "adapter_audit_flags (claude): headless -p with no write permission" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/adapters/claude.sh" + local -a got=(); local x + while IFS= read -r -d '' x; do got+=("$x"); done < <(adapter_audit_flags "do it" /tmp/wd) + [ "${got[0]}" = "-p" ] + [ "${got[1]}" = "do it" ] + printf '%s\n' "${got[@]}" | grep -q "dangerously-skip-permissions" && false # read-only: NO write grant + return 0 +} + +@test "adapter_audit_flags: a MULTI-LINE prompt stays ONE argv item (the audit blind spot)" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/adapters/codex.sh" + local ml; ml=$'analyze this:\n- point a\n- point b' + local -a got=(); local x + while IFS= read -r -d '' x; do got+=("$x"); done < <(adapter_audit_flags "$ml" /tmp/wd) + [ "${#got[@]}" -eq 7 ] # NOT split by the prompt's newlines + [ "${got[6]}" = "$ml" ] # whole multi-line prompt intact as the last arg +} diff --git a/tests/bats/git_id.bats b/tests/bats/git_id.bats new file mode 100644 index 0000000..29bc197 --- /dev/null +++ b/tests/bats/git_id.bats @@ -0,0 +1,118 @@ +#!/usr/bin/env bats +# tests/bats/git_id.bats — `clikae git-id`: per-tank git commit identity (issue #22). +# Covers the write/read/unset round-trip, that `clikae env` emits the four git env +# vars ONLY when an identity is set (safe default off), and name/field validation. +# (`[[ … ]]` carry `|| false`; see tests/README.md.) + +load '../helpers' + +@test "git-id: set then show round-trips name + email" { + clikae init claude work + run clikae git-id claude work --name "Chodai CT" --email "x@cver.net" + [ "$status" -eq 0 ] + [[ "$output" == *"x@cver.net"* ]] || false + run clikae git-id claude work + [ "$status" -eq 0 ] + [[ "$output" == *"Chodai CT "* ]] || false +} + +@test "git-id: bare form on a tank with no identity says so" { + clikae init claude work + run clikae git-id claude work + [ "$status" -eq 0 ] + [[ "$output" == *"No git identity"* ]] || false +} + +@test "git-id: --unset removes it" { + clikae init claude work + clikae git-id claude work --name N --email e@x.com + run clikae git-id claude work --unset + [ "$status" -eq 0 ] + [[ "$output" == *"Cleared"* ]] || false + run clikae git-id claude work + [[ "$output" == *"No git identity"* ]] || false +} + +@test "git-id: setting needs BOTH name and email" { + clikae init claude work + run clikae git-id claude work --name "Only Name" + [ "$status" -ne 0 ] + [[ "$output" == *"email"* ]] || false +} + +@test "git-id: refuses an unknown tank" { + run clikae git-id claude nope --name N --email e@x.com + [ "$status" -ne 0 ] + [[ "$output" == *"No such tank"* ]] || false +} + +@test "git-id: rejects a tab/newline in a field" { + clikae init claude work + run clikae git-id claude work --name "$(printf 'a\tb')" --email e@x.com + [ "$status" -ne 0 ] + [[ "$output" == *"tabs or newlines"* ]] || false +} + +# --- env integration: the whole point — exports appear ONLY when set --- + +@test "env: emits the four git vars when the tank has an identity" { + clikae init claude work + clikae git-id claude work --name "Chodai CT" --email "x@cver.net" + run clikae env claude work + [ "$status" -eq 0 ] + [[ "$output" == *"export GIT_AUTHOR_NAME="* ]] || false + [[ "$output" == *"export GIT_AUTHOR_EMAIL="* ]] || false + [[ "$output" == *"export GIT_COMMITTER_NAME="* ]] || false + [[ "$output" == *"export GIT_COMMITTER_EMAIL="* ]] || false + # The value survives an eval (spaces in the name). + eval "$(clikae env claude work)" + [ "$GIT_AUTHOR_NAME" = "Chodai CT" ] + [ "$GIT_AUTHOR_EMAIL" = "x@cver.net" ] + [ "$GIT_COMMITTER_EMAIL" = "x@cver.net" ] +} + +@test "env: emits NO git vars when the tank has no identity (safe default)" { + clikae init claude work + run clikae env claude work + [ "$status" -eq 0 ] + [[ "$output" == *"CLAUDE_CONFIG_DIR"* ]] || false # the normal export is still there + [[ "$output" != *"GIT_AUTHOR"* ]] || false # but no git vars +} + +# THE PROOF (the whole point of issue #22): a REAL `git commit` in a shell that +# eval'd `clikae env` is authored by the TANK's identity — and that identity beats +# the repo's own `git config`, which is the §13 incident's exact failure path. +@test "env: the exported identity actually stamps a real commit (beats git config)" { + command -v git >/dev/null 2>&1 || skip "git not available" + clikae init claude work + clikae git-id claude work --name "Chodai CT" --email "x@cver.net" + local repo="$BATS_TEST_TMPDIR/repo"; mkdir -p "$repo" + ( + cd "$repo" + git init -q + # The repo's OWN config says someone else — the env vars must win. + git config user.name "Wrong Person" + git config user.email "wrong@example.com" + eval "$(clikae env claude work)" + echo hi > f.txt; git add f.txt + git commit -q -m "test" + git log -1 --format='%an|%ae|%cn|%ce' > "$BATS_TEST_TMPDIR/who" + ) + [ "$(cat "$BATS_TEST_TMPDIR/who")" = "Chodai CT|x@cver.net|Chodai CT|x@cver.net" ] +} + +# Eval-injection safety: a hostile name/email survives the env round-trip as DATA, +# never executes. (cmd_git_id already blocks tabs/newlines; quotes/`$()`/`;` must +# pass through harmlessly via _env_shquote.) +@test "env: a hostile git identity is quoted, not executed" { + clikae init claude work + local pwn="$BATS_TEST_TMPDIR/pwn" pwn2="$BATS_TEST_TMPDIR/pwn2" + local nm='a$(touch '"$pwn"')b' # a command substitution + local em="e';touch $pwn2;'@x.com" # a quote-break + ; + clikae git-id claude work --name "$nm" --email "$em" + eval "$(clikae env claude work)" + [ ! -e "$pwn" ] # the $() did NOT run + [ ! -e "$pwn2" ] # the ; did NOT run + [ "$GIT_AUTHOR_NAME" = "$nm" ] # preserved verbatim + [ "$GIT_AUTHOR_EMAIL" = "$em" ] +}