firegoat 0.16.0 → 0.17.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.
- package/README.md +33 -29
- package/dist/cli.js +211 -160
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,19 +7,21 @@ Status: early. See `plan.md`.
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx firegoat init #
|
|
11
|
-
#
|
|
12
|
-
npx firegoat eval #
|
|
10
|
+
npx firegoat 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 eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
That's it: two commands. `claude` must be installed and logged in (run `claude` once).
|
|
16
16
|
|
|
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
|
+
|
|
17
19
|
## Upload to firegoat.ai
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
npx firegoat login #
|
|
21
|
-
npx firegoat eval #
|
|
22
|
-
npx firegoat logout #
|
|
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)
|
|
23
25
|
```
|
|
24
26
|
|
|
25
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.
|
|
@@ -27,7 +29,7 @@ Logging in means agreeing to the firegoat.ai [Terms of Service](https://firegoat
|
|
|
27
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.
|
|
28
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.
|
|
29
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.
|
|
30
|
-
- The key is never passed to the agent,
|
|
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.
|
|
31
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.
|
|
32
34
|
|
|
33
35
|
## Examples
|
|
@@ -35,8 +37,8 @@ Logging in means agreeing to the firegoat.ai [Terms of Service](https://firegoat
|
|
|
35
37
|
### Can an agent get started with my npm package?
|
|
36
38
|
|
|
37
39
|
```yaml
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
41
|
+
install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
40
42
|
tests:
|
|
41
43
|
- name: quickstart
|
|
42
44
|
prompt: >-
|
|
@@ -47,12 +49,12 @@ tests:
|
|
|
47
49
|
- run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
`
|
|
52
|
+
`build` builds your package once; `install` installs that build into each fresh folder, so the agent uses your local code, not the registry.
|
|
51
53
|
|
|
52
54
|
### Does my CLI make sense to an agent?
|
|
53
55
|
|
|
54
56
|
```yaml
|
|
55
|
-
|
|
57
|
+
build: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme
|
|
56
58
|
tests:
|
|
57
59
|
- name: deploy
|
|
58
60
|
prompt: Deploy the hello-world template to a preview environment with the acme CLI and print the URL.
|
|
@@ -61,7 +63,7 @@ tests:
|
|
|
61
63
|
- run: grep -q '\$ acme deploy' "$FIREGOAT_TRANSCRIPT"
|
|
62
64
|
```
|
|
63
65
|
|
|
64
|
-
Whatever `
|
|
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.
|
|
65
67
|
|
|
66
68
|
### Can an agent find the right package from my docs alone?
|
|
67
69
|
|
|
@@ -80,8 +82,8 @@ tests:
|
|
|
80
82
|
|
|
81
83
|
```yaml
|
|
82
84
|
runs: 3
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
build: uv build --wheel --out-dir "$FIREGOAT_OUT"
|
|
86
|
+
install: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
|
|
85
87
|
tests:
|
|
86
88
|
- name: sandbox-hello
|
|
87
89
|
prompt: >-
|
|
@@ -115,41 +117,43 @@ tests:
|
|
|
115
117
|
|
|
116
118
|
```yaml
|
|
117
119
|
env:
|
|
118
|
-
ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" #
|
|
120
|
+
ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # Sessions/config never collide across runs
|
|
119
121
|
ACME_TELEMETRY: "off"
|
|
120
|
-
UV_FIND_LINKS: "$FIREGOAT_OUT" #
|
|
122
|
+
UV_FIND_LINKS: "$FIREGOAT_OUT" # Resolve the wheel build: made
|
|
121
123
|
tests:
|
|
122
124
|
- name: login
|
|
123
125
|
prompt: Log in to acme with the CLI and print the current user.
|
|
124
126
|
end_goal: The agent completes acme's login flow and prints the user.
|
|
125
127
|
env:
|
|
126
|
-
ACME_BETA_LOGIN: "1" #
|
|
128
|
+
ACME_BETA_LOGIN: "1" # This test only, on top of env:
|
|
127
129
|
checks:
|
|
128
130
|
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
129
131
|
```
|
|
130
132
|
|
|
131
|
-
`env:` goes to the agent, and to `
|
|
133
|
+
`env:` goes to the agent, and to `install` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` (also `${...}`), the same dirs `build`, `install` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `build` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`.
|
|
134
|
+
|
|
135
|
+
Setting `HOME`: on macOS claude reads its login from `$HOME/Library`, so with a per-run `HOME` every run says "Not logged in" unless `install` runs `ln -s <real home>/Library "$HOME/Library"`. Get the real home in `build`, which doesn't get entries using `{{test.name}}` or `{{run.n}}` (e.g. `build: echo "$HOME" > "$FIREGOAT_OUT/.real-home"`).
|
|
132
136
|
|
|
133
137
|
## What eval does
|
|
134
138
|
|
|
135
139
|
- claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
|
|
136
|
-
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores everything except `fixtures
|
|
137
|
-
- 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 `
|
|
138
|
-
- 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,
|
|
140
|
+
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores everything except `fixtures/` and its own `.gitignore`: keep test fixtures (scripts, sample data) in `.firegoat/fixtures/` and commit them and `.firegoat/.gitignore` with `firegoat.yaml`.
|
|
141
|
+
- In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `build:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost, uploaded, uploadFailed`) to tell runs from different branches apart.
|
|
142
|
+
- The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too.
|
|
139
143
|
- Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
|
|
140
144
|
- 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.
|
|
141
|
-
- 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`), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
|
|
145
|
+
- Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
|
|
142
146
|
|
|
143
|
-
`firegoat.yaml` keys: `runs`, `model`, `
|
|
147
|
+
`firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
144
148
|
|
|
145
149
|
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).
|
|
146
150
|
|
|
147
151
|
| exit code | meaning |
|
|
148
152
|
|---|---|
|
|
149
|
-
| 0 |
|
|
150
|
-
| 1 |
|
|
151
|
-
| 2 |
|
|
152
|
-
| 3 |
|
|
153
|
+
| 0 | Every run's checks passed |
|
|
154
|
+
| 1 | A check failed |
|
|
155
|
+
| 2 | At least one run was not graded (claude missing or logged out, crash, timeout, build failed); wins over 1 |
|
|
156
|
+
| 3 | Usage or config error (including firegoat.ai rejecting the upload key) |
|
|
153
157
|
|
|
154
158
|
Upload failures don't change the exit code.
|
|
155
159
|
|
|
@@ -168,6 +172,6 @@ It goes to the firegoat team with the firegoat version, your OS and node version
|
|
|
168
172
|
```bash
|
|
169
173
|
bun install
|
|
170
174
|
bun run format
|
|
171
|
-
bun test #
|
|
172
|
-
FIREGOAT_E2E_REAL=1 bun test #
|
|
175
|
+
bun test # End-to-end: packs the package and drives the installed bin
|
|
176
|
+
FIREGOAT_E2E_REAL=1 bun test # Also runs one eval against your real claude
|
|
173
177
|
```
|
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.17.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
|
|
@@ -7076,16 +7076,16 @@ function parseConfig(text) {
|
|
|
7076
7076
|
onlyKeys(raw, "(root)", [
|
|
7077
7077
|
"runs",
|
|
7078
7078
|
"model",
|
|
7079
|
-
"
|
|
7080
|
-
"
|
|
7079
|
+
"build",
|
|
7080
|
+
"install",
|
|
7081
7081
|
"vars",
|
|
7082
7082
|
"env",
|
|
7083
7083
|
"tests"
|
|
7084
7084
|
]);
|
|
7085
7085
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7086
7086
|
const models = parseModels(raw.model);
|
|
7087
|
-
const
|
|
7088
|
-
const
|
|
7087
|
+
const build = commands(raw.build, "build");
|
|
7088
|
+
const install = commands(raw.install, "install");
|
|
7089
7089
|
const vars = parseVars(raw.vars);
|
|
7090
7090
|
const env = parseEnv(raw.env, "env");
|
|
7091
7091
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
@@ -7125,8 +7125,8 @@ function parseConfig(text) {
|
|
|
7125
7125
|
return {
|
|
7126
7126
|
runs,
|
|
7127
7127
|
models,
|
|
7128
|
-
|
|
7129
|
-
|
|
7128
|
+
build,
|
|
7129
|
+
install,
|
|
7130
7130
|
vars,
|
|
7131
7131
|
env,
|
|
7132
7132
|
tests
|
|
@@ -7236,7 +7236,7 @@ function render(text, vars) {
|
|
|
7236
7236
|
return text.replace(PLACEHOLDER, (_, key) => {
|
|
7237
7237
|
const v = vars[key];
|
|
7238
7238
|
if (v === undefined)
|
|
7239
|
-
throw new ConfigError(`
|
|
7239
|
+
throw new ConfigError(`No value for {{vars.${key}}}`);
|
|
7240
7240
|
return v;
|
|
7241
7241
|
});
|
|
7242
7242
|
}
|
|
@@ -7735,6 +7735,7 @@ var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
|
7735
7735
|
import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
|
|
7736
7736
|
import { join as join2 } from "node:path";
|
|
7737
7737
|
var GITIGNORE = `*
|
|
7738
|
+
!.gitignore
|
|
7738
7739
|
!fixtures/
|
|
7739
7740
|
!fixtures/**
|
|
7740
7741
|
`;
|
|
@@ -7803,11 +7804,11 @@ function gitRepo(dir) {
|
|
|
7803
7804
|
|
|
7804
7805
|
// src/help.ts
|
|
7805
7806
|
var EXIT_CODES = `Exit codes:
|
|
7806
|
-
0
|
|
7807
|
-
1
|
|
7808
|
-
2
|
|
7809
|
-
|
|
7810
|
-
3
|
|
7807
|
+
0 Every run's script checks passed
|
|
7808
|
+
1 A script check failed
|
|
7809
|
+
2 At least one run was not graded (claude missing or logged out, crash, timeout,
|
|
7810
|
+
build failed); wins over 1
|
|
7811
|
+
3 Usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml,
|
|
7811
7812
|
firegoat.ai answered 401/403 for the upload key); any other failure of
|
|
7812
7813
|
that key check exits 2
|
|
7813
7814
|
Upload failures don't change the exit code.`;
|
|
@@ -7816,74 +7817,85 @@ function topHelp(version) {
|
|
|
7816
7817
|
folder, with none of your claude config; firegoat saves the session and runs your
|
|
7817
7818
|
script checks.
|
|
7818
7819
|
|
|
7819
|
-
npx firegoat init
|
|
7820
|
-
npx firegoat eval
|
|
7820
|
+
npx firegoat init Create firegoat.yaml and .firegoat/, install the skill
|
|
7821
|
+
npx firegoat eval Run the tests (claude must be logged in: run \`claude\` once)
|
|
7821
7822
|
npx firegoat login Log in to firegoat.ai: eval then uploads each session there
|
|
7822
7823
|
npx firegoat logout Forget that login
|
|
7823
|
-
npx firegoat feedback "<what got in the way>"
|
|
7824
|
+
npx firegoat feedback "<what got in the way>" Tell the firegoat team
|
|
7824
7825
|
|
|
7825
7826
|
Typical flow for a coding agent driving firegoat:
|
|
7826
|
-
1.
|
|
7827
|
+
1. Read npx firegoat init --help, then edit firegoat.yaml
|
|
7827
7828
|
2. npx firegoat eval --json
|
|
7828
|
-
3.
|
|
7829
|
+
3. Judge each run's end_goal from its run.json, session and workdir: firegoat only
|
|
7829
7830
|
runs the script checks
|
|
7830
7831
|
|
|
7831
7832
|
Run firegoat <command> --help for details and examples.
|
|
7832
7833
|
|
|
7833
7834
|
${EXIT_CODES}`;
|
|
7834
7835
|
}
|
|
7835
|
-
var INIT_HELP = `Usage: firegoat init [--force] [--no-skills] [--no-login]
|
|
7836
|
+
var INIT_HELP = `Usage: firegoat init [--force] [--local-skills | --no-skills] [--no-login]
|
|
7836
7837
|
|
|
7837
7838
|
In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
|
|
7838
|
-
firegoat-onboarding skill into
|
|
7839
|
-
|
|
7840
|
-
terminal only; otherwise prints how), then prints a prompt to
|
|
7841
|
-
your coding agent. In .firegoat/, git ignores everything but fixtures
|
|
7842
|
-
(helper scripts, sample data) in
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
--
|
|
7846
|
-
--
|
|
7839
|
+
firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
|
|
7840
|
+
firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
|
|
7841
|
+
firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
|
|
7842
|
+
paste into your coding agent. In .firegoat/, git ignores everything but fixtures/
|
|
7843
|
+
and the .gitignore: keep test fixtures (helper scripts, sample data) in
|
|
7844
|
+
.firegoat/fixtures/ and commit them, and .firegoat/.gitignore, with firegoat.yaml.
|
|
7845
|
+
|
|
7846
|
+
--force Replace an existing firegoat.yaml
|
|
7847
|
+
--local-skills Install the skill into this repo's .claude/skills/ instead
|
|
7848
|
+
(commit it so teammates and CI get it), without asking
|
|
7849
|
+
--no-skills Don't install the skill
|
|
7850
|
+
--no-login Don't offer firegoat login
|
|
7847
7851
|
|
|
7848
7852
|
firegoat.yaml reference:
|
|
7849
|
-
runs: 1
|
|
7853
|
+
runs: 1 Runs per test
|
|
7850
7854
|
model: <name> | [<name>, ...]
|
|
7851
7855
|
claude --model; a list runs every test runs times
|
|
7852
7856
|
per model (n counts 1..runs x models). Unset: no
|
|
7853
7857
|
--model, claude's default. eval --model overrides
|
|
7854
|
-
|
|
7858
|
+
build: <sh> | [<sh>, ...] Once per eval, in this folder; what it writes to
|
|
7855
7859
|
$FIREGOAT_OUT is first on the agent's PATH
|
|
7856
|
-
|
|
7857
|
-
vars: {KEY: value}
|
|
7860
|
+
install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts
|
|
7861
|
+
vars: {KEY: value} Defaults for {{vars.KEY}} in prompts and checks;
|
|
7858
7862
|
--var KEY=value overrides
|
|
7859
|
-
env: {NAME: value}
|
|
7863
|
+
env: {NAME: value} Env vars for the agent, and for install and
|
|
7860
7864
|
checks of the same run; values are strings and can
|
|
7861
7865
|
use {{vars.KEY}}, {{run.id}}, {{run.n}},
|
|
7862
7866
|
{{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
|
|
7863
7867
|
(no other $ expansion: $HOME stays literal).
|
|
7864
|
-
|
|
7868
|
+
The build step gets only the entries that use only
|
|
7865
7869
|
{{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
|
|
7866
7870
|
PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
|
|
7867
7871
|
ANTHROPIC_MODEL (use model:) are reserved.
|
|
7868
7872
|
Runs go 4 at a time: give a tool with a global home
|
|
7869
7873
|
a per-run one (or use eval --parallel 1), e.g.
|
|
7870
7874
|
MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
|
|
7875
|
+
Setting HOME: on macOS claude reads its login from
|
|
7876
|
+
$HOME/Library, so a per-run HOME needs
|
|
7877
|
+
ln -s <real home>/Library "$HOME/Library" in
|
|
7878
|
+
install (get the real home in build, which
|
|
7879
|
+
doesn't get entries using {{test.name}}/{{run.n}}).
|
|
7871
7880
|
tests:
|
|
7872
|
-
- name: quickstart
|
|
7873
|
-
prompt: ...
|
|
7874
|
-
end_goal: ...
|
|
7881
|
+
- name: quickstart Letters, digits, '.', '_', '-'; unique
|
|
7882
|
+
prompt: ... What a user would ask the agent; one concrete outcome
|
|
7883
|
+
end_goal: ... What is true when the problem is solved; not graded by
|
|
7875
7884
|
firegoat, handed to you to judge
|
|
7876
|
-
checks:
|
|
7877
|
-
- run: ...
|
|
7878
|
-
timeout: 600
|
|
7879
|
-
env: {NAME: value}
|
|
7885
|
+
checks: Optional shell commands, run in the agent's folder
|
|
7886
|
+
- run: ... Afterwards; exit 0 = pass; they set eval's exit code
|
|
7887
|
+
timeout: 600 Optional, seconds
|
|
7888
|
+
env: {NAME: value} Optional, on top of the top-level env:
|
|
7880
7889
|
|
|
7881
7890
|
Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
|
|
7882
|
-
.firegoat/fixtures/ and copy them in
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7891
|
+
.firegoat/fixtures/ and copy them in build or install. The build and install
|
|
7892
|
+
commands and the checks get the vars as env vars, plus $FIREGOAT_OUT and
|
|
7893
|
+
$FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
|
|
7894
|
+
transcript, greppable line by line: "[1] $ <command>" on every line of a shell
|
|
7895
|
+
call (one line per line of the command, same n), "[2] Write <path>" for other
|
|
7896
|
+
tools, each output indented, and "agent: " on every line of the agent's text and
|
|
7897
|
+
final answer. So '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also
|
|
7898
|
+
get $FIREGOAT_SESSION (claude's full session file, JSON lines).
|
|
7887
7899
|
|
|
7888
7900
|
The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
|
|
7889
7901
|
(npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
|
|
@@ -7892,8 +7904,8 @@ firegoat.yaml reference:
|
|
|
7892
7904
|
(FIREGOAT_API_KEY above all) firegoat itself was started with.
|
|
7893
7905
|
|
|
7894
7906
|
Example:
|
|
7895
|
-
|
|
7896
|
-
|
|
7907
|
+
build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
7908
|
+
install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
7897
7909
|
tests:
|
|
7898
7910
|
- name: first-call
|
|
7899
7911
|
prompt: >-
|
|
@@ -7916,7 +7928,7 @@ Logs in to firegoat.ai so eval uploads each run's claude session there as a priv
|
|
|
7916
7928
|
trace (you share it from its page). OAuth device flow: prints a URL and a code (and
|
|
7917
7929
|
opens the browser in a terminal); approve it there, and the trace key is saved to
|
|
7918
7930
|
~/.firegoat/credentials.json (mode 0600). The key is never passed to the agent,
|
|
7919
|
-
|
|
7931
|
+
build, install or checks, but the agent runs as you, without a sandbox: it can
|
|
7920
7932
|
read ~/.firegoat, and a FIREGOAT_API_KEY in firegoat's own process env (ps eww,
|
|
7921
7933
|
/proc/<pid>/environ). Use a dedicated key in CI. The key can only upload and read
|
|
7922
7934
|
your own traces; revoke it on the firegoat.ai traces page.
|
|
@@ -7947,7 +7959,7 @@ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--pa
|
|
|
7947
7959
|
[--var KEY=value].. [--json] [--no-upload] [--claude-bin PATH]
|
|
7948
7960
|
|
|
7949
7961
|
Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
|
|
7950
|
-
in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with
|
|
7962
|
+
in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
|
|
7951
7963
|
outputs on PATH. Then the test's checks run in that folder.
|
|
7952
7964
|
|
|
7953
7965
|
claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
|
|
@@ -7957,20 +7969,21 @@ sandbox: the agent can reach the rest of your machine, including the repo.
|
|
|
7957
7969
|
|
|
7958
7970
|
In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
|
|
7959
7971
|
live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
|
|
7960
|
-
fixtures and
|
|
7972
|
+
fixtures and build: stay in the current folder.
|
|
7961
7973
|
|
|
7962
|
-
test..
|
|
7963
|
-
--runs N
|
|
7974
|
+
test.. Only these tests (by name)
|
|
7975
|
+
--runs N Runs per test (overrides runs: in firegoat.yaml)
|
|
7964
7976
|
--model NAME claude --model, replacing model: in firegoat.yaml; repeat it
|
|
7965
7977
|
(--model a --model b) to run each test runs times per model
|
|
7966
|
-
--parallel N
|
|
7978
|
+
--parallel N Runs at once (default 4; 1 runs them one after another).
|
|
7967
7979
|
Tools with global on-disk state collide: give each run its
|
|
7968
7980
|
own home via env: ({{run.id}}-{{test.name}}-{{run.n}}) or use 1
|
|
7969
|
-
--var KEY=value
|
|
7970
|
-
--json
|
|
7981
|
+
--var KEY=value Fills {{vars.KEY}}; env for build, install and checks
|
|
7982
|
+
--json The summary as JSON on stdout (or {error, exitCode} if eval
|
|
7971
7983
|
stops early); progress goes to stderr
|
|
7972
7984
|
--no-upload Keep runs local even when logged in to firegoat.ai
|
|
7973
|
-
--claude-bin PATH claude
|
|
7985
|
+
--claude-bin PATH Path to claude (env FIREGOAT_CLAUDE_BIN; default: claude on
|
|
7986
|
+
PATH)
|
|
7974
7987
|
|
|
7975
7988
|
Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
|
|
7976
7989
|
checks.txt and stderr.txt go to firegoat.ai as a private trace, and its URL is
|
|
@@ -7983,13 +7996,13 @@ Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
|
|
|
7983
7996
|
(default https://firegoat.ai/evals). See firegoat login --help.
|
|
7984
7997
|
|
|
7985
7998
|
Output: .firegoat/runs/<runId>/
|
|
7986
|
-
summary.json
|
|
7999
|
+
summary.json Every run, plus totals, dir (where eval ran),
|
|
7987
8000
|
git {branch, commit, dirty} (no git outside a repo)
|
|
7988
8001
|
and upload {origin, email} (null when not uploading)
|
|
7989
|
-
../index.jsonl
|
|
8002
|
+
../index.jsonl One line per eval: runId, startedAt, dir, branch,
|
|
7990
8003
|
commit, passed, failed, notGraded, cost, uploaded,
|
|
7991
8004
|
uploadFailed
|
|
7992
|
-
<test>/<n>/run.json
|
|
8005
|
+
<test>/<n>/run.json One run: status and reason (from the checks), model
|
|
7993
8006
|
(asked for, or null) and claudeModel (what claude's
|
|
7994
8007
|
init line reported), prompt, end_goal,
|
|
7995
8008
|
checks [{run, code}], and paths: workdir,
|
|
@@ -7997,19 +8010,21 @@ Output: .firegoat/runs/<runId>/
|
|
|
7997
8010
|
checksLog; startedAt; upload ({status: "uploaded",
|
|
7998
8011
|
url} | {status: "failed", error} | {status:
|
|
7999
8012
|
"skipped", reason}, null when not uploading)
|
|
8000
|
-
<test>/<n>/transcript.txt
|
|
8001
|
-
|
|
8002
|
-
|
|
8013
|
+
<test>/<n>/transcript.txt What the agent did: each tool call with its command
|
|
8014
|
+
("[n] $ " on every line of a shell call), short output
|
|
8015
|
+
(indented), its text and final answer ("agent: " on
|
|
8016
|
+
every line), files it made
|
|
8017
|
+
build.txt, <test>/<n>/install.txt, checks.txt Logs
|
|
8003
8018
|
|
|
8004
8019
|
Grading: firegoat runs only the script checks; they set the exit code. end_goal is
|
|
8005
8020
|
yours to judge: read transcript.txt first (a few KB); the session file has everything
|
|
8006
8021
|
(often 50x larger).
|
|
8007
8022
|
|
|
8008
8023
|
Examples:
|
|
8009
|
-
npx firegoat eval --json
|
|
8010
|
-
npx firegoat eval quickstart --runs 3
|
|
8024
|
+
npx firegoat eval --json All tests, JSON summary on stdout
|
|
8025
|
+
npx firegoat eval quickstart --runs 3 One test, three runs
|
|
8011
8026
|
npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
|
|
8012
|
-
|
|
8027
|
+
Each test on both models
|
|
8013
8028
|
npx firegoat eval --var SHA=$(git rev-parse HEAD)
|
|
8014
8029
|
|
|
8015
8030
|
A run can take minutes (default timeout 600s per test).
|
|
@@ -8022,8 +8037,8 @@ firegoat confused you, failed you or cost you tokens, say so here: one concrete
|
|
|
8022
8037
|
title, then what you ran and what happened in --body (- reads stdin).
|
|
8023
8038
|
|
|
8024
8039
|
"<title>" 8-200 characters, e.g. "eval hid claude's spend-limit error"
|
|
8025
|
-
--body TEXT
|
|
8026
|
-
--type bug, feature_request or feedback (default)
|
|
8040
|
+
--body TEXT Details: commands, output, what you expected
|
|
8041
|
+
--type One of bug, feature_request or feedback (default)
|
|
8027
8042
|
|
|
8028
8043
|
Sent along: the firegoat version, your OS and node version, and which agent you are
|
|
8029
8044
|
(claude-code/codex/cursor, from the environment). Don't include secrets: a report that
|
|
@@ -8037,7 +8052,7 @@ Example:
|
|
|
8037
8052
|
|
|
8038
8053
|
Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
|
|
8039
8054
|
|
|
8040
|
-
// src/
|
|
8055
|
+
// src/steps.ts
|
|
8041
8056
|
import { createWriteStream } from "node:fs";
|
|
8042
8057
|
var STEP_TIMEOUT_MS = 600000;
|
|
8043
8058
|
async function runSteps(opts) {
|
|
@@ -8077,18 +8092,20 @@ function truncate(text, maxLines, maxChars) {
|
|
|
8077
8092
|
return out.length < text.length ? `${out}
|
|
8078
8093
|
… (${text.length - out.length} more chars)` : out;
|
|
8079
8094
|
}
|
|
8080
|
-
function
|
|
8095
|
+
function prefixLines(prefix, text) {
|
|
8081
8096
|
return text.split(`
|
|
8082
|
-
`).map((l) =>
|
|
8097
|
+
`).map((l) => `${prefix}${l}`).join(`
|
|
8083
8098
|
`);
|
|
8084
8099
|
}
|
|
8085
|
-
|
|
8100
|
+
var indent = (text) => prefixLines(" ", text);
|
|
8101
|
+
var prose = (text) => prefixLines("agent: ", text);
|
|
8102
|
+
function describeCall(step, name, input) {
|
|
8086
8103
|
if (typeof input.command === "string")
|
|
8087
|
-
return
|
|
8104
|
+
return prefixLines(`[${step}] $ `, input.command);
|
|
8088
8105
|
for (const key of ["file_path", "path", "url", "pattern", "query"])
|
|
8089
8106
|
if (typeof input[key] === "string")
|
|
8090
|
-
return
|
|
8091
|
-
return
|
|
8107
|
+
return `[${step}] ${name} ${input[key]}`;
|
|
8108
|
+
return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
|
|
8092
8109
|
}
|
|
8093
8110
|
function resultText(content) {
|
|
8094
8111
|
if (typeof content === "string")
|
|
@@ -8125,10 +8142,10 @@ function writeTranscript(opts) {
|
|
|
8125
8142
|
if (e?.type === "assistant")
|
|
8126
8143
|
for (const b of content) {
|
|
8127
8144
|
if (b.type === "tool_use")
|
|
8128
|
-
out.push(
|
|
8145
|
+
out.push(describeCall(++step, String(b.name), b.input ?? {}));
|
|
8129
8146
|
else if (b.type === "text" && typeof b.text === "string") {
|
|
8130
8147
|
lastText = out.length;
|
|
8131
|
-
out.push(
|
|
8148
|
+
out.push(prose(truncate(b.text, 30, TEXT_CHARS)));
|
|
8132
8149
|
}
|
|
8133
8150
|
}
|
|
8134
8151
|
else if (e?.type === "user")
|
|
@@ -8143,9 +8160,9 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
|
|
|
8143
8160
|
else if (e?.type === "result" && typeof e.result === "string")
|
|
8144
8161
|
final = e.result;
|
|
8145
8162
|
}
|
|
8146
|
-
if (lastText === out.length - 1 && final && out[lastText] ===
|
|
8163
|
+
if (lastText === out.length - 1 && final && out[lastText] === prose(truncate(final, 30, TEXT_CHARS)))
|
|
8147
8164
|
out.pop();
|
|
8148
|
-
out.push("", "## final answer", final
|
|
8165
|
+
out.push("", "## final answer", final ? prose(final) : "(none)");
|
|
8149
8166
|
out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
|
|
8150
8167
|
writeFileSync2(opts.path, `${out.join(`
|
|
8151
8168
|
`)}
|
|
@@ -8243,13 +8260,13 @@ function plainUI(write) {
|
|
|
8243
8260
|
const printed = new Set;
|
|
8244
8261
|
const uploadPrinted = new Set;
|
|
8245
8262
|
return {
|
|
8246
|
-
auth: (text) => write(`
|
|
8263
|
+
auth: (text) => write(`Auth: ${text}`),
|
|
8247
8264
|
note: write,
|
|
8248
|
-
|
|
8265
|
+
build: (state, seconds) => {
|
|
8249
8266
|
if (state === "running")
|
|
8250
|
-
write("
|
|
8267
|
+
write("Build...");
|
|
8251
8268
|
else if (state === "fail")
|
|
8252
|
-
write(`
|
|
8269
|
+
write(`Build failed (${seconds}s)`);
|
|
8253
8270
|
},
|
|
8254
8271
|
runs: (r) => {
|
|
8255
8272
|
rows = r;
|
|
@@ -8277,14 +8294,14 @@ function plainUI(write) {
|
|
|
8277
8294
|
const tr = tier(all);
|
|
8278
8295
|
write("");
|
|
8279
8296
|
write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, $${t.cost.toFixed(2)})${tr ? `, tier ${tr}` : ""}`);
|
|
8280
|
-
write(`
|
|
8297
|
+
write(`Runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
|
|
8281
8298
|
}
|
|
8282
8299
|
};
|
|
8283
8300
|
}
|
|
8284
8301
|
function ttyUI(out, opts) {
|
|
8285
8302
|
const p = painter(opts.color);
|
|
8286
8303
|
let rows = [];
|
|
8287
|
-
let
|
|
8304
|
+
let build = null;
|
|
8288
8305
|
let authText = "";
|
|
8289
8306
|
const notes = [];
|
|
8290
8307
|
let frame = 0;
|
|
@@ -8301,9 +8318,9 @@ function ttyUI(out, opts) {
|
|
|
8301
8318
|
case "error":
|
|
8302
8319
|
return p(C.amber, "NOT GRADED");
|
|
8303
8320
|
case "running":
|
|
8304
|
-
return p(C.ember, "
|
|
8321
|
+
return p(C.ember, "Running ");
|
|
8305
8322
|
default:
|
|
8306
|
-
return p(C.gray, "
|
|
8323
|
+
return p(C.gray, "Queued ");
|
|
8307
8324
|
}
|
|
8308
8325
|
};
|
|
8309
8326
|
const glyph = (r) => {
|
|
@@ -8331,12 +8348,12 @@ function ttyUI(out, opts) {
|
|
|
8331
8348
|
rule
|
|
8332
8349
|
];
|
|
8333
8350
|
if (authText)
|
|
8334
|
-
out.push(p(C.gray, `
|
|
8351
|
+
out.push(p(C.gray, ` Auth ${authText}`));
|
|
8335
8352
|
for (const n of notes)
|
|
8336
8353
|
out.push(p(C.gray, ` ${cut(n, w - 2)}`));
|
|
8337
|
-
if (
|
|
8338
|
-
const mark =
|
|
8339
|
-
out.push(` ${
|
|
8354
|
+
if (build) {
|
|
8355
|
+
const mark = build.state === "ok" ? p(C.green, "✓") : build.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
|
|
8356
|
+
out.push(` ${build.state === "running" ? p(C.ember, "◐") : p(C.green, "●")} Build${" ".repeat(Math.max(1, w - 20))}${mark}${build.seconds !== undefined ? ` ${build.seconds}s` : ""}`);
|
|
8340
8357
|
}
|
|
8341
8358
|
const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
|
|
8342
8359
|
rows.forEach((r, i) => {
|
|
@@ -8346,7 +8363,7 @@ function ttyUI(out, opts) {
|
|
|
8346
8363
|
const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
|
|
8347
8364
|
let detail = "";
|
|
8348
8365
|
if (r.state === "running")
|
|
8349
|
-
detail = p(C.gray, `
|
|
8366
|
+
detail = p(C.gray, `Turn ${r.turns}`);
|
|
8350
8367
|
else if (r.state === "pass" || r.state === "fail")
|
|
8351
8368
|
detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
|
|
8352
8369
|
else if (r.state === "error")
|
|
@@ -8381,11 +8398,11 @@ function ttyUI(out, opts) {
|
|
|
8381
8398
|
const goated = c.pass === rows.length && rows.length > 0;
|
|
8382
8399
|
const tally = `${c.pass} PASS · ${c.fail} FAIL · ${c.error} NOT GRADED`;
|
|
8383
8400
|
const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
|
|
8384
|
-
const right = goated ? `GOATED · tier ${tr}` : tr ? `
|
|
8401
|
+
const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
|
|
8385
8402
|
out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
|
|
8386
|
-
out.push(p(C.gray, `
|
|
8387
|
-
out.push(p(C.gray, `
|
|
8388
|
-
out.push(`
|
|
8403
|
+
out.push(p(C.gray, ` Total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
|
|
8404
|
+
out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
|
|
8405
|
+
out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
|
|
8389
8406
|
return out;
|
|
8390
8407
|
};
|
|
8391
8408
|
const rowsOf = (line) => {
|
|
@@ -8413,8 +8430,8 @@ function ttyUI(out, opts) {
|
|
|
8413
8430
|
note: (text) => {
|
|
8414
8431
|
notes.push(text);
|
|
8415
8432
|
},
|
|
8416
|
-
|
|
8417
|
-
|
|
8433
|
+
build: (state, seconds) => {
|
|
8434
|
+
build = { state, seconds };
|
|
8418
8435
|
render();
|
|
8419
8436
|
},
|
|
8420
8437
|
runs: (r) => {
|
|
@@ -8668,7 +8685,7 @@ function parseArgs(args) {
|
|
|
8668
8685
|
} else if (flag === "--claude-bin")
|
|
8669
8686
|
opts.claudeBin = value();
|
|
8670
8687
|
else if (a.startsWith("-"))
|
|
8671
|
-
throw new UsageError(`
|
|
8688
|
+
throw new UsageError(`Unknown flag: ${a}`);
|
|
8672
8689
|
else
|
|
8673
8690
|
opts.names.push(a);
|
|
8674
8691
|
}
|
|
@@ -8772,13 +8789,13 @@ async function runOne(opts) {
|
|
|
8772
8789
|
FIREGOAT_WORKDIR: workdir
|
|
8773
8790
|
});
|
|
8774
8791
|
const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
|
|
8775
|
-
if (opts.
|
|
8792
|
+
if (opts.install.length > 0) {
|
|
8776
8793
|
const error = await runSteps({
|
|
8777
|
-
what: "
|
|
8778
|
-
commands: opts.
|
|
8794
|
+
what: "install",
|
|
8795
|
+
commands: opts.install,
|
|
8779
8796
|
cwd: workdir,
|
|
8780
8797
|
env: stepEnv,
|
|
8781
|
-
logPath: join5(dir, "
|
|
8798
|
+
logPath: join5(dir, "install.txt")
|
|
8782
8799
|
});
|
|
8783
8800
|
if (error)
|
|
8784
8801
|
return done("error", `not graded: ${error}`);
|
|
@@ -8869,7 +8886,7 @@ async function evalCommand(args, root) {
|
|
|
8869
8886
|
const known = new Set(tests.map((t) => t.name));
|
|
8870
8887
|
const unknown = opts.names.filter((n) => !known.has(n));
|
|
8871
8888
|
if (unknown.length > 0)
|
|
8872
|
-
return fatal(EXIT.usage, `
|
|
8889
|
+
return fatal(EXIT.usage, `Unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
|
|
8873
8890
|
tests = tests.filter((t) => opts.names.includes(t.name));
|
|
8874
8891
|
}
|
|
8875
8892
|
const runs = opts.runs ?? config.runs;
|
|
@@ -8883,10 +8900,10 @@ async function evalCommand(args, root) {
|
|
|
8883
8900
|
]).flatMap(varRefs).filter((k) => !(k in vars)))
|
|
8884
8901
|
];
|
|
8885
8902
|
if (missing.length > 0)
|
|
8886
|
-
return fatal(EXIT.usage, `
|
|
8903
|
+
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`);
|
|
8887
8904
|
const claudePath = resolveBin(opts.claudeBin);
|
|
8888
8905
|
if (!claudePath)
|
|
8889
|
-
return fatal(EXIT.notGraded, `
|
|
8906
|
+
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.`);
|
|
8890
8907
|
let repo;
|
|
8891
8908
|
try {
|
|
8892
8909
|
repo = gitRepo(root);
|
|
@@ -8903,7 +8920,7 @@ async function evalCommand(args, root) {
|
|
|
8903
8920
|
cwd: root
|
|
8904
8921
|
});
|
|
8905
8922
|
if (loginProblem)
|
|
8906
|
-
return fatal(EXIT.notGraded, `
|
|
8923
|
+
return fatal(EXIT.notGraded, `Not graded: ${loginProblem}`);
|
|
8907
8924
|
const evalStarted = Date.now();
|
|
8908
8925
|
const runId = newRunId();
|
|
8909
8926
|
let upload;
|
|
@@ -8935,23 +8952,23 @@ async function evalCommand(args, root) {
|
|
|
8935
8952
|
state: "queued",
|
|
8936
8953
|
turns: 0
|
|
8937
8954
|
}))));
|
|
8938
|
-
if (config.
|
|
8939
|
-
const
|
|
8940
|
-
ui.
|
|
8955
|
+
if (config.build.length > 0) {
|
|
8956
|
+
const buildStarted = Date.now();
|
|
8957
|
+
ui.build("running");
|
|
8941
8958
|
const error = await runSteps({
|
|
8942
|
-
what: "
|
|
8943
|
-
commands: config.
|
|
8959
|
+
what: "build",
|
|
8960
|
+
commands: config.build,
|
|
8944
8961
|
cwd: root,
|
|
8945
8962
|
env: {
|
|
8946
8963
|
...stepEnv,
|
|
8947
8964
|
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
|
|
8948
8965
|
},
|
|
8949
|
-
logPath: join5(runDir, "
|
|
8966
|
+
logPath: join5(runDir, "build.txt")
|
|
8950
8967
|
});
|
|
8951
|
-
const secs = Math.round((Date.now() -
|
|
8952
|
-
ui.
|
|
8968
|
+
const secs = Math.round((Date.now() - buildStarted) / 1000);
|
|
8969
|
+
ui.build(error ? "fail" : "ok", secs);
|
|
8953
8970
|
if (error)
|
|
8954
|
-
return fatal(EXIT.notGraded, `
|
|
8971
|
+
return fatal(EXIT.notGraded, `Not graded: ${error}`);
|
|
8955
8972
|
}
|
|
8956
8973
|
ui.runs(rows);
|
|
8957
8974
|
const results = new Array(rows.length);
|
|
@@ -8982,7 +8999,7 @@ async function evalCommand(args, root) {
|
|
|
8982
8999
|
runDir,
|
|
8983
9000
|
claudeBin: claudePath,
|
|
8984
9001
|
configDir,
|
|
8985
|
-
|
|
9002
|
+
install: config.install,
|
|
8986
9003
|
out,
|
|
8987
9004
|
stepEnv,
|
|
8988
9005
|
vars
|
|
@@ -9282,7 +9299,7 @@ async function feedbackCommand(args) {
|
|
|
9282
9299
|
throw new Error(`--type must be one of ${TYPES.join(", ")}, got ${v}`);
|
|
9283
9300
|
type = v;
|
|
9284
9301
|
} else if (a.startsWith("-"))
|
|
9285
|
-
throw new Error(`
|
|
9302
|
+
throw new Error(`Unknown flag: ${a}`);
|
|
9286
9303
|
else
|
|
9287
9304
|
titleParts.push(a);
|
|
9288
9305
|
} catch (e) {
|
|
@@ -9292,7 +9309,7 @@ async function feedbackCommand(args) {
|
|
|
9292
9309
|
}
|
|
9293
9310
|
const title = titleParts.join(" ").trim();
|
|
9294
9311
|
if (title.length < 8 || title.length > 200) {
|
|
9295
|
-
console.error(`firegoat feedback:
|
|
9312
|
+
console.error(`firegoat feedback: The title must be 8-200 characters (got ${title.length})`);
|
|
9296
9313
|
return EXIT.usage;
|
|
9297
9314
|
}
|
|
9298
9315
|
const client = new FeedbackClient({ domain: "firegoat.ai" });
|
|
@@ -9310,7 +9327,7 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
|
|
|
9310
9327
|
nodeVersion: process.version
|
|
9311
9328
|
}
|
|
9312
9329
|
});
|
|
9313
|
-
console.log(`
|
|
9330
|
+
console.log(`Sent ${type} ${id} to the firegoat team${similarCount > 0 ? ` (${similarCount} similar report${similarCount === 1 ? "" : "s"} already)` : ""}. Thanks.`);
|
|
9314
9331
|
return EXIT.pass;
|
|
9315
9332
|
} catch (e) {
|
|
9316
9333
|
if (e instanceof FeedbackError) {
|
|
@@ -9516,6 +9533,12 @@ var SKILLS_ARGV = [
|
|
|
9516
9533
|
"--copy",
|
|
9517
9534
|
"-y"
|
|
9518
9535
|
];
|
|
9536
|
+
function skillsDir(scope) {
|
|
9537
|
+
if (scope === "local")
|
|
9538
|
+
return ".claude/skills/";
|
|
9539
|
+
const config = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
9540
|
+
return config ? `${join6(config, "skills")}/` : "~/.claude/skills/";
|
|
9541
|
+
}
|
|
9519
9542
|
function template(product) {
|
|
9520
9543
|
return `# firegoat.yaml: evals for how well coding agents use ${product}.
|
|
9521
9544
|
# Each test runs your claude in an empty folder (.firegoat/runs/...) with \`prompt\`
|
|
@@ -9529,27 +9552,27 @@ runs: 1
|
|
|
9529
9552
|
# model: [claude-haiku-5-5, claude-sonnet-5-5]
|
|
9530
9553
|
|
|
9531
9554
|
# Optional: test your local build instead of the published package.
|
|
9532
|
-
#
|
|
9533
|
-
# agent's PATH.
|
|
9555
|
+
# build: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
|
|
9556
|
+
# agent's PATH. install: runs in each fresh folder before the agent starts.
|
|
9534
9557
|
#
|
|
9535
|
-
# npm:
|
|
9536
|
-
#
|
|
9537
|
-
#
|
|
9558
|
+
# For npm:
|
|
9559
|
+
# build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
9560
|
+
# install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
9538
9561
|
#
|
|
9539
|
-
# python:
|
|
9540
|
-
#
|
|
9541
|
-
#
|
|
9562
|
+
# For python:
|
|
9563
|
+
# build: uv build --wheel --out-dir "$FIREGOAT_OUT"
|
|
9564
|
+
# install: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
|
|
9542
9565
|
#
|
|
9543
|
-
# a CLI binary users call by bare name (on PATH as is):
|
|
9544
|
-
#
|
|
9545
|
-
# (
|
|
9566
|
+
# For a CLI binary users call by bare name (on PATH as is):
|
|
9567
|
+
# build: go build -o "$FIREGOAT_OUT/mycli" ./cmd/mycli
|
|
9568
|
+
# (An npm/pypi CLI that users run via npx/uvx: pack + install it as above instead;
|
|
9546
9569
|
# a binary on PATH does not stop "npx mycli" from fetching the registry release)
|
|
9547
9570
|
#
|
|
9548
9571
|
# Helper files (scripts, sample data) go in .firegoat/fixtures/ (committed), not inline here.
|
|
9549
|
-
#
|
|
9550
|
-
#
|
|
9572
|
+
# The build step runs in this folder, so: build: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
|
|
9573
|
+
# Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
|
|
9551
9574
|
|
|
9552
|
-
# Optional: env vars for the agent (and for
|
|
9575
|
+
# Optional: env vars for the agent (and for install and checks of the same
|
|
9553
9576
|
# run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
|
|
9554
9577
|
# {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
|
|
9555
9578
|
# other $ expansion: $HOME stays literal). A test can add its own env:.
|
|
@@ -9590,15 +9613,30 @@ function copyToClipboard(text) {
|
|
|
9590
9613
|
return !r.error && r.status === 0;
|
|
9591
9614
|
});
|
|
9592
9615
|
}
|
|
9593
|
-
async function
|
|
9616
|
+
async function ask(question) {
|
|
9594
9617
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9595
9618
|
try {
|
|
9596
|
-
|
|
9597
|
-
return a === "" || /^y(es)?$/i.test(a);
|
|
9619
|
+
return (await rl.question(question)).trim();
|
|
9598
9620
|
} finally {
|
|
9599
9621
|
rl.close();
|
|
9600
9622
|
}
|
|
9601
9623
|
}
|
|
9624
|
+
async function askYes(question) {
|
|
9625
|
+
const a = await ask(`${question} [Y/n] `);
|
|
9626
|
+
return a === "" || /^y(es)?$/i.test(a);
|
|
9627
|
+
}
|
|
9628
|
+
async function askScope(question) {
|
|
9629
|
+
for (;; ) {
|
|
9630
|
+
const a = await ask(`${question} [G/l/n] `);
|
|
9631
|
+
if (a === "" || /^g(lobal)?$/i.test(a))
|
|
9632
|
+
return "global";
|
|
9633
|
+
if (/^l(ocal)?$/i.test(a))
|
|
9634
|
+
return "local";
|
|
9635
|
+
if (/^no?$/i.test(a))
|
|
9636
|
+
return "none";
|
|
9637
|
+
process.stdout.write("\x1B[1A\x1B[2K");
|
|
9638
|
+
}
|
|
9639
|
+
}
|
|
9602
9640
|
var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
|
|
9603
9641
|
async function loginStep(interactive, done, paint) {
|
|
9604
9642
|
const label = "firegoat.ai login";
|
|
@@ -9634,7 +9672,8 @@ async function loginStep(interactive, done, paint) {
|
|
|
9634
9672
|
}
|
|
9635
9673
|
async function initCommand(args, cwd) {
|
|
9636
9674
|
let force = false;
|
|
9637
|
-
let
|
|
9675
|
+
let noSkills = false;
|
|
9676
|
+
let localSkills = false;
|
|
9638
9677
|
let login = true;
|
|
9639
9678
|
for (const a of args) {
|
|
9640
9679
|
if (a === "--help" || a === "-h") {
|
|
@@ -9644,16 +9683,25 @@ async function initCommand(args, cwd) {
|
|
|
9644
9683
|
if (a === "--force" || a === "-f")
|
|
9645
9684
|
force = true;
|
|
9646
9685
|
else if (a === "--no-skills")
|
|
9647
|
-
|
|
9686
|
+
noSkills = true;
|
|
9687
|
+
else if (a === "--local-skills")
|
|
9688
|
+
localSkills = true;
|
|
9648
9689
|
else if (a === "--no-login")
|
|
9649
9690
|
login = false;
|
|
9650
9691
|
else {
|
|
9651
|
-
console.error(`firegoat init:
|
|
9692
|
+
console.error(`firegoat init: Unknown argument: ${a}
|
|
9652
9693
|
|
|
9653
9694
|
${INIT_HELP}`);
|
|
9654
9695
|
return EXIT.usage;
|
|
9655
9696
|
}
|
|
9656
9697
|
}
|
|
9698
|
+
if (noSkills && localSkills) {
|
|
9699
|
+
console.error(`firegoat init: --no-skills and --local-skills contradict each other: pass one
|
|
9700
|
+
|
|
9701
|
+
${INIT_HELP}`);
|
|
9702
|
+
return EXIT.usage;
|
|
9703
|
+
}
|
|
9704
|
+
let scope = noSkills ? "none" : localSkills ? "local" : "global";
|
|
9657
9705
|
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
9658
9706
|
const color = interactive && !process.env.NO_COLOR;
|
|
9659
9707
|
const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
@@ -9671,21 +9719,24 @@ ${INIT_HELP}`);
|
|
|
9671
9719
|
if (!keep)
|
|
9672
9720
|
writeFileSync4(configPath, template(basename4(cwd)));
|
|
9673
9721
|
if (interactive) {
|
|
9674
|
-
step(1, CONFIG_FILE, keep ? "
|
|
9722
|
+
step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
|
|
9675
9723
|
console.log(paint("90", " │"));
|
|
9676
|
-
step(2, ".firegoat/", "
|
|
9724
|
+
step(2, ".firegoat/", "Ready");
|
|
9677
9725
|
} else
|
|
9678
|
-
console.log(keep ? `
|
|
9679
|
-
if (
|
|
9726
|
+
console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
|
|
9727
|
+
if (interactive && !noSkills)
|
|
9680
9728
|
console.log(paint("90", " │"));
|
|
9681
|
-
|
|
9729
|
+
if (interactive && !noSkills && !localSkills) {
|
|
9730
|
+
scope = await askScope(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill globally (~/.claude/skills) or in this repo (.claude/skills)?`);
|
|
9682
9731
|
process.stdout.write("\x1B[1A\x1B[2K");
|
|
9683
9732
|
}
|
|
9684
|
-
if (
|
|
9733
|
+
if (scope !== "none") {
|
|
9734
|
+
const argv = [...SKILLS_ARGV, ...scope === "global" ? ["-g"] : []];
|
|
9735
|
+
const where = skillsDir(scope);
|
|
9685
9736
|
if (!interactive)
|
|
9686
|
-
console.log(`
|
|
9737
|
+
console.log(`Installing the skill: npx ${argv.join(" ")}`);
|
|
9687
9738
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== "npm_config_package" && !k.startsWith("FIREGOAT_")));
|
|
9688
|
-
const r = spawnSync3("npx", ["-y", ...
|
|
9739
|
+
const r = spawnSync3("npx", ["-y", ...argv], {
|
|
9689
9740
|
cwd,
|
|
9690
9741
|
env,
|
|
9691
9742
|
stdio: "pipe",
|
|
@@ -9693,21 +9744,21 @@ ${INIT_HELP}`);
|
|
|
9693
9744
|
});
|
|
9694
9745
|
if (r.error || r.status !== 0) {
|
|
9695
9746
|
process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
9696
|
-
console.error(`firegoat init:
|
|
9747
|
+
console.error(`firegoat init: Installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${argv.join(" ")}`);
|
|
9697
9748
|
return EXIT.notGraded;
|
|
9698
9749
|
}
|
|
9699
9750
|
if (interactive)
|
|
9700
|
-
step(3, "firegoat-onboarding skill",
|
|
9751
|
+
step(3, "firegoat-onboarding skill", `Installed → ${where}`);
|
|
9701
9752
|
else
|
|
9702
|
-
console.log(
|
|
9703
|
-
const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
|
|
9753
|
+
console.log(`Installed skill firegoat-onboarding in ${where}`);
|
|
9754
|
+
const ignored = scope === "local" && spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
|
|
9704
9755
|
cwd,
|
|
9705
9756
|
env: hostEnv()
|
|
9706
|
-
});
|
|
9707
|
-
if (ignored
|
|
9708
|
-
console.error("
|
|
9757
|
+
}).status === 0;
|
|
9758
|
+
if (ignored)
|
|
9759
|
+
console.error("Warning: git ignores .claude/skills, so teammates and CI won't get the skill. Commit them with: git add -f .claude/skills");
|
|
9709
9760
|
} else if (interactive)
|
|
9710
|
-
step(3, "firegoat-onboarding skill", "
|
|
9761
|
+
step(3, "firegoat-onboarding skill", "Skipped");
|
|
9711
9762
|
if (login) {
|
|
9712
9763
|
if (interactive)
|
|
9713
9764
|
console.log(paint("90", " │"));
|
|
@@ -9721,12 +9772,12 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
|
|
|
9721
9772
|
return EXIT.pass;
|
|
9722
9773
|
}
|
|
9723
9774
|
console.log(rule);
|
|
9724
|
-
console.log(`
|
|
9775
|
+
console.log(` Next: hand this to your coding agent
|
|
9725
9776
|
`);
|
|
9726
9777
|
console.log(AGENT_PROMPT);
|
|
9727
9778
|
console.log("");
|
|
9728
9779
|
const copied = copyToClipboard(AGENT_PROMPT);
|
|
9729
|
-
console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}
|
|
9780
|
+
console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat eval")}`);
|
|
9730
9781
|
return EXIT.pass;
|
|
9731
9782
|
}
|
|
9732
9783
|
|
|
@@ -9754,7 +9805,7 @@ async function main(argv) {
|
|
|
9754
9805
|
case "logout":
|
|
9755
9806
|
return logoutCommand(rest);
|
|
9756
9807
|
default:
|
|
9757
|
-
console.error(`
|
|
9808
|
+
console.error(`Unknown command: ${cmd}
|
|
9758
9809
|
|
|
9759
9810
|
${topHelp(package_default.version)}`);
|
|
9760
9811
|
return EXIT.usage;
|
|
@@ -9767,7 +9818,7 @@ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
|
9767
9818
|
}
|
|
9768
9819
|
if (err?.code === "ABORT_ERR") {
|
|
9769
9820
|
console.error(`
|
|
9770
|
-
|
|
9821
|
+
Aborted.`);
|
|
9771
9822
|
process.exit(130);
|
|
9772
9823
|
}
|
|
9773
9824
|
console.error(err);
|