firegoat 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -29
  2. package/dist/cli.js +234 -172
  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. Commit `.firegoat/` with `firegoat.yaml`: its `.gitignore` ignores only `runs/` and `.claude/`, test fixtures (scripts, sample data) go in `.firegoat/fixtures/`, and its `README.md` says what each folder is.
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.1",
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
  }
@@ -7252,7 +7252,7 @@ import { randomBytes as randomBytes2 } from "node:crypto";
7252
7252
  import {
7253
7253
  appendFileSync,
7254
7254
  createWriteStream as createWriteStream2,
7255
- existsSync as existsSync3,
7255
+ existsSync as existsSync4,
7256
7256
  mkdirSync as mkdirSync3,
7257
7257
  readdirSync as readdirSync2,
7258
7258
  writeFileSync as writeFileSync3
@@ -7732,16 +7732,28 @@ function resolveKey(base) {
7732
7732
  var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7733
7733
 
7734
7734
  // src/firegoat-dir.ts
7735
- import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
7735
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
7736
7736
  import { join as join2 } from "node:path";
7737
- var GITIGNORE = `*
7738
- !fixtures/
7739
- !fixtures/**
7737
+ var GITIGNORE = `runs/
7738
+ .claude/
7739
+ `;
7740
+ var README = `# .firegoat
7741
+
7742
+ Files for the evals in firegoat.yaml (run them with \`npx firegoat eval\`).
7743
+
7744
+ - \`fixtures/\`: test fixtures (helper scripts, sample data) for build:, install: and checks. Commit them.
7745
+ - \`runs/\`: one folder per eval (transcripts, sessions, check output). Ignored by git.
7746
+ - \`.claude/\`: the empty claude config eval runs the agent with. Ignored by git.
7747
+
7748
+ Reference: \`npx firegoat init --help\`.
7740
7749
  `;
7741
7750
  function ensureFiregoatDir(root) {
7742
7751
  const dir = join2(root, ".firegoat");
7743
- mkdirSync2(dir, { recursive: true });
7752
+ mkdirSync2(join2(dir, "fixtures"), { recursive: true });
7744
7753
  writeFileSync(join2(dir, ".gitignore"), GITIGNORE);
7754
+ const readme = join2(dir, "README.md");
7755
+ if (!existsSync2(readme))
7756
+ writeFileSync(readme, README);
7745
7757
  return dir;
7746
7758
  }
7747
7759
 
@@ -7803,11 +7815,11 @@ function gitRepo(dir) {
7803
7815
 
7804
7816
  // src/help.ts
7805
7817
  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,
7818
+ 0 Every run's script checks passed
7819
+ 1 A script check failed
7820
+ 2 At least one run was not graded (claude missing or logged out, crash, timeout,
7821
+ build failed); wins over 1
7822
+ 3 Usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml,
7811
7823
  firegoat.ai answered 401/403 for the upload key); any other failure of
7812
7824
  that key check exits 2
7813
7825
  Upload failures don't change the exit code.`;
@@ -7816,74 +7828,85 @@ function topHelp(version) {
7816
7828
  folder, with none of your claude config; firegoat saves the session and runs your
7817
7829
  script checks.
7818
7830
 
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)
7831
+ npx firegoat init Create firegoat.yaml and .firegoat/, install the skill
7832
+ npx firegoat eval Run the tests (claude must be logged in: run \`claude\` once)
7821
7833
  npx firegoat login Log in to firegoat.ai: eval then uploads each session there
7822
7834
  npx firegoat logout Forget that login
7823
- npx firegoat feedback "<what got in the way>" tell the firegoat team
7835
+ npx firegoat feedback "<what got in the way>" Tell the firegoat team
7824
7836
 
7825
7837
  Typical flow for a coding agent driving firegoat:
7826
- 1. read npx firegoat init --help, then edit firegoat.yaml
7838
+ 1. Read npx firegoat init --help, then edit firegoat.yaml
7827
7839
  2. npx firegoat eval --json
7828
- 3. judge each run's end_goal from its run.json, session and workdir: firegoat only
7840
+ 3. Judge each run's end_goal from its run.json, session and workdir: firegoat only
7829
7841
  runs the script checks
7830
7842
 
7831
7843
  Run firegoat <command> --help for details and examples.
7832
7844
 
7833
7845
  ${EXIT_CODES}`;
7834
7846
  }
7835
- var INIT_HELP = `Usage: firegoat init [--force] [--no-skills] [--no-login]
7847
+ var INIT_HELP = `Usage: firegoat init [--force] [--local-skills | --no-skills] [--no-login]
7836
7848
 
7837
7849
  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
7850
+ firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
7851
+ firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
7852
+ firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
7853
+ paste into your coding agent. Commit .firegoat/ with firegoat.yaml: its .gitignore
7854
+ ignores only runs/ and .claude/, and test fixtures (helper scripts, sample data)
7855
+ go in .firegoat/fixtures/. Its README.md says what each folder is.
7856
+
7857
+ --force Replace an existing firegoat.yaml
7858
+ --local-skills Install the skill into this repo's .claude/skills/ instead
7859
+ (commit it so teammates and CI get it), without asking
7860
+ --no-skills Don't install the skill
7861
+ --no-login Don't offer firegoat login
7847
7862
 
7848
7863
  firegoat.yaml reference:
7849
- runs: 1 runs per test
7864
+ runs: 1 Runs per test
7850
7865
  model: <name> | [<name>, ...]
7851
7866
  claude --model; a list runs every test runs times
7852
7867
  per model (n counts 1..runs x models). Unset: no
7853
7868
  --model, claude's default. eval --model overrides
7854
- setup: <sh> | [<sh>, ...] once per eval, in this folder; what it writes to
7869
+ build: <sh> | [<sh>, ...] Once per eval, in this folder; what it writes to
7855
7870
  $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;
7871
+ install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts
7872
+ vars: {KEY: value} Defaults for {{vars.KEY}} in prompts and checks;
7858
7873
  --var KEY=value overrides
7859
- env: {NAME: value} env vars for the agent, and for workdir_setup and
7874
+ env: {NAME: value} Env vars for the agent, and for install and
7860
7875
  checks of the same run; values are strings and can
7861
7876
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
7862
7877
  {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
7863
7878
  (no other $ expansion: $HOME stays literal).
7864
- setup gets only the entries that use nothing but
7879
+ The build step gets only the entries that use only
7865
7880
  {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7866
7881
  PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
7867
7882
  ANTHROPIC_MODEL (use model:) are reserved.
7868
7883
  Runs go 4 at a time: give a tool with a global home
7869
7884
  a per-run one (or use eval --parallel 1), e.g.
7870
7885
  MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
7886
+ Setting HOME: on macOS claude reads its login from
7887
+ $HOME/Library, so a per-run HOME needs
7888
+ ln -s <real home>/Library "$HOME/Library" in
7889
+ install (get the real home in build, which
7890
+ doesn't get entries using {{test.name}}/{{run.n}}).
7871
7891
  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
7892
+ - name: quickstart Letters, digits, '.', '_', '-'; unique
7893
+ prompt: ... What a user would ask the agent; one concrete outcome
7894
+ end_goal: ... What is true when the problem is solved; not graded by
7875
7895
  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:
7896
+ checks: Optional shell commands, run in the agent's folder
7897
+ - run: ... Afterwards; exit 0 = pass; they set eval's exit code
7898
+ timeout: 600 Optional, seconds
7899
+ env: {NAME: value} Optional, on top of the top-level env:
7880
7900
 
7881
7901
  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).
7902
+ .firegoat/fixtures/ and copy them in build or install. The build and install
7903
+ commands and the checks get the vars as env vars, plus $FIREGOAT_OUT and
7904
+ $FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
7905
+ transcript, greppable line by line: "[1] $ <command>" on every line of a shell
7906
+ call (one line per line of the command, same n), "[2] Write <path>" for other
7907
+ tools, each output indented, and "agent: " on every line of the agent's text and
7908
+ final answer. So '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also
7909
+ get $FIREGOAT_SESSION (claude's full session file, JSON lines).
7887
7910
 
7888
7911
  The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
7889
7912
  (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
@@ -7892,8 +7915,8 @@ firegoat.yaml reference:
7892
7915
  (FIREGOAT_API_KEY above all) firegoat itself was started with.
7893
7916
 
7894
7917
  Example:
7895
- setup: npm pack --pack-destination "$FIREGOAT_OUT"
7896
- workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
7918
+ build: npm pack --pack-destination "$FIREGOAT_OUT"
7919
+ install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
7897
7920
  tests:
7898
7921
  - name: first-call
7899
7922
  prompt: >-
@@ -7916,7 +7939,7 @@ Logs in to firegoat.ai so eval uploads each run's claude session there as a priv
7916
7939
  trace (you share it from its page). OAuth device flow: prints a URL and a code (and
7917
7940
  opens the browser in a terminal); approve it there, and the trace key is saved to
7918
7941
  ~/.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
7942
+ build, install or checks, but the agent runs as you, without a sandbox: it can
7920
7943
  read ~/.firegoat, and a FIREGOAT_API_KEY in firegoat's own process env (ps eww,
7921
7944
  /proc/<pid>/environ). Use a dedicated key in CI. The key can only upload and read
7922
7945
  your own traces; revoke it on the firegoat.ai traces page.
@@ -7947,7 +7970,7 @@ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--pa
7947
7970
  [--var KEY=value].. [--json] [--no-upload] [--claude-bin PATH]
7948
7971
 
7949
7972
  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
7973
+ in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
7951
7974
  outputs on PATH. Then the test's checks run in that folder.
7952
7975
 
7953
7976
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
@@ -7957,20 +7980,21 @@ sandbox: the agent can reach the rest of your machine, including the repo.
7957
7980
 
7958
7981
  In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
7959
7982
  live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
7960
- fixtures and setup: stay in the current folder.
7983
+ fixtures and build: stay in the current folder.
7961
7984
 
7962
- test.. only these tests (by name)
7963
- --runs N runs per test (overrides runs: in firegoat.yaml)
7985
+ test.. Only these tests (by name)
7986
+ --runs N Runs per test (overrides runs: in firegoat.yaml)
7964
7987
  --model NAME claude --model, replacing model: in firegoat.yaml; repeat it
7965
7988
  (--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).
7989
+ --parallel N Runs at once (default 4; 1 runs them one after another).
7967
7990
  Tools with global on-disk state collide: give each run its
7968
7991
  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
7992
+ --var KEY=value Fills {{vars.KEY}}; env for build, install and checks
7993
+ --json The summary as JSON on stdout (or {error, exitCode} if eval
7971
7994
  stops early); progress goes to stderr
7972
7995
  --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)
7996
+ --claude-bin PATH Path to claude (env FIREGOAT_CLAUDE_BIN; default: claude on
7997
+ PATH)
7974
7998
 
7975
7999
  Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
7976
8000
  checks.txt and stderr.txt go to firegoat.ai as a private trace, and its URL is
@@ -7983,13 +8007,13 @@ Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
7983
8007
  (default https://firegoat.ai/evals). See firegoat login --help.
7984
8008
 
7985
8009
  Output: .firegoat/runs/<runId>/
7986
- summary.json every run, plus totals, dir (where eval ran),
8010
+ summary.json Every run, plus totals, dir (where eval ran),
7987
8011
  git {branch, commit, dirty} (no git outside a repo)
7988
8012
  and upload {origin, email} (null when not uploading)
7989
- ../index.jsonl one line per eval: runId, startedAt, dir, branch,
8013
+ ../index.jsonl One line per eval: runId, startedAt, dir, branch,
7990
8014
  commit, passed, failed, notGraded, cost, uploaded,
7991
8015
  uploadFailed
7992
- <test>/<n>/run.json one run: status and reason (from the checks), model
8016
+ <test>/<n>/run.json One run: status and reason (from the checks), model
7993
8017
  (asked for, or null) and claudeModel (what claude's
7994
8018
  init line reported), prompt, end_goal,
7995
8019
  checks [{run, code}], and paths: workdir,
@@ -7997,19 +8021,21 @@ Output: .firegoat/runs/<runId>/
7997
8021
  checksLog; startedAt; upload ({status: "uploaded",
7998
8022
  url} | {status: "failed", error} | {status:
7999
8023
  "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
8024
+ <test>/<n>/transcript.txt What the agent did: each tool call with its command
8025
+ ("[n] $ " on every line of a shell call), short output
8026
+ (indented), its text and final answer ("agent: " on
8027
+ every line), files it made
8028
+ build.txt, <test>/<n>/install.txt, checks.txt Logs
8003
8029
 
8004
8030
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
8005
8031
  yours to judge: read transcript.txt first (a few KB); the session file has everything
8006
8032
  (often 50x larger).
8007
8033
 
8008
8034
  Examples:
8009
- npx firegoat eval --json all tests, JSON summary on stdout
8010
- npx firegoat eval quickstart --runs 3 one test, three runs
8035
+ npx firegoat eval --json All tests, JSON summary on stdout
8036
+ npx firegoat eval quickstart --runs 3 One test, three runs
8011
8037
  npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
8012
- each test on both models
8038
+ Each test on both models
8013
8039
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
8014
8040
 
8015
8041
  A run can take minutes (default timeout 600s per test).
@@ -8022,8 +8048,8 @@ firegoat confused you, failed you or cost you tokens, say so here: one concrete
8022
8048
  title, then what you ran and what happened in --body (- reads stdin).
8023
8049
 
8024
8050
  "<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)
8051
+ --body TEXT Details: commands, output, what you expected
8052
+ --type One of bug, feature_request or feedback (default)
8027
8053
 
8028
8054
  Sent along: the firegoat version, your OS and node version, and which agent you are
8029
8055
  (claude-code/codex/cursor, from the environment). Don't include secrets: a report that
@@ -8037,7 +8063,7 @@ Example:
8037
8063
 
8038
8064
  Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
8039
8065
 
8040
- // src/setup.ts
8066
+ // src/steps.ts
8041
8067
  import { createWriteStream } from "node:fs";
8042
8068
  var STEP_TIMEOUT_MS = 600000;
8043
8069
  async function runSteps(opts) {
@@ -8077,18 +8103,20 @@ function truncate(text, maxLines, maxChars) {
8077
8103
  return out.length < text.length ? `${out}
8078
8104
  … (${text.length - out.length} more chars)` : out;
8079
8105
  }
8080
- function indent(text) {
8106
+ function prefixLines(prefix, text) {
8081
8107
  return text.split(`
8082
- `).map((l) => ` ${l}`).join(`
8108
+ `).map((l) => `${prefix}${l}`).join(`
8083
8109
  `);
8084
8110
  }
8085
- function describeCall(name, input) {
8111
+ var indent = (text) => prefixLines(" ", text);
8112
+ var prose = (text) => prefixLines("agent: ", text);
8113
+ function describeCall(step, name, input) {
8086
8114
  if (typeof input.command === "string")
8087
- return `$ ${input.command}`;
8115
+ return prefixLines(`[${step}] $ `, input.command);
8088
8116
  for (const key of ["file_path", "path", "url", "pattern", "query"])
8089
8117
  if (typeof input[key] === "string")
8090
- return `${name} ${input[key]}`;
8091
- return `${name} ${truncate(JSON.stringify(input), 1, 300)}`;
8118
+ return `[${step}] ${name} ${input[key]}`;
8119
+ return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
8092
8120
  }
8093
8121
  function resultText(content) {
8094
8122
  if (typeof content === "string")
@@ -8125,10 +8153,10 @@ function writeTranscript(opts) {
8125
8153
  if (e?.type === "assistant")
8126
8154
  for (const b of content) {
8127
8155
  if (b.type === "tool_use")
8128
- out.push(`[${++step}] ${describeCall(String(b.name), b.input ?? {})}`);
8156
+ out.push(describeCall(++step, String(b.name), b.input ?? {}));
8129
8157
  else if (b.type === "text" && typeof b.text === "string") {
8130
8158
  lastText = out.length;
8131
- out.push(`agent: ${truncate(b.text, 30, TEXT_CHARS)}`);
8159
+ out.push(prose(truncate(b.text, 30, TEXT_CHARS)));
8132
8160
  }
8133
8161
  }
8134
8162
  else if (e?.type === "user")
@@ -8143,9 +8171,9 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
8143
8171
  else if (e?.type === "result" && typeof e.result === "string")
8144
8172
  final = e.result;
8145
8173
  }
8146
- if (lastText === out.length - 1 && final && out[lastText] === `agent: ${truncate(final, 30, TEXT_CHARS)}`)
8174
+ if (lastText === out.length - 1 && final && out[lastText] === prose(truncate(final, 30, TEXT_CHARS)))
8147
8175
  out.pop();
8148
- out.push("", "## final answer", final || "(none)");
8176
+ out.push("", "## final answer", final ? prose(final) : "(none)");
8149
8177
  out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
8150
8178
  writeFileSync2(opts.path, `${out.join(`
8151
8179
  `)}
@@ -8243,13 +8271,13 @@ function plainUI(write) {
8243
8271
  const printed = new Set;
8244
8272
  const uploadPrinted = new Set;
8245
8273
  return {
8246
- auth: (text) => write(`auth: ${text}`),
8274
+ auth: (text) => write(`Auth: ${text}`),
8247
8275
  note: write,
8248
- setup: (state, seconds) => {
8276
+ build: (state, seconds) => {
8249
8277
  if (state === "running")
8250
- write("setup...");
8278
+ write("Build...");
8251
8279
  else if (state === "fail")
8252
- write(`setup failed (${seconds}s)`);
8280
+ write(`Build failed (${seconds}s)`);
8253
8281
  },
8254
8282
  runs: (r) => {
8255
8283
  rows = r;
@@ -8277,14 +8305,14 @@ function plainUI(write) {
8277
8305
  const tr = tier(all);
8278
8306
  write("");
8279
8307
  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)`);
8308
+ write(`Runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
8281
8309
  }
8282
8310
  };
8283
8311
  }
8284
8312
  function ttyUI(out, opts) {
8285
8313
  const p = painter(opts.color);
8286
8314
  let rows = [];
8287
- let setup = null;
8315
+ let build = null;
8288
8316
  let authText = "";
8289
8317
  const notes = [];
8290
8318
  let frame = 0;
@@ -8301,9 +8329,9 @@ function ttyUI(out, opts) {
8301
8329
  case "error":
8302
8330
  return p(C.amber, "NOT GRADED");
8303
8331
  case "running":
8304
- return p(C.ember, "running ");
8332
+ return p(C.ember, "Running ");
8305
8333
  default:
8306
- return p(C.gray, "queued ");
8334
+ return p(C.gray, "Queued ");
8307
8335
  }
8308
8336
  };
8309
8337
  const glyph = (r) => {
@@ -8331,12 +8359,12 @@ function ttyUI(out, opts) {
8331
8359
  rule
8332
8360
  ];
8333
8361
  if (authText)
8334
- out.push(p(C.gray, ` auth ${authText}`));
8362
+ out.push(p(C.gray, ` Auth ${authText}`));
8335
8363
  for (const n of notes)
8336
8364
  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` : ""}`);
8365
+ if (build) {
8366
+ const mark = build.state === "ok" ? p(C.green, "✓") : build.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
8367
+ 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
8368
  }
8341
8369
  const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
8342
8370
  rows.forEach((r, i) => {
@@ -8346,7 +8374,7 @@ function ttyUI(out, opts) {
8346
8374
  const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
8347
8375
  let detail = "";
8348
8376
  if (r.state === "running")
8349
- detail = p(C.gray, `turn ${r.turns}`);
8377
+ detail = p(C.gray, `Turn ${r.turns}`);
8350
8378
  else if (r.state === "pass" || r.state === "fail")
8351
8379
  detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
8352
8380
  else if (r.state === "error")
@@ -8381,11 +8409,11 @@ function ttyUI(out, opts) {
8381
8409
  const goated = c.pass === rows.length && rows.length > 0;
8382
8410
  const tally = `${c.pass} PASS · ${c.fail} FAIL · ${c.error} NOT GRADED`;
8383
8411
  const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
8384
- const right = goated ? `GOATED · tier ${tr}` : tr ? `tier ${tr}` : "";
8412
+ const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
8385
8413
  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")}`);
8414
+ out.push(p(C.gray, ` Total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
8415
+ out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
8416
+ out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
8389
8417
  return out;
8390
8418
  };
8391
8419
  const rowsOf = (line) => {
@@ -8413,8 +8441,8 @@ function ttyUI(out, opts) {
8413
8441
  note: (text) => {
8414
8442
  notes.push(text);
8415
8443
  },
8416
- setup: (state, seconds) => {
8417
- setup = { state, seconds };
8444
+ build: (state, seconds) => {
8445
+ build = { state, seconds };
8418
8446
  render();
8419
8447
  },
8420
8448
  runs: (r) => {
@@ -8431,7 +8459,7 @@ function ttyUI(out, opts) {
8431
8459
  }
8432
8460
 
8433
8461
  // src/upload.ts
8434
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
8462
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8435
8463
  import { basename as basename2 } from "node:path";
8436
8464
  var MAX_FILE = 20 * 1024 * 1024;
8437
8465
  var MAX_CHECKS = 200;
@@ -8507,7 +8535,7 @@ async function uploadRun(t, r) {
8507
8535
  };
8508
8536
  const bytes = {};
8509
8537
  for (const name of FILES) {
8510
- const b = existsSync2(paths[name]) ? readFileSync3(paths[name]) : Buffer.alloc(0);
8538
+ const b = existsSync3(paths[name]) ? readFileSync3(paths[name]) : Buffer.alloc(0);
8511
8539
  if (b.byteLength > MAX_FILE)
8512
8540
  return {
8513
8541
  status: "failed",
@@ -8668,7 +8696,7 @@ function parseArgs(args) {
8668
8696
  } else if (flag === "--claude-bin")
8669
8697
  opts.claudeBin = value();
8670
8698
  else if (a.startsWith("-"))
8671
- throw new UsageError(`unknown flag: ${a}`);
8699
+ throw new UsageError(`Unknown flag: ${a}`);
8672
8700
  else
8673
8701
  opts.names.push(a);
8674
8702
  }
@@ -8676,11 +8704,11 @@ function parseArgs(args) {
8676
8704
  }
8677
8705
  function findSession(configDir, sessionId) {
8678
8706
  const projects = join5(configDir, "projects");
8679
- if (!existsSync3(projects))
8707
+ if (!existsSync4(projects))
8680
8708
  return null;
8681
8709
  for (const d of readdirSync2(projects)) {
8682
8710
  const f = join5(projects, d, `${sessionId}.jsonl`);
8683
- if (existsSync3(f))
8711
+ if (existsSync4(f))
8684
8712
  return f;
8685
8713
  }
8686
8714
  return null;
@@ -8772,13 +8800,13 @@ async function runOne(opts) {
8772
8800
  FIREGOAT_WORKDIR: workdir
8773
8801
  });
8774
8802
  const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8775
- if (opts.workdirSetup.length > 0) {
8803
+ if (opts.install.length > 0) {
8776
8804
  const error = await runSteps({
8777
- what: "workdir_setup",
8778
- commands: opts.workdirSetup,
8805
+ what: "install",
8806
+ commands: opts.install,
8779
8807
  cwd: workdir,
8780
8808
  env: stepEnv,
8781
- logPath: join5(dir, "workdir_setup.txt")
8809
+ logPath: join5(dir, "install.txt")
8782
8810
  });
8783
8811
  if (error)
8784
8812
  return done("error", `not graded: ${error}`);
@@ -8869,7 +8897,7 @@ async function evalCommand(args, root) {
8869
8897
  const known = new Set(tests.map((t) => t.name));
8870
8898
  const unknown = opts.names.filter((n) => !known.has(n));
8871
8899
  if (unknown.length > 0)
8872
- return fatal(EXIT.usage, `unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
8900
+ return fatal(EXIT.usage, `Unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
8873
8901
  tests = tests.filter((t) => opts.names.includes(t.name));
8874
8902
  }
8875
8903
  const runs = opts.runs ?? config.runs;
@@ -8883,10 +8911,10 @@ async function evalCommand(args, root) {
8883
8911
  ]).flatMap(varRefs).filter((k) => !(k in vars)))
8884
8912
  ];
8885
8913
  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`);
8914
+ 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
8915
  const claudePath = resolveBin(opts.claudeBin);
8888
8916
  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.`);
8917
+ 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
8918
  let repo;
8891
8919
  try {
8892
8920
  repo = gitRepo(root);
@@ -8903,7 +8931,7 @@ async function evalCommand(args, root) {
8903
8931
  cwd: root
8904
8932
  });
8905
8933
  if (loginProblem)
8906
- return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
8934
+ return fatal(EXIT.notGraded, `Not graded: ${loginProblem}`);
8907
8935
  const evalStarted = Date.now();
8908
8936
  const runId = newRunId();
8909
8937
  let upload;
@@ -8935,23 +8963,23 @@ async function evalCommand(args, root) {
8935
8963
  state: "queued",
8936
8964
  turns: 0
8937
8965
  }))));
8938
- if (config.setup.length > 0) {
8939
- const setupStarted = Date.now();
8940
- ui.setup("running");
8966
+ if (config.build.length > 0) {
8967
+ const buildStarted = Date.now();
8968
+ ui.build("running");
8941
8969
  const error = await runSteps({
8942
- what: "setup",
8943
- commands: config.setup,
8970
+ what: "build",
8971
+ commands: config.build,
8944
8972
  cwd: root,
8945
8973
  env: {
8946
8974
  ...stepEnv,
8947
8975
  ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
8948
8976
  },
8949
- logPath: join5(runDir, "setup.txt")
8977
+ logPath: join5(runDir, "build.txt")
8950
8978
  });
8951
- const secs = Math.round((Date.now() - setupStarted) / 1000);
8952
- ui.setup(error ? "fail" : "ok", secs);
8979
+ const secs = Math.round((Date.now() - buildStarted) / 1000);
8980
+ ui.build(error ? "fail" : "ok", secs);
8953
8981
  if (error)
8954
- return fatal(EXIT.notGraded, `not graded: ${error}`);
8982
+ return fatal(EXIT.notGraded, `Not graded: ${error}`);
8955
8983
  }
8956
8984
  ui.runs(rows);
8957
8985
  const results = new Array(rows.length);
@@ -8982,7 +9010,7 @@ async function evalCommand(args, root) {
8982
9010
  runDir,
8983
9011
  claudeBin: claudePath,
8984
9012
  configDir,
8985
- workdirSetup: config.workdirSetup,
9013
+ install: config.install,
8986
9014
  out,
8987
9015
  stepEnv,
8988
9016
  vars
@@ -9282,7 +9310,7 @@ async function feedbackCommand(args) {
9282
9310
  throw new Error(`--type must be one of ${TYPES.join(", ")}, got ${v}`);
9283
9311
  type = v;
9284
9312
  } else if (a.startsWith("-"))
9285
- throw new Error(`unknown flag: ${a}`);
9313
+ throw new Error(`Unknown flag: ${a}`);
9286
9314
  else
9287
9315
  titleParts.push(a);
9288
9316
  } catch (e) {
@@ -9292,7 +9320,7 @@ async function feedbackCommand(args) {
9292
9320
  }
9293
9321
  const title = titleParts.join(" ").trim();
9294
9322
  if (title.length < 8 || title.length > 200) {
9295
- console.error(`firegoat feedback: the title must be 8-200 characters (got ${title.length})`);
9323
+ console.error(`firegoat feedback: The title must be 8-200 characters (got ${title.length})`);
9296
9324
  return EXIT.usage;
9297
9325
  }
9298
9326
  const client = new FeedbackClient({ domain: "firegoat.ai" });
@@ -9310,7 +9338,7 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9310
9338
  nodeVersion: process.version
9311
9339
  }
9312
9340
  });
9313
- console.log(`sent ${type} ${id} to the firegoat team${similarCount > 0 ? ` (${similarCount} similar report${similarCount === 1 ? "" : "s"} already)` : ""}. thanks.`);
9341
+ console.log(`Sent ${type} ${id} to the firegoat team${similarCount > 0 ? ` (${similarCount} similar report${similarCount === 1 ? "" : "s"} already)` : ""}. Thanks.`);
9314
9342
  return EXIT.pass;
9315
9343
  } catch (e) {
9316
9344
  if (e instanceof FeedbackError) {
@@ -9324,7 +9352,7 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9324
9352
 
9325
9353
  // src/init.ts
9326
9354
  import { spawnSync as spawnSync3 } from "node:child_process";
9327
- import { existsSync as existsSync4, writeFileSync as writeFileSync4 } from "node:fs";
9355
+ import { existsSync as existsSync5, writeFileSync as writeFileSync4 } from "node:fs";
9328
9356
  import { basename as basename4, join as join6 } from "node:path";
9329
9357
  import { createInterface } from "node:readline/promises";
9330
9358
 
@@ -9516,6 +9544,12 @@ var SKILLS_ARGV = [
9516
9544
  "--copy",
9517
9545
  "-y"
9518
9546
  ];
9547
+ function skillsDir(scope) {
9548
+ if (scope === "local")
9549
+ return ".claude/skills/";
9550
+ const config = process.env.CLAUDE_CONFIG_DIR?.trim();
9551
+ return config ? `${join6(config, "skills")}/` : "~/.claude/skills/";
9552
+ }
9519
9553
  function template(product) {
9520
9554
  return `# firegoat.yaml: evals for how well coding agents use ${product}.
9521
9555
  # Each test runs your claude in an empty folder (.firegoat/runs/...) with \`prompt\`
@@ -9529,27 +9563,27 @@ runs: 1
9529
9563
  # model: [claude-haiku-5-5, claude-sonnet-5-5]
9530
9564
 
9531
9565
  # 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.
9566
+ # build: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
9567
+ # agent's PATH. install: runs in each fresh folder before the agent starts.
9534
9568
  #
9535
- # npm:
9536
- # setup: npm pack --pack-destination "$FIREGOAT_OUT"
9537
- # workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
9569
+ # For npm:
9570
+ # build: npm pack --pack-destination "$FIREGOAT_OUT"
9571
+ # install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
9538
9572
  #
9539
- # python:
9540
- # setup: uv build --wheel --out-dir "$FIREGOAT_OUT"
9541
- # workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
9573
+ # For python:
9574
+ # build: uv build --wheel --out-dir "$FIREGOAT_OUT"
9575
+ # install: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
9542
9576
  #
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;
9577
+ # For a CLI binary users call by bare name (on PATH as is):
9578
+ # build: go build -o "$FIREGOAT_OUT/mycli" ./cmd/mycli
9579
+ # (An npm/pypi CLI that users run via npx/uvx: pack + install it as above instead;
9546
9580
  # a binary on PATH does not stop "npx mycli" from fetching the registry release)
9547
9581
  #
9548
9582
  # 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" .
9583
+ # The build step runs in this folder, so: build: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
9584
+ # Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
9551
9585
 
9552
- # Optional: env vars for the agent (and for workdir_setup and checks of the same
9586
+ # Optional: env vars for the agent (and for install and checks of the same
9553
9587
  # run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
9554
9588
  # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
9555
9589
  # other $ expansion: $HOME stays literal). A test can add its own env:.
@@ -9590,15 +9624,30 @@ function copyToClipboard(text) {
9590
9624
  return !r.error && r.status === 0;
9591
9625
  });
9592
9626
  }
9593
- async function askYes(question) {
9627
+ async function ask(question) {
9594
9628
  const rl = createInterface({ input: process.stdin, output: process.stdout });
9595
9629
  try {
9596
- const a = (await rl.question(`${question} [Y/n] `)).trim();
9597
- return a === "" || /^y(es)?$/i.test(a);
9630
+ return (await rl.question(question)).trim();
9598
9631
  } finally {
9599
9632
  rl.close();
9600
9633
  }
9601
9634
  }
9635
+ async function askYes(question) {
9636
+ const a = await ask(`${question} [Y/n] `);
9637
+ return a === "" || /^y(es)?$/i.test(a);
9638
+ }
9639
+ async function askScope(question) {
9640
+ for (;; ) {
9641
+ const a = await ask(`${question} [G/l/n] `);
9642
+ if (a === "" || /^g(lobal)?$/i.test(a))
9643
+ return "global";
9644
+ if (/^l(ocal)?$/i.test(a))
9645
+ return "local";
9646
+ if (/^no?$/i.test(a))
9647
+ return "none";
9648
+ process.stdout.write("\x1B[1A\x1B[2K");
9649
+ }
9650
+ }
9602
9651
  var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
9603
9652
  async function loginStep(interactive, done, paint) {
9604
9653
  const label = "firegoat.ai login";
@@ -9634,7 +9683,8 @@ async function loginStep(interactive, done, paint) {
9634
9683
  }
9635
9684
  async function initCommand(args, cwd) {
9636
9685
  let force = false;
9637
- let skills = true;
9686
+ let noSkills = false;
9687
+ let localSkills = false;
9638
9688
  let login = true;
9639
9689
  for (const a of args) {
9640
9690
  if (a === "--help" || a === "-h") {
@@ -9644,16 +9694,25 @@ async function initCommand(args, cwd) {
9644
9694
  if (a === "--force" || a === "-f")
9645
9695
  force = true;
9646
9696
  else if (a === "--no-skills")
9647
- skills = false;
9697
+ noSkills = true;
9698
+ else if (a === "--local-skills")
9699
+ localSkills = true;
9648
9700
  else if (a === "--no-login")
9649
9701
  login = false;
9650
9702
  else {
9651
- console.error(`firegoat init: unknown argument: ${a}
9703
+ console.error(`firegoat init: Unknown argument: ${a}
9652
9704
 
9653
9705
  ${INIT_HELP}`);
9654
9706
  return EXIT.usage;
9655
9707
  }
9656
9708
  }
9709
+ if (noSkills && localSkills) {
9710
+ console.error(`firegoat init: --no-skills and --local-skills contradict each other: pass one
9711
+
9712
+ ${INIT_HELP}`);
9713
+ return EXIT.usage;
9714
+ }
9715
+ let scope = noSkills ? "none" : localSkills ? "local" : "global";
9657
9716
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
9658
9717
  const color = interactive && !process.env.NO_COLOR;
9659
9718
  const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
@@ -9667,25 +9726,28 @@ ${INIT_HELP}`);
9667
9726
  }
9668
9727
  ensureFiregoatDir(cwd);
9669
9728
  const configPath = join6(cwd, CONFIG_FILE);
9670
- const keep = existsSync4(configPath) && !force;
9729
+ const keep = existsSync5(configPath) && !force;
9671
9730
  if (!keep)
9672
9731
  writeFileSync4(configPath, template(basename4(cwd)));
9673
9732
  if (interactive) {
9674
- step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
9733
+ step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
9675
9734
  console.log(paint("90", " │"));
9676
- step(2, ".firegoat/", "ready");
9735
+ step(2, ".firegoat/", "Ready");
9677
9736
  } 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) {
9737
+ console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
9738
+ if (interactive && !noSkills)
9680
9739
  console.log(paint("90", " │"));
9681
- skills = await askYes(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill?`);
9740
+ if (interactive && !noSkills && !localSkills) {
9741
+ 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
9742
  process.stdout.write("\x1B[1A\x1B[2K");
9683
9743
  }
9684
- if (skills) {
9744
+ if (scope !== "none") {
9745
+ const argv = [...SKILLS_ARGV, ...scope === "global" ? ["-g"] : []];
9746
+ const where = skillsDir(scope);
9685
9747
  if (!interactive)
9686
- console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
9748
+ console.log(`Installing the skill: npx ${argv.join(" ")}`);
9687
9749
  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], {
9750
+ const r = spawnSync3("npx", ["-y", ...argv], {
9689
9751
  cwd,
9690
9752
  env,
9691
9753
  stdio: "pipe",
@@ -9693,21 +9755,21 @@ ${INIT_HELP}`);
9693
9755
  });
9694
9756
  if (r.error || r.status !== 0) {
9695
9757
  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(" ")}`);
9758
+ console.error(`firegoat init: Installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${argv.join(" ")}`);
9697
9759
  return EXIT.notGraded;
9698
9760
  }
9699
9761
  if (interactive)
9700
- step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
9762
+ step(3, "firegoat-onboarding skill", `Installed → ${where}`);
9701
9763
  else
9702
- console.log("installed skill firegoat-onboarding in .claude/skills/");
9703
- const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
9764
+ console.log(`Installed skill firegoat-onboarding in ${where}`);
9765
+ const ignored = scope === "local" && spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
9704
9766
  cwd,
9705
9767
  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");
9768
+ }).status === 0;
9769
+ if (ignored)
9770
+ 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
9771
  } else if (interactive)
9710
- step(3, "firegoat-onboarding skill", "skipped");
9772
+ step(3, "firegoat-onboarding skill", "Skipped");
9711
9773
  if (login) {
9712
9774
  if (interactive)
9713
9775
  console.log(paint("90", " │"));
@@ -9721,12 +9783,12 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
9721
9783
  return EXIT.pass;
9722
9784
  }
9723
9785
  console.log(rule);
9724
- console.log(` next: hand this to your coding agent
9786
+ console.log(` Next: hand this to your coding agent
9725
9787
  `);
9726
9788
  console.log(AGENT_PROMPT);
9727
9789
  console.log("");
9728
9790
  const copied = copyToClipboard(AGENT_PROMPT);
9729
- console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}then: ${paint("36", "npx firegoat eval")}`);
9791
+ console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat eval")}`);
9730
9792
  return EXIT.pass;
9731
9793
  }
9732
9794
 
@@ -9754,7 +9816,7 @@ async function main(argv) {
9754
9816
  case "logout":
9755
9817
  return logoutCommand(rest);
9756
9818
  default:
9757
- console.error(`unknown command: ${cmd}
9819
+ console.error(`Unknown command: ${cmd}
9758
9820
 
9759
9821
  ${topHelp(package_default.version)}`);
9760
9822
  return EXIT.usage;
@@ -9767,7 +9829,7 @@ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
9767
9829
  }
9768
9830
  if (err?.code === "ABORT_ERR") {
9769
9831
  console.error(`
9770
- aborted`);
9832
+ Aborted.`);
9771
9833
  process.exit(130);
9772
9834
  }
9773
9835
  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.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"