firegoat 0.18.0 → 0.20.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 +86 -12
  2. package/dist/cli.js +309 -113
  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,56 @@ 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). Entries expand like `env:` values: `$FIREGOAT_WORKDIR`, `$FIREGOAT_HOME`, `$FIREGOAT_OUT` (also `${...}`), `{{vars.KEY}}`, `{{run.id}}`, `{{run.n}}`, `{{test.name}}`; no other `$` expansion. Each run's `run.json` records the expanded `claudeArgs`.
160
+
161
+ ### Testing an MCP server
162
+
163
+ ```yaml
164
+ install: |
165
+ cat > .mcp.json <<EOF
166
+ {"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
167
+ "env": {"HOME": "$FIREGOAT_HOME"}}}}
168
+ EOF
169
+ claude_args: [--mcp-config, .mcp.json]
170
+ ```
171
+
172
+ Write the MCP config per run in `install:` (or copy a committed one to `$FIREGOAT_OUT` in `build:` and pass `"$FIREGOAT_OUT/mcp.json"`, but then it can't hold the run's home), and pass it with `claude_args: [--mcp-config, .mcp.json]`: relative paths are from the run's folder, claude's cwd. `--strict-mcp-config` is always on, so only the configs you pass load. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "<the run's $FIREGOAT_HOME>"}` on the server, as above, or it writes to your real home.
173
+
174
+ 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.
175
+
176
+ ### Env vars for the agent: a feature flag, a tool that ignores HOME
117
177
 
118
178
  ```yaml
119
179
  env:
120
- ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # Sessions/config never collide across runs
180
+ ACME_HOME: "$FIREGOAT_HOME/.acme" # Only for a tool that ignores HOME
121
181
  ACME_TELEMETRY: "off"
122
182
  UV_FIND_LINKS: "$FIREGOAT_OUT" # Resolve the wheel build: made
123
183
  tests:
@@ -130,21 +190,35 @@ tests:
130
190
  - run: ls "$ACME_HOME"/sessions | grep -q .
131
191
  ```
132
192
 
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:`.
193
+ `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.
194
+
195
+ ### Each run gets its own HOME
196
+
197
+ 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`.
198
+
199
+ - 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).
200
+ - `install`, checks and `cleanup` get `HOME` and the XDG vars set to the run's home, plus `$FIREGOAT_HOME`. `build` keeps your real home.
201
+ - `run.json` records it as `home`.
202
+
203
+ Limits:
134
204
 
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"`).
205
+ - claude's own Read/Write/Edit tools expand `~` to your real home; only Bash commands get the run's home.
206
+ - Lookups by user id (`os.userInfo().homedir`, python's `pwd`, `getpwuid`) still return your real home.
207
+ - Keychain-backed logins (`gh`, `docker`, git credential helpers) look logged out inside a run. Log tools in from `install:`, or pass tokens with `env:`.
208
+ - The fresh home has no `.gitconfig`: if the agent commits, set `git config --global user.name ...` / `user.email` in `install:`.
209
+ - claude's shell snapshot still sources your real `~/.zshrc` (or `~/.bashrc`).
136
210
 
137
211
  ## What eval does
138
212
 
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.
213
+ - 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
214
  - 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
215
  - 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.
216
+ - 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`.
217
+ - 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
218
  - 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.
219
+ - 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
220
 
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`.
221
+ `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
222
 
149
223
  ### Stop what the agent left running, seed one test
150
224
 
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.20.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,42 @@ 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
+ checkPlaceholders(arg, p);
7215
+ return arg;
7216
+ });
7217
+ }
7175
7218
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7176
7219
  function parseVars(raw) {
7177
7220
  if (raw === undefined)
@@ -7193,10 +7236,18 @@ var RESERVED_ENV = new Set([
7193
7236
  "CLAUDE_CONFIG_DIR",
7194
7237
  "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7195
7238
  ]);
7239
+ var HOME_ENV = new Set([
7240
+ "HOME",
7241
+ "XDG_CONFIG_HOME",
7242
+ "XDG_CACHE_HOME",
7243
+ "XDG_DATA_HOME",
7244
+ "XDG_STATE_HOME",
7245
+ "CLAUDE_ENV_FILE"
7246
+ ]);
7196
7247
  var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
7197
7248
  var EVAL_PLACEHOLDERS = ["run.id"];
7198
7249
  var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7199
- var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
7250
+ var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)(?![A-Za-z0-9_]))/g;
7200
7251
  var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
7201
7252
  function parseEnv(raw, path) {
7202
7253
  if (raw === undefined)
@@ -7210,19 +7261,24 @@ function parseEnv(raw, path) {
7210
7261
  fail(p, "name must be letters, digits and _, not starting with a digit");
7211
7262
  if (k === "ANTHROPIC_MODEL")
7212
7263
  fail(p, "is reserved: choose the model with top-level model: (or eval --model)");
7264
+ if (HOME_ENV.has(k))
7265
+ fail(p, "Reserved: every run gets its own home ($FIREGOAT_HOME)");
7213
7266
  if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7214
7267
  fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7215
7268
  if (typeof v !== "string")
7216
7269
  fail(p, "must be a string (quote numbers and booleans)");
7217
- for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7218
- const name = m[1];
7219
- if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7220
- fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7221
- }
7270
+ checkPlaceholders(v, p);
7222
7271
  out[k] = v;
7223
7272
  }
7224
7273
  return out;
7225
7274
  }
7275
+ function checkPlaceholders(v, p) {
7276
+ for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7277
+ const name = m[1];
7278
+ if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7279
+ fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7280
+ }
7281
+ }
7226
7282
  function isEvalLevel(value) {
7227
7283
  const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
7228
7284
  return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
@@ -7230,16 +7286,19 @@ function isEvalLevel(value) {
7230
7286
  return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7231
7287
  });
7232
7288
  }
7289
+ function renderValue(value, vars, run, where) {
7290
+ return value.replace(ENV_REF, (whole, name, braced, bare) => {
7291
+ const key = name ?? braced ?? bare;
7292
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7293
+ if (filled === undefined)
7294
+ throw new ConfigError(`${where}: no value for ${whole}`);
7295
+ return filled;
7296
+ });
7297
+ }
7233
7298
  function renderEnv(env, vars, run) {
7234
7299
  const out = {};
7235
7300
  for (const [k, v] of Object.entries(env))
7236
- out[k] = v.replace(ENV_REF, (whole, name, braced, bare) => {
7237
- const key = name ?? braced ?? bare;
7238
- const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7239
- if (filled === undefined)
7240
- throw new ConfigError(`env.${k}: no value for ${whole}`);
7241
- return filled;
7242
- });
7301
+ out[k] = renderValue(v, vars, run, `env.${k}`);
7243
7302
  return out;
7244
7303
  }
7245
7304
  var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
@@ -7267,11 +7326,11 @@ import {
7267
7326
  appendFileSync,
7268
7327
  createWriteStream as createWriteStream2,
7269
7328
  existsSync as existsSync3,
7270
- mkdirSync as mkdirSync3,
7329
+ mkdirSync as mkdirSync4,
7271
7330
  readdirSync as readdirSync2,
7272
- writeFileSync as writeFileSync3
7331
+ writeFileSync as writeFileSync4
7273
7332
  } from "node:fs";
7274
- import { basename as basename3, delimiter as delimiter2, join as join5 } from "node:path";
7333
+ import { basename as basename3, delimiter as delimiter2, join as join6 } from "node:path";
7275
7334
 
7276
7335
  // src/claude.ts
7277
7336
  import { spawn, spawnSync } from "node:child_process";
@@ -7286,15 +7345,18 @@ var CLAUDE_FLAGS = [
7286
7345
  "--disable-slash-commands"
7287
7346
  ];
7288
7347
  var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7348
+ 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)$/;
7349
+ 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
7350
  function hostEnv() {
7290
- return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_")));
7351
+ 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
7352
  }
7292
- function claudeEnv(configDir, env = {}) {
7353
+ function claudeEnv(configDir, env = {}, envFile) {
7293
7354
  return {
7294
7355
  ...hostEnv(),
7295
7356
  ...env,
7296
7357
  CLAUDE_CONFIG_DIR: configDir,
7297
- CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
7358
+ CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
7359
+ ...envFile === undefined ? {} : { CLAUDE_ENV_FILE: envFile }
7298
7360
  };
7299
7361
  }
7300
7362
  function run(opts) {
@@ -7337,7 +7399,8 @@ async function runClaude(opts) {
7337
7399
  "-p",
7338
7400
  opts.prompt,
7339
7401
  ...CLAUDE_FLAGS,
7340
- ...opts.model === undefined ? [] : ["--model", opts.model]
7402
+ ...opts.model === undefined ? [] : ["--model", opts.model],
7403
+ ...opts.extraArgs
7341
7404
  ],
7342
7405
  cwd: opts.cwd,
7343
7406
  env: opts.env,
@@ -7828,15 +7891,19 @@ function topHelp(version) {
7828
7891
  folder, with none of your claude config; firegoat saves the session and runs your
7829
7892
  script checks.
7830
7893
 
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
7894
+ npx firegoat@latest init Create firegoat.yaml and .firegoat/, install the skill
7895
+ npx firegoat@latest eval Run the tests (claude must be logged in: run \`claude\`)
7896
+ npx firegoat@latest login Log in to firegoat.ai: eval then uploads each session
7897
+ npx firegoat@latest logout Forget that login
7898
+ npx firegoat@latest feedback "<what got in the way>"
7899
+ Tell the firegoat team
7900
+
7901
+ Use npx firegoat@latest: plain npx firegoat can keep running an old cached version.
7902
+ Each eval prints its version first.
7836
7903
 
7837
7904
  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
7905
+ 1. Read npx firegoat@latest init --help, then edit firegoat.yaml
7906
+ 2. npx firegoat@latest eval --json
7840
7907
  3. Judge each run's end_goal from its run.json, session and workdir: firegoat only
7841
7908
  runs the script checks
7842
7909
 
@@ -7880,20 +7947,32 @@ firegoat.yaml reference:
7880
7947
  env: {NAME: value} Env vars for the agent, and for install, checks
7881
7948
  and cleanup of the same run; values are strings and can
7882
7949
  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.
7950
+ {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR,
7951
+ $FIREGOAT_HOME (no other $ expansion: $HOME stays
7952
+ literal). The build step gets only the entries that
7953
+ use only {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7887
7954
  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}}).
7955
+ ANTHROPIC_MODEL (use model:) are reserved, and so
7956
+ are HOME, XDG_CONFIG_HOME, XDG_CACHE_HOME,
7957
+ XDG_DATA_HOME, XDG_STATE_HOME and CLAUDE_ENV_FILE:
7958
+ every run gets its own home ($FIREGOAT_HOME), so a
7959
+ tool that keeps state in ~ starts fresh in each
7960
+ run. Only a tool that ignores HOME needs its own
7961
+ var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
7962
+ claude_args: [<arg>, ...] Extra claude arguments, after firegoat's own flags
7963
+ and --model. Each entry is one argument, passed
7964
+ as-is (no shell splitting): [--chrome] (Claude in
7965
+ Chrome, for tests that need a browser),
7966
+ [--plugin-dir, ./plugins/x], --mcp-config, --agents,
7967
+ --append-system-prompt. Entries expand like env:
7968
+ values ($FIREGOAT_WORKDIR, $FIREGOAT_HOME,
7969
+ $FIREGOAT_OUT, {{vars.KEY}}, {{run.*}}); run.json
7970
+ claudeArgs has the result. Flags firegoat sets are
7971
+ errors, also as --flag=value: -p, --print,
7972
+ --output-format, --verbose, --setting-sources,
7973
+ --strict-mcp-config, --disable-slash-commands,
7974
+ --dangerously-skip-permissions, --model (use
7975
+ model:), --session-id, --resume, -r, --continue, -c
7897
7976
  tests:
7898
7977
  - name: quickstart Letters, digits, '.', '_', '-'; unique
7899
7978
  prompt: ... What a user would ask the agent; one concrete outcome
@@ -7905,14 +7984,20 @@ firegoat.yaml reference:
7905
7984
  env: {NAME: value} Optional, on top of the top-level env:
7906
7985
  install: <sh> | [...] Optional, runs after the top-level install
7907
7986
  cleanup: <sh> | [...] Optional, runs before the top-level cleanup
7987
+ claude_args: [...] Optional, after the top-level claude_args
7908
7988
 
7909
7989
  Step order: build once per eval; then per run: install (top-level, then the
7910
7990
  test's), the agent, checks, cleanup (the test's, then top-level).
7911
7991
 
7912
7992
  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
7993
+ e.g. fixtures/ (committed) and copy them in build or install. To start each run
7994
+ from a template project, commit it as e.g. fixtures/starter-app/, then:
7995
+ build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
7996
+ install: cp -R "$FIREGOAT_OUT/starter-app/." .
7997
+ The build, install and cleanup commands and the checks get the vars as env
7998
+ vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
7999
+ get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
8000
+ Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
7916
8001
  transcript, greppable line by line: "[1] $ <command>" on every line of a shell
7917
8002
  call (one line per line of the command, same n), "[2] Write <path>" for other
7918
8003
  tools, each output indented, and "agent: " on every line of the agent's text and
@@ -7925,6 +8010,18 @@ firegoat.yaml reference:
7925
8010
  every env firegoat starts, unless env: sets them. So is every FIREGOAT_* var
7926
8011
  (FIREGOAT_API_KEY above all) firegoat itself was started with.
7927
8012
 
8013
+ Testing an MCP server: write its config per run in install: and pass it with
8014
+ claude_args (relative paths are from the run's folder, claude's cwd). Only the
8015
+ configs you pass load: --strict-mcp-config is always on. An MCP server inherits
8016
+ claude's env, which keeps your real HOME (claude's login is there), so set the
8017
+ server's HOME to the run's home, or it writes to yours:
8018
+ install: |
8019
+ cat > .mcp.json <<EOF
8020
+ {"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
8021
+ "env": {"HOME": "$FIREGOAT_HOME"}}}}
8022
+ EOF
8023
+ claude_args: [--mcp-config, .mcp.json]
8024
+
7928
8025
  Example:
7929
8026
  build: npm pack --pack-destination "$FIREGOAT_OUT"
7930
8027
  install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
@@ -7942,6 +8039,12 @@ Example:
7942
8039
  - run: |
7943
8040
  test -s "$FIREGOAT_TRANSCRIPT" &&
7944
8041
  ! grep -Eq '^\\[[0-9]+\\] \\$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
8042
+ - name: dashboard-key
8043
+ prompt: >-
8044
+ Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
8045
+ it in .env as ACME_API_KEY.
8046
+ end_goal: .env holds a key copied from the dashboard, not a made-up one.
8047
+ claude_args: [--chrome]
7945
8048
 
7946
8049
  Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
7947
8050
  install failed, 3 usage error.`;
@@ -7993,9 +8096,30 @@ changes the run's status or the exit code.
7993
8096
 
7994
8097
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
7995
8098
  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
8099
+ plugins, skills or MCP servers (claude_args: in firegoat.yaml adds claude flags,
8100
+ e.g. --chrome or --plugin-dir). claude saves its session there. There is no
7997
8101
  sandbox: the agent can reach the rest of your machine, including the repo.
7998
8102
 
8103
+ Every run gets its own HOME, .firegoat/runs/<runId>/<test>/<n>/home (with
8104
+ XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
8105
+ $FIREGOAT_HOME: tools that keep state in ~ start fresh and parallel runs don't
8106
+ collide. claude itself keeps your real HOME (its login is there) and sources
8107
+ <n>/home.env (CLAUDE_ENV_FILE) before each Bash command the agent runs; install,
8108
+ checks and cleanup get the run's HOME directly; build keeps yours. Limits: claude's
8109
+ own Read/Write tools expand ~ to your real home; getpwuid lookups (os.userInfo,
8110
+ python pwd) still see it; keychain logins (gh, docker, git credential helpers) look
8111
+ logged out inside a run, so log tools in from install: or with env: tokens; the
8112
+ fresh home has no .gitconfig, so set user.name/user.email in install: if the agent
8113
+ commits; claude's shell snapshot still sources your real ~/.zshrc.
8114
+
8115
+ Vars a parent Claude Code session sets (CLAUDECODE, CLAUDE_CODE_*, CLAUDE_PID,
8116
+ CLAUDE_ENV_FILE, CLAUDE_SESSION_ID, CLAUDE_PROJECT_DIR, CLAUDE_EFFORT,
8117
+ CLAUDE_AGENT_SDK_*, CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA, CLAUDE_SKILL_DIR) are
8118
+ removed from every env firegoat starts, so the claude under test doesn't act as a
8119
+ nested session. Login and provider vars stay: ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOKEN,
8120
+ CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_USE_*, CLAUDE_CODE_SKIP_*_AUTH,
8121
+ CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, CLAUDE_CODE_CLIENT_KEY_PASSPHRASE.
8122
+
7999
8123
  In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
8000
8124
  live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
8001
8125
  fixtures and build: stay in the current folder.
@@ -8005,8 +8129,8 @@ fixtures and build: stay in the current folder.
8005
8129
  --model NAME claude --model, replacing model: in firegoat.yaml; repeat it
8006
8130
  (--model a --model b) to run each test runs times per model
8007
8131
  --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
8132
+ Each run has its own HOME; state outside it (a fixed port,
8133
+ /tmp paths) can still collide: use 1 then
8010
8134
  --var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
8011
8135
  cleanup
8012
8136
  --json The summary as JSON on stdout (or {error, exitCode} if eval
@@ -8034,9 +8158,10 @@ Output: .firegoat/runs/<runId>/
8034
8158
  uploadFailed
8035
8159
  <test>/<n>/run.json One run: status and reason (from the checks), model
8036
8160
  (asked for, or null) and claudeModel (what claude's
8037
- init line reported), prompt, end_goal,
8161
+ init line reported), claudeArgs (claude_args
8162
+ passed), prompt, end_goal,
8038
8163
  checks [{run, code}], cleanup [{run, code}],
8039
- and paths: workdir,
8164
+ and paths: workdir, home (the run's HOME),
8040
8165
  transcript, session (claude's session file), stderr,
8041
8166
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8042
8167
  url} | {status: "failed", error} | {status:
@@ -8045,6 +8170,8 @@ Output: .firegoat/runs/<runId>/
8045
8170
  ("[n] $ " on every line of a shell call), short output
8046
8171
  (indented), its text and final answer ("agent: " on
8047
8172
  every line), files it made
8173
+ <test>/<n>/home/, home.env The run's HOME, and the file claude sources
8174
+ before the agent's Bash commands
8048
8175
  build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
8049
8176
 
8050
8177
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
@@ -8083,6 +8210,30 @@ Example:
8083
8210
 
8084
8211
  Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
8085
8212
 
8213
+ // src/home.ts
8214
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
8215
+ import { join as join3 } from "node:path";
8216
+ function homeEnv(home) {
8217
+ return {
8218
+ HOME: home,
8219
+ XDG_CONFIG_HOME: join3(home, ".config"),
8220
+ XDG_CACHE_HOME: join3(home, ".cache"),
8221
+ XDG_DATA_HOME: join3(home, ".local", "share"),
8222
+ XDG_STATE_HOME: join3(home, ".local", "state")
8223
+ };
8224
+ }
8225
+ var shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
8226
+ function makeRunHome(dir) {
8227
+ const home = join3(dir, "home");
8228
+ const env = homeEnv(home);
8229
+ for (const d of Object.values(env))
8230
+ mkdirSync3(d, { recursive: true });
8231
+ const envFile = join3(dir, "home.env");
8232
+ writeFileSync2(envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}
8233
+ `).join(""));
8234
+ return { home, envFile };
8235
+ }
8236
+
8086
8237
  // src/steps.ts
8087
8238
  import { createWriteStream } from "node:fs";
8088
8239
  var STEP_TIMEOUT_MS = 600000;
@@ -8124,8 +8275,8 @@ async function runAllSteps(opts) {
8124
8275
  }
8125
8276
 
8126
8277
  // src/transcript.ts
8127
- import { readdirSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
8128
- import { join as join3, relative } from "node:path";
8278
+ import { readdirSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
8279
+ import { join as join4, relative } from "node:path";
8129
8280
  var OUTPUT_LINES = 15;
8130
8281
  var OUTPUT_CHARS = 1500;
8131
8282
  var TEXT_CHARS = 1500;
@@ -8169,7 +8320,7 @@ function workdirFiles(dir) {
8169
8320
  for (const name of readdirSync(d)) {
8170
8321
  if (SKIP_DIRS.has(name))
8171
8322
  continue;
8172
- const p = join3(d, name);
8323
+ const p = join4(d, name);
8173
8324
  if (statSync(p).isDirectory())
8174
8325
  walk(p);
8175
8326
  else
@@ -8212,7 +8363,7 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
8212
8363
  out.pop();
8213
8364
  out.push("", "## final answer", final ? prose(final) : "(none)");
8214
8365
  out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
8215
- writeFileSync2(opts.path, `${out.join(`
8366
+ writeFileSync3(opts.path, `${out.join(`
8216
8367
  `)}
8217
8368
  `);
8218
8369
  }
@@ -8392,7 +8543,7 @@ function ttyUI(out, opts) {
8392
8543
  const doneCount = c.pass + c.fail + c.error;
8393
8544
  const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
8394
8545
  const out = [
8395
- ...logoHeader(p, `firegoat eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
8546
+ ...logoHeader(p, `firegoat ${opts.version} eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
8396
8547
  rule
8397
8548
  ];
8398
8549
  if (authText)
@@ -8653,7 +8804,7 @@ async function uploadRun(t, r) {
8653
8804
 
8654
8805
  // src/which.ts
8655
8806
  import { accessSync, constants, statSync as statSync2 } from "node:fs";
8656
- import { delimiter, join as join4, resolve } from "node:path";
8807
+ import { delimiter, join as join5, resolve } from "node:path";
8657
8808
  function resolveBin(bin) {
8658
8809
  const executable = (p) => {
8659
8810
  try {
@@ -8666,8 +8817,8 @@ function resolveBin(bin) {
8666
8817
  if (bin.includes("/"))
8667
8818
  return executable(bin) ? resolve(bin) : undefined;
8668
8819
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
8669
- if (dir && executable(join4(dir, bin)))
8670
- return join4(dir, bin);
8820
+ if (dir && executable(join5(dir, bin)))
8821
+ return join5(dir, bin);
8671
8822
  }
8672
8823
  return;
8673
8824
  }
@@ -8740,11 +8891,11 @@ function parseArgs(args) {
8740
8891
  return opts;
8741
8892
  }
8742
8893
  function findSession(configDir, sessionId) {
8743
- const projects = join5(configDir, "projects");
8894
+ const projects = join6(configDir, "projects");
8744
8895
  if (!existsSync3(projects))
8745
8896
  return null;
8746
8897
  for (const d of readdirSync2(projects)) {
8747
- const f = join5(projects, d, `${sessionId}.jsonl`);
8898
+ const f = join6(projects, d, `${sessionId}.jsonl`);
8748
8899
  if (existsSync3(f))
8749
8900
  return f;
8750
8901
  }
@@ -8755,7 +8906,7 @@ function newRunId() {
8755
8906
  return `${ts}-${randomBytes2(2).toString("hex")}`;
8756
8907
  }
8757
8908
  function writeRunJson(r) {
8758
- writeFileSync3(join5(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
8909
+ writeFileSync4(join6(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
8759
8910
  `);
8760
8911
  }
8761
8912
  async function uploadPreflight(opts, ui, project, runGroup, git) {
@@ -8797,14 +8948,24 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
8797
8948
  async function runOne(opts) {
8798
8949
  const { test, n } = opts;
8799
8950
  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 });
8951
+ const dir = join6(opts.runDir, test.name, String(n));
8952
+ const workdir = join6(dir, "work");
8953
+ mkdirSync4(workdir, { recursive: true });
8954
+ const { home, envFile } = makeRunHome(dir);
8955
+ const fill = {
8956
+ "run.id": opts.runId,
8957
+ "run.n": String(n),
8958
+ "test.name": test.name,
8959
+ FIREGOAT_OUT: opts.out,
8960
+ FIREGOAT_WORKDIR: workdir,
8961
+ FIREGOAT_HOME: home
8962
+ };
8803
8963
  const r = {
8804
8964
  test: test.name,
8805
8965
  n,
8806
8966
  model: opts.model,
8807
8967
  claudeModel: null,
8968
+ claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
8808
8969
  status: "error",
8809
8970
  reason: "",
8810
8971
  prompt: render(test.prompt, opts.vars),
@@ -8812,27 +8973,28 @@ async function runOne(opts) {
8812
8973
  checks: [],
8813
8974
  dir,
8814
8975
  workdir,
8976
+ home,
8815
8977
  session: null,
8816
- transcript: join5(dir, "transcript.txt"),
8817
- stderr: join5(dir, "stderr.txt"),
8818
- checksLog: join5(dir, "checks.txt"),
8978
+ transcript: join6(dir, "transcript.txt"),
8979
+ stderr: join6(dir, "stderr.txt"),
8980
+ checksLog: join6(dir, "checks.txt"),
8819
8981
  cleanup: [],
8820
- cleanupLog: join5(dir, "cleanup.txt"),
8982
+ cleanupLog: join6(dir, "cleanup.txt"),
8821
8983
  num_turns: null,
8822
8984
  cost: null,
8823
8985
  duration_ms: 0,
8824
8986
  startedAt: new Date(started).toISOString(),
8825
8987
  upload: null
8826
8988
  };
8827
- const env = renderEnv(test.env, opts.vars, {
8828
- "run.id": opts.runId,
8829
- "run.n": String(n),
8830
- "test.name": test.name,
8831
- FIREGOAT_OUT: opts.out,
8832
- FIREGOAT_WORKDIR: workdir
8833
- });
8834
- const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8835
- const [status, reason] = await attempt(opts, r, env, stepEnv);
8989
+ const env = renderEnv(test.env, opts.vars, fill);
8990
+ const stepEnv = {
8991
+ ...opts.stepEnv,
8992
+ ...env,
8993
+ ...homeEnv(home),
8994
+ FIREGOAT_WORKDIR: workdir,
8995
+ FIREGOAT_HOME: home
8996
+ };
8997
+ const [status, reason] = await attempt(opts, r, env, stepEnv, envFile);
8836
8998
  const cleanup = [...test.cleanup, ...opts.cleanup];
8837
8999
  if (cleanup.length > 0)
8838
9000
  r.cleanup = await runAllSteps({
@@ -8847,7 +9009,7 @@ async function runOne(opts) {
8847
9009
  writeRunJson(r);
8848
9010
  return r;
8849
9011
  }
8850
- async function attempt(opts, r, env, stepEnv) {
9012
+ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
8851
9013
  const { test, n } = opts;
8852
9014
  const { dir, workdir } = r;
8853
9015
  const install = [...opts.install, ...test.install];
@@ -8857,7 +9019,7 @@ async function attempt(opts, r, env, stepEnv) {
8857
9019
  commands: install,
8858
9020
  cwd: workdir,
8859
9021
  env: stepEnv,
8860
- logPath: join5(dir, "install.txt")
9022
+ logPath: join6(dir, "install.txt")
8861
9023
  });
8862
9024
  if (error)
8863
9025
  return ["error", `not graded: ${error}`];
@@ -8867,10 +9029,14 @@ async function attempt(opts, r, env, stepEnv) {
8867
9029
  bin: opts.claudeBin,
8868
9030
  prompt: r.prompt,
8869
9031
  cwd: workdir,
8870
- env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
9032
+ env: {
9033
+ ...claudeEnv(opts.configDir, env, homeEnvFile),
9034
+ PATH: opts.stepEnv.PATH
9035
+ },
8871
9036
  stderr,
8872
9037
  timeoutMs: test.timeout * 1000,
8873
9038
  model: opts.model ?? undefined,
9039
+ extraArgs: r.claudeArgs,
8874
9040
  onEvent: opts.onEvent
8875
9041
  });
8876
9042
  r.claudeModel = outcome.claudeModel;
@@ -8921,6 +9087,20 @@ async function attempt(opts, r, env, stepEnv) {
8921
9087
  test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
8922
9088
  ];
8923
9089
  }
9090
+ var MAX_LISTED_URLS = 5;
9091
+ function uploadedSummary(origin, results, runDir) {
9092
+ const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
9093
+ if (urls.length === 0)
9094
+ return;
9095
+ const head = `Uploaded ${urls.length} session${urls.length === 1 ? "" : "s"} to ${new URL(origin).host} (private)`;
9096
+ if (urls.length > MAX_LISTED_URLS) {
9097
+ console.log(`${head}: each run's upload.url in ${join6(runDir, "summary.json")}`);
9098
+ return;
9099
+ }
9100
+ console.log(`${head}:`);
9101
+ for (const u of urls)
9102
+ console.log(` ${u}`);
9103
+ }
8924
9104
  async function evalCommand(args, root) {
8925
9105
  if (args.includes("--help") || args.includes("-h")) {
8926
9106
  console.log(EVAL_HELP);
@@ -8943,7 +9123,13 @@ async function evalCommand(args, root) {
8943
9123
  }
8944
9124
  const say = opts.json ? console.error : console.log;
8945
9125
  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);
9126
+ const ui = stream.isTTY ? ttyUI(stream, {
9127
+ project: basename3(root),
9128
+ version: package_default.version,
9129
+ color: !process.env.NO_COLOR
9130
+ }) : plainUI(say);
9131
+ if (!stream.isTTY)
9132
+ say(`firegoat ${package_default.version}`);
8947
9133
  let config;
8948
9134
  try {
8949
9135
  config = loadConfig(root);
@@ -8967,7 +9153,8 @@ async function evalCommand(args, root) {
8967
9153
  ...new Set(tests.flatMap((t) => [
8968
9154
  t.prompt,
8969
9155
  ...t.checks.map((c) => c.run),
8970
- ...Object.values(t.env)
9156
+ ...Object.values(t.env),
9157
+ ...t.claudeArgs
8971
9158
  ]).flatMap(varRefs).filter((k) => !(k in vars)))
8972
9159
  ];
8973
9160
  if (missing.length > 0)
@@ -8982,8 +9169,8 @@ async function evalCommand(args, root) {
8982
9169
  return fatal(EXIT.usage, e.message);
8983
9170
  }
8984
9171
  const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
8985
- const configDir = join5(firegoatDir, ".claude");
8986
- mkdirSync3(configDir, { recursive: true });
9172
+ const configDir = join6(firegoatDir, ".claude");
9173
+ mkdirSync4(configDir, { recursive: true });
8987
9174
  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
9175
  const loginProblem = checkLogin({
8989
9176
  bin: claudePath,
@@ -9005,10 +9192,10 @@ async function evalCommand(args, root) {
9005
9192
  return fatal(EXIT.usage, e.message);
9006
9193
  throw e;
9007
9194
  }
9008
- const runsRoot = join5(firegoatDir, "runs");
9009
- const runDir = join5(runsRoot, runId);
9010
- const out = join5(runDir, "out");
9011
- mkdirSync3(out, { recursive: true });
9195
+ const runsRoot = join6(firegoatDir, "runs");
9196
+ const runDir = join6(runsRoot, runId);
9197
+ const out = join6(runDir, "out");
9198
+ mkdirSync4(out, { recursive: true });
9012
9199
  const stepEnv = {
9013
9200
  ...hostEnv(),
9014
9201
  ...vars,
@@ -9034,7 +9221,7 @@ async function evalCommand(args, root) {
9034
9221
  ...stepEnv,
9035
9222
  ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
9036
9223
  },
9037
- logPath: join5(runDir, "build.txt")
9224
+ logPath: join6(runDir, "build.txt")
9038
9225
  });
9039
9226
  const secs = Math.round((Date.now() - buildStarted) / 1000);
9040
9227
  ui.build(error ? "fail" : "ok", secs);
@@ -9107,6 +9294,7 @@ async function evalCommand(args, root) {
9107
9294
  totals[r.status]++;
9108
9295
  const uploadsFailed = results.filter((r) => r.upload?.status === "failed").length;
9109
9296
  const doc = {
9297
+ firegoatVersion: package_default.version,
9110
9298
  runId,
9111
9299
  runsDir: runDir,
9112
9300
  dir: root,
@@ -9115,9 +9303,9 @@ async function evalCommand(args, root) {
9115
9303
  totals,
9116
9304
  runs: results
9117
9305
  };
9118
- writeFileSync3(join5(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
9306
+ writeFileSync4(join6(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
9119
9307
  `);
9120
- appendFileSync(join5(runsRoot, "index.jsonl"), `${JSON.stringify({
9308
+ appendFileSync(join6(runsRoot, "index.jsonl"), `${JSON.stringify({
9121
9309
  runId,
9122
9310
  startedAt: new Date(evalStarted).toISOString(),
9123
9311
  dir: root,
@@ -9137,6 +9325,8 @@ async function evalCommand(args, root) {
9137
9325
  rows,
9138
9326
  seconds: Math.round((Date.now() - evalStarted) / 1000)
9139
9327
  });
9328
+ if (upload && !opts.json)
9329
+ uploadedSummary(upload.origin, results, runDir);
9140
9330
  for (const r of results)
9141
9331
  for (const c of r.cleanup)
9142
9332
  if (c.code !== 0)
@@ -9417,8 +9607,8 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9417
9607
 
9418
9608
  // src/init.ts
9419
9609
  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";
9610
+ import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
9611
+ import { basename as basename4, join as join7 } from "node:path";
9422
9612
  import { createInterface } from "node:readline/promises";
9423
9613
 
9424
9614
  // src/login.ts
@@ -9613,7 +9803,7 @@ function skillsDir(scope) {
9613
9803
  if (scope === "local")
9614
9804
  return ".claude/skills/";
9615
9805
  const config = process.env.CLAUDE_CONFIG_DIR?.trim();
9616
- return config ? `${join6(config, "skills")}/` : "~/.claude/skills/";
9806
+ return config ? `${join7(config, "skills")}/` : "~/.claude/skills/";
9617
9807
  }
9618
9808
  function template(product) {
9619
9809
  return `# firegoat.yaml: evals for how well coding agents use ${product}.
@@ -9647,6 +9837,10 @@ runs: 1
9647
9837
  # Helper files (scripts, sample data) go in e.g. fixtures/ (committed), not inline here.
9648
9838
  # The build step runs in this folder, so: build: cp -R fixtures/. "$FIREGOAT_OUT"
9649
9839
  # Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
9840
+ #
9841
+ # To start each run from a template project, commit it as e.g. fixtures/starter-app/:
9842
+ # build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
9843
+ # install: cp -R "$FIREGOAT_OUT/starter-app/." .
9650
9844
 
9651
9845
  # Optional: cleanup: runs in each run's folder once the run is over (after the
9652
9846
  # checks, also after an error or timeout), e.g. to stop a server the agent left
@@ -9658,28 +9852,30 @@ runs: 1
9658
9852
 
9659
9853
  # Optional: env vars for the agent (and for install, checks and cleanup of the
9660
9854
  # 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.
9855
+ # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR /
9856
+ # $FIREGOAT_HOME (no other $ expansion: $HOME stays literal). A test can add its
9857
+ # own env:. Every run gets its own HOME ($FIREGOAT_HOME), so tools that keep state
9858
+ # in ~ are isolated per run already; set a tool's own *_HOME var only if the tool
9859
+ # ignores HOME, as below.
9665
9860
  # env:
9666
- # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
9861
+ # MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
9667
9862
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
9668
9863
 
9669
9864
  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.
9865
+ # A smoke test that firegoat and claude work. Replace it with tests for your
9866
+ # product: the firegoat-onboarding skill writes them.
9867
+ - name: goat
9868
+ # What a user would ask their agent.
9869
+ prompt: Who's the GOAT? Answer in one short sentence.
9675
9870
  # True when the user's problem is solved. Not graded by firegoat: it goes to
9676
9871
  # 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.
9872
+ end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
9680
9873
  # Shell commands run in the agent's folder afterwards; exit 0 = pass.
9874
+ # $FIREGOAT_TRANSCRIPT has "agent: " on every line of the agent's text.
9681
9875
  checks:
9682
- - run: test -s output.txt
9876
+ - run: >-
9877
+ test -s "$FIREGOAT_TRANSCRIPT" &&
9878
+ grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
9683
9879
  # Seconds before the agent run is stopped.
9684
9880
  timeout: 600
9685
9881
  `;
@@ -9798,16 +9994,16 @@ ${INIT_HELP}`);
9798
9994
  console.log(rule);
9799
9995
  }
9800
9996
  ensureFiregoatDir(cwd);
9801
- const configPath = join6(cwd, CONFIG_FILE);
9997
+ const configPath = join7(cwd, CONFIG_FILE);
9802
9998
  const keep = existsSync4(configPath) && !force;
9803
9999
  if (!keep)
9804
- writeFileSync4(configPath, template(basename4(cwd)));
10000
+ writeFileSync5(configPath, template(basename4(cwd)));
9805
10001
  if (interactive) {
9806
10002
  step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
9807
10003
  console.log(paint("90", " │"));
9808
10004
  step(2, ".firegoat/", "Ready");
9809
10005
  } else
9810
- console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
10006
+ console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: replace the goat smoke test with tests for your product`);
9811
10007
  if (interactive && !noSkills)
9812
10008
  console.log(paint("90", " │"));
9813
10009
  if (interactive && !noSkills && !localSkills) {
@@ -9861,7 +10057,7 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
9861
10057
  console.log(AGENT_PROMPT);
9862
10058
  console.log("");
9863
10059
  const copied = copyToClipboard(AGENT_PROMPT);
9864
- console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat eval")}`);
10060
+ console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat@latest eval")}`);
9865
10061
  return EXIT.pass;
9866
10062
  }
9867
10063
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"