firegoat 0.21.0 → 0.22.1

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 +20 -243
  2. package/dist/cli.js +118 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,40 +1,28 @@
1
1
  # firegoat
2
2
 
3
- Local AX evals: how well coding agents use your SDK, CLI, API and docs. firegoat gives your own `claude` one prompt in a fresh folder, saves the session, and runs your checks.
3
+ Firegoat is an agent experience platform that lets devtools see how agents use their product and improve the agent experience.
4
4
 
5
- Status: early. See `plan.md`.
5
+ It's for developers who build for agents as customers: SDKs, CLIs, APIs, MCP servers, cloud products, local products, and the people writing documentation.
6
6
 
7
- ## Quickstart
7
+ So far we've got the CLI. It lets anyone use a simplified version of our product, running on their local machine with their own Claude subscription and the Claude Code they already have installed. Support for other harnesses is coming soon.
8
8
 
9
- ```bash
10
- npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
11
- # Edit firegoat.yaml (or paste the prompt into your coding agent and let it)
12
- npx firegoat@latest eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
13
- ```
9
+ ## What you get
14
10
 
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 X.Y.Z`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
11
+ When you run `firegoat eval`, you get visibility into how agents use your product: whether they fail or succeed, and how much time they take doing it. You can point them at your local codebase or at your cloud product, and see how Claude acts on the simple tasks your product aims to do.
16
12
 
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
-
19
- ## Upload to firegoat.ai
13
+ ## Start
20
14
 
21
15
  ```bash
22
- npx firegoat login # Device flow: open the URL, enter the code; key saved to ~/.firegoat/credentials.json (0600)
23
- npx firegoat eval # Now also uploads each run as a private trace and prints its URL
24
- npx firegoat logout # Forgets the login (revoke the key itself on the firegoat.ai traces page)
16
+ npx firegoat@latest init
25
17
  ```
26
18
 
27
- Logging in means agreeing to the firegoat.ai [Terms of Service](https://firegoat.ai/terms) and [Privacy Policy](https://firegoat.ai/privacy); login says so before showing the code. `init` offers the login in a terminal (`--no-login` skips it). In CI, set `FIREGOAT_API_KEY=fire_...` instead. `eval --no-upload` keeps runs local; without a key, runs stay local and eval says so in one line.
28
-
29
- - Per run, eval uploads claude's session (`session.jsonl`: prompts, tool calls and outputs, file contents, so possibly secrets from your env or repo), `checks.txt` and `stderr.txt` (check output sees `vars:`), plus the run's test, status, checks, the model asked for and the one claude reported, and (in a git repo) the branch, commit and whether the tree was dirty. firegoat.ai masks secrets when it shows a trace, but stores what was sent. Traces start private; you share one from its page.
30
- - Before any run, eval checks the key (`GET /api/traces/me`) and prints `Uploading sessions to <origin> as <email>`: a key answered with 401/403 exits 3; any other failure (unreachable, 429, 5xx, a wrong `FIREGOAT_BASE_URL` path) exits 2.
31
- - Each run prints `✓ <test>#<n> <url>` or `✗ <test>#<n> Upload failed: <why>`, and `run.json` / `summary.json` get `upload` (`{status: "uploaded", url}`, `{status: "failed", error}`, `{status: "skipped", reason}`, or null when not uploading). A failed upload never changes a run's status or eval's exit code (0/1/2 stay the eval's verdict); eval ends with `N uploads failed` on stderr. Each file may be at most 20 MiB: a bigger one fails that run's upload. Runs without a session file are skipped.
32
- - The key is never passed to the agent, build, install or checks (every `FIREGOAT_*` var is removed from their env), is never written to run files, and goes only to the base URL's origin, never through a redirect; presigned upload URLs get no auth header. But there is no sandbox: the agent runs as you, so it can read `~/.firegoat/credentials.json`, and with `FIREGOAT_API_KEY` it can read the key from firegoat's own process environment (`ps eww $PPID`, `/proc/<pid>/environ`), which no program can scrub. In CI, use a dedicated key and revoke it if a run looks off. The key can only upload and read your own traces.
33
- - `FIREGOAT_BASE_URL` (default `https://firegoat.ai/evals`) points firegoat elsewhere: https only, http only for `localhost` / `127.0.0.1`. A saved login only works for the base URL it was made for.
19
+ Then paste the prompt it prints into your agent, and let it set up the evals that point agents at your product. Then run them and see the results:
34
20
 
35
- ## Examples
21
+ ```bash
22
+ npx firegoat@latest eval
23
+ ```
36
24
 
37
- ### Can an agent get started with my npm package?
25
+ ## Example
38
26
 
39
27
  ```yaml
40
28
  build: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -49,226 +37,15 @@ tests:
49
37
  - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
50
38
  ```
51
39
 
52
- `build` builds your package once; `install` installs that build into each fresh folder, so the agent uses your local code, not the registry.
53
-
54
- ### Does my CLI make sense to an agent?
55
-
56
- ```yaml
57
- build: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme
58
- tests:
59
- - name: deploy
60
- prompt: Deploy the hello-world template to a preview environment with the acme CLI and print the URL.
61
- end_goal: The agent finds the right acme commands and ends with a working preview URL.
62
- checks:
63
- - run: grep -q '\$ acme deploy' "$FIREGOAT_TRANSCRIPT"
64
- ```
65
-
66
- Whatever `build` writes to `$FIREGOAT_OUT` is first on the agent's `PATH`. That only helps for binaries called by bare name: if users run your CLI via `npx`/`uvx`, pack and install it like the npm example, or `npx` fetches the registry release.
67
-
68
- ### Can an agent find the right package from my docs alone?
69
-
70
- ```yaml
71
- tests:
72
- - name: which-packages
73
- prompt: >-
74
- I'm building a mobile app with Acme. It needs a local database, maps and Sign in
75
- with Apple. Which Acme packages should I use? Write the list to packages.txt.
76
- end_goal: packages.txt names the right Acme package for each need, found in Acme's docs.
77
- checks:
78
- - run: test -s packages.txt
79
- ```
80
-
81
- ### Python, three runs, and a value from the command line
82
-
83
- ```yaml
84
- runs: 3
85
- build: uv build --wheel --out-dir "$FIREGOAT_OUT"
86
- install: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
87
- tests:
88
- - name: sandbox-hello
89
- prompt: >-
90
- Using the acme Python SDK (already installed), create a sandbox on commit
91
- {{vars.SHA}}, run `echo hello-from-acme` in it, print the output, then delete it.
92
- end_goal: The script prints hello-from-acme from a sandbox and deletes the sandbox.
93
- checks:
94
- - run: grep -rq hello-from-acme "$FIREGOAT_WORKDIR"
95
- ```
96
-
97
- ```bash
98
- npx firegoat eval --var SHA=$(git rev-parse HEAD)
99
- ```
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
-
116
- ### Same tests on several models
117
-
118
- ```yaml
119
- model: [claude-haiku-5-5, claude-sonnet-5-5]
120
- runs: 2
121
- tests:
122
- - name: quickstart
123
- prompt: Get the acme SDK's hello-world example running and save its output to output.txt.
124
- end_goal: output.txt holds the example's output.
125
- checks:
126
- - run: test -s output.txt
127
- ```
128
-
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.
130
-
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
- 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
- ```
40
+ ## Uploads
169
41
 
170
- Copy it in `build:` and pass it with `claude_args:`:
171
-
172
- ```yaml
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]
178
- ```
179
-
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"`.
181
-
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.
183
-
184
- ### Env vars for the agent: a feature flag, a tool that ignores HOME
185
-
186
- ```yaml
187
- env:
188
- ACME_HOME: "$FIREGOAT_HOME/.acme" # Only for a tool that ignores HOME
189
- ACME_TELEMETRY: "off"
190
- UV_FIND_LINKS: "$FIREGOAT_OUT" # Resolve the wheel build: made
191
- tests:
192
- - name: login
193
- prompt: Log in to acme with the CLI and print the current user.
194
- end_goal: The agent completes acme's login flow and prints the user.
195
- env:
196
- ACME_BETA_LOGIN: "1" # This test only, on top of env:
197
- checks:
198
- - run: ls "$ACME_HOME"/sessions | grep -q .
199
- ```
42
+ Log in to your firegoat.ai account and each trace is uploaded to our system automatically. Secrets are masked in everything you see, but the uploaded files are stored as sent. You can opt out of uploading with `eval --no-upload`. You can also delete your traces on the dashboard, and the files and the record are gone from our site.
200
43
 
201
- `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.
44
+ ## Links
202
45
 
203
- ### Each run gets its own HOME
46
+ - Everything else (every config key, more examples, how eval runs claude): [docs/reference.md](docs/reference.md) and `npx firegoat@latest eval --help`
47
+ - The skill your agent uses to write evals: [firegoat-ai/skills](https://github.com/firegoat-ai/skills)
48
+ - Tell us what got in your way: `npx firegoat@latest feedback "<what happened>"`
49
+ - [firegoat.ai](https://firegoat.ai)
204
50
 
205
- 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`.
206
-
207
- - 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).
208
- - `install`, checks and `cleanup` get `HOME` and the XDG vars set to the run's home, plus `$FIREGOAT_HOME`. `build` keeps your real home.
209
- - `run.json` records it as `home`.
210
-
211
- Limits:
212
-
213
- - claude's own Read/Write/Edit tools expand `~` to your real home; only Bash commands get the run's home.
214
- - Lookups by user id (`os.userInfo().homedir`, python's `pwd`, `getpwuid`) still return your real home.
215
- - Keychain-backed logins (`gh`, `docker`, git credential helpers) look logged out inside a run. Log tools in from `install:`, or pass tokens with `env:`.
216
- - The fresh home has no `.gitconfig`: if the agent commits, set `git config --global user.name ...` / `user.email` in `install:`.
217
- - claude's shell snapshot still sources your real `~/.zshrc` (or `~/.bashrc`).
218
-
219
- ## What eval does
220
-
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.
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.
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.
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`.
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.
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.
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`; 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.
228
-
229
- `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`.
230
-
231
- ### Stop what the agent left running, seed one test
232
-
233
- ```yaml
234
- install: npm init -y >/dev/null
235
- cleanup: pkill -f "$FIREGOAT_WORKDIR" || true # A dev server the agent started
236
- tests:
237
- - name: migrate
238
- prompt: Migrate the app in this folder to acme v2 and start it.
239
- end_goal: The app runs on acme v2.
240
- install: cp -R "$FIREGOAT_OUT"/v1-app/. . # This test only, after the top-level install
241
- cleanup: acme down || true # This test only, before the top-level cleanup
242
- ```
243
-
244
- Step order: `build` once per eval, in your folder; then per run, in its folder: `install` (top-level, then the test's), the agent, checks, `cleanup` (the test's, then top-level). `cleanup` runs whenever the run's folder exists: after the checks, and also after a failed install, a claude error or a timeout. Every cleanup step runs; a failing one prints a warning and is recorded in `run.json` (`cleanup: [{run, code}]`, output in `cleanup.txt`), but never changes the run's status or eval's exit code.
245
-
246
- Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See [`examples/local-npm-registry`](examples/local-npm-registry/README.md).
247
-
248
- | exit code | meaning |
249
- |---|---|
250
- | 0 | Every run's checks passed |
251
- | 1 | A check failed |
252
- | 2 | At least one run was not graded (claude missing or logged out, crash, timeout, build failed); wins over 1 |
253
- | 3 | Usage or config error (including firegoat.ai rejecting the upload key) |
254
-
255
- Upload failures don't change the exit code.
256
-
257
- ## Feedback
258
-
259
- Something in firegoat got in your way (or your agent's)? Tell us from the terminal:
260
-
261
- ```sh
262
- npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
263
- ```
264
-
265
- It goes to the firegoat team with the firegoat version, your OS and node version, and which coding agent sent it. Don't include secrets: a report that looks like it has one is refused. The first send registers your device and saves its token to `~/.config/firegoat/feedback-device.json` (`$XDG_CONFIG_HOME` respected), which raises the daily report limit.
266
-
267
- ## Develop
268
-
269
- ```bash
270
- bun install
271
- bun run format
272
- bun test # End-to-end: packs the package and drives the installed bin
273
- FIREGOAT_E2E_REAL=1 bun test # Also runs one eval against your real claude
274
- ```
51
+ If every run passes, firegoat calls you GOATED. Who's the GOAT? You are.
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.21.0",
6950
+ version: "0.22.1",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -8010,11 +8010,9 @@ firegoat.yaml reference:
8010
8010
  vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
8011
8011
  get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
8012
8012
  Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
8013
- transcript, greppable line by line: "[1] $ <command>" on every line of a shell
8014
- call (one line per line of the command, same n), "[2] Write <path>" for other
8015
- tools, each output indented, and "agent: " on every line of the agent's text and
8016
- final answer. So '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also
8017
- 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).
8018
8016
 
8019
8017
  The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
8020
8018
  (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
@@ -8098,7 +8096,8 @@ page.
8098
8096
  Exit codes: 0 logged out (or was not logged in), 3 the file or ~/.firegoat is a
8099
8097
  symlink, not a regular file/directory, or someone else's (left alone).`;
8100
8098
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--parallel N]
8101
- [--var KEY=value].. [--json] [--no-upload] [--claude-bin PATH]
8099
+ [--var KEY=value].. [--json] [--no-upload] [--dry-run]
8100
+ [--claude-bin PATH]
8102
8101
 
8103
8102
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
8104
8103
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
@@ -8152,6 +8151,14 @@ fixtures and build: stay in the current folder.
8152
8151
  --json The summary as JSON on stdout (or {error, exitCode} if eval
8153
8152
  stops early); progress goes to stderr
8154
8153
  --no-upload Keep runs local even when logged in to firegoat.ai
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}]}
8155
8162
  --claude-bin PATH Path to claude (default: claude on PATH)
8156
8163
 
8157
8164
  Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
@@ -8181,14 +8188,25 @@ Output: .firegoat/runs/<runId>/
8181
8188
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8182
8189
  url} | {status: "failed", error} | {status:
8183
8190
  "skipped", reason}, null when not uploading)
8184
- <test>/<n>/transcript.txt What the agent did: each tool call with its command
8185
- ("[n] $ " on every line of a shell call), short output
8186
- (indented), its text and final answer ("agent: " on
8187
- every line), files it made
8191
+ <test>/<n>/transcript.txt What the agent did, below
8188
8192
  <test>/<n>/home/, home.env The run's HOME, and the file claude sources
8189
8193
  before the agent's Bash commands
8190
8194
  build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
8191
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
+
8192
8210
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
8193
8211
  yours to judge: read transcript.txt first (a few KB); the session file has everything
8194
8212
  (often 50x larger).
@@ -8199,6 +8217,7 @@ Examples:
8199
8217
  npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
8200
8218
  Each test on both models
8201
8219
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
8220
+ npx firegoat eval --dry-run Validate firegoat.yaml, list the runs
8202
8221
 
8203
8222
  A run can take minutes (default timeout 600s per test).
8204
8223
 
@@ -8295,6 +8314,7 @@ import { join as join4, relative } from "node:path";
8295
8314
  var OUTPUT_LINES = 15;
8296
8315
  var OUTPUT_CHARS = 1500;
8297
8316
  var TEXT_CHARS = 1500;
8317
+ var INPUT_CHARS = 4000;
8298
8318
  var SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "__pycache__"]);
8299
8319
  function truncate(text, maxLines, maxChars) {
8300
8320
  let out = text.length > maxChars ? text.slice(0, maxChars) : text;
@@ -8319,7 +8339,7 @@ function describeCall(step, name, input) {
8319
8339
  for (const key of ["file_path", "path", "url", "pattern", "query"])
8320
8340
  if (typeof input[key] === "string")
8321
8341
  return `[${step}] ${name} ${input[key]}`;
8322
- return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
8342
+ return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, INPUT_CHARS)}`;
8323
8343
  }
8324
8344
  function describeToolUse(step, b) {
8325
8345
  if (typeof b.name !== "string" || b.name === "")
@@ -8879,6 +8899,7 @@ function parseArgs(args) {
8879
8899
  parallel: 4,
8880
8900
  json: false,
8881
8901
  noUpload: false,
8902
+ dryRun: false,
8882
8903
  vars: {},
8883
8904
  claudeBin: "claude"
8884
8905
  };
@@ -8899,6 +8920,8 @@ function parseArgs(args) {
8899
8920
  opts.json = true;
8900
8921
  else if (flag === "--no-upload")
8901
8922
  opts.noUpload = true;
8923
+ else if (flag === "--dry-run")
8924
+ opts.dryRun = true;
8902
8925
  else if (flag === "--var") {
8903
8926
  const kv = value();
8904
8927
  const at = kv.indexOf("=");
@@ -8989,6 +9012,51 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
8989
9012
  };
8990
9013
  }
8991
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
+ }
8992
9060
  async function runOne(opts) {
8993
9061
  const { test, n } = opts;
8994
9062
  const started = Date.now();
@@ -8996,20 +9064,13 @@ async function runOne(opts) {
8996
9064
  const workdir = join6(dir, "work");
8997
9065
  mkdirSync4(workdir, { recursive: true });
8998
9066
  const { home, envFile } = makeRunHome(dir);
8999
- const fill = {
9000
- "run.id": opts.runId,
9001
- "run.n": String(n),
9002
- "test.name": test.name,
9003
- FIREGOAT_OUT: opts.out,
9004
- FIREGOAT_WORKDIR: workdir,
9005
- FIREGOAT_HOME: home
9006
- };
9067
+ const fill = runFill(opts.runId, n, test.name, opts.out, dir);
9007
9068
  const r = {
9008
9069
  test: test.name,
9009
9070
  n,
9010
9071
  model: opts.model,
9011
9072
  claudeModel: null,
9012
- claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
9073
+ claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9013
9074
  status: "error",
9014
9075
  reason: "",
9015
9076
  prompt: render(test.prompt, opts.vars),
@@ -9206,6 +9267,30 @@ async function evalCommand(args, root) {
9206
9267
  ];
9207
9268
  if (missing.length > 0)
9208
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
+ }
9209
9294
  const claudePath = resolveBin(opts.claudeBin);
9210
9295
  if (!claudePath)
9211
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.`);
@@ -9249,14 +9334,11 @@ async function evalCommand(args, root) {
9249
9334
  FIREGOAT_OUT: out,
9250
9335
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
9251
9336
  };
9252
- const matrix = models.length > 0 ? models : [null];
9253
- const rows = tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
9254
- test: t.name,
9255
- n: m * runs + i + 1,
9256
- model,
9337
+ const rows = planRuns(tests, models, runs).map((p) => ({
9338
+ ...p,
9257
9339
  state: "queued",
9258
9340
  turns: 0
9259
- }))));
9341
+ }));
9260
9342
  if (config.build.length > 0) {
9261
9343
  const buildStarted = Date.now();
9262
9344
  ui.build("running");
@@ -9664,7 +9746,8 @@ var TIMEOUT_MS2 = 30000;
9664
9746
  var MAX_EXPIRES_S = 900;
9665
9747
  var MAX_INTERVAL_S = 60;
9666
9748
  var FORM = { "Content-Type": "application/x-www-form-urlencoded" };
9667
- 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).`;
9668
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).";
9669
9752
  function str2(o, k, step) {
9670
9753
  const v = o?.[k];
@@ -9967,13 +10050,16 @@ async function askScope(question) {
9967
10050
  var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
9968
10051
  async function loginStep(interactive, done, paint) {
9969
10052
  const label = "firegoat.ai login";
10053
+ const uploadsOn = () => console.log(` ${paint("90", UPLOADS_ON)}`);
9970
10054
  const key = resolveKey(baseUrl());
9971
10055
  if (key) {
9972
10056
  const who = key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}`;
9973
- if (interactive)
10057
+ if (interactive) {
9974
10058
  done(label, who);
9975
- else
9976
- console.log(who);
10059
+ uploadsOn();
10060
+ } else
10061
+ console.log(`${who}
10062
+ ${UPLOADS_ON}`);
9977
10063
  return EXIT.pass;
9978
10064
  }
9979
10065
  if (!interactive) {
@@ -9989,6 +10075,7 @@ async function loginStep(interactive, done, paint) {
9989
10075
  try {
9990
10076
  const email = await loginFlow((l) => console.log(` ${l}`), true);
9991
10077
  done(label, `Logged in as ${email}`);
10078
+ uploadsOn();
9992
10079
  return EXIT.pass;
9993
10080
  } catch (e) {
9994
10081
  if (!(e instanceof ApiError))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"