firegoat 0.12.0 → 0.14.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 +14 -1
  2. package/dist/cli.js +408 -86
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -88,6 +88,7 @@ npx firegoat eval --var SHA=$(git rev-parse HEAD)
88
88
  env:
89
89
  ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # sessions/config never collide across runs
90
90
  ACME_TELEMETRY: "off"
91
+ UV_FIND_LINKS: "$FIREGOAT_OUT" # resolve the wheel setup built
91
92
  tests:
92
93
  - name: login
93
94
  prompt: Log in to acme with the CLI and print the current user.
@@ -98,13 +99,15 @@ tests:
98
99
  - run: ls "$ACME_HOME"/sessions | grep -q .
99
100
  ```
100
101
 
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.
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.
102
103
 
103
104
  ## What eval does
104
105
 
105
106
  - 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
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`.
108
+ - 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
109
  - 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.
108
111
  - 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.
109
112
  - 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.
110
113
 
@@ -119,6 +122,16 @@ Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See
119
122
  | 2 | at least one run was not graded (claude missing or logged out, crash, timeout, setup failed); wins over 1 |
120
123
  | 3 | usage or config error |
121
124
 
125
+ ## Feedback
126
+
127
+ Something in firegoat got in your way (or your agent's)? Tell us from the terminal:
128
+
129
+ ```sh
130
+ npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
131
+ ```
132
+
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.
134
+
122
135
  ## Develop
123
136
 
124
137
  ```bash
package/dist/cli.js CHANGED
@@ -6947,13 +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.12.0",
6950
+ version: "0.14.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
6958
  "@types/bun": "latest",
6958
6959
  typescript: "^7",
6959
6960
  yaml: "^2.9.1"
@@ -7159,9 +7160,11 @@ var RESERVED_ENV = new Set([
7159
7160
  "CLAUDE_CONFIG_DIR",
7160
7161
  "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7161
7162
  ]);
7162
- var RUN_PLACEHOLDERS = ["run.id", "run.n", "run.workdir", "test.name"];
7163
+ var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
7163
7164
  var EVAL_PLACEHOLDERS = ["run.id"];
7164
7165
  var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7166
+ var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
7167
+ var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
7165
7168
  function parseEnv(raw, path) {
7166
7169
  if (raw === undefined)
7167
7170
  return {};
@@ -7186,7 +7189,8 @@ function parseEnv(raw, path) {
7186
7189
  return out;
7187
7190
  }
7188
7191
  function isEvalLevel(value) {
7189
- return [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7192
+ const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
7193
+ return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7190
7194
  const name = m[1];
7191
7195
  return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7192
7196
  });
@@ -7194,8 +7198,9 @@ function isEvalLevel(value) {
7194
7198
  function renderEnv(env, vars, run) {
7195
7199
  const out = {};
7196
7200
  for (const [k, v] of Object.entries(env))
7197
- out[k] = v.replace(ANY_PLACEHOLDER, (whole, name) => {
7198
- const filled = name.startsWith("vars.") ? vars[name.slice("vars.".length)] : run[name];
7201
+ out[k] = v.replace(ENV_REF, (whole, name, braced, bare) => {
7202
+ const key = name ?? braced ?? bare;
7203
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7199
7204
  if (filled === undefined)
7200
7205
  throw new ConfigError(`env.${k}: no value for ${whole}`);
7201
7206
  return filled;
@@ -7224,13 +7229,14 @@ function loadConfig(dir) {
7224
7229
  // src/eval.ts
7225
7230
  import { randomBytes } from "node:crypto";
7226
7231
  import {
7232
+ appendFileSync,
7227
7233
  createWriteStream as createWriteStream2,
7228
7234
  existsSync as existsSync2,
7229
7235
  mkdirSync as mkdirSync2,
7230
7236
  readdirSync as readdirSync2,
7231
7237
  writeFileSync as writeFileSync3
7232
7238
  } from "node:fs";
7233
- import { basename, delimiter as delimiter2, join as join4 } from "node:path";
7239
+ import { basename as basename2, delimiter as delimiter2, join as join4 } from "node:path";
7234
7240
 
7235
7241
  // src/claude.ts
7236
7242
  import { spawn, spawnSync } from "node:child_process";
@@ -7424,6 +7430,51 @@ function ensureFiregoatDir(root) {
7424
7430
  return dir;
7425
7431
  }
7426
7432
 
7433
+ // src/git.ts
7434
+ import { spawnSync as spawnSync2 } from "node:child_process";
7435
+ import { basename, dirname } from "node:path";
7436
+ function git(dir, args) {
7437
+ const r = spawnSync2("git", args, { cwd: dir, encoding: "utf8" });
7438
+ if (r.error)
7439
+ throw r.error;
7440
+ return { code: r.status ?? -1, stdout: r.stdout.trim() };
7441
+ }
7442
+ function gitRepo(dir) {
7443
+ const r = spawnSync2("git", ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"], { cwd: dir, encoding: "utf8" });
7444
+ if (r.error?.code === "ENOENT")
7445
+ return null;
7446
+ if (r.error)
7447
+ throw r.error;
7448
+ if (r.status !== 0) {
7449
+ if (/not a git repository/.test(r.stderr))
7450
+ return null;
7451
+ throw new Error(`git rev-parse in ${dir} failed: ${r.stderr.trim()}`);
7452
+ }
7453
+ const [gitDir, commonDir] = r.stdout.trim().split(`
7454
+ `);
7455
+ if (!gitDir || !commonDir)
7456
+ throw new Error(`git rev-parse in ${dir} printed ${r.stdout}`);
7457
+ let mainCheckout = null;
7458
+ if (gitDir !== commonDir) {
7459
+ if (basename(commonDir) !== ".git")
7460
+ 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`);
7461
+ mainCheckout = dirname(commonDir);
7462
+ }
7463
+ const branch = git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]);
7464
+ const commit = git(dir, ["rev-parse", "-q", "--verify", "HEAD"]);
7465
+ const status = git(dir, ["status", "--porcelain"]);
7466
+ if (status.code !== 0)
7467
+ throw new Error(`git status in ${dir} failed`);
7468
+ return {
7469
+ info: {
7470
+ branch: branch.code === 0 ? branch.stdout : null,
7471
+ commit: commit.code === 0 ? commit.stdout : null,
7472
+ dirty: status.stdout !== ""
7473
+ },
7474
+ mainCheckout
7475
+ };
7476
+ }
7477
+
7427
7478
  // src/help.ts
7428
7479
  var EXIT_CODES = `Exit codes:
7429
7480
  0 every run's script checks passed
@@ -7438,6 +7489,7 @@ script checks.
7438
7489
 
7439
7490
  npx firegoat init create firegoat.yaml and .firegoat/, install the skills
7440
7491
  npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
7492
+ npx firegoat feedback "<what got in the way>" tell the firegoat team
7441
7493
 
7442
7494
  Typical flow for a coding agent driving firegoat:
7443
7495
  1. read npx firegoat init --help, then edit firegoat.yaml
@@ -7470,8 +7522,10 @@ firegoat.yaml reference:
7470
7522
  env: {NAME: value} env vars for the agent, and for workdir_setup and
7471
7523
  checks of the same run; values are strings and can
7472
7524
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
7473
- {{run.workdir}}, {{test.name}}. setup gets only the
7474
- entries that use nothing but {{vars.*}}/{{run.id}}.
7525
+ {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
7526
+ (no other $ expansion: $HOME stays literal).
7527
+ setup gets only the entries that use nothing but
7528
+ {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7475
7529
  PATH, CLAUDE_CONFIG_DIR and FIREGOAT_* are reserved.
7476
7530
  tests:
7477
7531
  - name: quickstart letters, digits, '.', '_', '-'; unique
@@ -7512,8 +7566,8 @@ Example:
7512
7566
  ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7513
7567
 
7514
7568
  Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7515
- var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--var KEY=value].. [--json]
7516
- [--claude-bin PATH]
7569
+ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--parallel N] [--var KEY=value]..
7570
+ [--json] [--claude-bin PATH]
7517
7571
 
7518
7572
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
7519
7573
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with setup:'s
@@ -7524,15 +7578,23 @@ with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
7524
7578
  plugins, skills or MCP servers. claude saves its session there. There is no
7525
7579
  sandbox: the agent can reach the rest of your machine, including the repo.
7526
7580
 
7581
+ In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
7582
+ live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
7583
+ fixtures and setup: stay in the current folder.
7584
+
7527
7585
  test.. only these tests (by name)
7528
7586
  --runs N runs per test (overrides runs: in firegoat.yaml)
7587
+ --parallel N runs at once (default 4; 1 runs them one after another)
7529
7588
  --var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
7530
7589
  --json the summary as JSON on stdout (or {error, exitCode} if eval
7531
7590
  stops early); progress goes to stderr
7532
7591
  --claude-bin PATH claude binary (env FIREGOAT_CLAUDE_BIN; default: claude on PATH)
7533
7592
 
7534
7593
  Output: .firegoat/runs/<runId>/
7535
- summary.json every run, plus totals
7594
+ summary.json every run, plus totals, dir (where eval ran) and
7595
+ git {branch, commit, dirty} (no git outside a repo)
7596
+ ../index.jsonl one line per eval: runId, startedAt, dir, branch,
7597
+ commit, passed, failed, notGraded, cost
7536
7598
  <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7537
7599
  end_goal, checks [{run, code}], and paths: workdir,
7538
7600
  transcript, session (claude's session file), stderr,
@@ -7553,6 +7615,25 @@ Examples:
7553
7615
  A run can take minutes (default timeout 600s per test).
7554
7616
 
7555
7617
  ${EXIT_CODES}`;
7618
+ var FEEDBACK_HELP = `Usage: firegoat feedback "<title>" [--body TEXT|-] [--type bug|feature_request|feedback]
7619
+
7620
+ Sends feedback about firegoat to the firegoat team (firegoat.ai). Coding agents: if
7621
+ firegoat confused you, failed you or cost you tokens, say so here: one concrete
7622
+ title, then what you ran and what happened in --body (- reads stdin).
7623
+
7624
+ "<title>" 8-200 characters, e.g. "eval hid claude's spend-limit error"
7625
+ --body TEXT details: commands, output, what you expected
7626
+ --type bug, feature_request or feedback (default)
7627
+
7628
+ Sent along: the firegoat version, your OS and node version, and which agent you are
7629
+ (claude-code/codex/cursor, from the environment). Don't include secrets: a report that
7630
+ looks like it contains one is refused.
7631
+
7632
+ Example:
7633
+ npx firegoat feedback "checks can't grep commands with quotes" --type bug \\
7634
+ --body "grep '"command":"...' stops at the first escaped quote"
7635
+
7636
+ Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
7556
7637
 
7557
7638
  // src/setup.ts
7558
7639
  import { createWriteStream } from "node:fs";
@@ -7670,10 +7751,14 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
7670
7751
  }
7671
7752
 
7672
7753
  // src/ui.ts
7673
- import { relative as relative2 } from "node:path";
7754
+ import { isAbsolute, relative as relative2 } from "node:path";
7674
7755
  var RAMP = "▁▂▃▄▅▆▇";
7675
7756
  var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7676
7757
  var SPIN = ["◐", "◓", "◑", "◒"];
7758
+ function shortPath(path) {
7759
+ const rel = relative2(process.cwd(), path);
7760
+ return rel.startsWith("..") || isAbsolute(rel) ? path : rel;
7761
+ }
7677
7762
  function painter(color) {
7678
7763
  return (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
7679
7764
  }
@@ -7686,6 +7771,17 @@ var C = {
7686
7771
  bold: "1",
7687
7772
  cyan: "36"
7688
7773
  };
7774
+ var BUTTERFLY = [" \\ /", " (_\\/_)", " (_/\\_)"];
7775
+ var LOGO_COLS = 10;
7776
+ function logoHeader(paint, title, right, width) {
7777
+ return BUTTERFLY.map((wing, i) => {
7778
+ const art = paint(C.ember, wing);
7779
+ if (i !== 1)
7780
+ return art;
7781
+ const used = LOGO_COLS + title.length + right.replace(ANSI, "").length;
7782
+ return `${art}${" ".repeat(LOGO_COLS - wing.length)}${paint(C.bold, title)}${" ".repeat(Math.max(2, width - used - 1))}${right}`;
7783
+ });
7784
+ }
7689
7785
  function tier(rows) {
7690
7786
  const graded = rows.filter((r) => r.state === "pass" || r.state === "fail");
7691
7787
  if (graded.length === 0)
@@ -7720,9 +7816,10 @@ function checksPassed(r) {
7720
7816
  function total(rows) {
7721
7817
  const cost = rows.reduce((s, r) => s + (r.cost ?? 0), 0);
7722
7818
  const turns = rows.reduce((s, r) => s + r.turns, 0);
7723
- const seconds = rows.reduce((s, r) => s + (r.seconds ?? 0), 0);
7724
- const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
7725
- return { cost, turns, time };
7819
+ return { cost, turns };
7820
+ }
7821
+ function duration(seconds) {
7822
+ return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
7726
7823
  }
7727
7824
  function counts(rows) {
7728
7825
  return {
@@ -7809,9 +7906,8 @@ function ttyUI(out, opts) {
7809
7906
  const c = counts(rows);
7810
7907
  const doneCount = c.pass + c.fail + c.error;
7811
7908
  const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
7812
- const title = ` \uD83E\uDD8B firegoat eval · ${opts.project}`;
7813
7909
  const out = [
7814
- `${p(C.ember, " \uD83E\uDD8B")}${title.slice(3)}${" ".repeat(Math.max(1, w - title.length - badge.length - 1))}${p(finished ? C.green : C.ember, badge)}`,
7910
+ ...logoHeader(p, `firegoat eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
7815
7911
  rule
7816
7912
  ];
7817
7913
  if (authText)
@@ -7847,22 +7943,23 @@ function ttyUI(out, opts) {
7847
7943
  if (!finished) {
7848
7944
  const track = Math.max(10, w - 34);
7849
7945
  const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
7850
- const goat = `${"·".repeat(at)}\uD83D\uDC10${"·".repeat(Math.max(0, track - at - 1))}`;
7851
- out.push(` ${p(C.ember, goat)} ${p(C.gray, `${doneCount}/${rows.length} · $${t.cost.toFixed(2)} so far`)}`);
7946
+ const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
7947
+ out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · $${t.cost.toFixed(2)} so far`)}`);
7852
7948
  return out;
7853
7949
  }
7854
7950
  const tr = tier(rows);
7855
7951
  const goated = c.pass === rows.length && rows.length > 0;
7856
7952
  const tally = `${c.pass} PASS · ${c.fail} FAIL · ${c.error} NOT GRADED`;
7857
- const right = goated ? p(C.ember, `\uD83D\uDC10 GOATED · tier ${tr}`) : tr ? `tier ${tr}` : "";
7858
- out.push(` ${goated ? p(C.green, `${rows.length}/${rows.length} runs passed their checks`) : tally}${" ".repeat(Math.max(2, w - tally.length - 22))}${right}`);
7859
- out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${t.time}`));
7860
- out.push(p(C.gray, ` runs ${relative2(process.cwd(), finished.runsDir)}/`));
7953
+ const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
7954
+ const right = goated ? `GOATED · tier ${tr}` : tr ? `tier ${tr}` : "";
7955
+ out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
7956
+ out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
7957
+ out.push(p(C.gray, ` runs ${shortPath(finished.runsDir)}/`));
7861
7958
  out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
7862
7959
  return out;
7863
7960
  };
7864
7961
  const rowsOf = (line) => {
7865
- const visible = line.replace(ANSI, "").replace(/🐐|🦋/g, "gg");
7962
+ const visible = line.replace(ANSI, "");
7866
7963
  return Math.max(1, Math.ceil(visible.length / (out.columns || 80)));
7867
7964
  };
7868
7965
  const render = () => {
@@ -7892,9 +7989,9 @@ function ttyUI(out, opts) {
7892
7989
  render();
7893
7990
  },
7894
7991
  update: render,
7895
- done: ({ runId, runsDir }) => {
7992
+ done: ({ runId, runsDir, seconds }) => {
7896
7993
  clearInterval(timer);
7897
- finished = { runId, runsDir };
7994
+ finished = { runId, runsDir, seconds };
7898
7995
  render();
7899
7996
  }
7900
7997
  };
@@ -7929,6 +8026,7 @@ class UsageError extends Error {
7929
8026
  function parseArgs(args) {
7930
8027
  const opts = {
7931
8028
  names: [],
8029
+ parallel: 4,
7932
8030
  json: false,
7933
8031
  vars: {},
7934
8032
  claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
@@ -7961,6 +8059,12 @@ function parseArgs(args) {
7961
8059
  if (!/^\d+$/.test(v) || n < 1)
7962
8060
  throw new UsageError(`--runs must be a positive integer, got ${v}`);
7963
8061
  opts.runs = n;
8062
+ } else if (flag === "--parallel") {
8063
+ const v = value();
8064
+ const n = Number(v);
8065
+ if (!/^\d+$/.test(v) || n < 1)
8066
+ throw new UsageError(`--parallel must be a positive integer, got ${v}`);
8067
+ opts.parallel = n;
7964
8068
  } else if (flag === "--claude-bin")
7965
8069
  opts.claudeBin = value();
7966
8070
  else if (a.startsWith("-"))
@@ -8020,8 +8124,9 @@ async function runOne(opts) {
8020
8124
  const env = renderEnv(test.env, opts.vars, {
8021
8125
  "run.id": opts.runId,
8022
8126
  "run.n": String(n),
8023
- "run.workdir": workdir,
8024
- "test.name": test.name
8127
+ "test.name": test.name,
8128
+ FIREGOAT_OUT: opts.out,
8129
+ FIREGOAT_WORKDIR: workdir
8025
8130
  });
8026
8131
  const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8027
8132
  if (opts.workdirSetup.length > 0) {
@@ -8105,7 +8210,7 @@ async function evalCommand(args, root) {
8105
8210
  }
8106
8211
  const say = opts.json ? console.error : console.log;
8107
8212
  const stream = opts.json ? process.stderr : process.stdout;
8108
- const ui = stream.isTTY ? ttyUI(stream, { project: basename(root), color: !process.env.NO_COLOR }) : plainUI(say);
8213
+ const ui = stream.isTTY ? ttyUI(stream, { project: basename2(root), color: !process.env.NO_COLOR }) : plainUI(say);
8109
8214
  let config;
8110
8215
  try {
8111
8216
  config = loadConfig(root);
@@ -8136,7 +8241,13 @@ async function evalCommand(args, root) {
8136
8241
  const claudePath = resolveBin(opts.claudeBin);
8137
8242
  if (!claudePath)
8138
8243
  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.`);
8139
- const firegoatDir = ensureFiregoatDir(root);
8244
+ let repo;
8245
+ try {
8246
+ repo = gitRepo(root);
8247
+ } catch (e) {
8248
+ return fatal(EXIT.usage, e.message);
8249
+ }
8250
+ const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
8140
8251
  const configDir = join4(firegoatDir, ".claude");
8141
8252
  mkdirSync2(configDir, { recursive: true });
8142
8253
  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");
@@ -8147,8 +8258,10 @@ async function evalCommand(args, root) {
8147
8258
  });
8148
8259
  if (loginProblem)
8149
8260
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
8261
+ const evalStarted = Date.now();
8150
8262
  const runId = newRunId();
8151
- const runDir = join4(firegoatDir, "runs", runId);
8263
+ const runsRoot = join4(firegoatDir, "runs");
8264
+ const runDir = join4(runsRoot, runId);
8152
8265
  const out = join4(runDir, "out");
8153
8266
  mkdirSync2(out, { recursive: true });
8154
8267
  const stepEnv = {
@@ -8172,7 +8285,7 @@ async function evalCommand(args, root) {
8172
8285
  cwd: root,
8173
8286
  env: {
8174
8287
  ...stepEnv,
8175
- ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
8288
+ ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
8176
8289
  },
8177
8290
  logPath: join4(runDir, "setup.txt")
8178
8291
  });
@@ -8182,65 +8295,271 @@ async function evalCommand(args, root) {
8182
8295
  return fatal(EXIT.notGraded, `not graded: ${error}`);
8183
8296
  }
8184
8297
  ui.runs(rows);
8185
- const results = [];
8186
- for (const test of tests)
8187
- for (let n = 1;n <= runs; n++) {
8188
- const row = rows.find((x) => x.test === test.name && x.n === n);
8189
- row.state = "running";
8190
- ui.update();
8191
- const r = await runOne({
8192
- onEvent: (e) => {
8193
- if (e.type !== "assistant")
8194
- return;
8195
- row.turns++;
8196
- const content = e.message?.content;
8197
- if (!Array.isArray(content))
8198
- return;
8199
- for (const b of content) {
8200
- const cmd = b.input?.command;
8201
- if (b.type === "tool_use" && typeof cmd === "string")
8202
- row.command = cmd.split(`
8298
+ const results = new Array(rows.length);
8299
+ const runAt = async (i) => {
8300
+ const row = rows[i];
8301
+ const test = tests.find((t) => t.name === row.test);
8302
+ row.state = "running";
8303
+ ui.update();
8304
+ const r = await runOne({
8305
+ onEvent: (e) => {
8306
+ if (e.type !== "assistant")
8307
+ return;
8308
+ row.turns++;
8309
+ const content = e.message?.content;
8310
+ if (!Array.isArray(content))
8311
+ return;
8312
+ for (const b of content) {
8313
+ const cmd = b.input?.command;
8314
+ if (b.type === "tool_use" && typeof cmd === "string")
8315
+ row.command = cmd.split(`
8203
8316
  `)[0];
8204
- }
8205
- },
8206
- test,
8207
- n,
8208
- runId,
8209
- runDir,
8210
- claudeBin: claudePath,
8211
- configDir,
8212
- workdirSetup: config.workdirSetup,
8213
- stepEnv,
8214
- vars
8215
- });
8216
- results.push(r);
8217
- Object.assign(row, {
8218
- state: r.status,
8219
- turns: r.num_turns ?? row.turns,
8220
- reason: r.reason,
8221
- checks: r.checks,
8222
- cost: r.cost,
8223
- seconds: Math.round(r.duration_ms / 1000),
8224
- command: undefined
8225
- });
8226
- ui.update();
8227
- }
8317
+ }
8318
+ },
8319
+ test,
8320
+ n: row.n,
8321
+ runId,
8322
+ runDir,
8323
+ claudeBin: claudePath,
8324
+ configDir,
8325
+ workdirSetup: config.workdirSetup,
8326
+ out,
8327
+ stepEnv,
8328
+ vars
8329
+ });
8330
+ results[i] = r;
8331
+ Object.assign(row, {
8332
+ state: r.status,
8333
+ turns: r.num_turns ?? row.turns,
8334
+ reason: r.reason,
8335
+ checks: r.checks,
8336
+ cost: r.cost,
8337
+ seconds: Math.round(r.duration_ms / 1000),
8338
+ command: undefined
8339
+ });
8340
+ ui.update();
8341
+ };
8342
+ let next = 0;
8343
+ const worker = async () => {
8344
+ while (next < rows.length)
8345
+ await runAt(next++);
8346
+ };
8347
+ await Promise.all(Array.from({ length: Math.min(opts.parallel, rows.length) }, worker));
8228
8348
  const totals = { pass: 0, fail: 0, error: 0 };
8229
8349
  for (const r of results)
8230
8350
  totals[r.status]++;
8231
- const doc = { runId, runsDir: runDir, totals, runs: results };
8351
+ const doc = {
8352
+ runId,
8353
+ runsDir: runDir,
8354
+ dir: root,
8355
+ ...repo && { git: repo.info },
8356
+ totals,
8357
+ runs: results
8358
+ };
8232
8359
  writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
8233
8360
  `);
8234
- ui.done({ runId, runsDir: runDir, rows });
8361
+ appendFileSync(join4(runsRoot, "index.jsonl"), `${JSON.stringify({
8362
+ runId,
8363
+ startedAt: new Date(evalStarted).toISOString(),
8364
+ dir: root,
8365
+ branch: repo?.info.branch ?? null,
8366
+ commit: repo?.info.commit ?? null,
8367
+ passed: totals.pass,
8368
+ failed: totals.fail,
8369
+ notGraded: totals.error,
8370
+ cost: results.reduce((sum, r) => sum + (r.cost ?? 0), 0)
8371
+ })}
8372
+ `);
8373
+ ui.done({
8374
+ runId,
8375
+ runsDir: runDir,
8376
+ rows,
8377
+ seconds: Math.round((Date.now() - evalStarted) / 1000)
8378
+ });
8235
8379
  if (opts.json)
8236
8380
  console.log(JSON.stringify(doc, null, 2));
8237
8381
  return totals.error > 0 ? EXIT.notGraded : totals.fail > 0 ? EXIT.fail : EXIT.pass;
8238
8382
  }
8239
8383
 
8384
+ // src/feedback.ts
8385
+ import { readFileSync as readFileSync2 } from "node:fs";
8386
+
8387
+ // node_modules/@firegoat/feedback/dist/index.js
8388
+ var VERSION = "0.0.0";
8389
+ var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
8390
+ var TIMEOUT_MS = 15000;
8391
+ function envBaseUrl() {
8392
+ return globalThis.process?.env?.FEEDBACK_API_URL;
8393
+ }
8394
+
8395
+ class FeedbackError extends Error {
8396
+ status;
8397
+ code;
8398
+ retryAfterSeconds;
8399
+ detected;
8400
+ constructor(status, code, message, retryAfterSeconds, detected) {
8401
+ super(message);
8402
+ this.status = status;
8403
+ this.code = code;
8404
+ this.retryAfterSeconds = retryAfterSeconds;
8405
+ this.detected = detected;
8406
+ this.name = "FeedbackError";
8407
+ }
8408
+ }
8409
+ var TITLE_MIN = 8;
8410
+ var TITLE_MAX = 200;
8411
+
8412
+ class FeedbackClient {
8413
+ domain;
8414
+ baseUrl;
8415
+ token;
8416
+ fetchImpl;
8417
+ constructor(options) {
8418
+ if (!options.domain?.trim()) {
8419
+ throw new Error("FeedbackClient: `domain` is required (your product's domain, e.g. example.com)");
8420
+ }
8421
+ this.domain = options.domain.trim();
8422
+ this.baseUrl = (options.baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8423
+ this.token = options.token;
8424
+ this.fetchImpl = options.fetch ?? fetch;
8425
+ }
8426
+ async registerDevice() {
8427
+ const data = await this.post("/api/feedback/device", undefined);
8428
+ if (typeof data.token !== "string" || !data.token) {
8429
+ throw new FeedbackError(201, "invalid_response", "server response missing token");
8430
+ }
8431
+ this.token = data.token;
8432
+ return data.token;
8433
+ }
8434
+ async send(input) {
8435
+ const title = input.title.trim();
8436
+ if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
8437
+ throw new FeedbackError(400, "invalid_request", `title must be ${TITLE_MIN}-${TITLE_MAX} characters, got ${title.length}`);
8438
+ }
8439
+ const meta = {
8440
+ ...input.meta,
8441
+ cliVersion: `@firegoat/feedback@${VERSION}`,
8442
+ feedbackType: input.type
8443
+ };
8444
+ const data = await this.post("/api/feedback", {
8445
+ domain: this.domain,
8446
+ title,
8447
+ body: input.body ?? "",
8448
+ meta,
8449
+ maskSecrets: input.maskSecrets ?? false
8450
+ });
8451
+ if (typeof data.id !== "string" || !data.id) {
8452
+ throw new FeedbackError(201, "invalid_response", "server response missing id");
8453
+ }
8454
+ return { id: data.id, similarCount: data.similarCount ?? 0 };
8455
+ }
8456
+ async post(path, body) {
8457
+ const headers = {
8458
+ "content-type": "application/json"
8459
+ };
8460
+ if (this.token)
8461
+ headers["x-feedback-token"] = this.token;
8462
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
8463
+ method: "POST",
8464
+ headers,
8465
+ body: body === undefined ? undefined : JSON.stringify(body),
8466
+ signal: AbortSignal.timeout(TIMEOUT_MS)
8467
+ });
8468
+ const text = await res.text();
8469
+ let data;
8470
+ try {
8471
+ data = JSON.parse(text);
8472
+ } catch {
8473
+ throw new FeedbackError(res.status, "invalid_response", `non-JSON response (${res.status}): ${text.slice(0, 200)}`);
8474
+ }
8475
+ if (!res.ok) {
8476
+ throw new FeedbackError(res.status, String(data.error ?? "unknown_error"), String(data.message ?? `request failed with status ${res.status}`), typeof data.retryAfterSeconds === "number" ? data.retryAfterSeconds : undefined, Array.isArray(data.detected) ? data.detected : undefined);
8477
+ }
8478
+ return data;
8479
+ }
8480
+ }
8481
+
8482
+ // src/feedback.ts
8483
+ var TYPES = ["bug", "feature_request", "feedback"];
8484
+ function agentName() {
8485
+ if (process.env.CLAUDECODE)
8486
+ return "claude-code";
8487
+ if (process.env.CODEX_HOME || process.env.CODEX_SANDBOX)
8488
+ return "codex";
8489
+ if (process.env.CURSOR_TRACE_ID)
8490
+ return "cursor";
8491
+ return;
8492
+ }
8493
+ async function feedbackCommand(args) {
8494
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
8495
+ console.log(FEEDBACK_HELP);
8496
+ return args.length === 0 ? EXIT.usage : EXIT.pass;
8497
+ }
8498
+ const titleParts = [];
8499
+ let body;
8500
+ let type = "feedback";
8501
+ for (let i = 0;i < args.length; i++) {
8502
+ const a = args[i];
8503
+ const value = () => {
8504
+ const v = args[++i];
8505
+ if (v === undefined)
8506
+ throw new Error(`${a} needs a value`);
8507
+ return v;
8508
+ };
8509
+ try {
8510
+ if (a === "--body") {
8511
+ const v = value();
8512
+ body = v === "-" ? readFileSync2(0, "utf8") : v;
8513
+ } else if (a === "--type") {
8514
+ const v = value();
8515
+ if (!TYPES.includes(v))
8516
+ throw new Error(`--type must be one of ${TYPES.join(", ")}, got ${v}`);
8517
+ type = v;
8518
+ } else if (a.startsWith("-"))
8519
+ throw new Error(`unknown flag: ${a}`);
8520
+ else
8521
+ titleParts.push(a);
8522
+ } catch (e) {
8523
+ console.error(`firegoat feedback: ${e.message}`);
8524
+ return EXIT.usage;
8525
+ }
8526
+ }
8527
+ const title = titleParts.join(" ").trim();
8528
+ if (title.length < 8 || title.length > 200) {
8529
+ console.error(`firegoat feedback: the title must be 8-200 characters (got ${title.length})`);
8530
+ return EXIT.usage;
8531
+ }
8532
+ const client = new FeedbackClient({ domain: "firegoat.ai" });
8533
+ try {
8534
+ const agent = agentName();
8535
+ const { id, similarCount } = await client.send({
8536
+ title,
8537
+ body: body?.trim() ? `${body.trim()}
8538
+
8539
+ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
8540
+ type,
8541
+ meta: {
8542
+ ...agent ? { agent } : {},
8543
+ os: process.platform,
8544
+ nodeVersion: process.version
8545
+ }
8546
+ });
8547
+ console.log(`sent ${type} ${id} to the firegoat team${similarCount > 0 ? ` (${similarCount} similar report${similarCount === 1 ? "" : "s"} already)` : ""}. thanks.`);
8548
+ return EXIT.pass;
8549
+ } catch (e) {
8550
+ if (e instanceof FeedbackError) {
8551
+ const hint = e.code === "possible_secret" ? " Remove the secret from the title/body and send again." : e.code === "rate_limited" && e.retryAfterSeconds ? ` Try again in ${e.retryAfterSeconds}s.` : "";
8552
+ console.error(`firegoat feedback: ${e.code}: ${e.message}.${hint}`);
8553
+ return e.code === "invalid_request" || e.code === "possible_secret" ? EXIT.usage : EXIT.notGraded;
8554
+ }
8555
+ throw e;
8556
+ }
8557
+ }
8558
+
8240
8559
  // src/init.ts
8241
- import { spawnSync as spawnSync2 } from "node:child_process";
8560
+ import { spawnSync as spawnSync3 } from "node:child_process";
8242
8561
  import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
8243
- import { basename as basename2, join as join5 } from "node:path";
8562
+ import { basename as basename3, join as join5 } from "node:path";
8244
8563
  import { createInterface } from "node:readline/promises";
8245
8564
  var SKILLS_ARGV = [
8246
8565
  "skills",
@@ -8286,9 +8605,11 @@ runs: 1
8286
8605
 
8287
8606
  # Optional: env vars for the agent (and for workdir_setup and checks of the same
8288
8607
  # run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
8289
- # {{run.id}}, {{run.n}}, {{run.workdir}}, {{test.name}}. A test can add its own env:.
8608
+ # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
8609
+ # other $ expansion: $HOME stays literal). A test can add its own env:.
8290
8610
  # env:
8291
8611
  # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
8612
+ # UV_FIND_LINKS: "$FIREGOAT_OUT"
8292
8613
 
8293
8614
  tests:
8294
8615
  - name: quickstart
@@ -8317,7 +8638,7 @@ var CLIPBOARD = [
8317
8638
  ];
8318
8639
  function copyToClipboard(text) {
8319
8640
  return CLIPBOARD.some(([cmd, args]) => {
8320
- const r = spawnSync2(cmd, args, { input: text });
8641
+ const r = spawnSync3(cmd, args, { input: text });
8321
8642
  return !r.error && r.status === 0;
8322
8643
  });
8323
8644
  }
@@ -8356,16 +8677,15 @@ ${INIT_HELP}`);
8356
8677
  const rule = paint("90", ` ${"─".repeat(width - 2)}`);
8357
8678
  const step = (n, label, status) => console.log(` ${paint("32", "●")} ${paint("90", String(n).padStart(2, "0"))} ${label.padEnd(28)}${paint("90", status)}`);
8358
8679
  if (interactive) {
8359
- const title = " \uD83E\uDD8B firegoat init";
8360
- const tag = "Get agent-ready.";
8361
- console.log(`${paint("38;5;208", " \uD83E\uDD8B")} firegoat init${" ".repeat(Math.max(2, width - title.length - tag.length - 2))}${paint("90", tag)}`);
8680
+ for (const line of logoHeader(paint, "firegoat init", paint("90", "Get agent-ready."), width))
8681
+ console.log(line);
8362
8682
  console.log(rule);
8363
8683
  }
8364
8684
  ensureFiregoatDir(cwd);
8365
8685
  const configPath = join5(cwd, CONFIG_FILE);
8366
8686
  const keep = existsSync3(configPath) && !force;
8367
8687
  if (!keep)
8368
- writeFileSync4(configPath, template(basename2(cwd)));
8688
+ writeFileSync4(configPath, template(basename3(cwd)));
8369
8689
  if (interactive) {
8370
8690
  step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
8371
8691
  console.log(paint("90", " │"));
@@ -8382,7 +8702,7 @@ ${INIT_HELP}`);
8382
8702
  console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
8383
8703
  const env = { ...process.env };
8384
8704
  delete env.npm_config_package;
8385
- const r = spawnSync2("npx", ["-y", ...SKILLS_ARGV], {
8705
+ const r = spawnSync3("npx", ["-y", ...SKILLS_ARGV], {
8386
8706
  cwd,
8387
8707
  env,
8388
8708
  stdio: "pipe",
@@ -8397,7 +8717,7 @@ ${INIT_HELP}`);
8397
8717
  step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
8398
8718
  else
8399
8719
  console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8400
- const ignored = spawnSync2("git", ["check-ignore", "-q", ".claude/skills"], {
8720
+ const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
8401
8721
  cwd
8402
8722
  });
8403
8723
  if (ignored.status === 0)
@@ -8436,6 +8756,8 @@ async function main(argv) {
8436
8756
  return initCommand(rest, process.cwd());
8437
8757
  case "eval":
8438
8758
  return evalCommand(rest, process.cwd());
8759
+ case "feedback":
8760
+ return feedbackCommand(rest);
8439
8761
  default:
8440
8762
  console.error(`unknown command: ${cmd}
8441
8763
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.12.0",
3
+ "version": "0.14.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
11
  "@types/bun": "latest",
11
12
  "typescript": "^7",
12
13
  "yaml": "^2.9.1"