firegoat 0.13.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 +23 -6
  2. package/dist/cli.js +322 -57
  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,12 +82,28 @@ 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
88
103
  env:
89
104
  ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # sessions/config never collide across runs
90
105
  ACME_TELEMETRY: "off"
106
+ UV_FIND_LINKS: "$FIREGOAT_OUT" # resolve the wheel setup built
91
107
  tests:
92
108
  - name: login
93
109
  prompt: Log in to acme with the CLI and print the current user.
@@ -98,18 +114,19 @@ tests:
98
114
  - run: ls "$ACME_HOME"/sessions | grep -q .
99
115
  ```
100
116
 
101
- `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), `{{run.workdir}}`, `{{test.name}}`. `setup` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}` and `{{run.id}}`. 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:`.
102
118
 
103
119
  ## What eval does
104
120
 
105
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.
106
- - 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`.
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.
107
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.
108
- - 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.
109
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.
110
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.
111
128
 
112
- `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`.
113
130
 
114
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).
115
132
 
@@ -128,7 +145,7 @@ Something in firegoat got in your way (or your agent's)? Tell us from the termin
128
145
  npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
129
146
  ```
130
147
 
131
- 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.
132
149
 
133
150
  ## Develop
134
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.13.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)
@@ -7160,9 +7179,11 @@ var RESERVED_ENV = new Set([
7160
7179
  "CLAUDE_CONFIG_DIR",
7161
7180
  "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7162
7181
  ]);
7163
- var RUN_PLACEHOLDERS = ["run.id", "run.n", "run.workdir", "test.name"];
7182
+ var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
7164
7183
  var EVAL_PLACEHOLDERS = ["run.id"];
7165
7184
  var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7185
+ var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
7186
+ var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
7166
7187
  function parseEnv(raw, path) {
7167
7188
  if (raw === undefined)
7168
7189
  return {};
@@ -7173,6 +7194,8 @@ function parseEnv(raw, path) {
7173
7194
  const p = `${path}.${k}`;
7174
7195
  if (!VAR_KEY.test(k))
7175
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)");
7176
7199
  if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7177
7200
  fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7178
7201
  if (typeof v !== "string")
@@ -7187,7 +7210,8 @@ function parseEnv(raw, path) {
7187
7210
  return out;
7188
7211
  }
7189
7212
  function isEvalLevel(value) {
7190
- return [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7213
+ const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
7214
+ return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7191
7215
  const name = m[1];
7192
7216
  return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7193
7217
  });
@@ -7195,8 +7219,9 @@ function isEvalLevel(value) {
7195
7219
  function renderEnv(env, vars, run) {
7196
7220
  const out = {};
7197
7221
  for (const [k, v] of Object.entries(env))
7198
- out[k] = v.replace(ANY_PLACEHOLDER, (whole, name) => {
7199
- const filled = name.startsWith("vars.") ? vars[name.slice("vars.".length)] : run[name];
7222
+ out[k] = v.replace(ENV_REF, (whole, name, braced, bare) => {
7223
+ const key = name ?? braced ?? bare;
7224
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7200
7225
  if (filled === undefined)
7201
7226
  throw new ConfigError(`env.${k}: no value for ${whole}`);
7202
7227
  return filled;
@@ -7225,13 +7250,14 @@ function loadConfig(dir) {
7225
7250
  // src/eval.ts
7226
7251
  import { randomBytes } from "node:crypto";
7227
7252
  import {
7253
+ appendFileSync,
7228
7254
  createWriteStream as createWriteStream2,
7229
7255
  existsSync as existsSync2,
7230
7256
  mkdirSync as mkdirSync2,
7231
7257
  readdirSync as readdirSync2,
7232
7258
  writeFileSync as writeFileSync3
7233
7259
  } from "node:fs";
7234
- import { basename, delimiter as delimiter2, join as join4 } from "node:path";
7260
+ import { basename as basename2, delimiter as delimiter2, join as join4 } from "node:path";
7235
7261
 
7236
7262
  // src/claude.ts
7237
7263
  import { spawn, spawnSync } from "node:child_process";
@@ -7293,7 +7319,12 @@ async function runClaude(opts) {
7293
7319
  let pending = "";
7294
7320
  const exited = await run({
7295
7321
  cmd: opts.bin,
7296
- args: ["-p", opts.prompt, ...CLAUDE_FLAGS],
7322
+ args: [
7323
+ "-p",
7324
+ opts.prompt,
7325
+ ...CLAUDE_FLAGS,
7326
+ ...opts.model === undefined ? [] : ["--model", opts.model]
7327
+ ],
7297
7328
  cwd: opts.cwd,
7298
7329
  env: opts.env,
7299
7330
  timeoutMs: opts.timeoutMs,
@@ -7317,6 +7348,7 @@ async function runClaude(opts) {
7317
7348
  if (exited.error)
7318
7349
  return {
7319
7350
  events: [],
7351
+ claudeModel: null,
7320
7352
  ok: false,
7321
7353
  reason: `could not start ${opts.bin}: ${exited.error.message}`
7322
7354
  };
@@ -7334,15 +7366,18 @@ async function runClaude(opts) {
7334
7366
  `)[0]?.slice(0, 300);
7335
7367
  return {
7336
7368
  events,
7369
+ claudeModel: null,
7337
7370
  ok: false,
7338
7371
  reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7339
7372
  };
7340
7373
  }
7341
7374
  const sessionId = String(init.session_id);
7375
+ const claudeModel = typeof init.model === "string" ? init.model : null;
7342
7376
  const result = events.findLast((e) => e?.type === "result");
7343
7377
  if (exited.timedOut)
7344
7378
  return {
7345
7379
  events,
7380
+ claudeModel,
7346
7381
  ok: false,
7347
7382
  reason: `timed out after ${opts.timeoutMs / 1000}s`,
7348
7383
  result,
@@ -7351,6 +7386,7 @@ async function runClaude(opts) {
7351
7386
  if (!result)
7352
7387
  return {
7353
7388
  events,
7389
+ claudeModel,
7354
7390
  ok: false,
7355
7391
  reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7356
7392
  sessionId
@@ -7358,12 +7394,13 @@ async function runClaude(opts) {
7358
7394
  if (result.is_error === true || result.subtype !== "success")
7359
7395
  return {
7360
7396
  events,
7397
+ claudeModel,
7361
7398
  ok: false,
7362
7399
  reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7363
7400
  result,
7364
7401
  sessionId
7365
7402
  };
7366
- return { events, ok: true, result, sessionId };
7403
+ return { events, claudeModel, ok: true, result, sessionId };
7367
7404
  }
7368
7405
  async function runShell(opts) {
7369
7406
  opts.log.write(`$ ${opts.run}
@@ -7415,8 +7452,9 @@ var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7415
7452
  // src/firegoat-dir.ts
7416
7453
  import { mkdirSync, writeFileSync } from "node:fs";
7417
7454
  import { join } from "node:path";
7418
- var GITIGNORE = `runs/
7419
- .claude/
7455
+ var GITIGNORE = `*
7456
+ !fixtures/
7457
+ !fixtures/**
7420
7458
  `;
7421
7459
  function ensureFiregoatDir(root) {
7422
7460
  const dir = join(root, ".firegoat");
@@ -7425,6 +7463,51 @@ function ensureFiregoatDir(root) {
7425
7463
  return dir;
7426
7464
  }
7427
7465
 
7466
+ // src/git.ts
7467
+ import { spawnSync as spawnSync2 } from "node:child_process";
7468
+ import { basename, dirname } from "node:path";
7469
+ function git(dir, args) {
7470
+ const r = spawnSync2("git", args, { cwd: dir, encoding: "utf8" });
7471
+ if (r.error)
7472
+ throw r.error;
7473
+ return { code: r.status ?? -1, stdout: r.stdout.trim() };
7474
+ }
7475
+ function gitRepo(dir) {
7476
+ const r = spawnSync2("git", ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"], { cwd: dir, encoding: "utf8" });
7477
+ if (r.error?.code === "ENOENT")
7478
+ return null;
7479
+ if (r.error)
7480
+ throw r.error;
7481
+ if (r.status !== 0) {
7482
+ if (/not a git repository/.test(r.stderr))
7483
+ return null;
7484
+ throw new Error(`git rev-parse in ${dir} failed: ${r.stderr.trim()}`);
7485
+ }
7486
+ const [gitDir, commonDir] = r.stdout.trim().split(`
7487
+ `);
7488
+ if (!gitDir || !commonDir)
7489
+ throw new Error(`git rev-parse in ${dir} printed ${r.stdout}`);
7490
+ let mainCheckout = null;
7491
+ if (gitDir !== commonDir) {
7492
+ if (basename(commonDir) !== ".git")
7493
+ throw new Error(`${dir} is a linked git worktree, but its common git dir ${commonDir} is not a checkout's .git (a bare repo?), so there is no main checkout to keep .firegoat/runs in`);
7494
+ mainCheckout = dirname(commonDir);
7495
+ }
7496
+ const branch = git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]);
7497
+ const commit = git(dir, ["rev-parse", "-q", "--verify", "HEAD"]);
7498
+ const status = git(dir, ["status", "--porcelain"]);
7499
+ if (status.code !== 0)
7500
+ throw new Error(`git status in ${dir} failed`);
7501
+ return {
7502
+ info: {
7503
+ branch: branch.code === 0 ? branch.stdout : null,
7504
+ commit: commit.code === 0 ? commit.stdout : null,
7505
+ dirty: status.stdout !== ""
7506
+ },
7507
+ mainCheckout
7508
+ };
7509
+ }
7510
+
7428
7511
  // src/help.ts
7429
7512
  var EXIT_CODES = `Exit codes:
7430
7513
  0 every run's script checks passed
@@ -7437,7 +7520,7 @@ function topHelp(version) {
7437
7520
  folder, with none of your claude config; firegoat saves the session and runs your
7438
7521
  script checks.
7439
7522
 
7440
- npx firegoat init create firegoat.yaml and .firegoat/, install the skills
7523
+ npx firegoat init create firegoat.yaml and .firegoat/, install the skill
7441
7524
  npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
7442
7525
  npx firegoat feedback "<what got in the way>" tell the firegoat team
7443
7526
 
@@ -7454,16 +7537,20 @@ ${EXIT_CODES}`;
7454
7537
  var INIT_HELP = `Usage: firegoat init [--force] [--no-skills]
7455
7538
 
7456
7539
  In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
7457
- firegoat-onboarding and suggesting-eval-prompts skills into .claude/skills/ (npx skills
7540
+ firegoat-onboarding skill into .claude/skills/ (npx skills
7458
7541
  add firegoat-ai/skills; asks first in a terminal), then prints a prompt to paste into
7459
- 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
7460
7543
  (helper scripts, sample data) in .firegoat/fixtures/ and commit them.
7461
7544
 
7462
7545
  --force replace an existing firegoat.yaml
7463
- --no-skills don't install the skills
7546
+ --no-skills don't install the skill
7464
7547
 
7465
7548
  firegoat.yaml reference:
7466
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
7467
7554
  setup: <sh> | [<sh>, ...] once per eval, in this folder; what it writes to
7468
7555
  $FIREGOAT_OUT is first on the agent's PATH
7469
7556
  workdir_setup: <sh> | [...] in each fresh folder before the agent starts
@@ -7472,9 +7559,15 @@ firegoat.yaml reference:
7472
7559
  env: {NAME: value} env vars for the agent, and for workdir_setup and
7473
7560
  checks of the same run; values are strings and can
7474
7561
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
7475
- {{run.workdir}}, {{test.name}}. setup gets only the
7476
- entries that use nothing but {{vars.*}}/{{run.id}}.
7477
- PATH, CLAUDE_CONFIG_DIR and FIREGOAT_* are reserved.
7562
+ {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
7563
+ (no other $ expansion: $HOME stays literal).
7564
+ setup gets only the entries that use nothing but
7565
+ {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
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}}"
7478
7571
  tests:
7479
7572
  - name: quickstart letters, digits, '.', '_', '-'; unique
7480
7573
  prompt: ... what a user would ask the agent; one concrete outcome
@@ -7513,9 +7606,9 @@ Example:
7513
7606
  - run: |
7514
7607
  ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7515
7608
 
7516
- Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7517
- var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--parallel N] [--var KEY=value]..
7518
- [--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]
7519
7612
 
7520
7613
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
7521
7614
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with setup:'s
@@ -7526,18 +7619,31 @@ with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
7526
7619
  plugins, skills or MCP servers. claude saves its session there. There is no
7527
7620
  sandbox: the agent can reach the rest of your machine, including the repo.
7528
7621
 
7622
+ In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
7623
+ live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
7624
+ fixtures and setup: stay in the current folder.
7625
+
7529
7626
  test.. only these tests (by name)
7530
7627
  --runs N runs per test (overrides runs: in firegoat.yaml)
7531
- --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
7532
7633
  --var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
7533
7634
  --json the summary as JSON on stdout (or {error, exitCode} if eval
7534
7635
  stops early); progress goes to stderr
7535
7636
  --claude-bin PATH claude binary (env FIREGOAT_CLAUDE_BIN; default: claude on PATH)
7536
7637
 
7537
7638
  Output: .firegoat/runs/<runId>/
7538
- summary.json every run, plus totals
7539
- <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7540
- end_goal, checks [{run, code}], and paths: workdir,
7639
+ summary.json every run, plus totals, dir (where eval ran) and
7640
+ git {branch, commit, dirty} (no git outside a repo)
7641
+ ../index.jsonl one line per eval: runId, startedAt, dir, branch,
7642
+ commit, passed, failed, notGraded, cost
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,
7541
7647
  transcript, session (claude's session file), stderr,
7542
7648
  checksLog
7543
7649
  <test>/<n>/transcript.txt what the agent did: each tool call with its command,
@@ -7551,6 +7657,8 @@ yours to judge: read transcript.txt first (a few KB); the session file has every
7551
7657
  Examples:
7552
7658
  npx firegoat eval --json all tests, JSON summary on stdout
7553
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
7554
7662
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
7555
7663
 
7556
7664
  A run can take minutes (default timeout 600s per test).
@@ -7568,7 +7676,9 @@ title, then what you ran and what happened in --body (- reads stdin).
7568
7676
 
7569
7677
  Sent along: the firegoat version, your OS and node version, and which agent you are
7570
7678
  (claude-code/codex/cursor, from the environment). Don't include secrets: a report that
7571
- 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.
7572
7682
 
7573
7683
  Example:
7574
7684
  npx firegoat feedback "checks can't grep commands with quotes" --type bug \\
@@ -7692,10 +7802,14 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
7692
7802
  }
7693
7803
 
7694
7804
  // src/ui.ts
7695
- import { relative as relative2 } from "node:path";
7805
+ import { isAbsolute, relative as relative2 } from "node:path";
7696
7806
  var RAMP = "▁▂▃▄▅▆▇";
7697
7807
  var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7698
7808
  var SPIN = ["◐", "◓", "◑", "◒"];
7809
+ function shortPath(path) {
7810
+ const rel = relative2(process.cwd(), path);
7811
+ return rel.startsWith("..") || isAbsolute(rel) ? path : rel;
7812
+ }
7699
7813
  function painter(color) {
7700
7814
  return (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
7701
7815
  }
@@ -7786,7 +7900,8 @@ function plainUI(write) {
7786
7900
  printed.add(r);
7787
7901
  const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
7788
7902
  const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
7789
- 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})`);
7790
7905
  }
7791
7906
  },
7792
7907
  done: ({ runsDir, rows: all }) => {
@@ -7853,10 +7968,12 @@ function ttyUI(out, opts) {
7853
7968
  const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
7854
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` : ""}`);
7855
7970
  }
7971
+ const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
7856
7972
  rows.forEach((r, i) => {
7857
7973
  out.push(p(C.gray, " │"));
7858
7974
  const num = String(i + 1).padStart(2, "0");
7859
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))}` : "";
7860
7977
  let detail = "";
7861
7978
  if (r.state === "running")
7862
7979
  detail = p(C.gray, `turn ${r.turns}`);
@@ -7864,7 +7981,7 @@ function ttyUI(out, opts) {
7864
7981
  detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
7865
7982
  else if (r.state === "error")
7866
7983
  detail = p(C.gray, usage(r));
7867
- 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}`);
7868
7985
  if (r.state === "running" && r.command)
7869
7986
  out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
7870
7987
  if (r.state === "fail")
@@ -7891,7 +8008,7 @@ function ttyUI(out, opts) {
7891
8008
  const right = goated ? `GOATED · tier ${tr}` : tr ? `tier ${tr}` : "";
7892
8009
  out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
7893
8010
  out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
7894
- out.push(p(C.gray, ` runs ${relative2(process.cwd(), finished.runsDir)}/`));
8011
+ out.push(p(C.gray, ` runs ${shortPath(finished.runsDir)}/`));
7895
8012
  out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
7896
8013
  return out;
7897
8014
  };
@@ -7963,6 +8080,7 @@ class UsageError extends Error {
7963
8080
  function parseArgs(args) {
7964
8081
  const opts = {
7965
8082
  names: [],
8083
+ models: [],
7966
8084
  parallel: 4,
7967
8085
  json: false,
7968
8086
  vars: {},
@@ -7996,6 +8114,13 @@ function parseArgs(args) {
7996
8114
  if (!/^\d+$/.test(v) || n < 1)
7997
8115
  throw new UsageError(`--runs must be a positive integer, got ${v}`);
7998
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);
7999
8124
  } else if (flag === "--parallel") {
8000
8125
  const v = value();
8001
8126
  const n = Number(v);
@@ -8022,6 +8147,10 @@ function findSession(configDir, sessionId) {
8022
8147
  }
8023
8148
  return null;
8024
8149
  }
8150
+ function grepsSessionJson(run) {
8151
+ const unescaped = run.replace(/\\/g, "");
8152
+ return unescaped.includes("FIREGOAT_SESSION") && /"command":"|"name":"Write"/.test(unescaped);
8153
+ }
8025
8154
  function newRunId() {
8026
8155
  const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
8027
8156
  return `${ts}-${randomBytes(2).toString("hex")}`;
@@ -8035,6 +8164,8 @@ async function runOne(opts) {
8035
8164
  const r = {
8036
8165
  test: test.name,
8037
8166
  n,
8167
+ model: opts.model,
8168
+ claudeModel: null,
8038
8169
  status: "error",
8039
8170
  reason: "",
8040
8171
  prompt: render(test.prompt, opts.vars),
@@ -8061,8 +8192,9 @@ async function runOne(opts) {
8061
8192
  const env = renderEnv(test.env, opts.vars, {
8062
8193
  "run.id": opts.runId,
8063
8194
  "run.n": String(n),
8064
- "run.workdir": workdir,
8065
- "test.name": test.name
8195
+ "test.name": test.name,
8196
+ FIREGOAT_OUT: opts.out,
8197
+ FIREGOAT_WORKDIR: workdir
8066
8198
  });
8067
8199
  const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8068
8200
  if (opts.workdirSetup.length > 0) {
@@ -8084,14 +8216,16 @@ async function runOne(opts) {
8084
8216
  env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
8085
8217
  stderr,
8086
8218
  timeoutMs: test.timeout * 1000,
8219
+ model: opts.model ?? undefined,
8087
8220
  onEvent: opts.onEvent
8088
8221
  });
8222
+ r.claudeModel = outcome.claudeModel;
8089
8223
  await new Promise((res) => stderr.end(res));
8090
8224
  if (outcome.sessionId)
8091
8225
  r.session = findSession(opts.configDir, outcome.sessionId);
8092
8226
  writeTranscript({
8093
8227
  path: r.transcript,
8094
- title: `${test.name}#${n}`,
8228
+ title: `${test.name}#${n}${opts.model ? ` (model ${opts.model})` : ""}`,
8095
8229
  prompt: r.prompt,
8096
8230
  events: outcome.events,
8097
8231
  workdir
@@ -8146,7 +8280,7 @@ async function evalCommand(args, root) {
8146
8280
  }
8147
8281
  const say = opts.json ? console.error : console.log;
8148
8282
  const stream = opts.json ? process.stderr : process.stdout;
8149
- const ui = stream.isTTY ? ttyUI(stream, { project: basename(root), color: !process.env.NO_COLOR }) : plainUI(say);
8283
+ const ui = stream.isTTY ? ttyUI(stream, { project: basename2(root), color: !process.env.NO_COLOR }) : plainUI(say);
8150
8284
  let config;
8151
8285
  try {
8152
8286
  config = loadConfig(root);
@@ -8163,7 +8297,10 @@ async function evalCommand(args, root) {
8163
8297
  return fatal(EXIT.usage, `unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
8164
8298
  tests = tests.filter((t) => opts.names.includes(t.name));
8165
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.');
8166
8302
  const runs = opts.runs ?? config.runs;
8303
+ const models = opts.models.length > 0 ? opts.models : config.models;
8167
8304
  const vars = { ...config.vars, ...opts.vars };
8168
8305
  const missing = [
8169
8306
  ...new Set(tests.flatMap((t) => [
@@ -8177,7 +8314,13 @@ async function evalCommand(args, root) {
8177
8314
  const claudePath = resolveBin(opts.claudeBin);
8178
8315
  if (!claudePath)
8179
8316
  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.`);
8180
- const firegoatDir = ensureFiregoatDir(root);
8317
+ let repo;
8318
+ try {
8319
+ repo = gitRepo(root);
8320
+ } catch (e) {
8321
+ return fatal(EXIT.usage, e.message);
8322
+ }
8323
+ const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
8181
8324
  const configDir = join4(firegoatDir, ".claude");
8182
8325
  mkdirSync2(configDir, { recursive: true });
8183
8326
  ui.auth(process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login");
@@ -8190,7 +8333,8 @@ async function evalCommand(args, root) {
8190
8333
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
8191
8334
  const evalStarted = Date.now();
8192
8335
  const runId = newRunId();
8193
- const runDir = join4(firegoatDir, "runs", runId);
8336
+ const runsRoot = join4(firegoatDir, "runs");
8337
+ const runDir = join4(runsRoot, runId);
8194
8338
  const out = join4(runDir, "out");
8195
8339
  mkdirSync2(out, { recursive: true });
8196
8340
  const stepEnv = {
@@ -8199,12 +8343,14 @@ async function evalCommand(args, root) {
8199
8343
  FIREGOAT_OUT: out,
8200
8344
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
8201
8345
  };
8202
- 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) => ({
8203
8348
  test: t.name,
8204
- n: i + 1,
8349
+ n: m * runs + i + 1,
8350
+ model,
8205
8351
  state: "queued",
8206
8352
  turns: 0
8207
- })));
8353
+ }))));
8208
8354
  if (config.setup.length > 0) {
8209
8355
  const setupStarted = Date.now();
8210
8356
  ui.setup("running");
@@ -8214,7 +8360,7 @@ async function evalCommand(args, root) {
8214
8360
  cwd: root,
8215
8361
  env: {
8216
8362
  ...stepEnv,
8217
- ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
8363
+ ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
8218
8364
  },
8219
8365
  logPath: join4(runDir, "setup.txt")
8220
8366
  });
@@ -8247,11 +8393,13 @@ async function evalCommand(args, root) {
8247
8393
  },
8248
8394
  test,
8249
8395
  n: row.n,
8396
+ model: row.model,
8250
8397
  runId,
8251
8398
  runDir,
8252
8399
  claudeBin: claudePath,
8253
8400
  configDir,
8254
8401
  workdirSetup: config.workdirSetup,
8402
+ out,
8255
8403
  stepEnv,
8256
8404
  vars
8257
8405
  });
@@ -8276,8 +8424,27 @@ async function evalCommand(args, root) {
8276
8424
  const totals = { pass: 0, fail: 0, error: 0 };
8277
8425
  for (const r of results)
8278
8426
  totals[r.status]++;
8279
- const doc = { runId, runsDir: runDir, totals, runs: results };
8427
+ const doc = {
8428
+ runId,
8429
+ runsDir: runDir,
8430
+ dir: root,
8431
+ ...repo && { git: repo.info },
8432
+ totals,
8433
+ runs: results
8434
+ };
8280
8435
  writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
8436
+ `);
8437
+ appendFileSync(join4(runsRoot, "index.jsonl"), `${JSON.stringify({
8438
+ runId,
8439
+ startedAt: new Date(evalStarted).toISOString(),
8440
+ dir: root,
8441
+ branch: repo?.info.branch ?? null,
8442
+ commit: repo?.info.commit ?? null,
8443
+ passed: totals.pass,
8444
+ failed: totals.fail,
8445
+ notGraded: totals.error,
8446
+ cost: results.reduce((sum, r) => sum + (r.cost ?? 0), 0)
8447
+ })}
8281
8448
  `);
8282
8449
  ui.done({
8283
8450
  runId,
@@ -8294,7 +8461,68 @@ async function evalCommand(args, root) {
8294
8461
  import { readFileSync as readFileSync2 } from "node:fs";
8295
8462
 
8296
8463
  // node_modules/@firegoat/feedback/dist/index.js
8297
- 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";
8298
8526
  var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
8299
8527
  var TIMEOUT_MS = 15000;
8300
8528
  function envBaseUrl() {
@@ -8323,6 +8551,7 @@ class FeedbackClient {
8323
8551
  baseUrl;
8324
8552
  token;
8325
8553
  fetchImpl;
8554
+ device;
8326
8555
  constructor(options) {
8327
8556
  if (!options.domain?.trim()) {
8328
8557
  throw new Error("FeedbackClient: `domain` is required (your product's domain, e.g. example.com)");
@@ -8331,6 +8560,7 @@ class FeedbackClient {
8331
8560
  this.baseUrl = (options.baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8332
8561
  this.token = options.token;
8333
8562
  this.fetchImpl = options.fetch ?? fetch;
8563
+ this.device = options.token || options.device === false ? undefined : options.device ?? defaultDeviceStore();
8334
8564
  }
8335
8565
  async registerDevice() {
8336
8566
  const data = await this.post("/api/feedback/device", undefined);
@@ -8338,6 +8568,7 @@ class FeedbackClient {
8338
8568
  throw new FeedbackError(201, "invalid_response", "server response missing token");
8339
8569
  }
8340
8570
  this.token = data.token;
8571
+ await this.device?.save(this.baseUrl, data.token);
8341
8572
  return data.token;
8342
8573
  }
8343
8574
  async send(input) {
@@ -8350,18 +8581,43 @@ class FeedbackClient {
8350
8581
  cliVersion: `@firegoat/feedback@${VERSION}`,
8351
8582
  feedbackType: input.type
8352
8583
  };
8353
- const data = await this.post("/api/feedback", {
8584
+ const payload = {
8354
8585
  domain: this.domain,
8355
8586
  title,
8356
8587
  body: input.body ?? "",
8357
8588
  meta,
8358
8589
  maskSecrets: input.maskSecrets ?? false
8359
- });
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
+ }
8360
8603
  if (typeof data.id !== "string" || !data.id) {
8361
8604
  throw new FeedbackError(201, "invalid_response", "server response missing id");
8362
8605
  }
8363
8606
  return { id: data.id, similarCount: data.similarCount ?? 0 };
8364
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
+ }
8365
8621
  async post(path, body) {
8366
8622
  const headers = {
8367
8623
  "content-type": "application/json"
@@ -8387,6 +8643,9 @@ class FeedbackClient {
8387
8643
  return data;
8388
8644
  }
8389
8645
  }
8646
+ function isInvalidToken(err) {
8647
+ return err instanceof FeedbackError && err.code === "invalid_token";
8648
+ }
8390
8649
 
8391
8650
  // src/feedback.ts
8392
8651
  var TYPES = ["bug", "feature_request", "feedback"];
@@ -8466,9 +8725,9 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
8466
8725
  }
8467
8726
 
8468
8727
  // src/init.ts
8469
- import { spawnSync as spawnSync2 } from "node:child_process";
8728
+ import { spawnSync as spawnSync3 } from "node:child_process";
8470
8729
  import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
8471
- import { basename as basename2, join as join5 } from "node:path";
8730
+ import { basename as basename3, join as join5 } from "node:path";
8472
8731
  import { createInterface } from "node:readline/promises";
8473
8732
  var SKILLS_ARGV = [
8474
8733
  "skills",
@@ -8476,8 +8735,6 @@ var SKILLS_ARGV = [
8476
8735
  "firegoat-ai/skills",
8477
8736
  "--skill",
8478
8737
  "firegoat-onboarding",
8479
- "--skill",
8480
- "suggesting-eval-prompts",
8481
8738
  "--agent",
8482
8739
  "claude-code",
8483
8740
  "--copy",
@@ -8491,6 +8748,10 @@ function template(product) {
8491
8748
  # How many times to run each test.
8492
8749
  runs: 1
8493
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
+
8494
8755
  # Optional: test your local build instead of the published package.
8495
8756
  # setup: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
8496
8757
  # agent's PATH. workdir_setup: runs in each fresh folder before the agent starts.
@@ -8514,9 +8775,13 @@ runs: 1
8514
8775
 
8515
8776
  # Optional: env vars for the agent (and for workdir_setup and checks of the same
8516
8777
  # run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
8517
- # {{run.id}}, {{run.n}}, {{run.workdir}}, {{test.name}}. A test can add its own env:.
8778
+ # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
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.
8518
8782
  # env:
8519
- # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
8783
+ # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
8784
+ # UV_FIND_LINKS: "$FIREGOAT_OUT"
8520
8785
 
8521
8786
  tests:
8522
8787
  - name: quickstart
@@ -8545,7 +8810,7 @@ var CLIPBOARD = [
8545
8810
  ];
8546
8811
  function copyToClipboard(text) {
8547
8812
  return CLIPBOARD.some(([cmd, args]) => {
8548
- const r = spawnSync2(cmd, args, { input: text });
8813
+ const r = spawnSync3(cmd, args, { input: text });
8549
8814
  return !r.error && r.status === 0;
8550
8815
  });
8551
8816
  }
@@ -8592,7 +8857,7 @@ ${INIT_HELP}`);
8592
8857
  const configPath = join5(cwd, CONFIG_FILE);
8593
8858
  const keep = existsSync3(configPath) && !force;
8594
8859
  if (!keep)
8595
- writeFileSync4(configPath, template(basename2(cwd)));
8860
+ writeFileSync4(configPath, template(basename3(cwd)));
8596
8861
  if (interactive) {
8597
8862
  step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
8598
8863
  console.log(paint("90", " │"));
@@ -8609,7 +8874,7 @@ ${INIT_HELP}`);
8609
8874
  console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
8610
8875
  const env = { ...process.env };
8611
8876
  delete env.npm_config_package;
8612
- const r = spawnSync2("npx", ["-y", ...SKILLS_ARGV], {
8877
+ const r = spawnSync3("npx", ["-y", ...SKILLS_ARGV], {
8613
8878
  cwd,
8614
8879
  env,
8615
8880
  stdio: "pipe",
@@ -8617,18 +8882,18 @@ ${INIT_HELP}`);
8617
8882
  });
8618
8883
  if (r.error || r.status !== 0) {
8619
8884
  process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
8620
- 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(" ")}`);
8621
8886
  return EXIT.notGraded;
8622
8887
  }
8623
8888
  if (interactive)
8624
8889
  step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
8625
8890
  else
8626
- console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8627
- const ignored = spawnSync2("git", ["check-ignore", "-q", ".claude/skills"], {
8891
+ console.log("installed skill firegoat-onboarding in .claude/skills/");
8892
+ const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
8628
8893
  cwd
8629
8894
  });
8630
8895
  if (ignored.status === 0)
8631
- 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");
8632
8897
  } else if (interactive)
8633
8898
  step(3, "firegoat-onboarding skill", "skipped");
8634
8899
  if (!interactive) {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.13.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"