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.
- package/README.md +20 -243
- package/dist/cli.js +118 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,40 +1,28 @@
|
|
|
1
1
|
# firegoat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Firegoat is an agent experience platform that lets devtools see how agents use their product and improve the agent experience.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
## Upload to firegoat.ai
|
|
13
|
+
## Start
|
|
20
14
|
|
|
21
15
|
```bash
|
|
22
|
-
npx firegoat
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
```bash
|
|
22
|
+
npx firegoat@latest eval
|
|
23
|
+
```
|
|
36
24
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
## Links
|
|
202
45
|
|
|
203
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
8014
|
-
|
|
8015
|
-
|
|
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] [--
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
9253
|
-
|
|
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
|
|
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
|
-
|
|
9976
|
-
|
|
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))
|