firegoat 0.20.0 → 0.22.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 +29 -11
  2. package/dist/cli.js +210 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs th
12
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. 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).
15
+ That's it: two commands. `npx firegoat@latest eval --dry-run` checks `firegoat.yaml` and lists the runs it would start (`<test>#<n> [model]`, with step counts) without building, running claude or uploading; config errors exit 3 as in a real eval. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat X.Y.Z`), 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
 
@@ -160,16 +160,24 @@ tests:
160
160
 
161
161
  ### Testing an MCP server
162
162
 
163
+ Commit the server's config as `fixtures/mcp.json`:
164
+
165
+ ```json
166
+ {"mcpServers": {"acme": {"command": "./node_modules/.bin/acme-mcp",
167
+ "env": {"HOME": "${FIREGOAT_HOME}"}}}}
168
+ ```
169
+
170
+ Copy it in `build:` and pass it with `claude_args:`:
171
+
163
172
  ```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]
173
+ build:
174
+ - npm pack --pack-destination "$FIREGOAT_OUT"
175
+ - cp -R fixtures/. "$FIREGOAT_OUT"
176
+ install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
177
+ claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
170
178
  ```
171
179
 
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.
180
+ claude gets `FIREGOAT_HOME`, `FIREGOAT_WORKDIR` and `FIREGOAT_OUT` in its own env, set per run, and expands `${VAR}` in an MCP config's `command`, `args`, `env`, `url` and `headers` from its env (an unset var stays the literal text). Relative paths in the config are from the run's folder, claude's cwd: `./node_modules/.bin/acme-mcp` is the build under test, installed by `install:`. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "${FIREGOAT_HOME}"}` on the server, as above, or it writes to your real home. Per-run state the server keeps elsewhere goes the same way, e.g. `"ACME_DB": "${FIREGOAT_WORKDIR}/acme.db"`. `--strict-mcp-config` is always on, so only the configs you pass load. To check that the agent used a tool, grep the transcript: `grep -Eq '^\[[0-9]+\] mcp__acme__search' "$FIREGOAT_TRANSCRIPT"`.
173
181
 
174
182
  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
183
 
@@ -211,12 +219,22 @@ Limits:
211
219
  ## What eval does
212
220
 
213
221
  - 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.
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.
222
+ - 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) in `fixtures/` next to `firegoat.yaml`, committed.
215
223
  - 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.
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`.
224
+ - 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 (claude then gets the run's own `FIREGOAT_OUT`, `FIREGOAT_WORKDIR` and `FIREGOAT_HOME`, for MCP configs; the agent's commands see them 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
225
  - 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.
218
226
  - 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.
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.
227
+ - 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`; format below), `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.
228
+
229
+ ### The transcript
230
+
231
+ `transcript.txt` is greppable line by line; `n` counts the agent's tool calls:
232
+
233
+ - `[n] $ <command>`: a shell call, on every line of the command (heredocs and multi-line scripts too), never cut. So `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands.
234
+ - `[n] <Tool> <target>`: a tool (MCP ones too) whose input has a `file_path`, `path`, `url`, `pattern` or `query`: just that value, e.g. `[2] Write src/app.ts`. The written body is not in the transcript: check the file in the workdir.
235
+ - `[n] <Tool> <json>`: any other tool, MCP tools included, with its whole input as one line of JSON, cut at 4000 chars: `[3] mcp__acme__push_html {"canvas":"demo","html":"<p>Hi</p>"}`. So `grep -Eq '^\[[0-9]+\] mcp__acme__push_html .*<h1>' "$FIREGOAT_TRANSCRIPT"` checks what the agent pushed.
236
+ - ` → ok` or ` → error`, then the tool's output indented 4 spaces, cut at 15 lines or 1500 chars (`… (N more chars)`).
237
+ - `agent: ` on every line of the agent's text (cut at 30 lines or 1500 chars) and of its final answer (uncut), then the files in the workdir (without `node_modules`, `.git`, `.venv`, `__pycache__`).
220
238
 
221
239
  `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`.
222
240
 
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.20.0",
6950
+ version: "0.22.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7448,8 +7448,15 @@ async function runClaude(opts) {
7448
7448
  reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7449
7449
  };
7450
7450
  }
7451
- const sessionId = String(init.session_id);
7452
7451
  const claudeModel = typeof init.model === "string" ? init.model : null;
7452
+ if (typeof init.session_id !== "string" || init.session_id === "")
7453
+ return {
7454
+ events,
7455
+ claudeModel,
7456
+ ok: false,
7457
+ reason: "claude's init line has no session_id"
7458
+ };
7459
+ const sessionId = init.session_id;
7453
7460
  const result = events.findLast((e) => e?.type === "result");
7454
7461
  if (exited.timedOut)
7455
7462
  return {
@@ -7646,7 +7653,7 @@ async function send(opts) {
7646
7653
  function replyError(step, r) {
7647
7654
  const j = r.json ?? {};
7648
7655
  const error = typeof j.error === "string" ? j.error : "no error message";
7649
- const more = j.error_description ?? j.details ?? j.message;
7656
+ const more = j.error_description ?? j.details;
7650
7657
  const details = more === undefined ? "" : ` ${typeof more === "string" ? more : JSON.stringify(more)}`;
7651
7658
  return new ApiError(printable(`${step} ${r.status}: ${error}${details}`), r.status);
7652
7659
  }
@@ -7816,7 +7823,12 @@ var GITIGNORE = `*
7816
7823
  function ensureFiregoatDir(root) {
7817
7824
  const dir = join2(root, ".firegoat");
7818
7825
  mkdirSync2(dir, { recursive: true });
7819
- writeFileSync(join2(dir, ".gitignore"), GITIGNORE);
7826
+ try {
7827
+ writeFileSync(join2(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
7828
+ } catch (e) {
7829
+ if (e.code !== "EEXIST")
7830
+ throw e;
7831
+ }
7820
7832
  return dir;
7821
7833
  }
7822
7834
 
@@ -7918,8 +7930,8 @@ firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
7918
7930
  firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
7919
7931
  firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
7920
7932
  paste into your coding agent. .firegoat/ holds local run output and ignores itself
7921
- in git. Keep test fixtures (helper scripts, sample data) outside it, e.g. in
7922
- fixtures/ next to firegoat.yaml, and commit them.
7933
+ in git. Keep test fixtures (helper scripts, sample data) in fixtures/ next to
7934
+ firegoat.yaml, committed.
7923
7935
 
7924
7936
  --force Replace an existing firegoat.yaml
7925
7937
  --local-skills Install the skill into this repo's .claude/skills/ instead
@@ -7998,29 +8010,31 @@ firegoat.yaml reference:
7998
8010
  vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
7999
8011
  get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
8000
8012
  Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
8001
- transcript, greppable line by line: "[1] $ <command>" on every line of a shell
8002
- call (one line per line of the command, same n), "[2] Write <path>" for other
8003
- tools, each output indented, and "agent: " on every line of the agent's text and
8004
- final answer. So '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also
8005
- get $FIREGOAT_SESSION (claude's full session file, JSON lines).
8013
+ transcript, greppable line by line (format: eval --help, Transcript). So
8014
+ '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also get
8015
+ $FIREGOAT_SESSION (claude's full session file, JSON lines).
8006
8016
 
8007
8017
  The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
8008
8018
  (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
8009
8019
  npm_command, INIT_CWD; e.g. npm_config_yes=true from npx -y) are removed from
8010
8020
  every env firegoat starts, unless env: sets them. So is every FIREGOAT_* var
8011
- (FIREGOAT_API_KEY above all) firegoat itself was started with.
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]
8021
+ (FIREGOAT_API_KEY above all) firegoat itself was started with. claude itself
8022
+ gets $FIREGOAT_OUT, $FIREGOAT_WORKDIR and $FIREGOAT_HOME (for MCP configs, below),
8023
+ so the agent's commands see them too.
8024
+
8025
+ Testing an MCP server: commit its config as fixtures/mcp.json, copy it in build:
8026
+ and pass it with claude_args. Only the configs you pass load: --strict-mcp-config
8027
+ is always on. An MCP server inherits claude's env, which keeps your real HOME
8028
+ (claude's login is there), so set the server's HOME to the run's home, or it
8029
+ writes to yours. claude gets $FIREGOAT_HOME, $FIREGOAT_WORKDIR and $FIREGOAT_OUT
8030
+ in its env and expands \${VAR} in the config (an unset one stays literal);
8031
+ relative paths in it are from the run's folder, claude's cwd:
8032
+ fixtures/mcp.json:
8033
+ {"mcpServers": {"acme": {"command": "./node_modules/.bin/acme-mcp",
8034
+ "env": {"HOME": "\${FIREGOAT_HOME}"}}}}
8035
+ firegoat.yaml:
8036
+ build: cp -R fixtures/. "$FIREGOAT_OUT"
8037
+ claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8024
8038
 
8025
8039
  Example:
8026
8040
  build: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -8082,7 +8096,8 @@ page.
8082
8096
  Exit codes: 0 logged out (or was not logged in), 3 the file or ~/.firegoat is a
8083
8097
  symlink, not a regular file/directory, or someone else's (left alone).`;
8084
8098
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--parallel N]
8085
- [--var KEY=value].. [--json] [--no-upload] [--claude-bin PATH]
8099
+ [--var KEY=value].. [--json] [--no-upload] [--dry-run]
8100
+ [--claude-bin PATH]
8086
8101
 
8087
8102
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
8088
8103
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
@@ -8136,8 +8151,15 @@ fixtures and build: stay in the current folder.
8136
8151
  --json The summary as JSON on stdout (or {error, exitCode} if eval
8137
8152
  stops early); progress goes to stderr
8138
8153
  --no-upload Keep runs local even when logged in to firegoat.ai
8139
- --claude-bin PATH Path to claude (env FIREGOAT_CLAUDE_BIN; default: claude on
8140
- PATH)
8154
+ --dry-run Check firegoat.yaml as eval would (test names, --var,
8155
+ --model, missing vars), print each planned run
8156
+ ("<test>#<n> [model]" with its install, checks and cleanup
8157
+ counts and rendered claude_args, paths under
8158
+ runs/<runId>/) and exit 0. Builds nothing, starts no
8159
+ claude, uploads nothing, writes no files. Config errors
8160
+ exit 3. With --json: {dryRun, build, runs: [{test, n,
8161
+ model, claudeArgs, install, checks, cleanup}]}
8162
+ --claude-bin PATH Path to claude (default: claude on PATH)
8141
8163
 
8142
8164
  Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
8143
8165
  checks.txt and stderr.txt go to firegoat.ai as a private trace, and its URL is
@@ -8154,8 +8176,8 @@ Output: .firegoat/runs/<runId>/
8154
8176
  git {branch, commit, dirty} (no git outside a repo)
8155
8177
  and upload {origin, email} (null when not uploading)
8156
8178
  ../index.jsonl One line per eval: runId, startedAt, dir, branch,
8157
- commit, passed, failed, notGraded, cost, uploaded,
8158
- uploadFailed
8179
+ commit, passed, failed, notGraded, cost (null when
8180
+ any run's cost is unknown), uploaded, uploadFailed
8159
8181
  <test>/<n>/run.json One run: status and reason (from the checks), model
8160
8182
  (asked for, or null) and claudeModel (what claude's
8161
8183
  init line reported), claudeArgs (claude_args
@@ -8166,14 +8188,25 @@ Output: .firegoat/runs/<runId>/
8166
8188
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8167
8189
  url} | {status: "failed", error} | {status:
8168
8190
  "skipped", reason}, null when not uploading)
8169
- <test>/<n>/transcript.txt What the agent did: each tool call with its command
8170
- ("[n] $ " on every line of a shell call), short output
8171
- (indented), its text and final answer ("agent: " on
8172
- every line), files it made
8191
+ <test>/<n>/transcript.txt What the agent did, below
8173
8192
  <test>/<n>/home/, home.env The run's HOME, and the file claude sources
8174
8193
  before the agent's Bash commands
8175
8194
  build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
8176
8195
 
8196
+ Transcript ($FIREGOAT_TRANSCRIPT), greppable line by line; n counts tool calls:
8197
+ [n] $ <command> A shell call, on every line of the command, uncut
8198
+ [n] <Tool> <target> A tool (MCP too) with a file_path, path, url, pattern
8199
+ or query input: that value, e.g. "[2] Write src/a.ts"
8200
+ [n] <Tool> <json> Any other tool, MCP ones included: its input as one
8201
+ line of JSON, e.g. [3] mcp__<server>__<tool> {"q":"x"};
8202
+ cut at 4000 chars
8203
+ → ok | → error Then the tool's output, indented 4 spaces, cut at 15
8204
+ lines or 1500 chars ("… (N more chars)")
8205
+ agent: <text> Every line of the agent's text (cut at 30 lines or
8206
+ 1500 chars) and of its final answer (uncut)
8207
+ Then the files in the workdir (not node_modules, .git, .venv, __pycache__).
8208
+ A written file's body is not in the transcript: check the file in the workdir.
8209
+
8177
8210
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
8178
8211
  yours to judge: read transcript.txt first (a few KB); the session file has everything
8179
8212
  (often 50x larger).
@@ -8184,6 +8217,7 @@ Examples:
8184
8217
  npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
8185
8218
  Each test on both models
8186
8219
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
8220
+ npx firegoat eval --dry-run Validate firegoat.yaml, list the runs
8187
8221
 
8188
8222
  A run can take minutes (default timeout 600s per test).
8189
8223
 
@@ -8280,6 +8314,7 @@ import { join as join4, relative } from "node:path";
8280
8314
  var OUTPUT_LINES = 15;
8281
8315
  var OUTPUT_CHARS = 1500;
8282
8316
  var TEXT_CHARS = 1500;
8317
+ var INPUT_CHARS = 4000;
8283
8318
  var SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "__pycache__"]);
8284
8319
  function truncate(text, maxLines, maxChars) {
8285
8320
  let out = text.length > maxChars ? text.slice(0, maxChars) : text;
@@ -8304,7 +8339,14 @@ function describeCall(step, name, input) {
8304
8339
  for (const key of ["file_path", "path", "url", "pattern", "query"])
8305
8340
  if (typeof input[key] === "string")
8306
8341
  return `[${step}] ${name} ${input[key]}`;
8307
- return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
8342
+ return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, INPUT_CHARS)}`;
8343
+ }
8344
+ function describeToolUse(step, b) {
8345
+ if (typeof b.name !== "string" || b.name === "")
8346
+ return `[${step}] (tool call without a name)`;
8347
+ if (typeof b.input !== "object" || b.input === null)
8348
+ return `[${step}] ${b.name} (no input)`;
8349
+ return describeCall(step, b.name, b.input);
8308
8350
  }
8309
8351
  function resultText(content) {
8310
8352
  if (typeof content === "string")
@@ -8341,7 +8383,7 @@ function writeTranscript(opts) {
8341
8383
  if (e?.type === "assistant")
8342
8384
  for (const b of content) {
8343
8385
  if (b.type === "tool_use")
8344
- out.push(describeCall(++step, String(b.name), b.input ?? {}));
8386
+ out.push(describeToolUse(++step, b));
8345
8387
  else if (b.type === "text" && typeof b.text === "string") {
8346
8388
  lastText = out.length;
8347
8389
  out.push(prose(truncate(b.text, 30, TEXT_CHARS)));
@@ -8418,23 +8460,38 @@ function ramp(r) {
8418
8460
  const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
8419
8461
  return (blocks + end).padEnd(8);
8420
8462
  }
8421
- function usage(r) {
8463
+ function usageParts(r) {
8422
8464
  const parts = [];
8423
- if (r.cost !== null && r.cost !== undefined)
8465
+ if (typeof r.cost === "number")
8424
8466
  parts.push(`$${r.cost.toFixed(2)}`);
8425
- parts.push(`${r.turns} turns`);
8467
+ if (typeof r.reportedTurns === "number")
8468
+ parts.push(`${r.reportedTurns} turns`);
8426
8469
  if (r.seconds !== undefined)
8427
8470
  parts.push(`${r.seconds}s`);
8428
- return parts.join(" ");
8471
+ return parts;
8472
+ }
8473
+ function usage(r) {
8474
+ return usageParts(r).join(" ");
8429
8475
  }
8430
8476
  function checksPassed(r) {
8431
8477
  const all = r.checks ?? [];
8432
8478
  return `${all.filter((c) => c.code === 0).length}/${all.length} checks`;
8433
8479
  }
8434
- function total(rows) {
8435
- const cost = rows.reduce((s, r) => s + (r.cost ?? 0), 0);
8436
- const turns = rows.reduce((s, r) => s + r.turns, 0);
8437
- return { cost, turns };
8480
+ function totalCost(rows) {
8481
+ const done = rows.filter((r) => r.state !== "queued" && r.state !== "running");
8482
+ const unknown = done.filter((r) => typeof r.cost !== "number").length;
8483
+ if (unknown > 0)
8484
+ return `cost unknown for ${unknown} run${unknown === 1 ? "" : "s"}`;
8485
+ return `$${done.reduce((s, r) => s + r.cost, 0).toFixed(2)}`;
8486
+ }
8487
+ function totalTurns(rows) {
8488
+ let sum = 0;
8489
+ for (const r of rows) {
8490
+ if (typeof r.reportedTurns !== "number")
8491
+ return null;
8492
+ sum += r.reportedTurns;
8493
+ }
8494
+ return sum;
8438
8495
  }
8439
8496
  function duration(seconds) {
8440
8497
  return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
@@ -8476,9 +8533,9 @@ function plainUI(write) {
8476
8533
  continue;
8477
8534
  printed.add(r);
8478
8535
  const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
8479
- const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
8536
+ const u = usageParts(r).join(", ");
8480
8537
  const model = r.model === null ? "" : ` [${r.model}]`;
8481
- write(`${label} ${r.test}#${r.n}${model} ${r.reason} (${u})`);
8538
+ write(`${label} ${r.test}#${r.n}${model} ${r.reason}${u ? ` (${u})` : ""}`);
8482
8539
  }
8483
8540
  for (const r of rows) {
8484
8541
  if (uploadPrinted.has(r) || !r.upload || r.upload === "uploading")
@@ -8489,10 +8546,9 @@ function plainUI(write) {
8489
8546
  },
8490
8547
  done: ({ runsDir, rows: all }) => {
8491
8548
  const c = counts(all);
8492
- const t = total(all);
8493
8549
  const tr = tier(all);
8494
8550
  write("");
8495
- write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, $${t.cost.toFixed(2)})${tr ? `, tier ${tr}` : ""}`);
8551
+ write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, ${totalCost(all)})${tr ? `, tier ${tr}` : ""}`);
8496
8552
  write(`Runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
8497
8553
  }
8498
8554
  };
@@ -8585,12 +8641,11 @@ function ttyUI(out, opts) {
8585
8641
  out.push(p(C.amber, ` │ ${cut(r.upload.status === "failed" ? `↑ Upload failed: ${r.upload.error}` : `↑ Not uploaded: ${r.upload.reason}`, w - 12)}`));
8586
8642
  });
8587
8643
  out.push(rule);
8588
- const t = total(rows);
8589
8644
  if (!finished) {
8590
8645
  const track = Math.max(10, w - 34);
8591
8646
  const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
8592
8647
  const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
8593
- out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · $${t.cost.toFixed(2)} so far`)}`);
8648
+ out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · ${totalCost(rows)} so far`)}`);
8594
8649
  return out;
8595
8650
  }
8596
8651
  const tr = tier(rows);
@@ -8599,7 +8654,8 @@ function ttyUI(out, opts) {
8599
8654
  const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
8600
8655
  const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
8601
8656
  out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
8602
- out.push(p(C.gray, ` Total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
8657
+ const turns = totalTurns(rows);
8658
+ out.push(p(C.gray, ` Total ${totalCost(rows)}${turns === null ? "" : ` · ${turns} turns`} · ${duration(finished.seconds)}`));
8603
8659
  out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
8604
8660
  out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
8605
8661
  return out;
@@ -8666,6 +8722,12 @@ function label(v, what) {
8666
8722
  throw new Error(`${what} is ${v.length} characters, the upload takes at most ${MAX_LABEL}`);
8667
8723
  return v;
8668
8724
  }
8725
+ function projectName(v) {
8726
+ const name = label(v, "Project name");
8727
+ if (name === null)
8728
+ throw new Error("Project name is empty");
8729
+ return name;
8730
+ }
8669
8731
  function runModel(r) {
8670
8732
  return {
8671
8733
  requested: label(r.model, "Model"),
@@ -8731,9 +8793,11 @@ async function uploadRun(t, r) {
8731
8793
  };
8732
8794
  bytes[name] = b;
8733
8795
  }
8796
+ let project;
8734
8797
  let model;
8735
8798
  let git;
8736
8799
  try {
8800
+ project = projectName(t.project);
8737
8801
  model = runModel(r);
8738
8802
  git = t.git ? runGit(t.git) : undefined;
8739
8803
  } catch (e) {
@@ -8742,7 +8806,7 @@ async function uploadRun(t, r) {
8742
8806
  const body = {
8743
8807
  sessionId,
8744
8808
  runGroup: t.runGroup,
8745
- project: t.project,
8809
+ project,
8746
8810
  timestamp: r.startedAt,
8747
8811
  run: {
8748
8812
  test: r.test,
@@ -8835,8 +8899,9 @@ function parseArgs(args) {
8835
8899
  parallel: 4,
8836
8900
  json: false,
8837
8901
  noUpload: false,
8902
+ dryRun: false,
8838
8903
  vars: {},
8839
- claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
8904
+ claudeBin: "claude"
8840
8905
  };
8841
8906
  for (let i = 0;i < args.length; i++) {
8842
8907
  const a = args[i];
@@ -8855,6 +8920,8 @@ function parseArgs(args) {
8855
8920
  opts.json = true;
8856
8921
  else if (flag === "--no-upload")
8857
8922
  opts.noUpload = true;
8923
+ else if (flag === "--dry-run")
8924
+ opts.dryRun = true;
8858
8925
  else if (flag === "--var") {
8859
8926
  const kv = value();
8860
8927
  const at = kv.indexOf("=");
@@ -8945,6 +9012,51 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
8945
9012
  };
8946
9013
  }
8947
9014
  }
9015
+ function runFill(runId, n, testName, out, dir) {
9016
+ return {
9017
+ "run.id": runId,
9018
+ "run.n": String(n),
9019
+ "test.name": testName,
9020
+ FIREGOAT_OUT: out,
9021
+ FIREGOAT_WORKDIR: join6(dir, "work"),
9022
+ FIREGOAT_HOME: join6(dir, "home")
9023
+ };
9024
+ }
9025
+ function renderClaudeArgs(test, vars, fill) {
9026
+ return test.claudeArgs.map((a, i) => renderValue(a, vars, fill, `claude_args[${i}]`));
9027
+ }
9028
+ function planRuns(tests, models, runs) {
9029
+ const matrix = models.length > 0 ? models : [null];
9030
+ return tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
9031
+ test: t.name,
9032
+ n: m * runs + i + 1,
9033
+ model
9034
+ }))));
9035
+ }
9036
+ var DRY_RUN_ID = "<runId>";
9037
+ function dryRun(opts) {
9038
+ const out = join6(opts.runsRoot, DRY_RUN_ID, "out");
9039
+ const runs = opts.plan.map((p) => {
9040
+ const test = opts.tests.find((t) => t.name === p.test);
9041
+ const fill = runFill(DRY_RUN_ID, p.n, test.name, out, join6(opts.runsRoot, DRY_RUN_ID, test.name, String(p.n)));
9042
+ renderEnv(test.env, opts.vars, fill);
9043
+ return {
9044
+ ...p,
9045
+ claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9046
+ install: [...opts.config.install, ...test.install],
9047
+ checks: test.checks.map((c) => render(c.run, opts.vars)),
9048
+ cleanup: [...test.cleanup, ...opts.config.cleanup]
9049
+ };
9050
+ });
9051
+ if (opts.json) {
9052
+ console.log(JSON.stringify({ dryRun: true, build: opts.config.build, runs }, null, 2));
9053
+ return;
9054
+ }
9055
+ const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
9056
+ opts.say(`Dry run: ${count(runs.length, "run")}, build ${count(opts.config.build.length, "step")}. Nothing was started.`);
9057
+ for (const r of runs)
9058
+ opts.say(`${r.test}#${r.n}${r.model === null ? "" : ` [${r.model}]`} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.claudeArgs.length > 0 ? `, claude_args ${JSON.stringify(r.claudeArgs)}` : ""}`);
9059
+ }
8948
9060
  async function runOne(opts) {
8949
9061
  const { test, n } = opts;
8950
9062
  const started = Date.now();
@@ -8952,20 +9064,13 @@ async function runOne(opts) {
8952
9064
  const workdir = join6(dir, "work");
8953
9065
  mkdirSync4(workdir, { recursive: true });
8954
9066
  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
- };
9067
+ const fill = runFill(opts.runId, n, test.name, opts.out, dir);
8963
9068
  const r = {
8964
9069
  test: test.name,
8965
9070
  n,
8966
9071
  model: opts.model,
8967
9072
  claudeModel: null,
8968
- claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
9073
+ claudeArgs: renderClaudeArgs(test, opts.vars, fill),
8969
9074
  status: "error",
8970
9075
  reason: "",
8971
9076
  prompt: render(test.prompt, opts.vars),
@@ -9031,7 +9136,10 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9031
9136
  cwd: workdir,
9032
9137
  env: {
9033
9138
  ...claudeEnv(opts.configDir, env, homeEnvFile),
9034
- PATH: opts.stepEnv.PATH
9139
+ PATH: opts.stepEnv.PATH,
9140
+ FIREGOAT_OUT: opts.out,
9141
+ FIREGOAT_WORKDIR: workdir,
9142
+ FIREGOAT_HOME: r.home
9035
9143
  },
9036
9144
  stderr,
9037
9145
  timeoutMs: test.timeout * 1000,
@@ -9159,9 +9267,33 @@ async function evalCommand(args, root) {
9159
9267
  ];
9160
9268
  if (missing.length > 0)
9161
9269
  return fatal(EXIT.usage, `No value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
9270
+ if (opts.dryRun) {
9271
+ let repo;
9272
+ try {
9273
+ repo = gitRepo(root);
9274
+ } catch (e) {
9275
+ return fatal(EXIT.usage, e.message);
9276
+ }
9277
+ try {
9278
+ dryRun({
9279
+ config,
9280
+ tests,
9281
+ plan: planRuns(tests, models, runs),
9282
+ vars,
9283
+ runsRoot: join6(repo?.mainCheckout ?? root, ".firegoat", "runs"),
9284
+ json: opts.json,
9285
+ say
9286
+ });
9287
+ } catch (e) {
9288
+ if (e instanceof ConfigError)
9289
+ return fatal(EXIT.usage, e.message);
9290
+ throw e;
9291
+ }
9292
+ return EXIT.pass;
9293
+ }
9162
9294
  const claudePath = resolveBin(opts.claudeBin);
9163
9295
  if (!claudePath)
9164
- return fatal(EXIT.notGraded, `Not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH / FIREGOAT_CLAUDE_BIN.`);
9296
+ return fatal(EXIT.notGraded, `Not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH.`);
9165
9297
  let repo;
9166
9298
  try {
9167
9299
  repo = gitRepo(root);
@@ -9183,7 +9315,7 @@ async function evalCommand(args, root) {
9183
9315
  const runId = newRunId();
9184
9316
  let upload;
9185
9317
  try {
9186
- const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root).slice(0, 256), runId, repo?.info ?? null);
9318
+ const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
9187
9319
  if (pre && "exitCode" in pre)
9188
9320
  return fatal(pre.exitCode, pre.error);
9189
9321
  upload = pre;
@@ -9202,14 +9334,11 @@ async function evalCommand(args, root) {
9202
9334
  FIREGOAT_OUT: out,
9203
9335
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
9204
9336
  };
9205
- const matrix = models.length > 0 ? models : [null];
9206
- const rows = tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
9207
- test: t.name,
9208
- n: m * runs + i + 1,
9209
- model,
9337
+ const rows = planRuns(tests, models, runs).map((p) => ({
9338
+ ...p,
9210
9339
  state: "queued",
9211
9340
  turns: 0
9212
- }))));
9341
+ }));
9213
9342
  if (config.build.length > 0) {
9214
9343
  const buildStarted = Date.now();
9215
9344
  ui.build("running");
@@ -9266,7 +9395,7 @@ async function evalCommand(args, root) {
9266
9395
  results[i] = r;
9267
9396
  Object.assign(row, {
9268
9397
  state: r.status,
9269
- turns: r.num_turns ?? row.turns,
9398
+ reportedTurns: r.num_turns,
9270
9399
  reason: r.reason,
9271
9400
  checks: r.checks,
9272
9401
  cost: r.cost,
@@ -9314,7 +9443,7 @@ async function evalCommand(args, root) {
9314
9443
  passed: totals.pass,
9315
9444
  failed: totals.fail,
9316
9445
  notGraded: totals.error,
9317
- cost: results.reduce((sum, r) => sum + (r.cost ?? 0), 0),
9446
+ cost: results.every((r) => r.cost !== null) ? results.reduce((sum, r) => sum + r.cost, 0) : null,
9318
9447
  uploaded: results.filter((r) => r.upload?.status === "uploaded").length,
9319
9448
  uploadFailed: uploadsFailed
9320
9449
  })}
@@ -9617,7 +9746,8 @@ var TIMEOUT_MS2 = 30000;
9617
9746
  var MAX_EXPIRES_S = 900;
9618
9747
  var MAX_INTERVAL_S = 60;
9619
9748
  var FORM = { "Content-Type": "application/x-www-form-urlencoded" };
9620
- var UPLOAD_NOTICE = "Each eval uploads every agent session (prompts, tool outputs, file contents, possibly secrets from your env or repo) to firegoat.ai as private traces; --no-upload keeps runs local. The agent runs as you and can read ~/.firegoat (and a FIREGOAT_API_KEY in firegoat's own process env).";
9749
+ var UPLOADS_ON = "Eval uploads each session to firegoat.ai as a private trace; --no-upload keeps runs local.";
9750
+ var UPLOAD_NOTICE = `${UPLOADS_ON} A session holds prompts, tool outputs and file contents: possibly secrets from your env or repo. The agent runs as you and can read ~/.firegoat (and a FIREGOAT_API_KEY in firegoat's own process env).`;
9621
9751
  var TERMS = "By logging in, you agree to the firegoat.ai Terms of Service (https://firegoat.ai/terms) and Privacy Policy (https://firegoat.ai/privacy).";
9622
9752
  function str2(o, k, step) {
9623
9753
  const v = o?.[k];
@@ -9920,13 +10050,16 @@ async function askScope(question) {
9920
10050
  var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
9921
10051
  async function loginStep(interactive, done, paint) {
9922
10052
  const label = "firegoat.ai login";
10053
+ const uploadsOn = () => console.log(` ${paint("90", UPLOADS_ON)}`);
9923
10054
  const key = resolveKey(baseUrl());
9924
10055
  if (key) {
9925
10056
  const who = key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}`;
9926
- if (interactive)
10057
+ if (interactive) {
9927
10058
  done(label, who);
9928
- else
9929
- console.log(who);
10059
+ uploadsOn();
10060
+ } else
10061
+ console.log(`${who}
10062
+ ${UPLOADS_ON}`);
9930
10063
  return EXIT.pass;
9931
10064
  }
9932
10065
  if (!interactive) {
@@ -9942,6 +10075,7 @@ async function loginStep(interactive, done, paint) {
9942
10075
  try {
9943
10076
  const email = await loginFlow((l) => console.log(` ${l}`), true);
9944
10077
  done(label, `Logged in as ${email}`);
10078
+ uploadsOn();
9945
10079
  return EXIT.pass;
9946
10080
  } catch (e) {
9947
10081
  if (!(e instanceof ApiError))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"