firegoat 0.14.0 → 0.15.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 +21 -6
  2. package/dist/cli.js +205 -33
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -7,7 +7,7 @@ Status: early (v0.11). See `plan.md`.
7
7
  ## Quickstart
8
8
 
9
9
  ```bash
10
- npx firegoat init # creates firegoat.yaml and .firegoat/, installs the skills, copies a prompt for your agent
10
+ npx firegoat init # creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
11
11
  # edit firegoat.yaml (or paste the prompt into your coding agent and let it)
12
12
  npx firegoat eval # runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
13
13
  ```
@@ -82,6 +82,21 @@ tests:
82
82
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
83
83
  ```
84
84
 
85
+ ### Same tests on several models
86
+
87
+ ```yaml
88
+ model: [claude-haiku-5-5, claude-sonnet-5-5]
89
+ runs: 2
90
+ tests:
91
+ - name: quickstart
92
+ prompt: Get the acme SDK's hello-world example running and save its output to output.txt.
93
+ end_goal: output.txt holds the example's output.
94
+ checks:
95
+ - run: test -s output.txt
96
+ ```
97
+
98
+ `model:` is passed to claude as `--model`. A list is a matrix: each test runs `runs` times per model, numbered `1..runs×models` (here `quickstart/1`, `2` on haiku, `3`, `4` on sonnet). `npx firegoat eval --model claude-opus-5-5` replaces it for one eval; repeat the flag for a matrix. Unset, firegoat passes no `--model` and claude uses its default. Each run's `run.json` records `model` (what firegoat asked for, or null) and `claudeModel` (what claude reported starting with), and the output labels each run with its model.
99
+
85
100
  ### Env vars for the agent: a home dir per run, a feature flag
86
101
 
87
102
  ```yaml
@@ -99,19 +114,19 @@ tests:
99
114
  - run: ls "$ACME_HOME"/sessions | grep -q .
100
115
  ```
101
116
 
102
- `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), `{{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.
117
+ `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:`.
103
118
 
104
119
  ## What eval does
105
120
 
106
121
  - 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.
107
- - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores `runs/` and `.claude/`; keep test fixtures (scripts, sample data) in `.firegoat/fixtures/` and commit them with `firegoat.yaml`.
122
+ - 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`.
108
123
  - 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`) to tell runs from different branches apart.
109
124
  - 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.
110
- - Runs (tests × `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.
125
+ - 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.
111
126
  - 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.
112
127
  - 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.
113
128
 
114
- `firegoat.yaml` keys: `runs`, `setup`, `workdir_setup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
129
+ `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`.
115
130
 
116
131
  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).
117
132
 
@@ -130,7 +145,7 @@ Something in firegoat got in your way (or your agent's)? Tell us from the termin
130
145
  npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
131
146
  ```
132
147
 
133
- It goes to the firegoat team with the firegoat version, your OS and node version, and which coding agent sent it. Don't include secrets: a report that looks like it has one is refused.
148
+ It goes to the firegoat team with the firegoat version, your OS and node version, and which coding agent sent it. Don't include secrets: a report that looks like it has one is refused. The first send registers your device and saves its token to `~/.config/firegoat/feedback-device.json` (`$XDG_CONFIG_HOME` respected), which raises the daily report limit.
134
149
 
135
150
  ## Develop
136
151
 
package/dist/cli.js CHANGED
@@ -6947,14 +6947,14 @@ var require_public_api = __commonJS(function(exports) {
6947
6947
  // package.json
6948
6948
  var package_default = {
6949
6949
  name: "firegoat",
6950
- version: "0.14.0",
6950
+ version: "0.15.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
6954
6954
  },
6955
6955
  devDependencies: {
6956
6956
  "@biomejs/biome": "^2.5.15",
6957
- "@firegoat/feedback": "0.0.0",
6957
+ "@firegoat/feedback": "0.2.0",
6958
6958
  "@types/bun": "latest",
6959
6959
  typescript: "^7",
6960
6960
  yaml: "^2.9.1"
@@ -7075,6 +7075,7 @@ function parseConfig(text) {
7075
7075
  fail("(root)", "must be a mapping");
7076
7076
  onlyKeys(raw, "(root)", [
7077
7077
  "runs",
7078
+ "model",
7078
7079
  "setup",
7079
7080
  "workdir_setup",
7080
7081
  "vars",
@@ -7082,6 +7083,7 @@ function parseConfig(text) {
7082
7083
  "tests"
7083
7084
  ]);
7084
7085
  const runs = posInt(raw.runs, "runs", 1);
7086
+ const models = parseModels(raw.model);
7085
7087
  const setup = commands(raw.setup, "setup");
7086
7088
  const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
7087
7089
  const vars = parseVars(raw.vars);
@@ -7122,6 +7124,7 @@ function parseConfig(text) {
7122
7124
  });
7123
7125
  return {
7124
7126
  runs,
7127
+ models,
7125
7128
  setup,
7126
7129
  workdirSetup,
7127
7130
  vars,
@@ -7139,6 +7142,22 @@ function commands(raw, path) {
7139
7142
  fail(path, "must be a shell command or a list of them");
7140
7143
  return raw.map((c, i) => str(c, `${path}[${i}]`));
7141
7144
  }
7145
+ function parseModels(raw) {
7146
+ if (raw === undefined)
7147
+ return [];
7148
+ if (typeof raw === "string")
7149
+ return [str(raw, "model")];
7150
+ if (!Array.isArray(raw) || raw.length === 0)
7151
+ fail("model", "must be a model name or a non-empty list of them");
7152
+ const seen = new Set;
7153
+ return raw.map((m, i) => {
7154
+ const name = str(m, `model[${i}]`);
7155
+ if (seen.has(name))
7156
+ fail(`model[${i}]`, `duplicate model ${JSON.stringify(name)}`);
7157
+ seen.add(name);
7158
+ return name;
7159
+ });
7160
+ }
7142
7161
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7143
7162
  function parseVars(raw) {
7144
7163
  if (raw === undefined)
@@ -7175,6 +7194,8 @@ function parseEnv(raw, path) {
7175
7194
  const p = `${path}.${k}`;
7176
7195
  if (!VAR_KEY.test(k))
7177
7196
  fail(p, "name must be letters, digits and _, not starting with a digit");
7197
+ if (k === "ANTHROPIC_MODEL")
7198
+ fail(p, "is reserved: choose the model with top-level model: (or eval --model)");
7178
7199
  if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7179
7200
  fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7180
7201
  if (typeof v !== "string")
@@ -7298,7 +7319,12 @@ async function runClaude(opts) {
7298
7319
  let pending = "";
7299
7320
  const exited = await run({
7300
7321
  cmd: opts.bin,
7301
- args: ["-p", opts.prompt, ...CLAUDE_FLAGS],
7322
+ args: [
7323
+ "-p",
7324
+ opts.prompt,
7325
+ ...CLAUDE_FLAGS,
7326
+ ...opts.model === undefined ? [] : ["--model", opts.model]
7327
+ ],
7302
7328
  cwd: opts.cwd,
7303
7329
  env: opts.env,
7304
7330
  timeoutMs: opts.timeoutMs,
@@ -7322,6 +7348,7 @@ async function runClaude(opts) {
7322
7348
  if (exited.error)
7323
7349
  return {
7324
7350
  events: [],
7351
+ claudeModel: null,
7325
7352
  ok: false,
7326
7353
  reason: `could not start ${opts.bin}: ${exited.error.message}`
7327
7354
  };
@@ -7339,15 +7366,18 @@ async function runClaude(opts) {
7339
7366
  `)[0]?.slice(0, 300);
7340
7367
  return {
7341
7368
  events,
7369
+ claudeModel: null,
7342
7370
  ok: false,
7343
7371
  reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7344
7372
  };
7345
7373
  }
7346
7374
  const sessionId = String(init.session_id);
7375
+ const claudeModel = typeof init.model === "string" ? init.model : null;
7347
7376
  const result = events.findLast((e) => e?.type === "result");
7348
7377
  if (exited.timedOut)
7349
7378
  return {
7350
7379
  events,
7380
+ claudeModel,
7351
7381
  ok: false,
7352
7382
  reason: `timed out after ${opts.timeoutMs / 1000}s`,
7353
7383
  result,
@@ -7356,6 +7386,7 @@ async function runClaude(opts) {
7356
7386
  if (!result)
7357
7387
  return {
7358
7388
  events,
7389
+ claudeModel,
7359
7390
  ok: false,
7360
7391
  reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7361
7392
  sessionId
@@ -7363,12 +7394,13 @@ async function runClaude(opts) {
7363
7394
  if (result.is_error === true || result.subtype !== "success")
7364
7395
  return {
7365
7396
  events,
7397
+ claudeModel,
7366
7398
  ok: false,
7367
7399
  reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7368
7400
  result,
7369
7401
  sessionId
7370
7402
  };
7371
- return { events, ok: true, result, sessionId };
7403
+ return { events, claudeModel, ok: true, result, sessionId };
7372
7404
  }
7373
7405
  async function runShell(opts) {
7374
7406
  opts.log.write(`$ ${opts.run}
@@ -7420,8 +7452,9 @@ var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7420
7452
  // src/firegoat-dir.ts
7421
7453
  import { mkdirSync, writeFileSync } from "node:fs";
7422
7454
  import { join } from "node:path";
7423
- var GITIGNORE = `runs/
7424
- .claude/
7455
+ var GITIGNORE = `*
7456
+ !fixtures/
7457
+ !fixtures/**
7425
7458
  `;
7426
7459
  function ensureFiregoatDir(root) {
7427
7460
  const dir = join(root, ".firegoat");
@@ -7487,7 +7520,7 @@ function topHelp(version) {
7487
7520
  folder, with none of your claude config; firegoat saves the session and runs your
7488
7521
  script checks.
7489
7522
 
7490
- npx firegoat init create firegoat.yaml and .firegoat/, install the skills
7523
+ npx firegoat init create firegoat.yaml and .firegoat/, install the skill
7491
7524
  npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
7492
7525
  npx firegoat feedback "<what got in the way>" tell the firegoat team
7493
7526
 
@@ -7504,16 +7537,20 @@ ${EXIT_CODES}`;
7504
7537
  var INIT_HELP = `Usage: firegoat init [--force] [--no-skills]
7505
7538
 
7506
7539
  In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
7507
- firegoat-onboarding and suggesting-eval-prompts skills into .claude/skills/ (npx skills
7540
+ firegoat-onboarding skill into .claude/skills/ (npx skills
7508
7541
  add firegoat-ai/skills; asks first in a terminal), then prints a prompt to paste into
7509
- your coding agent. In .firegoat/, git ignores runs/ and .claude/; keep test fixtures
7542
+ your coding agent. In .firegoat/, git ignores everything but fixtures/: keep test fixtures
7510
7543
  (helper scripts, sample data) in .firegoat/fixtures/ and commit them.
7511
7544
 
7512
7545
  --force replace an existing firegoat.yaml
7513
- --no-skills don't install the skills
7546
+ --no-skills don't install the skill
7514
7547
 
7515
7548
  firegoat.yaml reference:
7516
7549
  runs: 1 runs per test
7550
+ model: <name> | [<name>, ...]
7551
+ claude --model; a list runs every test runs times
7552
+ per model (n counts 1..runs x models). Unset: no
7553
+ --model, claude's default. eval --model overrides
7517
7554
  setup: <sh> | [<sh>, ...] once per eval, in this folder; what it writes to
7518
7555
  $FIREGOAT_OUT is first on the agent's PATH
7519
7556
  workdir_setup: <sh> | [...] in each fresh folder before the agent starts
@@ -7526,7 +7563,11 @@ firegoat.yaml reference:
7526
7563
  (no other $ expansion: $HOME stays literal).
7527
7564
  setup gets only the entries that use nothing but
7528
7565
  {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7529
- PATH, CLAUDE_CONFIG_DIR and FIREGOAT_* are reserved.
7566
+ PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
7567
+ ANTHROPIC_MODEL (use model:) are reserved.
7568
+ Runs go 4 at a time: give a tool with a global home
7569
+ a per-run one (or use eval --parallel 1), e.g.
7570
+ MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
7530
7571
  tests:
7531
7572
  - name: quickstart letters, digits, '.', '_', '-'; unique
7532
7573
  prompt: ... what a user would ask the agent; one concrete outcome
@@ -7565,9 +7606,9 @@ Example:
7565
7606
  - run: |
7566
7607
  ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7567
7608
 
7568
- Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7569
- var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--parallel N] [--var KEY=value]..
7570
- [--json] [--claude-bin PATH]
7609
+ Exit codes: 0 done, 2 the skill install failed, 3 usage error.`;
7610
+ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--parallel N]
7611
+ [--var KEY=value].. [--json] [--claude-bin PATH]
7571
7612
 
7572
7613
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
7573
7614
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with setup:'s
@@ -7584,7 +7625,11 @@ fixtures and setup: stay in the current folder.
7584
7625
 
7585
7626
  test.. only these tests (by name)
7586
7627
  --runs N runs per test (overrides runs: in firegoat.yaml)
7587
- --parallel N runs at once (default 4; 1 runs them one after another)
7628
+ --model NAME claude --model, replacing model: in firegoat.yaml; repeat it
7629
+ (--model a --model b) to run each test runs times per model
7630
+ --parallel N runs at once (default 4; 1 runs them one after another).
7631
+ Tools with global on-disk state collide: give each run its
7632
+ own home via env: ({{run.id}}-{{test.name}}-{{run.n}}) or use 1
7588
7633
  --var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
7589
7634
  --json the summary as JSON on stdout (or {error, exitCode} if eval
7590
7635
  stops early); progress goes to stderr
@@ -7595,8 +7640,10 @@ Output: .firegoat/runs/<runId>/
7595
7640
  git {branch, commit, dirty} (no git outside a repo)
7596
7641
  ../index.jsonl one line per eval: runId, startedAt, dir, branch,
7597
7642
  commit, passed, failed, notGraded, cost
7598
- <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7599
- end_goal, checks [{run, code}], and paths: workdir,
7643
+ <test>/<n>/run.json one run: status and reason (from the checks), model
7644
+ (asked for, or null) and claudeModel (what claude's
7645
+ init line reported), prompt, end_goal,
7646
+ checks [{run, code}], and paths: workdir,
7600
7647
  transcript, session (claude's session file), stderr,
7601
7648
  checksLog
7602
7649
  <test>/<n>/transcript.txt what the agent did: each tool call with its command,
@@ -7610,6 +7657,8 @@ yours to judge: read transcript.txt first (a few KB); the session file has every
7610
7657
  Examples:
7611
7658
  npx firegoat eval --json all tests, JSON summary on stdout
7612
7659
  npx firegoat eval quickstart --runs 3 one test, three runs
7660
+ npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
7661
+ each test on both models
7613
7662
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
7614
7663
 
7615
7664
  A run can take minutes (default timeout 600s per test).
@@ -7627,7 +7676,9 @@ title, then what you ran and what happened in --body (- reads stdin).
7627
7676
 
7628
7677
  Sent along: the firegoat version, your OS and node version, and which agent you are
7629
7678
  (claude-code/codex/cursor, from the environment). Don't include secrets: a report that
7630
- looks like it contains one is refused.
7679
+ looks like it contains one is refused. The first send registers this device and saves
7680
+ its token to ~/.config/firegoat/feedback-device.json ($XDG_CONFIG_HOME respected),
7681
+ which raises the daily report limit.
7631
7682
 
7632
7683
  Example:
7633
7684
  npx firegoat feedback "checks can't grep commands with quotes" --type bug \\
@@ -7849,7 +7900,8 @@ function plainUI(write) {
7849
7900
  printed.add(r);
7850
7901
  const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
7851
7902
  const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
7852
- write(`${label} ${r.test}#${r.n} ${r.reason} (${u})`);
7903
+ const model = r.model === null ? "" : ` [${r.model}]`;
7904
+ write(`${label} ${r.test}#${r.n}${model} ${r.reason} (${u})`);
7853
7905
  }
7854
7906
  },
7855
7907
  done: ({ runsDir, rows: all }) => {
@@ -7916,10 +7968,12 @@ function ttyUI(out, opts) {
7916
7968
  const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
7917
7969
  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` : ""}`);
7918
7970
  }
7971
+ const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
7919
7972
  rows.forEach((r, i) => {
7920
7973
  out.push(p(C.gray, " │"));
7921
7974
  const num = String(i + 1).padStart(2, "0");
7922
7975
  const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
7976
+ const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
7923
7977
  let detail = "";
7924
7978
  if (r.state === "running")
7925
7979
  detail = p(C.gray, `turn ${r.turns}`);
@@ -7927,7 +7981,7 @@ function ttyUI(out, opts) {
7927
7981
  detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
7928
7982
  else if (r.state === "error")
7929
7983
  detail = p(C.gray, usage(r));
7930
- out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
7984
+ out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)}${model} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
7931
7985
  if (r.state === "running" && r.command)
7932
7986
  out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
7933
7987
  if (r.state === "fail")
@@ -8026,6 +8080,7 @@ class UsageError extends Error {
8026
8080
  function parseArgs(args) {
8027
8081
  const opts = {
8028
8082
  names: [],
8083
+ models: [],
8029
8084
  parallel: 4,
8030
8085
  json: false,
8031
8086
  vars: {},
@@ -8059,6 +8114,13 @@ function parseArgs(args) {
8059
8114
  if (!/^\d+$/.test(v) || n < 1)
8060
8115
  throw new UsageError(`--runs must be a positive integer, got ${v}`);
8061
8116
  opts.runs = n;
8117
+ } else if (flag === "--model") {
8118
+ const v = value();
8119
+ if (v.trim() === "")
8120
+ throw new UsageError("--model needs a model name");
8121
+ if (opts.models.includes(v))
8122
+ throw new UsageError(`--model ${v} given twice`);
8123
+ opts.models.push(v);
8062
8124
  } else if (flag === "--parallel") {
8063
8125
  const v = value();
8064
8126
  const n = Number(v);
@@ -8085,6 +8147,10 @@ function findSession(configDir, sessionId) {
8085
8147
  }
8086
8148
  return null;
8087
8149
  }
8150
+ function grepsSessionJson(run) {
8151
+ const unescaped = run.replace(/\\/g, "");
8152
+ return unescaped.includes("FIREGOAT_SESSION") && /"command":"|"name":"Write"/.test(unescaped);
8153
+ }
8088
8154
  function newRunId() {
8089
8155
  const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
8090
8156
  return `${ts}-${randomBytes(2).toString("hex")}`;
@@ -8098,6 +8164,8 @@ async function runOne(opts) {
8098
8164
  const r = {
8099
8165
  test: test.name,
8100
8166
  n,
8167
+ model: opts.model,
8168
+ claudeModel: null,
8101
8169
  status: "error",
8102
8170
  reason: "",
8103
8171
  prompt: render(test.prompt, opts.vars),
@@ -8148,14 +8216,16 @@ async function runOne(opts) {
8148
8216
  env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
8149
8217
  stderr,
8150
8218
  timeoutMs: test.timeout * 1000,
8219
+ model: opts.model ?? undefined,
8151
8220
  onEvent: opts.onEvent
8152
8221
  });
8222
+ r.claudeModel = outcome.claudeModel;
8153
8223
  await new Promise((res) => stderr.end(res));
8154
8224
  if (outcome.sessionId)
8155
8225
  r.session = findSession(opts.configDir, outcome.sessionId);
8156
8226
  writeTranscript({
8157
8227
  path: r.transcript,
8158
- title: `${test.name}#${n}`,
8228
+ title: `${test.name}#${n}${opts.model ? ` (model ${opts.model})` : ""}`,
8159
8229
  prompt: r.prompt,
8160
8230
  events: outcome.events,
8161
8231
  workdir
@@ -8227,7 +8297,10 @@ async function evalCommand(args, root) {
8227
8297
  return fatal(EXIT.usage, `unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
8228
8298
  tests = tests.filter((t) => opts.names.includes(t.name));
8229
8299
  }
8300
+ if (tests.some((t) => t.checks.some((c) => grepsSessionJson(c.run))))
8301
+ console.error('firegoat eval: warning: a check greps $FIREGOAT_SESSION for session JSON ("command":" / "name":"Write"), which breaks on escaping. Grep $FIREGOAT_TRANSCRIPT instead: one "[n] $ <command>" line per shell call, "[n] Write <path>" per file write.');
8230
8302
  const runs = opts.runs ?? config.runs;
8303
+ const models = opts.models.length > 0 ? opts.models : config.models;
8231
8304
  const vars = { ...config.vars, ...opts.vars };
8232
8305
  const missing = [
8233
8306
  ...new Set(tests.flatMap((t) => [
@@ -8270,12 +8343,14 @@ async function evalCommand(args, root) {
8270
8343
  FIREGOAT_OUT: out,
8271
8344
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
8272
8345
  };
8273
- const rows = tests.flatMap((t) => Array.from({ length: runs }, (_, i) => ({
8346
+ const matrix = models.length > 0 ? models : [null];
8347
+ const rows = tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
8274
8348
  test: t.name,
8275
- n: i + 1,
8349
+ n: m * runs + i + 1,
8350
+ model,
8276
8351
  state: "queued",
8277
8352
  turns: 0
8278
- })));
8353
+ }))));
8279
8354
  if (config.setup.length > 0) {
8280
8355
  const setupStarted = Date.now();
8281
8356
  ui.setup("running");
@@ -8318,6 +8393,7 @@ async function evalCommand(args, root) {
8318
8393
  },
8319
8394
  test,
8320
8395
  n: row.n,
8396
+ model: row.model,
8321
8397
  runId,
8322
8398
  runDir,
8323
8399
  claudeBin: claudePath,
@@ -8385,7 +8461,68 @@ async function evalCommand(args, root) {
8385
8461
  import { readFileSync as readFileSync2 } from "node:fs";
8386
8462
 
8387
8463
  // node_modules/@firegoat/feedback/dist/index.js
8388
- var VERSION = "0.0.0";
8464
+ function memoryDeviceStore() {
8465
+ const tokens = {};
8466
+ return {
8467
+ load: async (baseUrl) => tokens[baseUrl],
8468
+ save: async (baseUrl, token) => {
8469
+ tokens[baseUrl] = token;
8470
+ },
8471
+ clear: async (baseUrl) => {
8472
+ delete tokens[baseUrl];
8473
+ }
8474
+ };
8475
+ }
8476
+ async function defaultDevicePath() {
8477
+ const { homedir } = await import("node:os");
8478
+ const { join } = await import("node:path");
8479
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
8480
+ return join(configHome, "firegoat", "feedback-device.json");
8481
+ }
8482
+ function fileDeviceStore(path) {
8483
+ const resolvePath = async () => path ?? await defaultDevicePath();
8484
+ async function read() {
8485
+ const { readFile } = await import("node:fs/promises");
8486
+ const file = await resolvePath();
8487
+ let text;
8488
+ try {
8489
+ text = await readFile(file, "utf8");
8490
+ } catch (err) {
8491
+ if (err.code === "ENOENT")
8492
+ return {};
8493
+ throw err;
8494
+ }
8495
+ try {
8496
+ return JSON.parse(text);
8497
+ } catch {
8498
+ throw new Error(`@firegoat/feedback: ${file} is not valid JSON; delete it to register a new device`);
8499
+ }
8500
+ }
8501
+ async function write(tokens) {
8502
+ const { mkdir, writeFile } = await import("node:fs/promises");
8503
+ const { dirname } = await import("node:path");
8504
+ const file = await resolvePath();
8505
+ await mkdir(dirname(file), { recursive: true });
8506
+ await writeFile(file, `${JSON.stringify(tokens, null, 2)}
8507
+ `, {
8508
+ mode: 384
8509
+ });
8510
+ }
8511
+ return {
8512
+ load: async (baseUrl) => (await read())[baseUrl],
8513
+ save: async (baseUrl, token) => write({ ...await read(), [baseUrl]: token }),
8514
+ clear: async (baseUrl) => {
8515
+ const tokens = await read();
8516
+ delete tokens[baseUrl];
8517
+ await write(tokens);
8518
+ }
8519
+ };
8520
+ }
8521
+ function defaultDeviceStore() {
8522
+ const isNode = Boolean(globalThis.process?.versions?.node);
8523
+ return isNode ? fileDeviceStore() : memoryDeviceStore();
8524
+ }
8525
+ var VERSION = "0.2.0";
8389
8526
  var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
8390
8527
  var TIMEOUT_MS = 15000;
8391
8528
  function envBaseUrl() {
@@ -8414,6 +8551,7 @@ class FeedbackClient {
8414
8551
  baseUrl;
8415
8552
  token;
8416
8553
  fetchImpl;
8554
+ device;
8417
8555
  constructor(options) {
8418
8556
  if (!options.domain?.trim()) {
8419
8557
  throw new Error("FeedbackClient: `domain` is required (your product's domain, e.g. example.com)");
@@ -8422,6 +8560,7 @@ class FeedbackClient {
8422
8560
  this.baseUrl = (options.baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8423
8561
  this.token = options.token;
8424
8562
  this.fetchImpl = options.fetch ?? fetch;
8563
+ this.device = options.token || options.device === false ? undefined : options.device ?? defaultDeviceStore();
8425
8564
  }
8426
8565
  async registerDevice() {
8427
8566
  const data = await this.post("/api/feedback/device", undefined);
@@ -8429,6 +8568,7 @@ class FeedbackClient {
8429
8568
  throw new FeedbackError(201, "invalid_response", "server response missing token");
8430
8569
  }
8431
8570
  this.token = data.token;
8571
+ await this.device?.save(this.baseUrl, data.token);
8432
8572
  return data.token;
8433
8573
  }
8434
8574
  async send(input) {
@@ -8441,18 +8581,43 @@ class FeedbackClient {
8441
8581
  cliVersion: `@firegoat/feedback@${VERSION}`,
8442
8582
  feedbackType: input.type
8443
8583
  };
8444
- const data = await this.post("/api/feedback", {
8584
+ const payload = {
8445
8585
  domain: this.domain,
8446
8586
  title,
8447
8587
  body: input.body ?? "",
8448
8588
  meta,
8449
8589
  maskSecrets: input.maskSecrets ?? false
8450
- });
8590
+ };
8591
+ await this.resolveToken();
8592
+ let data;
8593
+ try {
8594
+ data = await this.post("/api/feedback", payload);
8595
+ } catch (err) {
8596
+ if (!(this.device && isInvalidToken(err)))
8597
+ throw err;
8598
+ await this.device.clear(this.baseUrl);
8599
+ this.token = undefined;
8600
+ await this.registerDevice();
8601
+ data = await this.post("/api/feedback", payload);
8602
+ }
8451
8603
  if (typeof data.id !== "string" || !data.id) {
8452
8604
  throw new FeedbackError(201, "invalid_response", "server response missing id");
8453
8605
  }
8454
8606
  return { id: data.id, similarCount: data.similarCount ?? 0 };
8455
8607
  }
8608
+ async resolveToken() {
8609
+ if (this.token || !this.device)
8610
+ return;
8611
+ this.token = await this.device.load(this.baseUrl);
8612
+ if (this.token)
8613
+ return;
8614
+ try {
8615
+ await this.registerDevice();
8616
+ } catch (err) {
8617
+ if (!(err instanceof FeedbackError && err.code === "rate_limited"))
8618
+ throw err;
8619
+ }
8620
+ }
8456
8621
  async post(path, body) {
8457
8622
  const headers = {
8458
8623
  "content-type": "application/json"
@@ -8478,6 +8643,9 @@ class FeedbackClient {
8478
8643
  return data;
8479
8644
  }
8480
8645
  }
8646
+ function isInvalidToken(err) {
8647
+ return err instanceof FeedbackError && err.code === "invalid_token";
8648
+ }
8481
8649
 
8482
8650
  // src/feedback.ts
8483
8651
  var TYPES = ["bug", "feature_request", "feedback"];
@@ -8567,8 +8735,6 @@ var SKILLS_ARGV = [
8567
8735
  "firegoat-ai/skills",
8568
8736
  "--skill",
8569
8737
  "firegoat-onboarding",
8570
- "--skill",
8571
- "suggesting-eval-prompts",
8572
8738
  "--agent",
8573
8739
  "claude-code",
8574
8740
  "--copy",
@@ -8582,6 +8748,10 @@ function template(product) {
8582
8748
  # How many times to run each test.
8583
8749
  runs: 1
8584
8750
 
8751
+ # Optional: claude's --model. A list runs every test \`runs\` times per model.
8752
+ # Unset: claude's default. eval --model NAME overrides it.
8753
+ # model: [claude-haiku-5-5, claude-sonnet-5-5]
8754
+
8585
8755
  # Optional: test your local build instead of the published package.
8586
8756
  # setup: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
8587
8757
  # agent's PATH. workdir_setup: runs in each fresh folder before the agent starts.
@@ -8607,8 +8777,10 @@ runs: 1
8607
8777
  # run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
8608
8778
  # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
8609
8779
  # other $ expansion: $HOME stays literal). A test can add its own env:.
8780
+ # Runs go 4 at a time (--parallel), so give a tool with a global home (~/.mytool)
8781
+ # a per-run one, as below, or use --parallel 1.
8610
8782
  # env:
8611
- # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
8783
+ # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
8612
8784
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
8613
8785
 
8614
8786
  tests:
@@ -8710,18 +8882,18 @@ ${INIT_HELP}`);
8710
8882
  });
8711
8883
  if (r.error || r.status !== 0) {
8712
8884
  process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
8713
- console.error(`firegoat init: installing the skills failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
8885
+ console.error(`firegoat init: installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
8714
8886
  return EXIT.notGraded;
8715
8887
  }
8716
8888
  if (interactive)
8717
8889
  step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
8718
8890
  else
8719
- console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8891
+ console.log("installed skill firegoat-onboarding in .claude/skills/");
8720
8892
  const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
8721
8893
  cwd
8722
8894
  });
8723
8895
  if (ignored.status === 0)
8724
- console.error("warning: git ignores .claude/skills, so teammates and CI won't get the skills. Commit them with: git add -f .claude/skills");
8896
+ console.error("warning: git ignores .claude/skills, so teammates and CI won't get the skill. Commit them with: git add -f .claude/skills");
8725
8897
  } else if (interactive)
8726
8898
  step(3, "firegoat-onboarding skill", "skipped");
8727
8899
  if (!interactive) {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"
7
7
  },
8
8
  "devDependencies": {
9
9
  "@biomejs/biome": "^2.5.15",
10
- "@firegoat/feedback": "0.0.0",
10
+ "@firegoat/feedback": "0.2.0",
11
11
  "@types/bun": "latest",
12
12
  "typescript": "^7",
13
13
  "yaml": "^2.9.1"