firegoat 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +73 -12
  2. package/dist/cli.js +250 -94
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,12 +7,12 @@ Status: early. See `plan.md`.
7
7
  ## Quickstart
8
8
 
9
9
  ```bash
10
- npx firegoat init # Creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
10
+ npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
11
11
  # Edit firegoat.yaml (or paste the prompt into your coding agent and let it)
12
- npx firegoat eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
12
+ npx firegoat@latest eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
13
13
  ```
14
14
 
15
- That's it: two commands. `claude` must be installed and logged in (run `claude` once).
15
+ That's it: two commands. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat 0.18.0`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
16
16
 
17
17
  `init` installs the firegoat-onboarding skill globally, into `~/.claude/skills/` (in a terminal it asks: global, this repo, or none). `--local-skills` puts it in this repo's `.claude/skills/` instead (commit it so teammates and CI get it); `--no-skills` skips it.
18
18
 
@@ -98,6 +98,21 @@ tests:
98
98
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
99
99
  ```
100
100
 
101
+ ### Start from a template project
102
+
103
+ ```yaml
104
+ build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
105
+ install: cp -R "$FIREGOAT_OUT/starter-app/." .
106
+ tests:
107
+ - name: add-auth
108
+ prompt: Add acme login to this app. Keep the existing pages working.
109
+ end_goal: The app signs users in with acme, and the existing pages still render.
110
+ checks:
111
+ - run: npm test
112
+ ```
113
+
114
+ No template feature needed: keep the project committed in `fixtures/starter-app/`, `build` copies it once per eval, and `install` copies it into each fresh folder, so every run begins in its own copy of that project.
115
+
101
116
  ### Same tests on several models
102
117
 
103
118
  ```yaml
@@ -113,11 +128,43 @@ tests:
113
128
 
114
129
  `model:` is passed to claude as `--model`. A list is a matrix: each test runs `runs` times per model, numbered `1..runs×models` (here `quickstart/1`, `2` on haiku, `3`, `4` on sonnet). `npx firegoat eval --model claude-opus-5-5` replaces it for one eval; repeat the flag for a matrix. Unset, firegoat passes no `--model` and claude uses its default. Each run's `run.json` records `model` (what firegoat asked for, or null) and `claudeModel` (what claude reported starting with), and the output labels each run with its model.
115
130
 
116
- ### Env vars for the agent: a home dir per run, a feature flag
131
+ ### Compare two setups
132
+
133
+ ```yaml
134
+ build: npm pack --pack-destination "$FIREGOAT_OUT"
135
+ tests:
136
+ - name: deploy-local-build
137
+ prompt: &deploy Deploy this app to an acme preview environment and print the URL.
138
+ end_goal: &deployed The app runs on an acme preview URL, and the URL is printed.
139
+ install: npm install "$FIREGOAT_OUT"/acme-*.tgz
140
+ - name: deploy-npm-release
141
+ prompt: *deploy
142
+ end_goal: *deployed
143
+ install: npm install acme@latest
144
+ ```
145
+
146
+ There is no variants feature: to compare setups (your local build vs the npm release, with vs without `--chrome`, docs A vs docs B), write one test per setup with the same prompt and a different per-test `install:`, `env:` or `claude_args:`. A YAML anchor (`&deploy`) and alias (`*deploy`) keep the prompt in one place. Skills installed into the run's folder are not loaded as skills: eval runs claude with `--setting-sources ""` and `--disable-slash-commands`, so a with/without-skill comparison isn't possible yet.
147
+
148
+ ### Extra claude flags
149
+
150
+ ```yaml
151
+ claude_args: [--plugin-dir, ./plugins/acme] # Every test
152
+ tests:
153
+ - name: dashboard-key
154
+ prompt: Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put it in .env as ACME_API_KEY.
155
+ end_goal: .env holds a key copied from the dashboard, not a made-up one.
156
+ claude_args: [--chrome] # Claude in Chrome, this test only
157
+ ```
158
+
159
+ `claude_args:` (top level, and per test after the top-level ones) is passed to claude after firegoat's own flags and `--model`. Each entry is one argument, as-is, with no shell splitting: `[--plugin-dir, ./plugins/acme]`, not `["--plugin-dir ./plugins/acme"]`. Use it for `--chrome`, `--plugin-dir`, `--mcp-config`, `--agents`, `--append-system-prompt`. The flags firegoat sets itself are config errors, also as `--flag=value`: `-p`, `--print`, `--output-format`, `--verbose`, `--setting-sources`, `--strict-mcp-config`, `--disable-slash-commands`, `--dangerously-skip-permissions`, `--model` (use `model:`), and `--session-id`, `--resume`, `-r`, `--continue`, `-c` (every run is a fresh session). Each run's `run.json` records `claudeArgs`.
160
+
161
+ With `--chrome`, claude points the Claude in Chrome extension's native host (`~/Library/Application Support/<browser>/NativeMessagingHosts/com.anthropic.claude_code_browser_extension.json`) at `.firegoat/.claude`. If you delete `.firegoat/`, run a normal `claude --chrome` once to point it back.
162
+
163
+ ### Env vars for the agent: a feature flag, a tool that ignores HOME
117
164
 
118
165
  ```yaml
119
166
  env:
120
- ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # Sessions/config never collide across runs
167
+ ACME_HOME: "$FIREGOAT_HOME/.acme" # Only for a tool that ignores HOME
121
168
  ACME_TELEMETRY: "off"
122
169
  UV_FIND_LINKS: "$FIREGOAT_OUT" # Resolve the wheel build: made
123
170
  tests:
@@ -130,21 +177,35 @@ tests:
130
177
  - run: ls "$ACME_HOME"/sessions | grep -q .
131
178
  ```
132
179
 
133
- `env:` goes to the agent, and to `install`, checks and `cleanup` of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` (also `${...}`), the same dirs `build`, `install` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `build` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`.
180
+ `env:` goes to the agent, and to `install`, checks and `cleanup` of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` / `$FIREGOAT_HOME` (also `${...}`), the same dirs `build`, `install` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `build` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`. `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME` and `CLAUDE_ENV_FILE` are reserved too: every run gets its own home.
181
+
182
+ ### Each run gets its own HOME
183
+
184
+ Every run has its own home, `.firegoat/runs/<runId>/<test>/<n>/home/`, with `.config`, `.cache`, `.local/share` and `.local/state` for the XDG dirs. A tool that keeps logins, config or caches in `~` starts fresh in every run, and parallel runs don't collide. Point a tool's own `*_HOME` var at `$FIREGOAT_HOME` only if the tool ignores `HOME`.
185
+
186
+ - The agent's shell commands see it: firegoat writes `<n>/home.env` (exporting `HOME` and `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`) and passes it to claude as `CLAUDE_ENV_FILE`, which claude sources before every Bash command. claude itself keeps your real `HOME`, where its login is (the macOS keychain lookup, `~/.claude/.credentials.json` on Linux).
187
+ - `install`, checks and `cleanup` get `HOME` and the XDG vars set to the run's home, plus `$FIREGOAT_HOME`. `build` keeps your real home.
188
+ - `run.json` records it as `home`.
189
+
190
+ Limits:
134
191
 
135
- Setting `HOME`: on macOS claude reads its login from `$HOME/Library`, so with a per-run `HOME` every run says "Not logged in" unless `install` runs `ln -s <real home>/Library "$HOME/Library"`. Get the real home in `build`, which doesn't get entries using `{{test.name}}` or `{{run.n}}` (e.g. `build: echo "$HOME" > "$FIREGOAT_OUT/.real-home"`).
192
+ - claude's own Read/Write/Edit tools expand `~` to your real home; only Bash commands get the run's home.
193
+ - Lookups by user id (`os.userInfo().homedir`, python's `pwd`, `getpwuid`) still return your real home.
194
+ - Keychain-backed logins (`gh`, `docker`, git credential helpers) look logged out inside a run. Log tools in from `install:`, or pass tokens with `env:`.
195
+ - The fresh home has no `.gitconfig`: if the agent commits, set `git config --global user.name ...` / `user.email` in `install:`.
196
+ - claude's shell snapshot still sources your real `~/.zshrc` (or `~/.bashrc`).
136
197
 
137
198
  ## What eval does
138
199
 
139
- - claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
200
+ - claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers (add flags with `claude_args:`, see above). claude saves its session there, in its own format.
140
201
  - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. `.firegoat/` holds local run output and ignores itself in git. Keep test fixtures (scripts, sample data) outside it, e.g. in `fixtures/` next to `firegoat.yaml`, and commit them.
141
202
  - In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `build:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost, uploaded, uploadFailed`) to tell runs from different branches apart.
142
- - The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too.
143
- - Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
203
+ - The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too, and so are the vars a parent Claude Code session sets (when firegoat runs from claude, or its agent runs firegoat), so the claude under test doesn't act as a nested session: `CLAUDECODE`, `CLAUDE_CODE_*`, `CLAUDE_PID`, `CLAUDE_ENV_FILE`, `CLAUDE_SESSION_ID`, `CLAUDE_PROJECT_DIR`, `CLAUDE_EFFORT`, `CLAUDE_AGENT_SDK_*`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA`, `CLAUDE_SKILL_DIR`. Login and provider vars stay: `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN`, `CLAUDE_CODE_USE_*` (Bedrock, Vertex, ...), `CLAUDE_CODE_SKIP_*_AUTH`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE`.
204
+ - Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session and HOME; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
144
205
  - Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
145
- - Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
206
+ - Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, `home/` and `home.env` (the run's HOME), logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
146
207
 
147
- `firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `cleanup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`, `install`, `cleanup`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
208
+ `firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `cleanup`, `vars`, `env`, `claude_args`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`, `install`, `cleanup`, `claude_args`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
148
209
 
149
210
  ### Stop what the agent left running, seed one test
150
211
 
package/dist/cli.js CHANGED
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS(function(exports) {
6947
6947
  // package.json
6948
6948
  var package_default = {
6949
6949
  name: "firegoat",
6950
- version: "0.18.0",
6950
+ version: "0.19.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7081,6 +7081,7 @@ function parseConfig(text) {
7081
7081
  "cleanup",
7082
7082
  "vars",
7083
7083
  "env",
7084
+ "claude_args",
7084
7085
  "tests"
7085
7086
  ]);
7086
7087
  const runs = posInt(raw.runs, "runs", 1);
@@ -7090,6 +7091,7 @@ function parseConfig(text) {
7090
7091
  const cleanup = commands(raw.cleanup, "cleanup");
7091
7092
  const vars = parseVars(raw.vars);
7092
7093
  const env = parseEnv(raw.env, "env");
7094
+ const claudeArgs = parseClaudeArgs(raw.claude_args, "claude_args");
7093
7095
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7094
7096
  fail("tests", "must be a non-empty list");
7095
7097
  const names = new Set;
@@ -7105,7 +7107,8 @@ function parseConfig(text) {
7105
7107
  "timeout",
7106
7108
  "env",
7107
7109
  "install",
7108
- "cleanup"
7110
+ "cleanup",
7111
+ "claude_args"
7109
7112
  ]);
7110
7113
  const name = str(t.name, `${p}.name`);
7111
7114
  if (!NAME.test(name))
@@ -7132,7 +7135,11 @@ function parseConfig(text) {
7132
7135
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
7133
7136
  env: { ...env, ...parseEnv(t.env, `${p}.env`) },
7134
7137
  install: commands(t.install, `${p}.install`),
7135
- cleanup: commands(t.cleanup, `${p}.cleanup`)
7138
+ cleanup: commands(t.cleanup, `${p}.cleanup`),
7139
+ claudeArgs: [
7140
+ ...claudeArgs,
7141
+ ...parseClaudeArgs(t.claude_args, `${p}.claude_args`)
7142
+ ]
7136
7143
  };
7137
7144
  });
7138
7145
  return {
@@ -7172,6 +7179,41 @@ function parseModels(raw) {
7172
7179
  return name;
7173
7180
  });
7174
7181
  }
7182
+ var OWNED_FLAGS = [
7183
+ [
7184
+ [
7185
+ "-p",
7186
+ "--print",
7187
+ "--output-format",
7188
+ "--verbose",
7189
+ "--setting-sources",
7190
+ "--strict-mcp-config",
7191
+ "--disable-slash-commands",
7192
+ "--dangerously-skip-permissions"
7193
+ ],
7194
+ "firegoat sets it for every run"
7195
+ ],
7196
+ [["--model"], "choose the model with top-level model: (or eval --model)"],
7197
+ [
7198
+ ["--session-id", "--resume", "-r", "--continue", "-c"],
7199
+ "every run is a fresh session"
7200
+ ]
7201
+ ];
7202
+ function parseClaudeArgs(raw, path) {
7203
+ if (raw === undefined)
7204
+ return [];
7205
+ if (!Array.isArray(raw))
7206
+ fail(path, "must be a list of claude arguments, one per entry, e.g. [--chrome]");
7207
+ return raw.map((a, i) => {
7208
+ const p = `${path}[${i}]`;
7209
+ const arg = str(a, p);
7210
+ for (const [flags, why] of OWNED_FLAGS)
7211
+ for (const f of flags)
7212
+ if (arg === f || arg.startsWith(`${f}=`))
7213
+ fail(p, `${f} is not allowed: ${why}`);
7214
+ return arg;
7215
+ });
7216
+ }
7175
7217
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7176
7218
  function parseVars(raw) {
7177
7219
  if (raw === undefined)
@@ -7193,10 +7235,18 @@ var RESERVED_ENV = new Set([
7193
7235
  "CLAUDE_CONFIG_DIR",
7194
7236
  "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7195
7237
  ]);
7238
+ var HOME_ENV = new Set([
7239
+ "HOME",
7240
+ "XDG_CONFIG_HOME",
7241
+ "XDG_CACHE_HOME",
7242
+ "XDG_DATA_HOME",
7243
+ "XDG_STATE_HOME",
7244
+ "CLAUDE_ENV_FILE"
7245
+ ]);
7196
7246
  var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
7197
7247
  var EVAL_PLACEHOLDERS = ["run.id"];
7198
7248
  var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7199
- var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
7249
+ var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)(?![A-Za-z0-9_]))/g;
7200
7250
  var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
7201
7251
  function parseEnv(raw, path) {
7202
7252
  if (raw === undefined)
@@ -7210,6 +7260,8 @@ function parseEnv(raw, path) {
7210
7260
  fail(p, "name must be letters, digits and _, not starting with a digit");
7211
7261
  if (k === "ANTHROPIC_MODEL")
7212
7262
  fail(p, "is reserved: choose the model with top-level model: (or eval --model)");
7263
+ if (HOME_ENV.has(k))
7264
+ fail(p, "Reserved: every run gets its own home ($FIREGOAT_HOME)");
7213
7265
  if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7214
7266
  fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7215
7267
  if (typeof v !== "string")
@@ -7267,11 +7319,11 @@ import {
7267
7319
  appendFileSync,
7268
7320
  createWriteStream as createWriteStream2,
7269
7321
  existsSync as existsSync3,
7270
- mkdirSync as mkdirSync3,
7322
+ mkdirSync as mkdirSync4,
7271
7323
  readdirSync as readdirSync2,
7272
- writeFileSync as writeFileSync3
7324
+ writeFileSync as writeFileSync4
7273
7325
  } from "node:fs";
7274
- import { basename as basename3, delimiter as delimiter2, join as join5 } from "node:path";
7326
+ import { basename as basename3, delimiter as delimiter2, join as join6 } from "node:path";
7275
7327
 
7276
7328
  // src/claude.ts
7277
7329
  import { spawn, spawnSync } from "node:child_process";
@@ -7286,15 +7338,18 @@ var CLAUDE_FLAGS = [
7286
7338
  "--disable-slash-commands"
7287
7339
  ];
7288
7340
  var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7341
+ var CLAUDE_SESSION = /^(CLAUDECODE|CLAUDE_CODE_.+|CLAUDE_PID|CLAUDE_ENV_FILE|CLAUDE_SESSION_ID|CLAUDE_PROJECT_DIR|CLAUDE_EFFORT|CLAUDE_AGENT_SDK_.+|CLAUDE_PLUGIN_ROOT|CLAUDE_PLUGIN_DATA|CLAUDE_SKILL_DIR)$/;
7342
+ var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKIP_[A-Z]+_AUTH|CLIENT_CERT|CLIENT_KEY|CLIENT_KEY_PASSPHRASE)$/;
7289
7343
  function hostEnv() {
7290
- return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_")));
7344
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
7291
7345
  }
7292
- function claudeEnv(configDir, env = {}) {
7346
+ function claudeEnv(configDir, env = {}, envFile) {
7293
7347
  return {
7294
7348
  ...hostEnv(),
7295
7349
  ...env,
7296
7350
  CLAUDE_CONFIG_DIR: configDir,
7297
- CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
7351
+ CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
7352
+ ...envFile === undefined ? {} : { CLAUDE_ENV_FILE: envFile }
7298
7353
  };
7299
7354
  }
7300
7355
  function run(opts) {
@@ -7337,7 +7392,8 @@ async function runClaude(opts) {
7337
7392
  "-p",
7338
7393
  opts.prompt,
7339
7394
  ...CLAUDE_FLAGS,
7340
- ...opts.model === undefined ? [] : ["--model", opts.model]
7395
+ ...opts.model === undefined ? [] : ["--model", opts.model],
7396
+ ...opts.extraArgs
7341
7397
  ],
7342
7398
  cwd: opts.cwd,
7343
7399
  env: opts.env,
@@ -7828,15 +7884,19 @@ function topHelp(version) {
7828
7884
  folder, with none of your claude config; firegoat saves the session and runs your
7829
7885
  script checks.
7830
7886
 
7831
- npx firegoat init Create firegoat.yaml and .firegoat/, install the skill
7832
- npx firegoat eval Run the tests (claude must be logged in: run \`claude\` once)
7833
- npx firegoat login Log in to firegoat.ai: eval then uploads each session there
7834
- npx firegoat logout Forget that login
7835
- npx firegoat feedback "<what got in the way>" Tell the firegoat team
7887
+ npx firegoat@latest init Create firegoat.yaml and .firegoat/, install the skill
7888
+ npx firegoat@latest eval Run the tests (claude must be logged in: run \`claude\`)
7889
+ npx firegoat@latest login Log in to firegoat.ai: eval then uploads each session
7890
+ npx firegoat@latest logout Forget that login
7891
+ npx firegoat@latest feedback "<what got in the way>"
7892
+ Tell the firegoat team
7893
+
7894
+ Use npx firegoat@latest: plain npx firegoat can keep running an old cached version.
7895
+ Each eval prints its version first.
7836
7896
 
7837
7897
  Typical flow for a coding agent driving firegoat:
7838
- 1. Read npx firegoat init --help, then edit firegoat.yaml
7839
- 2. npx firegoat eval --json
7898
+ 1. Read npx firegoat@latest init --help, then edit firegoat.yaml
7899
+ 2. npx firegoat@latest eval --json
7840
7900
  3. Judge each run's end_goal from its run.json, session and workdir: firegoat only
7841
7901
  runs the script checks
7842
7902
 
@@ -7880,20 +7940,29 @@ firegoat.yaml reference:
7880
7940
  env: {NAME: value} Env vars for the agent, and for install, checks
7881
7941
  and cleanup of the same run; values are strings and can
7882
7942
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
7883
- {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
7884
- (no other $ expansion: $HOME stays literal).
7885
- The build step gets only the entries that use only
7886
- {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7943
+ {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR,
7944
+ $FIREGOAT_HOME (no other $ expansion: $HOME stays
7945
+ literal). The build step gets only the entries that
7946
+ use only {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7887
7947
  PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
7888
- ANTHROPIC_MODEL (use model:) are reserved.
7889
- Runs go 4 at a time: give a tool with a global home
7890
- a per-run one (or use eval --parallel 1), e.g.
7891
- MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
7892
- Setting HOME: on macOS claude reads its login from
7893
- $HOME/Library, so a per-run HOME needs
7894
- ln -s <real home>/Library "$HOME/Library" in
7895
- install (get the real home in build, which
7896
- doesn't get entries using {{test.name}}/{{run.n}}).
7948
+ ANTHROPIC_MODEL (use model:) are reserved, and so
7949
+ are HOME, XDG_CONFIG_HOME, XDG_CACHE_HOME,
7950
+ XDG_DATA_HOME, XDG_STATE_HOME and CLAUDE_ENV_FILE:
7951
+ every run gets its own home ($FIREGOAT_HOME), so a
7952
+ tool that keeps state in ~ starts fresh in each
7953
+ run. Only a tool that ignores HOME needs its own
7954
+ var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
7955
+ claude_args: [<arg>, ...] Extra claude arguments, after firegoat's own flags
7956
+ and --model. Each entry is one argument, passed
7957
+ as-is (no shell splitting): [--chrome] (Claude in
7958
+ Chrome, for tests that need a browser),
7959
+ [--plugin-dir, ./plugins/x], --mcp-config, --agents,
7960
+ --append-system-prompt. Flags firegoat sets are
7961
+ errors, also as --flag=value: -p, --print,
7962
+ --output-format, --verbose, --setting-sources,
7963
+ --strict-mcp-config, --disable-slash-commands,
7964
+ --dangerously-skip-permissions, --model (use
7965
+ model:), --session-id, --resume, -r, --continue, -c
7897
7966
  tests:
7898
7967
  - name: quickstart Letters, digits, '.', '_', '-'; unique
7899
7968
  prompt: ... What a user would ask the agent; one concrete outcome
@@ -7905,14 +7974,20 @@ firegoat.yaml reference:
7905
7974
  env: {NAME: value} Optional, on top of the top-level env:
7906
7975
  install: <sh> | [...] Optional, runs after the top-level install
7907
7976
  cleanup: <sh> | [...] Optional, runs before the top-level cleanup
7977
+ claude_args: [...] Optional, after the top-level claude_args
7908
7978
 
7909
7979
  Step order: build once per eval; then per run: install (top-level, then the
7910
7980
  test's), the agent, checks, cleanup (the test's, then top-level).
7911
7981
 
7912
7982
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7913
- e.g. fixtures/ (committed) and copy them in build or install. The build, install
7914
- and cleanup commands and the checks get the vars as env vars, plus $FIREGOAT_OUT and
7915
- $FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
7983
+ e.g. fixtures/ (committed) and copy them in build or install. To start each run
7984
+ from a template project, commit it as e.g. fixtures/starter-app/, then:
7985
+ build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
7986
+ install: cp -R "$FIREGOAT_OUT/starter-app/." .
7987
+ The build, install and cleanup commands and the checks get the vars as env
7988
+ vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
7989
+ get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
7990
+ Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
7916
7991
  transcript, greppable line by line: "[1] $ <command>" on every line of a shell
7917
7992
  call (one line per line of the command, same n), "[2] Write <path>" for other
7918
7993
  tools, each output indented, and "agent: " on every line of the agent's text and
@@ -7942,6 +8017,12 @@ Example:
7942
8017
  - run: |
7943
8018
  test -s "$FIREGOAT_TRANSCRIPT" &&
7944
8019
  ! grep -Eq '^\\[[0-9]+\\] \\$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
8020
+ - name: dashboard-key
8021
+ prompt: >-
8022
+ Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
8023
+ it in .env as ACME_API_KEY.
8024
+ end_goal: .env holds a key copied from the dashboard, not a made-up one.
8025
+ claude_args: [--chrome]
7945
8026
 
7946
8027
  Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
7947
8028
  install failed, 3 usage error.`;
@@ -7993,9 +8074,30 @@ changes the run's status or the exit code.
7993
8074
 
7994
8075
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
7995
8076
  with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
7996
- plugins, skills or MCP servers. claude saves its session there. There is no
8077
+ plugins, skills or MCP servers (claude_args: in firegoat.yaml adds claude flags,
8078
+ e.g. --chrome or --plugin-dir). claude saves its session there. There is no
7997
8079
  sandbox: the agent can reach the rest of your machine, including the repo.
7998
8080
 
8081
+ Every run gets its own HOME, .firegoat/runs/<runId>/<test>/<n>/home (with
8082
+ XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
8083
+ $FIREGOAT_HOME: tools that keep state in ~ start fresh and parallel runs don't
8084
+ collide. claude itself keeps your real HOME (its login is there) and sources
8085
+ <n>/home.env (CLAUDE_ENV_FILE) before each Bash command the agent runs; install,
8086
+ checks and cleanup get the run's HOME directly; build keeps yours. Limits: claude's
8087
+ own Read/Write tools expand ~ to your real home; getpwuid lookups (os.userInfo,
8088
+ python pwd) still see it; keychain logins (gh, docker, git credential helpers) look
8089
+ logged out inside a run, so log tools in from install: or with env: tokens; the
8090
+ fresh home has no .gitconfig, so set user.name/user.email in install: if the agent
8091
+ commits; claude's shell snapshot still sources your real ~/.zshrc.
8092
+
8093
+ Vars a parent Claude Code session sets (CLAUDECODE, CLAUDE_CODE_*, CLAUDE_PID,
8094
+ CLAUDE_ENV_FILE, CLAUDE_SESSION_ID, CLAUDE_PROJECT_DIR, CLAUDE_EFFORT,
8095
+ CLAUDE_AGENT_SDK_*, CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA, CLAUDE_SKILL_DIR) are
8096
+ removed from every env firegoat starts, so the claude under test doesn't act as a
8097
+ nested session. Login and provider vars stay: ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOKEN,
8098
+ CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_USE_*, CLAUDE_CODE_SKIP_*_AUTH,
8099
+ CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, CLAUDE_CODE_CLIENT_KEY_PASSPHRASE.
8100
+
7999
8101
  In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
8000
8102
  live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
8001
8103
  fixtures and build: stay in the current folder.
@@ -8005,8 +8107,8 @@ fixtures and build: stay in the current folder.
8005
8107
  --model NAME claude --model, replacing model: in firegoat.yaml; repeat it
8006
8108
  (--model a --model b) to run each test runs times per model
8007
8109
  --parallel N Runs at once (default 4; 1 runs them one after another).
8008
- Tools with global on-disk state collide: give each run its
8009
- own home via env: ({{run.id}}-{{test.name}}-{{run.n}}) or use 1
8110
+ Each run has its own HOME; state outside it (a fixed port,
8111
+ /tmp paths) can still collide: use 1 then
8010
8112
  --var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
8011
8113
  cleanup
8012
8114
  --json The summary as JSON on stdout (or {error, exitCode} if eval
@@ -8034,9 +8136,10 @@ Output: .firegoat/runs/<runId>/
8034
8136
  uploadFailed
8035
8137
  <test>/<n>/run.json One run: status and reason (from the checks), model
8036
8138
  (asked for, or null) and claudeModel (what claude's
8037
- init line reported), prompt, end_goal,
8139
+ init line reported), claudeArgs (claude_args
8140
+ passed), prompt, end_goal,
8038
8141
  checks [{run, code}], cleanup [{run, code}],
8039
- and paths: workdir,
8142
+ and paths: workdir, home (the run's HOME),
8040
8143
  transcript, session (claude's session file), stderr,
8041
8144
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8042
8145
  url} | {status: "failed", error} | {status:
@@ -8045,6 +8148,8 @@ Output: .firegoat/runs/<runId>/
8045
8148
  ("[n] $ " on every line of a shell call), short output
8046
8149
  (indented), its text and final answer ("agent: " on
8047
8150
  every line), files it made
8151
+ <test>/<n>/home/, home.env The run's HOME, and the file claude sources
8152
+ before the agent's Bash commands
8048
8153
  build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
8049
8154
 
8050
8155
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
@@ -8083,6 +8188,30 @@ Example:
8083
8188
 
8084
8189
  Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
8085
8190
 
8191
+ // src/home.ts
8192
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
8193
+ import { join as join3 } from "node:path";
8194
+ function homeEnv(home) {
8195
+ return {
8196
+ HOME: home,
8197
+ XDG_CONFIG_HOME: join3(home, ".config"),
8198
+ XDG_CACHE_HOME: join3(home, ".cache"),
8199
+ XDG_DATA_HOME: join3(home, ".local", "share"),
8200
+ XDG_STATE_HOME: join3(home, ".local", "state")
8201
+ };
8202
+ }
8203
+ var shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
8204
+ function makeRunHome(dir) {
8205
+ const home = join3(dir, "home");
8206
+ const env = homeEnv(home);
8207
+ for (const d of Object.values(env))
8208
+ mkdirSync3(d, { recursive: true });
8209
+ const envFile = join3(dir, "home.env");
8210
+ writeFileSync2(envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}
8211
+ `).join(""));
8212
+ return { home, envFile };
8213
+ }
8214
+
8086
8215
  // src/steps.ts
8087
8216
  import { createWriteStream } from "node:fs";
8088
8217
  var STEP_TIMEOUT_MS = 600000;
@@ -8124,8 +8253,8 @@ async function runAllSteps(opts) {
8124
8253
  }
8125
8254
 
8126
8255
  // src/transcript.ts
8127
- import { readdirSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
8128
- import { join as join3, relative } from "node:path";
8256
+ import { readdirSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
8257
+ import { join as join4, relative } from "node:path";
8129
8258
  var OUTPUT_LINES = 15;
8130
8259
  var OUTPUT_CHARS = 1500;
8131
8260
  var TEXT_CHARS = 1500;
@@ -8169,7 +8298,7 @@ function workdirFiles(dir) {
8169
8298
  for (const name of readdirSync(d)) {
8170
8299
  if (SKIP_DIRS.has(name))
8171
8300
  continue;
8172
- const p = join3(d, name);
8301
+ const p = join4(d, name);
8173
8302
  if (statSync(p).isDirectory())
8174
8303
  walk(p);
8175
8304
  else
@@ -8212,7 +8341,7 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
8212
8341
  out.pop();
8213
8342
  out.push("", "## final answer", final ? prose(final) : "(none)");
8214
8343
  out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
8215
- writeFileSync2(opts.path, `${out.join(`
8344
+ writeFileSync3(opts.path, `${out.join(`
8216
8345
  `)}
8217
8346
  `);
8218
8347
  }
@@ -8392,7 +8521,7 @@ function ttyUI(out, opts) {
8392
8521
  const doneCount = c.pass + c.fail + c.error;
8393
8522
  const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
8394
8523
  const out = [
8395
- ...logoHeader(p, `firegoat eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
8524
+ ...logoHeader(p, `firegoat ${opts.version} eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
8396
8525
  rule
8397
8526
  ];
8398
8527
  if (authText)
@@ -8653,7 +8782,7 @@ async function uploadRun(t, r) {
8653
8782
 
8654
8783
  // src/which.ts
8655
8784
  import { accessSync, constants, statSync as statSync2 } from "node:fs";
8656
- import { delimiter, join as join4, resolve } from "node:path";
8785
+ import { delimiter, join as join5, resolve } from "node:path";
8657
8786
  function resolveBin(bin) {
8658
8787
  const executable = (p) => {
8659
8788
  try {
@@ -8666,8 +8795,8 @@ function resolveBin(bin) {
8666
8795
  if (bin.includes("/"))
8667
8796
  return executable(bin) ? resolve(bin) : undefined;
8668
8797
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
8669
- if (dir && executable(join4(dir, bin)))
8670
- return join4(dir, bin);
8798
+ if (dir && executable(join5(dir, bin)))
8799
+ return join5(dir, bin);
8671
8800
  }
8672
8801
  return;
8673
8802
  }
@@ -8740,11 +8869,11 @@ function parseArgs(args) {
8740
8869
  return opts;
8741
8870
  }
8742
8871
  function findSession(configDir, sessionId) {
8743
- const projects = join5(configDir, "projects");
8872
+ const projects = join6(configDir, "projects");
8744
8873
  if (!existsSync3(projects))
8745
8874
  return null;
8746
8875
  for (const d of readdirSync2(projects)) {
8747
- const f = join5(projects, d, `${sessionId}.jsonl`);
8876
+ const f = join6(projects, d, `${sessionId}.jsonl`);
8748
8877
  if (existsSync3(f))
8749
8878
  return f;
8750
8879
  }
@@ -8755,7 +8884,7 @@ function newRunId() {
8755
8884
  return `${ts}-${randomBytes2(2).toString("hex")}`;
8756
8885
  }
8757
8886
  function writeRunJson(r) {
8758
- writeFileSync3(join5(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
8887
+ writeFileSync4(join6(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
8759
8888
  `);
8760
8889
  }
8761
8890
  async function uploadPreflight(opts, ui, project, runGroup, git) {
@@ -8797,14 +8926,16 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
8797
8926
  async function runOne(opts) {
8798
8927
  const { test, n } = opts;
8799
8928
  const started = Date.now();
8800
- const dir = join5(opts.runDir, test.name, String(n));
8801
- const workdir = join5(dir, "work");
8802
- mkdirSync3(workdir, { recursive: true });
8929
+ const dir = join6(opts.runDir, test.name, String(n));
8930
+ const workdir = join6(dir, "work");
8931
+ mkdirSync4(workdir, { recursive: true });
8932
+ const { home, envFile } = makeRunHome(dir);
8803
8933
  const r = {
8804
8934
  test: test.name,
8805
8935
  n,
8806
8936
  model: opts.model,
8807
8937
  claudeModel: null,
8938
+ claudeArgs: test.claudeArgs,
8808
8939
  status: "error",
8809
8940
  reason: "",
8810
8941
  prompt: render(test.prompt, opts.vars),
@@ -8812,12 +8943,13 @@ async function runOne(opts) {
8812
8943
  checks: [],
8813
8944
  dir,
8814
8945
  workdir,
8946
+ home,
8815
8947
  session: null,
8816
- transcript: join5(dir, "transcript.txt"),
8817
- stderr: join5(dir, "stderr.txt"),
8818
- checksLog: join5(dir, "checks.txt"),
8948
+ transcript: join6(dir, "transcript.txt"),
8949
+ stderr: join6(dir, "stderr.txt"),
8950
+ checksLog: join6(dir, "checks.txt"),
8819
8951
  cleanup: [],
8820
- cleanupLog: join5(dir, "cleanup.txt"),
8952
+ cleanupLog: join6(dir, "cleanup.txt"),
8821
8953
  num_turns: null,
8822
8954
  cost: null,
8823
8955
  duration_ms: 0,
@@ -8829,10 +8961,17 @@ async function runOne(opts) {
8829
8961
  "run.n": String(n),
8830
8962
  "test.name": test.name,
8831
8963
  FIREGOAT_OUT: opts.out,
8832
- FIREGOAT_WORKDIR: workdir
8964
+ FIREGOAT_WORKDIR: workdir,
8965
+ FIREGOAT_HOME: home
8833
8966
  });
8834
- const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8835
- const [status, reason] = await attempt(opts, r, env, stepEnv);
8967
+ const stepEnv = {
8968
+ ...opts.stepEnv,
8969
+ ...env,
8970
+ ...homeEnv(home),
8971
+ FIREGOAT_WORKDIR: workdir,
8972
+ FIREGOAT_HOME: home
8973
+ };
8974
+ const [status, reason] = await attempt(opts, r, env, stepEnv, envFile);
8836
8975
  const cleanup = [...test.cleanup, ...opts.cleanup];
8837
8976
  if (cleanup.length > 0)
8838
8977
  r.cleanup = await runAllSteps({
@@ -8847,7 +8986,7 @@ async function runOne(opts) {
8847
8986
  writeRunJson(r);
8848
8987
  return r;
8849
8988
  }
8850
- async function attempt(opts, r, env, stepEnv) {
8989
+ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
8851
8990
  const { test, n } = opts;
8852
8991
  const { dir, workdir } = r;
8853
8992
  const install = [...opts.install, ...test.install];
@@ -8857,7 +8996,7 @@ async function attempt(opts, r, env, stepEnv) {
8857
8996
  commands: install,
8858
8997
  cwd: workdir,
8859
8998
  env: stepEnv,
8860
- logPath: join5(dir, "install.txt")
8999
+ logPath: join6(dir, "install.txt")
8861
9000
  });
8862
9001
  if (error)
8863
9002
  return ["error", `not graded: ${error}`];
@@ -8867,10 +9006,14 @@ async function attempt(opts, r, env, stepEnv) {
8867
9006
  bin: opts.claudeBin,
8868
9007
  prompt: r.prompt,
8869
9008
  cwd: workdir,
8870
- env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
9009
+ env: {
9010
+ ...claudeEnv(opts.configDir, env, homeEnvFile),
9011
+ PATH: opts.stepEnv.PATH
9012
+ },
8871
9013
  stderr,
8872
9014
  timeoutMs: test.timeout * 1000,
8873
9015
  model: opts.model ?? undefined,
9016
+ extraArgs: r.claudeArgs,
8874
9017
  onEvent: opts.onEvent
8875
9018
  });
8876
9019
  r.claudeModel = outcome.claudeModel;
@@ -8943,7 +9086,13 @@ async function evalCommand(args, root) {
8943
9086
  }
8944
9087
  const say = opts.json ? console.error : console.log;
8945
9088
  const stream = opts.json ? process.stderr : process.stdout;
8946
- const ui = stream.isTTY ? ttyUI(stream, { project: basename3(root), color: !process.env.NO_COLOR }) : plainUI(say);
9089
+ const ui = stream.isTTY ? ttyUI(stream, {
9090
+ project: basename3(root),
9091
+ version: package_default.version,
9092
+ color: !process.env.NO_COLOR
9093
+ }) : plainUI(say);
9094
+ if (!stream.isTTY)
9095
+ say(`firegoat ${package_default.version}`);
8947
9096
  let config;
8948
9097
  try {
8949
9098
  config = loadConfig(root);
@@ -8982,8 +9131,8 @@ async function evalCommand(args, root) {
8982
9131
  return fatal(EXIT.usage, e.message);
8983
9132
  }
8984
9133
  const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
8985
- const configDir = join5(firegoatDir, ".claude");
8986
- mkdirSync3(configDir, { recursive: true });
9134
+ const configDir = join6(firegoatDir, ".claude");
9135
+ mkdirSync4(configDir, { recursive: true });
8987
9136
  ui.auth(process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login");
8988
9137
  const loginProblem = checkLogin({
8989
9138
  bin: claudePath,
@@ -9005,10 +9154,10 @@ async function evalCommand(args, root) {
9005
9154
  return fatal(EXIT.usage, e.message);
9006
9155
  throw e;
9007
9156
  }
9008
- const runsRoot = join5(firegoatDir, "runs");
9009
- const runDir = join5(runsRoot, runId);
9010
- const out = join5(runDir, "out");
9011
- mkdirSync3(out, { recursive: true });
9157
+ const runsRoot = join6(firegoatDir, "runs");
9158
+ const runDir = join6(runsRoot, runId);
9159
+ const out = join6(runDir, "out");
9160
+ mkdirSync4(out, { recursive: true });
9012
9161
  const stepEnv = {
9013
9162
  ...hostEnv(),
9014
9163
  ...vars,
@@ -9034,7 +9183,7 @@ async function evalCommand(args, root) {
9034
9183
  ...stepEnv,
9035
9184
  ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
9036
9185
  },
9037
- logPath: join5(runDir, "build.txt")
9186
+ logPath: join6(runDir, "build.txt")
9038
9187
  });
9039
9188
  const secs = Math.round((Date.now() - buildStarted) / 1000);
9040
9189
  ui.build(error ? "fail" : "ok", secs);
@@ -9107,6 +9256,7 @@ async function evalCommand(args, root) {
9107
9256
  totals[r.status]++;
9108
9257
  const uploadsFailed = results.filter((r) => r.upload?.status === "failed").length;
9109
9258
  const doc = {
9259
+ firegoatVersion: package_default.version,
9110
9260
  runId,
9111
9261
  runsDir: runDir,
9112
9262
  dir: root,
@@ -9115,9 +9265,9 @@ async function evalCommand(args, root) {
9115
9265
  totals,
9116
9266
  runs: results
9117
9267
  };
9118
- writeFileSync3(join5(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
9268
+ writeFileSync4(join6(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
9119
9269
  `);
9120
- appendFileSync(join5(runsRoot, "index.jsonl"), `${JSON.stringify({
9270
+ appendFileSync(join6(runsRoot, "index.jsonl"), `${JSON.stringify({
9121
9271
  runId,
9122
9272
  startedAt: new Date(evalStarted).toISOString(),
9123
9273
  dir: root,
@@ -9417,8 +9567,8 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9417
9567
 
9418
9568
  // src/init.ts
9419
9569
  import { spawnSync as spawnSync3 } from "node:child_process";
9420
- import { existsSync as existsSync4, writeFileSync as writeFileSync4 } from "node:fs";
9421
- import { basename as basename4, join as join6 } from "node:path";
9570
+ import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
9571
+ import { basename as basename4, join as join7 } from "node:path";
9422
9572
  import { createInterface } from "node:readline/promises";
9423
9573
 
9424
9574
  // src/login.ts
@@ -9613,7 +9763,7 @@ function skillsDir(scope) {
9613
9763
  if (scope === "local")
9614
9764
  return ".claude/skills/";
9615
9765
  const config = process.env.CLAUDE_CONFIG_DIR?.trim();
9616
- return config ? `${join6(config, "skills")}/` : "~/.claude/skills/";
9766
+ return config ? `${join7(config, "skills")}/` : "~/.claude/skills/";
9617
9767
  }
9618
9768
  function template(product) {
9619
9769
  return `# firegoat.yaml: evals for how well coding agents use ${product}.
@@ -9647,6 +9797,10 @@ runs: 1
9647
9797
  # Helper files (scripts, sample data) go in e.g. fixtures/ (committed), not inline here.
9648
9798
  # The build step runs in this folder, so: build: cp -R fixtures/. "$FIREGOAT_OUT"
9649
9799
  # Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
9800
+ #
9801
+ # To start each run from a template project, commit it as e.g. fixtures/starter-app/:
9802
+ # build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
9803
+ # install: cp -R "$FIREGOAT_OUT/starter-app/." .
9650
9804
 
9651
9805
  # Optional: cleanup: runs in each run's folder once the run is over (after the
9652
9806
  # checks, also after an error or timeout), e.g. to stop a server the agent left
@@ -9658,28 +9812,30 @@ runs: 1
9658
9812
 
9659
9813
  # Optional: env vars for the agent (and for install, checks and cleanup of the
9660
9814
  # same run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
9661
- # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
9662
- # other $ expansion: $HOME stays literal). A test can add its own env:.
9663
- # Runs go 4 at a time (--parallel), so give a tool with a global home (~/.mytool)
9664
- # a per-run one, as below, or use --parallel 1.
9815
+ # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR /
9816
+ # $FIREGOAT_HOME (no other $ expansion: $HOME stays literal). A test can add its
9817
+ # own env:. Every run gets its own HOME ($FIREGOAT_HOME), so tools that keep state
9818
+ # in ~ are isolated per run already; set a tool's own *_HOME var only if the tool
9819
+ # ignores HOME, as below.
9665
9820
  # env:
9666
- # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
9821
+ # MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
9667
9822
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
9668
9823
 
9669
9824
  tests:
9670
- - name: quickstart
9671
- # What a user would ask their agent. Edit this.
9672
- prompt: >-
9673
- I want to try ${product}. Get the smallest working example going in a new
9674
- project, run it, and save what it prints to output.txt. Keep it minimal.
9825
+ # A smoke test that firegoat and claude work. Replace it with tests for your
9826
+ # product: the firegoat-onboarding skill writes them.
9827
+ - name: goat
9828
+ # What a user would ask their agent.
9829
+ prompt: Who's the GOAT? Answer in one short sentence.
9675
9830
  # True when the user's problem is solved. Not graded by firegoat: it goes to
9676
9831
  # your agent (eval --json, run.json) to judge.
9677
- end_goal: >-
9678
- A minimal project uses ${product}, runs without errors, and output.txt holds
9679
- its output.
9832
+ end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
9680
9833
  # Shell commands run in the agent's folder afterwards; exit 0 = pass.
9834
+ # $FIREGOAT_TRANSCRIPT has "agent: " on every line of the agent's text.
9681
9835
  checks:
9682
- - run: test -s output.txt
9836
+ - run: >-
9837
+ test -s "$FIREGOAT_TRANSCRIPT" &&
9838
+ grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
9683
9839
  # Seconds before the agent run is stopped.
9684
9840
  timeout: 600
9685
9841
  `;
@@ -9798,16 +9954,16 @@ ${INIT_HELP}`);
9798
9954
  console.log(rule);
9799
9955
  }
9800
9956
  ensureFiregoatDir(cwd);
9801
- const configPath = join6(cwd, CONFIG_FILE);
9957
+ const configPath = join7(cwd, CONFIG_FILE);
9802
9958
  const keep = existsSync4(configPath) && !force;
9803
9959
  if (!keep)
9804
- writeFileSync4(configPath, template(basename4(cwd)));
9960
+ writeFileSync5(configPath, template(basename4(cwd)));
9805
9961
  if (interactive) {
9806
9962
  step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
9807
9963
  console.log(paint("90", " │"));
9808
9964
  step(2, ".firegoat/", "Ready");
9809
9965
  } else
9810
- console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
9966
+ console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: replace the goat smoke test with tests for your product`);
9811
9967
  if (interactive && !noSkills)
9812
9968
  console.log(paint("90", " │"));
9813
9969
  if (interactive && !noSkills && !localSkills) {
@@ -9861,7 +10017,7 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
9861
10017
  console.log(AGENT_PROMPT);
9862
10018
  console.log("");
9863
10019
  const copied = copyToClipboard(AGENT_PROMPT);
9864
- console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat eval")}`);
10020
+ console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat@latest eval")}`);
9865
10021
  return EXIT.pass;
9866
10022
  }
9867
10023
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"