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.
Files changed (3) hide show
  1. package/README.md +33 -29
  2. package/dist/cli.js +211 -160
  3. 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 # 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
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 # device flow: open the URL, enter the code; key saved to ~/.firegoat/credentials.json (0600)
21
- npx firegoat eval # now also uploads each run as a private trace and prints its URL
22
- npx firegoat logout # forgets the login (revoke the key itself on the firegoat.ai traces page)
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, setup, workdir_setup 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.
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
- setup: npm pack --pack-destination "$FIREGOAT_OUT"
39
- workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
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
- `setup` builds your package once; `workdir_setup` installs that build into each fresh folder, so the agent uses your local code, not the registry.
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
- setup: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme
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 `setup` 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.
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
- setup: uv build --wheel --out-dir "$FIREGOAT_OUT"
84
- workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
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}}" # sessions/config never collide across runs
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" # resolve the wheel setup built
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" # this test only, on top of env:
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 `workdir_setup` 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 `setup`, `workdir_setup` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `setup` 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:`.
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/`: keep test fixtures (scripts, sample data) in `.firegoat/fixtures/` and commit them with `firegoat.yaml`.
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 `setup:` 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.
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, setup, workdir_setup, 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.
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`, `setup`, `workdir_setup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
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 | every run's checks passed |
150
- | 1 | a check failed |
151
- | 2 | at least one run was not graded (claude missing or logged out, crash, timeout, setup failed); wins over 1 |
152
- | 3 | usage or config error (including firegoat.ai rejecting the upload key) |
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 # e2e: packs the package and drives the installed bin
172
- FIREGOAT_E2E_REAL=1 bun test # also runs one eval against your real claude
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.16.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
- "setup",
7080
- "workdir_setup",
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 setup = commands(raw.setup, "setup");
7088
- const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
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
- setup,
7129
- workdirSetup,
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(`no value for {{vars.${key}}}`);
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 every run's script checks passed
7807
- 1 a script check failed
7808
- 2 at least one run was not graded (claude missing or logged out, crash, timeout,
7809
- setup failed); wins over 1
7810
- 3 usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml,
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 create firegoat.yaml and .firegoat/, install the skill
7820
- npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
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>" tell the firegoat team
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. read npx firegoat init --help, then edit firegoat.yaml
7827
+ 1. Read npx firegoat init --help, then edit firegoat.yaml
7827
7828
  2. npx firegoat eval --json
7828
- 3. judge each run's end_goal from its run.json, session and workdir: firegoat only
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 .claude/skills/ (npx skills
7839
- add firegoat-ai/skills; asks first in a terminal), offers firegoat login (in a
7840
- terminal only; otherwise prints how), then prints a prompt to paste into
7841
- your coding agent. In .firegoat/, git ignores everything but fixtures/: keep test fixtures
7842
- (helper scripts, sample data) in .firegoat/fixtures/ and commit them.
7843
-
7844
- --force replace an existing firegoat.yaml
7845
- --no-skills don't install the skill
7846
- --no-login Don't offer firegoat login
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 runs per test
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
- setup: <sh> | [<sh>, ...] once per eval, in this folder; what it writes to
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
- workdir_setup: <sh> | [...] in each fresh folder before the agent starts
7857
- vars: {KEY: value} defaults for {{vars.KEY}} in prompts and checks;
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} env vars for the agent, and for workdir_setup and
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
- setup gets only the entries that use nothing but
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 letters, digits, '.', '_', '-'; unique
7873
- prompt: ... what a user would ask the agent; one concrete outcome
7874
- end_goal: ... what is true when the problem is solved; not graded by
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: optional shell commands, run in the agent's folder
7877
- - run: ... afterwards; exit 0 = pass; they set eval's exit code
7878
- timeout: 600 optional, seconds
7879
- env: {NAME: value} optional, on top of the top-level env:
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 setup or workdir_setup. setup, workdir_setup and checks get the vars as env vars,
7883
- plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short
7884
- plain-text transcript ("[1] $ <command>" per shell call, "[2] Write <path>", then
7885
- each output), and
7886
- $FIREGOAT_SESSION (claude's full session file, JSON lines).
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
- setup: npm pack --pack-destination "$FIREGOAT_OUT"
7896
- workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
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
- setup, workdir_setup or checks, but the agent runs as you, without a sandbox: it can
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 setup:'s
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 setup: stay in the current folder.
7972
+ fixtures and build: stay in the current folder.
7961
7973
 
7962
- test.. only these tests (by name)
7963
- --runs N runs per test (overrides runs: in firegoat.yaml)
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 runs at once (default 4; 1 runs them one after another).
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 fills {{vars.KEY}}; env for setup, workdir_setup and checks
7970
- --json the summary as JSON on stdout (or {error, exitCode} if eval
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 binary (env FIREGOAT_CLAUDE_BIN; default: claude on PATH)
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 every run, plus totals, dir (where eval ran),
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 one line per eval: runId, startedAt, dir, branch,
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 one run: status and reason (from the checks), model
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 what the agent did: each tool call with its command,
8001
- short output, its final answer, files it made
8002
- setup.txt, <test>/<n>/workdir_setup.txt, checks.txt logs
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 all tests, JSON summary on stdout
8010
- npx firegoat eval quickstart --runs 3 one test, three runs
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
- each test on both models
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 details: commands, output, what you expected
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/setup.ts
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 indent(text) {
8095
+ function prefixLines(prefix, text) {
8081
8096
  return text.split(`
8082
- `).map((l) => ` ${l}`).join(`
8097
+ `).map((l) => `${prefix}${l}`).join(`
8083
8098
  `);
8084
8099
  }
8085
- function describeCall(name, input) {
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 `$ ${input.command}`;
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 `${name} ${input[key]}`;
8091
- return `${name} ${truncate(JSON.stringify(input), 1, 300)}`;
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(`[${++step}] ${describeCall(String(b.name), b.input ?? {})}`);
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(`agent: ${truncate(b.text, 30, TEXT_CHARS)}`);
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] === `agent: ${truncate(final, 30, TEXT_CHARS)}`)
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 || "(none)");
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(`auth: ${text}`),
8263
+ auth: (text) => write(`Auth: ${text}`),
8247
8264
  note: write,
8248
- setup: (state, seconds) => {
8265
+ build: (state, seconds) => {
8249
8266
  if (state === "running")
8250
- write("setup...");
8267
+ write("Build...");
8251
8268
  else if (state === "fail")
8252
- write(`setup failed (${seconds}s)`);
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(`runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
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 setup = null;
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, "running ");
8321
+ return p(C.ember, "Running ");
8305
8322
  default:
8306
- return p(C.gray, "queued ");
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, ` auth ${authText}`));
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 (setup) {
8338
- const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
8339
- out.push(` ${setup.state === "running" ? p(C.ember, "◐") : p(C.green, "●")} setup${" ".repeat(Math.max(1, w - 20))}${mark}${setup.seconds !== undefined ? ` ${setup.seconds}s` : ""}`);
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, `turn ${r.turns}`);
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 ? `tier ${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, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
8387
- out.push(p(C.gray, ` runs ${shortPath(finished.runsDir)}/`));
8388
- out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
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
- setup: (state, seconds) => {
8417
- setup = { state, seconds };
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(`unknown flag: ${a}`);
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.workdirSetup.length > 0) {
8792
+ if (opts.install.length > 0) {
8776
8793
  const error = await runSteps({
8777
- what: "workdir_setup",
8778
- commands: opts.workdirSetup,
8794
+ what: "install",
8795
+ commands: opts.install,
8779
8796
  cwd: workdir,
8780
8797
  env: stepEnv,
8781
- logPath: join5(dir, "workdir_setup.txt")
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, `unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
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, `no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
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, `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.`);
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, `not graded: ${loginProblem}`);
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.setup.length > 0) {
8939
- const setupStarted = Date.now();
8940
- ui.setup("running");
8955
+ if (config.build.length > 0) {
8956
+ const buildStarted = Date.now();
8957
+ ui.build("running");
8941
8958
  const error = await runSteps({
8942
- what: "setup",
8943
- commands: config.setup,
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, "setup.txt")
8966
+ logPath: join5(runDir, "build.txt")
8950
8967
  });
8951
- const secs = Math.round((Date.now() - setupStarted) / 1000);
8952
- ui.setup(error ? "fail" : "ok", secs);
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, `not graded: ${error}`);
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
- workdirSetup: config.workdirSetup,
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(`unknown flag: ${a}`);
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: the title must be 8-200 characters (got ${title.length})`);
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(`sent ${type} ${id} to the firegoat team${similarCount > 0 ? ` (${similarCount} similar report${similarCount === 1 ? "" : "s"} already)` : ""}. thanks.`);
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
- # setup: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
9533
- # agent's PATH. workdir_setup: runs in each fresh folder before the agent starts.
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
- # setup: npm pack --pack-destination "$FIREGOAT_OUT"
9537
- # workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
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
- # setup: uv build --wheel --out-dir "$FIREGOAT_OUT"
9541
- # workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
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
- # setup: go build -o "$FIREGOAT_OUT/mycli" ./cmd/mycli
9545
- # (an npm/pypi CLI that users run via npx/uvx: pack + install it as above instead;
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
- # setup runs in this folder, so: setup: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
9550
- # then e.g. workdir_setup: cp "$FIREGOAT_OUT/wizard.mjs" .
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 workdir_setup and checks of the same
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 askYes(question) {
9616
+ async function ask(question) {
9594
9617
  const rl = createInterface({ input: process.stdin, output: process.stdout });
9595
9618
  try {
9596
- const a = (await rl.question(`${question} [Y/n] `)).trim();
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 skills = true;
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
- skills = false;
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: unknown argument: ${a}
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 ? "kept (--force replaces it)" : "created");
9722
+ step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
9675
9723
  console.log(paint("90", " │"));
9676
- step(2, ".firegoat/", "ready");
9724
+ step(2, ".firegoat/", "Ready");
9677
9725
  } else
9678
- console.log(keep ? `kept ${CONFIG_FILE} (--force replaces it)` : `wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
9679
- if (skills && interactive) {
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
- skills = await askYes(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill?`);
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 (skills) {
9733
+ if (scope !== "none") {
9734
+ const argv = [...SKILLS_ARGV, ...scope === "global" ? ["-g"] : []];
9735
+ const where = skillsDir(scope);
9685
9736
  if (!interactive)
9686
- console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
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", ...SKILLS_ARGV], {
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: installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
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", "installed → .claude/skills/");
9751
+ step(3, "firegoat-onboarding skill", `Installed → ${where}`);
9701
9752
  else
9702
- console.log("installed skill firegoat-onboarding in .claude/skills/");
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.status === 0)
9708
- console.error("warning: git ignores .claude/skills, so teammates and CI won't get the skill. Commit them with: git add -f .claude/skills");
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", "skipped");
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(` next: hand this to your coding agent
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!")} ` : ""}then: ${paint("36", "npx firegoat eval")}`);
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(`unknown command: ${cmd}
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
- aborted`);
9821
+ Aborted.`);
9771
9822
  process.exit(130);
9772
9823
  }
9773
9824
  console.error(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"