firegoat 0.11.0 → 0.13.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 +31 -1
  2. package/dist/cli.js +395 -71
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -82,14 +82,34 @@ tests:
82
82
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
83
83
  ```
84
84
 
85
+ ### Env vars for the agent: a home dir per run, a feature flag
86
+
87
+ ```yaml
88
+ env:
89
+ ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # sessions/config never collide across runs
90
+ ACME_TELEMETRY: "off"
91
+ tests:
92
+ - name: login
93
+ prompt: Log in to acme with the CLI and print the current user.
94
+ end_goal: The agent completes acme's login flow and prints the user.
95
+ env:
96
+ ACME_BETA_LOGIN: "1" # this test only, on top of env:
97
+ checks:
98
+ - run: ls "$ACME_HOME"/sessions | grep -q .
99
+ ```
100
+
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
+
85
103
  ## What eval does
86
104
 
87
105
  - 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.
88
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`.
107
+ - 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.
89
109
  - 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.
90
110
  - 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.
91
111
 
92
- `firegoat.yaml` keys: `runs`, `setup`, `workdir_setup`, `vars`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
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`.
93
113
 
94
114
  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).
95
115
 
@@ -100,6 +120,16 @@ Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See
100
120
  | 2 | at least one run was not graded (claude missing or logged out, crash, timeout, setup failed); wins over 1 |
101
121
  | 3 | usage or config error |
102
122
 
123
+ ## Feedback
124
+
125
+ Something in firegoat got in your way (or your agent's)? Tell us from the terminal:
126
+
127
+ ```sh
128
+ npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
129
+ ```
130
+
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.
132
+
103
133
  ## Develop
104
134
 
105
135
  ```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.11.0",
6950
+ version: "0.13.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"
@@ -7072,11 +7073,19 @@ function parseConfig(text) {
7072
7073
  }
7073
7074
  if (!isRecord(raw))
7074
7075
  fail("(root)", "must be a mapping");
7075
- onlyKeys(raw, "(root)", ["runs", "setup", "workdir_setup", "vars", "tests"]);
7076
+ onlyKeys(raw, "(root)", [
7077
+ "runs",
7078
+ "setup",
7079
+ "workdir_setup",
7080
+ "vars",
7081
+ "env",
7082
+ "tests"
7083
+ ]);
7076
7084
  const runs = posInt(raw.runs, "runs", 1);
7077
7085
  const setup = commands(raw.setup, "setup");
7078
7086
  const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
7079
7087
  const vars = parseVars(raw.vars);
7088
+ const env = parseEnv(raw.env, "env");
7080
7089
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7081
7090
  fail("tests", "must be a non-empty list");
7082
7091
  const names = new Set;
@@ -7084,7 +7093,7 @@ function parseConfig(text) {
7084
7093
  const p = `tests[${i}]`;
7085
7094
  if (!isRecord(t))
7086
7095
  fail(p, "must be a mapping");
7087
- onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout"]);
7096
+ onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout", "env"]);
7088
7097
  const name = str(t.name, `${p}.name`);
7089
7098
  if (!NAME.test(name))
7090
7099
  fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
@@ -7107,7 +7116,8 @@ function parseConfig(text) {
7107
7116
  prompt: str(t.prompt, `${p}.prompt`),
7108
7117
  end_goal: str(t.end_goal, `${p}.end_goal`),
7109
7118
  checks,
7110
- timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
7119
+ timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
7120
+ env: { ...env, ...parseEnv(t.env, `${p}.env`) }
7111
7121
  };
7112
7122
  });
7113
7123
  return {
@@ -7115,6 +7125,7 @@ function parseConfig(text) {
7115
7125
  setup,
7116
7126
  workdirSetup,
7117
7127
  vars,
7128
+ env,
7118
7129
  tests
7119
7130
  };
7120
7131
  }
@@ -7144,6 +7155,54 @@ function parseVars(raw) {
7144
7155
  }
7145
7156
  return out;
7146
7157
  }
7158
+ var RESERVED_ENV = new Set([
7159
+ "PATH",
7160
+ "CLAUDE_CONFIG_DIR",
7161
+ "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7162
+ ]);
7163
+ var RUN_PLACEHOLDERS = ["run.id", "run.n", "run.workdir", "test.name"];
7164
+ var EVAL_PLACEHOLDERS = ["run.id"];
7165
+ var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7166
+ function parseEnv(raw, path) {
7167
+ if (raw === undefined)
7168
+ return {};
7169
+ if (!isRecord(raw))
7170
+ fail(path, "must be a mapping of NAME to value");
7171
+ const out = {};
7172
+ for (const [k, v] of Object.entries(raw)) {
7173
+ const p = `${path}.${k}`;
7174
+ if (!VAR_KEY.test(k))
7175
+ fail(p, "name must be letters, digits and _, not starting with a digit");
7176
+ if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7177
+ fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7178
+ if (typeof v !== "string")
7179
+ fail(p, "must be a string (quote numbers and booleans)");
7180
+ for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7181
+ const name = m[1];
7182
+ if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7183
+ fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7184
+ }
7185
+ out[k] = v;
7186
+ }
7187
+ return out;
7188
+ }
7189
+ function isEvalLevel(value) {
7190
+ return [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7191
+ const name = m[1];
7192
+ return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7193
+ });
7194
+ }
7195
+ function renderEnv(env, vars, run) {
7196
+ const out = {};
7197
+ 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];
7200
+ if (filled === undefined)
7201
+ throw new ConfigError(`env.${k}: no value for ${whole}`);
7202
+ return filled;
7203
+ });
7204
+ return out;
7205
+ }
7147
7206
  var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
7148
7207
  function varRefs(text) {
7149
7208
  return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
@@ -7186,9 +7245,14 @@ var CLAUDE_FLAGS = [
7186
7245
  "",
7187
7246
  "--disable-slash-commands"
7188
7247
  ];
7189
- function claudeEnv(configDir) {
7248
+ var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7249
+ function hostEnv() {
7250
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k)));
7251
+ }
7252
+ function claudeEnv(configDir, env = {}) {
7190
7253
  return {
7191
- ...process.env,
7254
+ ...hostEnv(),
7255
+ ...env,
7192
7256
  CLAUDE_CONFIG_DIR: configDir,
7193
7257
  CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
7194
7258
  };
@@ -7375,6 +7439,7 @@ script checks.
7375
7439
 
7376
7440
  npx firegoat init create firegoat.yaml and .firegoat/, install the skills
7377
7441
  npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
7442
+ npx firegoat feedback "<what got in the way>" tell the firegoat team
7378
7443
 
7379
7444
  Typical flow for a coding agent driving firegoat:
7380
7445
  1. read npx firegoat init --help, then edit firegoat.yaml
@@ -7404,6 +7469,12 @@ firegoat.yaml reference:
7404
7469
  workdir_setup: <sh> | [...] in each fresh folder before the agent starts
7405
7470
  vars: {KEY: value} defaults for {{vars.KEY}} in prompts and checks;
7406
7471
  --var KEY=value overrides
7472
+ env: {NAME: value} env vars for the agent, and for workdir_setup and
7473
+ checks of the same run; values are strings and can
7474
+ 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.
7407
7478
  tests:
7408
7479
  - name: quickstart letters, digits, '.', '_', '-'; unique
7409
7480
  prompt: ... what a user would ask the agent; one concrete outcome
@@ -7412,6 +7483,7 @@ firegoat.yaml reference:
7412
7483
  checks: optional shell commands, run in the agent's folder
7413
7484
  - run: ... afterwards; exit 0 = pass; they set eval's exit code
7414
7485
  timeout: 600 optional, seconds
7486
+ env: {NAME: value} optional, on top of the top-level env:
7415
7487
 
7416
7488
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7417
7489
  .firegoat/fixtures/ and copy them in setup or workdir_setup. setup, workdir_setup and checks get the vars as env vars,
@@ -7420,6 +7492,11 @@ firegoat.yaml reference:
7420
7492
  each output), and
7421
7493
  $FIREGOAT_SESSION (claude's full session file, JSON lines).
7422
7494
 
7495
+ The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
7496
+ (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
7497
+ npm_command, INIT_CWD; e.g. npm_config_yes=true from npx -y) are removed from
7498
+ every env firegoat starts, unless env: sets them.
7499
+
7423
7500
  Example:
7424
7501
  setup: npm pack --pack-destination "$FIREGOAT_OUT"
7425
7502
  workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
@@ -7437,8 +7514,8 @@ Example:
7437
7514
  ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7438
7515
 
7439
7516
  Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7440
- var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--var KEY=value].. [--json]
7441
- [--claude-bin PATH]
7517
+ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--parallel N] [--var KEY=value]..
7518
+ [--json] [--claude-bin PATH]
7442
7519
 
7443
7520
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
7444
7521
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with setup:'s
@@ -7451,6 +7528,7 @@ sandbox: the agent can reach the rest of your machine, including the repo.
7451
7528
 
7452
7529
  test.. only these tests (by name)
7453
7530
  --runs N runs per test (overrides runs: in firegoat.yaml)
7531
+ --parallel N runs at once (default 4; 1 runs them one after another)
7454
7532
  --var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
7455
7533
  --json the summary as JSON on stdout (or {error, exitCode} if eval
7456
7534
  stops early); progress goes to stderr
@@ -7478,6 +7556,25 @@ Examples:
7478
7556
  A run can take minutes (default timeout 600s per test).
7479
7557
 
7480
7558
  ${EXIT_CODES}`;
7559
+ var FEEDBACK_HELP = `Usage: firegoat feedback "<title>" [--body TEXT|-] [--type bug|feature_request|feedback]
7560
+
7561
+ Sends feedback about firegoat to the firegoat team (firegoat.ai). Coding agents: if
7562
+ firegoat confused you, failed you or cost you tokens, say so here: one concrete
7563
+ title, then what you ran and what happened in --body (- reads stdin).
7564
+
7565
+ "<title>" 8-200 characters, e.g. "eval hid claude's spend-limit error"
7566
+ --body TEXT details: commands, output, what you expected
7567
+ --type bug, feature_request or feedback (default)
7568
+
7569
+ Sent along: the firegoat version, your OS and node version, and which agent you are
7570
+ (claude-code/codex/cursor, from the environment). Don't include secrets: a report that
7571
+ looks like it contains one is refused.
7572
+
7573
+ Example:
7574
+ npx firegoat feedback "checks can't grep commands with quotes" --type bug \\
7575
+ --body "grep '"command":"...' stops at the first escaped quote"
7576
+
7577
+ Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
7481
7578
 
7482
7579
  // src/setup.ts
7483
7580
  import { createWriteStream } from "node:fs";
@@ -7611,6 +7708,17 @@ var C = {
7611
7708
  bold: "1",
7612
7709
  cyan: "36"
7613
7710
  };
7711
+ var BUTTERFLY = [" \\ /", " (_\\/_)", " (_/\\_)"];
7712
+ var LOGO_COLS = 10;
7713
+ function logoHeader(paint, title, right, width) {
7714
+ return BUTTERFLY.map((wing, i) => {
7715
+ const art = paint(C.ember, wing);
7716
+ if (i !== 1)
7717
+ return art;
7718
+ const used = LOGO_COLS + title.length + right.replace(ANSI, "").length;
7719
+ return `${art}${" ".repeat(LOGO_COLS - wing.length)}${paint(C.bold, title)}${" ".repeat(Math.max(2, width - used - 1))}${right}`;
7720
+ });
7721
+ }
7614
7722
  function tier(rows) {
7615
7723
  const graded = rows.filter((r) => r.state === "pass" || r.state === "fail");
7616
7724
  if (graded.length === 0)
@@ -7645,9 +7753,10 @@ function checksPassed(r) {
7645
7753
  function total(rows) {
7646
7754
  const cost = rows.reduce((s, r) => s + (r.cost ?? 0), 0);
7647
7755
  const turns = rows.reduce((s, r) => s + r.turns, 0);
7648
- const seconds = rows.reduce((s, r) => s + (r.seconds ?? 0), 0);
7649
- const time = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
7650
- return { cost, turns, time };
7756
+ return { cost, turns };
7757
+ }
7758
+ function duration(seconds) {
7759
+ return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
7651
7760
  }
7652
7761
  function counts(rows) {
7653
7762
  return {
@@ -7734,9 +7843,8 @@ function ttyUI(out, opts) {
7734
7843
  const c = counts(rows);
7735
7844
  const doneCount = c.pass + c.fail + c.error;
7736
7845
  const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
7737
- const title = ` \uD83E\uDD8B firegoat eval · ${opts.project}`;
7738
7846
  const out = [
7739
- `${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)}`,
7847
+ ...logoHeader(p, `firegoat eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
7740
7848
  rule
7741
7849
  ];
7742
7850
  if (authText)
@@ -7772,22 +7880,23 @@ function ttyUI(out, opts) {
7772
7880
  if (!finished) {
7773
7881
  const track = Math.max(10, w - 34);
7774
7882
  const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
7775
- const goat = `${"·".repeat(at)}\uD83D\uDC10${"·".repeat(Math.max(0, track - at - 1))}`;
7776
- out.push(` ${p(C.ember, goat)} ${p(C.gray, `${doneCount}/${rows.length} · $${t.cost.toFixed(2)} so far`)}`);
7883
+ const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
7884
+ out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · $${t.cost.toFixed(2)} so far`)}`);
7777
7885
  return out;
7778
7886
  }
7779
7887
  const tr = tier(rows);
7780
7888
  const goated = c.pass === rows.length && rows.length > 0;
7781
7889
  const tally = `${c.pass} PASS · ${c.fail} FAIL · ${c.error} NOT GRADED`;
7782
- const right = goated ? p(C.ember, `\uD83D\uDC10 GOATED · tier ${tr}`) : tr ? `tier ${tr}` : "";
7783
- out.push(` ${goated ? p(C.green, `${rows.length}/${rows.length} runs passed their checks`) : tally}${" ".repeat(Math.max(2, w - tally.length - 22))}${right}`);
7784
- out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${t.time}`));
7890
+ const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
7891
+ const right = goated ? `GOATED · tier ${tr}` : tr ? `tier ${tr}` : "";
7892
+ 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
+ out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
7785
7894
  out.push(p(C.gray, ` runs ${relative2(process.cwd(), finished.runsDir)}/`));
7786
7895
  out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
7787
7896
  return out;
7788
7897
  };
7789
7898
  const rowsOf = (line) => {
7790
- const visible = line.replace(ANSI, "").replace(/🐐|🦋/g, "gg");
7899
+ const visible = line.replace(ANSI, "");
7791
7900
  return Math.max(1, Math.ceil(visible.length / (out.columns || 80)));
7792
7901
  };
7793
7902
  const render = () => {
@@ -7817,9 +7926,9 @@ function ttyUI(out, opts) {
7817
7926
  render();
7818
7927
  },
7819
7928
  update: render,
7820
- done: ({ runId, runsDir }) => {
7929
+ done: ({ runId, runsDir, seconds }) => {
7821
7930
  clearInterval(timer);
7822
- finished = { runId, runsDir };
7931
+ finished = { runId, runsDir, seconds };
7823
7932
  render();
7824
7933
  }
7825
7934
  };
@@ -7854,6 +7963,7 @@ class UsageError extends Error {
7854
7963
  function parseArgs(args) {
7855
7964
  const opts = {
7856
7965
  names: [],
7966
+ parallel: 4,
7857
7967
  json: false,
7858
7968
  vars: {},
7859
7969
  claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
@@ -7886,6 +7996,12 @@ function parseArgs(args) {
7886
7996
  if (!/^\d+$/.test(v) || n < 1)
7887
7997
  throw new UsageError(`--runs must be a positive integer, got ${v}`);
7888
7998
  opts.runs = n;
7999
+ } else if (flag === "--parallel") {
8000
+ const v = value();
8001
+ const n = Number(v);
8002
+ if (!/^\d+$/.test(v) || n < 1)
8003
+ throw new UsageError(`--parallel must be a positive integer, got ${v}`);
8004
+ opts.parallel = n;
7889
8005
  } else if (flag === "--claude-bin")
7890
8006
  opts.claudeBin = value();
7891
8007
  else if (a.startsWith("-"))
@@ -7942,7 +8058,13 @@ async function runOne(opts) {
7942
8058
  `);
7943
8059
  return r;
7944
8060
  };
7945
- const stepEnv = { ...opts.stepEnv, FIREGOAT_WORKDIR: workdir };
8061
+ const env = renderEnv(test.env, opts.vars, {
8062
+ "run.id": opts.runId,
8063
+ "run.n": String(n),
8064
+ "run.workdir": workdir,
8065
+ "test.name": test.name
8066
+ });
8067
+ const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
7946
8068
  if (opts.workdirSetup.length > 0) {
7947
8069
  const error = await runSteps({
7948
8070
  what: "workdir_setup",
@@ -7959,7 +8081,7 @@ async function runOne(opts) {
7959
8081
  bin: opts.claudeBin,
7960
8082
  prompt: r.prompt,
7961
8083
  cwd: workdir,
7962
- env: { ...claudeEnv(opts.configDir), PATH: opts.stepEnv.PATH },
8084
+ env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
7963
8085
  stderr,
7964
8086
  timeoutMs: test.timeout * 1000,
7965
8087
  onEvent: opts.onEvent
@@ -8044,7 +8166,11 @@ async function evalCommand(args, root) {
8044
8166
  const runs = opts.runs ?? config.runs;
8045
8167
  const vars = { ...config.vars, ...opts.vars };
8046
8168
  const missing = [
8047
- ...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(varRefs).filter((k) => !(k in vars)))
8169
+ ...new Set(tests.flatMap((t) => [
8170
+ t.prompt,
8171
+ ...t.checks.map((c) => c.run),
8172
+ ...Object.values(t.env)
8173
+ ]).flatMap(varRefs).filter((k) => !(k in vars)))
8048
8174
  ];
8049
8175
  if (missing.length > 0)
8050
8176
  return fatal(EXIT.usage, `no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
@@ -8062,12 +8188,13 @@ async function evalCommand(args, root) {
8062
8188
  });
8063
8189
  if (loginProblem)
8064
8190
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
8191
+ const evalStarted = Date.now();
8065
8192
  const runId = newRunId();
8066
8193
  const runDir = join4(firegoatDir, "runs", runId);
8067
8194
  const out = join4(runDir, "out");
8068
8195
  mkdirSync2(out, { recursive: true });
8069
8196
  const stepEnv = {
8070
- ...process.env,
8197
+ ...hostEnv(),
8071
8198
  ...vars,
8072
8199
  FIREGOAT_OUT: out,
8073
8200
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
@@ -8085,7 +8212,10 @@ async function evalCommand(args, root) {
8085
8212
  what: "setup",
8086
8213
  commands: config.setup,
8087
8214
  cwd: root,
8088
- env: stepEnv,
8215
+ env: {
8216
+ ...stepEnv,
8217
+ ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
8218
+ },
8089
8219
  logPath: join4(runDir, "setup.txt")
8090
8220
  });
8091
8221
  const secs = Math.round((Date.now() - setupStarted) / 1000);
@@ -8094,60 +8224,247 @@ async function evalCommand(args, root) {
8094
8224
  return fatal(EXIT.notGraded, `not graded: ${error}`);
8095
8225
  }
8096
8226
  ui.runs(rows);
8097
- const results = [];
8098
- for (const test of tests)
8099
- for (let n = 1;n <= runs; n++) {
8100
- const row = rows.find((x) => x.test === test.name && x.n === n);
8101
- row.state = "running";
8102
- ui.update();
8103
- const r = await runOne({
8104
- onEvent: (e) => {
8105
- if (e.type !== "assistant")
8106
- return;
8107
- row.turns++;
8108
- const content = e.message?.content;
8109
- if (!Array.isArray(content))
8110
- return;
8111
- for (const b of content) {
8112
- const cmd = b.input?.command;
8113
- if (b.type === "tool_use" && typeof cmd === "string")
8114
- row.command = cmd.split(`
8227
+ const results = new Array(rows.length);
8228
+ const runAt = async (i) => {
8229
+ const row = rows[i];
8230
+ const test = tests.find((t) => t.name === row.test);
8231
+ row.state = "running";
8232
+ ui.update();
8233
+ const r = await runOne({
8234
+ onEvent: (e) => {
8235
+ if (e.type !== "assistant")
8236
+ return;
8237
+ row.turns++;
8238
+ const content = e.message?.content;
8239
+ if (!Array.isArray(content))
8240
+ return;
8241
+ for (const b of content) {
8242
+ const cmd = b.input?.command;
8243
+ if (b.type === "tool_use" && typeof cmd === "string")
8244
+ row.command = cmd.split(`
8115
8245
  `)[0];
8116
- }
8117
- },
8118
- test,
8119
- n,
8120
- runDir,
8121
- claudeBin: claudePath,
8122
- configDir,
8123
- workdirSetup: config.workdirSetup,
8124
- stepEnv,
8125
- vars
8126
- });
8127
- results.push(r);
8128
- Object.assign(row, {
8129
- state: r.status,
8130
- turns: r.num_turns ?? row.turns,
8131
- reason: r.reason,
8132
- checks: r.checks,
8133
- cost: r.cost,
8134
- seconds: Math.round(r.duration_ms / 1000),
8135
- command: undefined
8136
- });
8137
- ui.update();
8138
- }
8246
+ }
8247
+ },
8248
+ test,
8249
+ n: row.n,
8250
+ runId,
8251
+ runDir,
8252
+ claudeBin: claudePath,
8253
+ configDir,
8254
+ workdirSetup: config.workdirSetup,
8255
+ stepEnv,
8256
+ vars
8257
+ });
8258
+ results[i] = r;
8259
+ Object.assign(row, {
8260
+ state: r.status,
8261
+ turns: r.num_turns ?? row.turns,
8262
+ reason: r.reason,
8263
+ checks: r.checks,
8264
+ cost: r.cost,
8265
+ seconds: Math.round(r.duration_ms / 1000),
8266
+ command: undefined
8267
+ });
8268
+ ui.update();
8269
+ };
8270
+ let next = 0;
8271
+ const worker = async () => {
8272
+ while (next < rows.length)
8273
+ await runAt(next++);
8274
+ };
8275
+ await Promise.all(Array.from({ length: Math.min(opts.parallel, rows.length) }, worker));
8139
8276
  const totals = { pass: 0, fail: 0, error: 0 };
8140
8277
  for (const r of results)
8141
8278
  totals[r.status]++;
8142
8279
  const doc = { runId, runsDir: runDir, totals, runs: results };
8143
8280
  writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
8144
8281
  `);
8145
- ui.done({ runId, runsDir: runDir, rows });
8282
+ ui.done({
8283
+ runId,
8284
+ runsDir: runDir,
8285
+ rows,
8286
+ seconds: Math.round((Date.now() - evalStarted) / 1000)
8287
+ });
8146
8288
  if (opts.json)
8147
8289
  console.log(JSON.stringify(doc, null, 2));
8148
8290
  return totals.error > 0 ? EXIT.notGraded : totals.fail > 0 ? EXIT.fail : EXIT.pass;
8149
8291
  }
8150
8292
 
8293
+ // src/feedback.ts
8294
+ import { readFileSync as readFileSync2 } from "node:fs";
8295
+
8296
+ // node_modules/@firegoat/feedback/dist/index.js
8297
+ var VERSION = "0.0.0";
8298
+ var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
8299
+ var TIMEOUT_MS = 15000;
8300
+ function envBaseUrl() {
8301
+ return globalThis.process?.env?.FEEDBACK_API_URL;
8302
+ }
8303
+
8304
+ class FeedbackError extends Error {
8305
+ status;
8306
+ code;
8307
+ retryAfterSeconds;
8308
+ detected;
8309
+ constructor(status, code, message, retryAfterSeconds, detected) {
8310
+ super(message);
8311
+ this.status = status;
8312
+ this.code = code;
8313
+ this.retryAfterSeconds = retryAfterSeconds;
8314
+ this.detected = detected;
8315
+ this.name = "FeedbackError";
8316
+ }
8317
+ }
8318
+ var TITLE_MIN = 8;
8319
+ var TITLE_MAX = 200;
8320
+
8321
+ class FeedbackClient {
8322
+ domain;
8323
+ baseUrl;
8324
+ token;
8325
+ fetchImpl;
8326
+ constructor(options) {
8327
+ if (!options.domain?.trim()) {
8328
+ throw new Error("FeedbackClient: `domain` is required (your product's domain, e.g. example.com)");
8329
+ }
8330
+ this.domain = options.domain.trim();
8331
+ this.baseUrl = (options.baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
8332
+ this.token = options.token;
8333
+ this.fetchImpl = options.fetch ?? fetch;
8334
+ }
8335
+ async registerDevice() {
8336
+ const data = await this.post("/api/feedback/device", undefined);
8337
+ if (typeof data.token !== "string" || !data.token) {
8338
+ throw new FeedbackError(201, "invalid_response", "server response missing token");
8339
+ }
8340
+ this.token = data.token;
8341
+ return data.token;
8342
+ }
8343
+ async send(input) {
8344
+ const title = input.title.trim();
8345
+ if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
8346
+ throw new FeedbackError(400, "invalid_request", `title must be ${TITLE_MIN}-${TITLE_MAX} characters, got ${title.length}`);
8347
+ }
8348
+ const meta = {
8349
+ ...input.meta,
8350
+ cliVersion: `@firegoat/feedback@${VERSION}`,
8351
+ feedbackType: input.type
8352
+ };
8353
+ const data = await this.post("/api/feedback", {
8354
+ domain: this.domain,
8355
+ title,
8356
+ body: input.body ?? "",
8357
+ meta,
8358
+ maskSecrets: input.maskSecrets ?? false
8359
+ });
8360
+ if (typeof data.id !== "string" || !data.id) {
8361
+ throw new FeedbackError(201, "invalid_response", "server response missing id");
8362
+ }
8363
+ return { id: data.id, similarCount: data.similarCount ?? 0 };
8364
+ }
8365
+ async post(path, body) {
8366
+ const headers = {
8367
+ "content-type": "application/json"
8368
+ };
8369
+ if (this.token)
8370
+ headers["x-feedback-token"] = this.token;
8371
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
8372
+ method: "POST",
8373
+ headers,
8374
+ body: body === undefined ? undefined : JSON.stringify(body),
8375
+ signal: AbortSignal.timeout(TIMEOUT_MS)
8376
+ });
8377
+ const text = await res.text();
8378
+ let data;
8379
+ try {
8380
+ data = JSON.parse(text);
8381
+ } catch {
8382
+ throw new FeedbackError(res.status, "invalid_response", `non-JSON response (${res.status}): ${text.slice(0, 200)}`);
8383
+ }
8384
+ if (!res.ok) {
8385
+ 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);
8386
+ }
8387
+ return data;
8388
+ }
8389
+ }
8390
+
8391
+ // src/feedback.ts
8392
+ var TYPES = ["bug", "feature_request", "feedback"];
8393
+ function agentName() {
8394
+ if (process.env.CLAUDECODE)
8395
+ return "claude-code";
8396
+ if (process.env.CODEX_HOME || process.env.CODEX_SANDBOX)
8397
+ return "codex";
8398
+ if (process.env.CURSOR_TRACE_ID)
8399
+ return "cursor";
8400
+ return;
8401
+ }
8402
+ async function feedbackCommand(args) {
8403
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
8404
+ console.log(FEEDBACK_HELP);
8405
+ return args.length === 0 ? EXIT.usage : EXIT.pass;
8406
+ }
8407
+ const titleParts = [];
8408
+ let body;
8409
+ let type = "feedback";
8410
+ for (let i = 0;i < args.length; i++) {
8411
+ const a = args[i];
8412
+ const value = () => {
8413
+ const v = args[++i];
8414
+ if (v === undefined)
8415
+ throw new Error(`${a} needs a value`);
8416
+ return v;
8417
+ };
8418
+ try {
8419
+ if (a === "--body") {
8420
+ const v = value();
8421
+ body = v === "-" ? readFileSync2(0, "utf8") : v;
8422
+ } else if (a === "--type") {
8423
+ const v = value();
8424
+ if (!TYPES.includes(v))
8425
+ throw new Error(`--type must be one of ${TYPES.join(", ")}, got ${v}`);
8426
+ type = v;
8427
+ } else if (a.startsWith("-"))
8428
+ throw new Error(`unknown flag: ${a}`);
8429
+ else
8430
+ titleParts.push(a);
8431
+ } catch (e) {
8432
+ console.error(`firegoat feedback: ${e.message}`);
8433
+ return EXIT.usage;
8434
+ }
8435
+ }
8436
+ const title = titleParts.join(" ").trim();
8437
+ if (title.length < 8 || title.length > 200) {
8438
+ console.error(`firegoat feedback: the title must be 8-200 characters (got ${title.length})`);
8439
+ return EXIT.usage;
8440
+ }
8441
+ const client = new FeedbackClient({ domain: "firegoat.ai" });
8442
+ try {
8443
+ const agent = agentName();
8444
+ const { id, similarCount } = await client.send({
8445
+ title,
8446
+ body: body?.trim() ? `${body.trim()}
8447
+
8448
+ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
8449
+ type,
8450
+ meta: {
8451
+ ...agent ? { agent } : {},
8452
+ os: process.platform,
8453
+ nodeVersion: process.version
8454
+ }
8455
+ });
8456
+ console.log(`sent ${type} ${id} to the firegoat team${similarCount > 0 ? ` (${similarCount} similar report${similarCount === 1 ? "" : "s"} already)` : ""}. thanks.`);
8457
+ return EXIT.pass;
8458
+ } catch (e) {
8459
+ if (e instanceof FeedbackError) {
8460
+ 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.` : "";
8461
+ console.error(`firegoat feedback: ${e.code}: ${e.message}.${hint}`);
8462
+ return e.code === "invalid_request" || e.code === "possible_secret" ? EXIT.usage : EXIT.notGraded;
8463
+ }
8464
+ throw e;
8465
+ }
8466
+ }
8467
+
8151
8468
  // src/init.ts
8152
8469
  import { spawnSync as spawnSync2 } from "node:child_process";
8153
8470
  import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
@@ -8195,6 +8512,12 @@ runs: 1
8195
8512
  # setup runs in this folder, so: setup: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
8196
8513
  # then e.g. workdir_setup: cp "$FIREGOAT_OUT/wizard.mjs" .
8197
8514
 
8515
+ # Optional: env vars for the agent (and for workdir_setup and checks of the same
8516
+ # 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:.
8518
+ # env:
8519
+ # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
8520
+
8198
8521
  tests:
8199
8522
  - name: quickstart
8200
8523
  # What a user would ask their agent. Edit this.
@@ -8261,9 +8584,8 @@ ${INIT_HELP}`);
8261
8584
  const rule = paint("90", ` ${"─".repeat(width - 2)}`);
8262
8585
  const step = (n, label, status) => console.log(` ${paint("32", "●")} ${paint("90", String(n).padStart(2, "0"))} ${label.padEnd(28)}${paint("90", status)}`);
8263
8586
  if (interactive) {
8264
- const title = " \uD83E\uDD8B firegoat init";
8265
- const tag = "Get agent-ready.";
8266
- console.log(`${paint("38;5;208", " \uD83E\uDD8B")} firegoat init${" ".repeat(Math.max(2, width - title.length - tag.length - 2))}${paint("90", tag)}`);
8587
+ for (const line of logoHeader(paint, "firegoat init", paint("90", "Get agent-ready."), width))
8588
+ console.log(line);
8267
8589
  console.log(rule);
8268
8590
  }
8269
8591
  ensureFiregoatDir(cwd);
@@ -8341,6 +8663,8 @@ async function main(argv) {
8341
8663
  return initCommand(rest, process.cwd());
8342
8664
  case "eval":
8343
8665
  return evalCommand(rest, process.cwd());
8666
+ case "feedback":
8667
+ return feedbackCommand(rest);
8344
8668
  default:
8345
8669
  console.error(`unknown command: ${cmd}
8346
8670
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.11.0",
3
+ "version": "0.13.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"