firegoat 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +444 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local AX evals: how well coding agents use your SDK, CLI, API and docs. firegoat gives your own `claude` one prompt in a fresh folder, saves the session, and runs your checks.
4
4
 
5
- Status: early (v0.10). See `plan.md`.
5
+ Status: early (v0.11). See `plan.md`.
6
6
 
7
7
  ## Quickstart
8
8
 
@@ -82,14 +82,33 @@ 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.
89
108
  - 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
109
  - 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
110
 
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`.
111
+ `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
112
 
94
113
  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
114
 
package/dist/cli.js CHANGED
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS(function(exports) {
6947
6947
  // package.json
6948
6948
  var package_default = {
6949
6949
  name: "firegoat",
6950
- version: "0.10.0",
6950
+ version: "0.12.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7072,11 +7072,19 @@ function parseConfig(text) {
7072
7072
  }
7073
7073
  if (!isRecord(raw))
7074
7074
  fail("(root)", "must be a mapping");
7075
- onlyKeys(raw, "(root)", ["runs", "setup", "workdir_setup", "vars", "tests"]);
7075
+ onlyKeys(raw, "(root)", [
7076
+ "runs",
7077
+ "setup",
7078
+ "workdir_setup",
7079
+ "vars",
7080
+ "env",
7081
+ "tests"
7082
+ ]);
7076
7083
  const runs = posInt(raw.runs, "runs", 1);
7077
7084
  const setup = commands(raw.setup, "setup");
7078
7085
  const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
7079
7086
  const vars = parseVars(raw.vars);
7087
+ const env = parseEnv(raw.env, "env");
7080
7088
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7081
7089
  fail("tests", "must be a non-empty list");
7082
7090
  const names = new Set;
@@ -7084,7 +7092,7 @@ function parseConfig(text) {
7084
7092
  const p = `tests[${i}]`;
7085
7093
  if (!isRecord(t))
7086
7094
  fail(p, "must be a mapping");
7087
- onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout"]);
7095
+ onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout", "env"]);
7088
7096
  const name = str(t.name, `${p}.name`);
7089
7097
  if (!NAME.test(name))
7090
7098
  fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
@@ -7107,7 +7115,8 @@ function parseConfig(text) {
7107
7115
  prompt: str(t.prompt, `${p}.prompt`),
7108
7116
  end_goal: str(t.end_goal, `${p}.end_goal`),
7109
7117
  checks,
7110
- timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
7118
+ timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
7119
+ env: { ...env, ...parseEnv(t.env, `${p}.env`) }
7111
7120
  };
7112
7121
  });
7113
7122
  return {
@@ -7115,6 +7124,7 @@ function parseConfig(text) {
7115
7124
  setup,
7116
7125
  workdirSetup,
7117
7126
  vars,
7127
+ env,
7118
7128
  tests
7119
7129
  };
7120
7130
  }
@@ -7144,6 +7154,54 @@ function parseVars(raw) {
7144
7154
  }
7145
7155
  return out;
7146
7156
  }
7157
+ var RESERVED_ENV = new Set([
7158
+ "PATH",
7159
+ "CLAUDE_CONFIG_DIR",
7160
+ "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7161
+ ]);
7162
+ var RUN_PLACEHOLDERS = ["run.id", "run.n", "run.workdir", "test.name"];
7163
+ var EVAL_PLACEHOLDERS = ["run.id"];
7164
+ var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7165
+ function parseEnv(raw, path) {
7166
+ if (raw === undefined)
7167
+ return {};
7168
+ if (!isRecord(raw))
7169
+ fail(path, "must be a mapping of NAME to value");
7170
+ const out = {};
7171
+ for (const [k, v] of Object.entries(raw)) {
7172
+ const p = `${path}.${k}`;
7173
+ if (!VAR_KEY.test(k))
7174
+ fail(p, "name must be letters, digits and _, not starting with a digit");
7175
+ if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7176
+ fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7177
+ if (typeof v !== "string")
7178
+ fail(p, "must be a string (quote numbers and booleans)");
7179
+ for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7180
+ const name = m[1];
7181
+ if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7182
+ fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7183
+ }
7184
+ out[k] = v;
7185
+ }
7186
+ return out;
7187
+ }
7188
+ function isEvalLevel(value) {
7189
+ return [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7190
+ const name = m[1];
7191
+ return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7192
+ });
7193
+ }
7194
+ function renderEnv(env, vars, run) {
7195
+ const out = {};
7196
+ 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];
7199
+ if (filled === undefined)
7200
+ throw new ConfigError(`env.${k}: no value for ${whole}`);
7201
+ return filled;
7202
+ });
7203
+ return out;
7204
+ }
7147
7205
  var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
7148
7206
  function varRefs(text) {
7149
7207
  return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
@@ -7172,7 +7230,7 @@ import {
7172
7230
  readdirSync as readdirSync2,
7173
7231
  writeFileSync as writeFileSync3
7174
7232
  } from "node:fs";
7175
- import { delimiter as delimiter2, join as join4 } from "node:path";
7233
+ import { basename, delimiter as delimiter2, join as join4 } from "node:path";
7176
7234
 
7177
7235
  // src/claude.ts
7178
7236
  import { spawn, spawnSync } from "node:child_process";
@@ -7186,9 +7244,14 @@ var CLAUDE_FLAGS = [
7186
7244
  "",
7187
7245
  "--disable-slash-commands"
7188
7246
  ];
7189
- function claudeEnv(configDir) {
7247
+ var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7248
+ function hostEnv() {
7249
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k)));
7250
+ }
7251
+ function claudeEnv(configDir, env = {}) {
7190
7252
  return {
7191
- ...process.env,
7253
+ ...hostEnv(),
7254
+ ...env,
7192
7255
  CLAUDE_CONFIG_DIR: configDir,
7193
7256
  CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
7194
7257
  };
@@ -7226,6 +7289,7 @@ function run(opts) {
7226
7289
  }
7227
7290
  async function runClaude(opts) {
7228
7291
  let stdout = "";
7292
+ let pending = "";
7229
7293
  const exited = await run({
7230
7294
  cmd: opts.bin,
7231
7295
  args: ["-p", opts.prompt, ...CLAUDE_FLAGS],
@@ -7233,7 +7297,19 @@ async function runClaude(opts) {
7233
7297
  env: opts.env,
7234
7298
  timeoutMs: opts.timeoutMs,
7235
7299
  onStdout: (c) => {
7236
- stdout += c.toString();
7300
+ const text = c.toString();
7301
+ stdout += text;
7302
+ if (!opts.onEvent)
7303
+ return;
7304
+ pending += text;
7305
+ const lines = pending.split(`
7306
+ `);
7307
+ pending = lines.pop() ?? "";
7308
+ for (const line of lines) {
7309
+ try {
7310
+ opts.onEvent(JSON.parse(line));
7311
+ } catch {}
7312
+ }
7237
7313
  },
7238
7314
  onStderr: (c) => opts.stderr.write(c)
7239
7315
  });
@@ -7391,6 +7467,12 @@ firegoat.yaml reference:
7391
7467
  workdir_setup: <sh> | [...] in each fresh folder before the agent starts
7392
7468
  vars: {KEY: value} defaults for {{vars.KEY}} in prompts and checks;
7393
7469
  --var KEY=value overrides
7470
+ env: {NAME: value} env vars for the agent, and for workdir_setup and
7471
+ checks of the same run; values are strings and can
7472
+ 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}}.
7475
+ PATH, CLAUDE_CONFIG_DIR and FIREGOAT_* are reserved.
7394
7476
  tests:
7395
7477
  - name: quickstart letters, digits, '.', '_', '-'; unique
7396
7478
  prompt: ... what a user would ask the agent; one concrete outcome
@@ -7399,6 +7481,7 @@ firegoat.yaml reference:
7399
7481
  checks: optional shell commands, run in the agent's folder
7400
7482
  - run: ... afterwards; exit 0 = pass; they set eval's exit code
7401
7483
  timeout: 600 optional, seconds
7484
+ env: {NAME: value} optional, on top of the top-level env:
7402
7485
 
7403
7486
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7404
7487
  .firegoat/fixtures/ and copy them in setup or workdir_setup. setup, workdir_setup and checks get the vars as env vars,
@@ -7407,6 +7490,11 @@ firegoat.yaml reference:
7407
7490
  each output), and
7408
7491
  $FIREGOAT_SESSION (claude's full session file, JSON lines).
7409
7492
 
7493
+ The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
7494
+ (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
7495
+ npm_command, INIT_CWD; e.g. npm_config_yes=true from npx -y) are removed from
7496
+ every env firegoat starts, unless env: sets them.
7497
+
7410
7498
  Example:
7411
7499
  setup: npm pack --pack-destination "$FIREGOAT_OUT"
7412
7500
  workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
@@ -7581,6 +7669,237 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
7581
7669
  `);
7582
7670
  }
7583
7671
 
7672
+ // src/ui.ts
7673
+ import { relative as relative2 } from "node:path";
7674
+ var RAMP = "▁▂▃▄▅▆▇";
7675
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7676
+ var SPIN = ["◐", "◓", "◑", "◒"];
7677
+ function painter(color) {
7678
+ return (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
7679
+ }
7680
+ var C = {
7681
+ ember: "38;5;208",
7682
+ green: "32",
7683
+ red: "31",
7684
+ amber: "33",
7685
+ gray: "90",
7686
+ bold: "1",
7687
+ cyan: "36"
7688
+ };
7689
+ function tier(rows) {
7690
+ const graded = rows.filter((r) => r.state === "pass" || r.state === "fail");
7691
+ if (graded.length === 0)
7692
+ return null;
7693
+ const share = graded.filter((r) => r.state === "pass").length / graded.length;
7694
+ return share === 1 ? "S" : share >= 0.75 ? "A" : share >= 0.5 ? "B" : "C";
7695
+ }
7696
+ function notGradedWhy(reason) {
7697
+ const said = /claude result is an error \(subtype=[^)]*\): (.*)$/s.exec(reason);
7698
+ if (said)
7699
+ return `claude said: "${said[1]}"`;
7700
+ return reason.replace(/^not graded: /, "");
7701
+ }
7702
+ function ramp(r) {
7703
+ const blocks = Array.from({ length: Math.min(r.turns, 7) }, (_, i) => RAMP[i]).join("");
7704
+ const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
7705
+ return (blocks + end).padEnd(8);
7706
+ }
7707
+ function usage(r) {
7708
+ const parts = [];
7709
+ if (r.cost !== null && r.cost !== undefined)
7710
+ parts.push(`$${r.cost.toFixed(2)}`);
7711
+ parts.push(`${r.turns} turns`);
7712
+ if (r.seconds !== undefined)
7713
+ parts.push(`${r.seconds}s`);
7714
+ return parts.join(" ");
7715
+ }
7716
+ function checksPassed(r) {
7717
+ const all = r.checks ?? [];
7718
+ return `${all.filter((c) => c.code === 0).length}/${all.length} checks`;
7719
+ }
7720
+ function total(rows) {
7721
+ const cost = rows.reduce((s, r) => s + (r.cost ?? 0), 0);
7722
+ 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 };
7726
+ }
7727
+ function counts(rows) {
7728
+ return {
7729
+ pass: rows.filter((r) => r.state === "pass").length,
7730
+ fail: rows.filter((r) => r.state === "fail").length,
7731
+ error: rows.filter((r) => r.state === "error").length
7732
+ };
7733
+ }
7734
+ function plainUI(write) {
7735
+ let rows = [];
7736
+ const printed = new Set;
7737
+ return {
7738
+ auth: (text) => write(`auth: ${text}`),
7739
+ setup: (state, seconds) => {
7740
+ if (state === "running")
7741
+ write("setup...");
7742
+ else if (state === "fail")
7743
+ write(`setup failed (${seconds}s)`);
7744
+ },
7745
+ runs: (r) => {
7746
+ rows = r;
7747
+ },
7748
+ update: () => {
7749
+ for (const r of rows) {
7750
+ if (printed.has(r) || r.state === "queued" || r.state === "running")
7751
+ continue;
7752
+ printed.add(r);
7753
+ const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
7754
+ const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
7755
+ write(`${label} ${r.test}#${r.n} ${r.reason} (${u})`);
7756
+ }
7757
+ },
7758
+ done: ({ runsDir, rows: all }) => {
7759
+ const c = counts(all);
7760
+ const t = total(all);
7761
+ const tr = tier(all);
7762
+ write("");
7763
+ write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, $${t.cost.toFixed(2)})${tr ? `, tier ${tr}` : ""}`);
7764
+ write(`runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
7765
+ }
7766
+ };
7767
+ }
7768
+ function ttyUI(out, opts) {
7769
+ const p = painter(opts.color);
7770
+ let rows = [];
7771
+ let setup = null;
7772
+ let authText = "";
7773
+ let frame = 0;
7774
+ let drawn = 0;
7775
+ let finished = null;
7776
+ const width = () => Math.min(out.columns || 80, 100);
7777
+ const cut = (text, max) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
7778
+ const statusWord = (r) => {
7779
+ switch (r.state) {
7780
+ case "pass":
7781
+ return p(C.green, "PASS ");
7782
+ case "fail":
7783
+ return p(C.red, "FAIL ");
7784
+ case "error":
7785
+ return p(C.amber, "NOT GRADED");
7786
+ case "running":
7787
+ return p(C.ember, "running ");
7788
+ default:
7789
+ return p(C.gray, "queued ");
7790
+ }
7791
+ };
7792
+ const glyph = (r) => {
7793
+ switch (r.state) {
7794
+ case "pass":
7795
+ return p(C.green, "●");
7796
+ case "fail":
7797
+ return p(C.red, "✗");
7798
+ case "error":
7799
+ return p(C.amber, "?");
7800
+ case "running":
7801
+ return p(C.ember, SPIN[frame % SPIN.length]);
7802
+ default:
7803
+ return p(C.gray, "○");
7804
+ }
7805
+ };
7806
+ const lines = () => {
7807
+ const w = width();
7808
+ const rule = p(C.gray, ` ${"─".repeat(w - 2)}`);
7809
+ const c = counts(rows);
7810
+ const doneCount = c.pass + c.fail + c.error;
7811
+ const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
7812
+ const title = ` \uD83E\uDD8B firegoat eval · ${opts.project}`;
7813
+ 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)}`,
7815
+ rule
7816
+ ];
7817
+ if (authText)
7818
+ out.push(p(C.gray, ` auth ${authText}`));
7819
+ if (setup) {
7820
+ const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
7821
+ 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` : ""}`);
7822
+ }
7823
+ rows.forEach((r, i) => {
7824
+ out.push(p(C.gray, " │"));
7825
+ const num = String(i + 1).padStart(2, "0");
7826
+ const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
7827
+ let detail = "";
7828
+ if (r.state === "running")
7829
+ detail = p(C.gray, `turn ${r.turns}`);
7830
+ else if (r.state === "pass" || r.state === "fail")
7831
+ detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
7832
+ else if (r.state === "error")
7833
+ detail = p(C.gray, usage(r));
7834
+ out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
7835
+ if (r.state === "running" && r.command)
7836
+ out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
7837
+ if (r.state === "fail")
7838
+ for (const ck of (r.checks ?? []).filter((x) => x.code !== 0)) {
7839
+ out.push(` ${p(C.gray, "│")} ${p(C.red, "✗")} ${cut(ck.run, w - 14)}`);
7840
+ out.push(p(C.gray, ` │ ${ck.error ?? `exit ${ck.code}`}`));
7841
+ }
7842
+ if (r.state === "error")
7843
+ out.push(p(C.amber, ` ${cut(notGradedWhy(r.reason ?? ""), w - 12)}`));
7844
+ });
7845
+ out.push(rule);
7846
+ const t = total(rows);
7847
+ if (!finished) {
7848
+ const track = Math.max(10, w - 34);
7849
+ 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`)}`);
7852
+ return out;
7853
+ }
7854
+ const tr = tier(rows);
7855
+ const goated = c.pass === rows.length && rows.length > 0;
7856
+ 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)}/`));
7861
+ out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
7862
+ return out;
7863
+ };
7864
+ const rowsOf = (line) => {
7865
+ const visible = line.replace(ANSI, "").replace(/🐐|🦋/g, "gg");
7866
+ return Math.max(1, Math.ceil(visible.length / (out.columns || 80)));
7867
+ };
7868
+ const render = () => {
7869
+ const text = lines();
7870
+ let s = drawn > 0 ? `\x1B[${drawn}A\x1B[J` : "";
7871
+ s += `${text.join(`
7872
+ `)}
7873
+ `;
7874
+ out.write(s);
7875
+ drawn = text.reduce((n, l) => n + rowsOf(l), 0);
7876
+ };
7877
+ const timer = setInterval(() => {
7878
+ frame++;
7879
+ render();
7880
+ }, 120);
7881
+ timer.unref();
7882
+ return {
7883
+ auth: (text) => {
7884
+ authText = text;
7885
+ },
7886
+ setup: (state, seconds) => {
7887
+ setup = { state, seconds };
7888
+ render();
7889
+ },
7890
+ runs: (r) => {
7891
+ rows = r;
7892
+ render();
7893
+ },
7894
+ update: render,
7895
+ done: ({ runId, runsDir }) => {
7896
+ clearInterval(timer);
7897
+ finished = { runId, runsDir };
7898
+ render();
7899
+ }
7900
+ };
7901
+ }
7902
+
7584
7903
  // src/which.ts
7585
7904
  import { accessSync, constants, statSync as statSync2 } from "node:fs";
7586
7905
  import { delimiter, join as join3, resolve } from "node:path";
@@ -7662,15 +7981,6 @@ function findSession(configDir, sessionId) {
7662
7981
  }
7663
7982
  return null;
7664
7983
  }
7665
- function usage(r) {
7666
- const parts = [];
7667
- if (r.cost !== null)
7668
- parts.push(`$${r.cost.toFixed(2)}`);
7669
- if (r.num_turns !== null)
7670
- parts.push(`${r.num_turns} turns`);
7671
- parts.push(`${Math.round(r.duration_ms / 1000)}s`);
7672
- return parts.join(", ");
7673
- }
7674
7984
  function newRunId() {
7675
7985
  const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
7676
7986
  return `${ts}-${randomBytes(2).toString("hex")}`;
@@ -7707,7 +8017,13 @@ async function runOne(opts) {
7707
8017
  `);
7708
8018
  return r;
7709
8019
  };
7710
- const stepEnv = { ...opts.stepEnv, FIREGOAT_WORKDIR: workdir };
8020
+ const env = renderEnv(test.env, opts.vars, {
8021
+ "run.id": opts.runId,
8022
+ "run.n": String(n),
8023
+ "run.workdir": workdir,
8024
+ "test.name": test.name
8025
+ });
8026
+ const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
7711
8027
  if (opts.workdirSetup.length > 0) {
7712
8028
  const error = await runSteps({
7713
8029
  what: "workdir_setup",
@@ -7724,9 +8040,10 @@ async function runOne(opts) {
7724
8040
  bin: opts.claudeBin,
7725
8041
  prompt: r.prompt,
7726
8042
  cwd: workdir,
7727
- env: { ...claudeEnv(opts.configDir), PATH: opts.stepEnv.PATH },
8043
+ env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
7728
8044
  stderr,
7729
- timeoutMs: test.timeout * 1000
8045
+ timeoutMs: test.timeout * 1000,
8046
+ onEvent: opts.onEvent
7730
8047
  });
7731
8048
  await new Promise((res) => stderr.end(res));
7732
8049
  if (outcome.sessionId)
@@ -7787,6 +8104,8 @@ async function evalCommand(args, root) {
7787
8104
  throw e;
7788
8105
  }
7789
8106
  const say = opts.json ? console.error : console.log;
8107
+ 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);
7790
8109
  let config;
7791
8110
  try {
7792
8111
  config = loadConfig(root);
@@ -7806,7 +8125,11 @@ async function evalCommand(args, root) {
7806
8125
  const runs = opts.runs ?? config.runs;
7807
8126
  const vars = { ...config.vars, ...opts.vars };
7808
8127
  const missing = [
7809
- ...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(varRefs).filter((k) => !(k in vars)))
8128
+ ...new Set(tests.flatMap((t) => [
8129
+ t.prompt,
8130
+ ...t.checks.map((c) => c.run),
8131
+ ...Object.values(t.env)
8132
+ ]).flatMap(varRefs).filter((k) => !(k in vars)))
7810
8133
  ];
7811
8134
  if (missing.length > 0)
7812
8135
  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`);
@@ -7816,7 +8139,7 @@ async function evalCommand(args, root) {
7816
8139
  const firegoatDir = ensureFiregoatDir(root);
7817
8140
  const configDir = join4(firegoatDir, ".claude");
7818
8141
  mkdirSync2(configDir, { recursive: true });
7819
- say(process.env.ANTHROPIC_API_KEY ? "auth: ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "auth: your claude login");
8142
+ 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");
7820
8143
  const loginProblem = checkLogin({
7821
8144
  bin: claudePath,
7822
8145
  env: claudeEnv(configDir),
@@ -7829,29 +8152,60 @@ async function evalCommand(args, root) {
7829
8152
  const out = join4(runDir, "out");
7830
8153
  mkdirSync2(out, { recursive: true });
7831
8154
  const stepEnv = {
7832
- ...process.env,
8155
+ ...hostEnv(),
7833
8156
  ...vars,
7834
8157
  FIREGOAT_OUT: out,
7835
8158
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
7836
8159
  };
8160
+ const rows = tests.flatMap((t) => Array.from({ length: runs }, (_, i) => ({
8161
+ test: t.name,
8162
+ n: i + 1,
8163
+ state: "queued",
8164
+ turns: 0
8165
+ })));
7837
8166
  if (config.setup.length > 0) {
7838
- say("setup...");
8167
+ const setupStarted = Date.now();
8168
+ ui.setup("running");
7839
8169
  const error = await runSteps({
7840
8170
  what: "setup",
7841
8171
  commands: config.setup,
7842
8172
  cwd: root,
7843
- env: stepEnv,
8173
+ env: {
8174
+ ...stepEnv,
8175
+ ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
8176
+ },
7844
8177
  logPath: join4(runDir, "setup.txt")
7845
8178
  });
8179
+ const secs = Math.round((Date.now() - setupStarted) / 1000);
8180
+ ui.setup(error ? "fail" : "ok", secs);
7846
8181
  if (error)
7847
8182
  return fatal(EXIT.notGraded, `not graded: ${error}`);
7848
8183
  }
8184
+ ui.runs(rows);
7849
8185
  const results = [];
7850
8186
  for (const test of tests)
7851
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();
7852
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(`
8203
+ `)[0];
8204
+ }
8205
+ },
7853
8206
  test,
7854
8207
  n,
8208
+ runId,
7855
8209
  runDir,
7856
8210
  claudeBin: claudePath,
7857
8211
  configDir,
@@ -7860,7 +8214,16 @@ async function evalCommand(args, root) {
7860
8214
  vars
7861
8215
  });
7862
8216
  results.push(r);
7863
- say(`${r.status.toUpperCase()} ${r.test}#${r.n} ${r.reason} (${usage(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();
7864
8227
  }
7865
8228
  const totals = { pass: 0, fail: 0, error: 0 };
7866
8229
  for (const r of results)
@@ -7868,10 +8231,7 @@ async function evalCommand(args, root) {
7868
8231
  const doc = { runId, runsDir: runDir, totals, runs: results };
7869
8232
  writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
7870
8233
  `);
7871
- const cost = results.reduce((sum, r) => sum + (r.cost ?? 0), 0);
7872
- say(`
7873
- ${totals.pass} passed, ${totals.fail} failed, ${totals.error} not graded (${results.length} runs, $${cost.toFixed(2)})`);
7874
- say(`runs: ${runDir} (end_goal is for you to judge: see run.json per run)`);
8234
+ ui.done({ runId, runsDir: runDir, rows });
7875
8235
  if (opts.json)
7876
8236
  console.log(JSON.stringify(doc, null, 2));
7877
8237
  return totals.error > 0 ? EXIT.notGraded : totals.fail > 0 ? EXIT.fail : EXIT.pass;
@@ -7880,7 +8240,7 @@ ${totals.pass} passed, ${totals.fail} failed, ${totals.error} not graded (${resu
7880
8240
  // src/init.ts
7881
8241
  import { spawnSync as spawnSync2 } from "node:child_process";
7882
8242
  import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
7883
- import { basename, join as join5 } from "node:path";
8243
+ import { basename as basename2, join as join5 } from "node:path";
7884
8244
  import { createInterface } from "node:readline/promises";
7885
8245
  var SKILLS_ARGV = [
7886
8246
  "skills",
@@ -7924,6 +8284,12 @@ runs: 1
7924
8284
  # setup runs in this folder, so: setup: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
7925
8285
  # then e.g. workdir_setup: cp "$FIREGOAT_OUT/wizard.mjs" .
7926
8286
 
8287
+ # Optional: env vars for the agent (and for workdir_setup and checks of the same
8288
+ # 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:.
8290
+ # env:
8291
+ # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
8292
+
7927
8293
  tests:
7928
8294
  - name: quickstart
7929
8295
  # What a user would ask their agent. Edit this.
@@ -7983,48 +8349,73 @@ ${INIT_HELP}`);
7983
8349
  return EXIT.usage;
7984
8350
  }
7985
8351
  }
8352
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
8353
+ const color = interactive && !process.env.NO_COLOR;
8354
+ const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
8355
+ const width = Math.min(process.stdout.columns || 80, 80);
8356
+ const rule = paint("90", ` ${"─".repeat(width - 2)}`);
8357
+ const step = (n, label, status) => console.log(` ${paint("32", "●")} ${paint("90", String(n).padStart(2, "0"))} ${label.padEnd(28)}${paint("90", status)}`);
8358
+ 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)}`);
8362
+ console.log(rule);
8363
+ }
7986
8364
  ensureFiregoatDir(cwd);
7987
8365
  const configPath = join5(cwd, CONFIG_FILE);
7988
- if (existsSync3(configPath) && !force)
7989
- console.log(`kept ${CONFIG_FILE} (--force replaces it)`);
7990
- else {
7991
- writeFileSync4(configPath, template(basename(cwd)));
7992
- console.log(`wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
7993
- }
7994
- const interactive = process.stdin.isTTY && process.stdout.isTTY;
7995
- if (skills && interactive)
7996
- skills = await askYes("Install the firegoat-onboarding skill for your coding agent?");
8366
+ const keep = existsSync3(configPath) && !force;
8367
+ if (!keep)
8368
+ writeFileSync4(configPath, template(basename2(cwd)));
8369
+ if (interactive) {
8370
+ step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
8371
+ console.log(paint("90", " │"));
8372
+ step(2, ".firegoat/", "ready");
8373
+ } else
8374
+ console.log(keep ? `kept ${CONFIG_FILE} (--force replaces it)` : `wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
8375
+ if (skills && interactive) {
8376
+ console.log(paint("90", " │"));
8377
+ skills = await askYes(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill?`);
8378
+ process.stdout.write("\x1B[1A\x1B[2K");
8379
+ }
7997
8380
  if (skills) {
7998
- console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
8381
+ if (!interactive)
8382
+ console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
7999
8383
  const env = { ...process.env };
8000
8384
  delete env.npm_config_package;
8001
8385
  const r = spawnSync2("npx", ["-y", ...SKILLS_ARGV], {
8002
8386
  cwd,
8003
8387
  env,
8004
- stdio: interactive ? "inherit" : "pipe",
8388
+ stdio: "pipe",
8005
8389
  encoding: "utf8"
8006
8390
  });
8007
8391
  if (r.error || r.status !== 0) {
8008
- if (!interactive)
8009
- process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
8392
+ process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
8010
8393
  console.error(`firegoat init: installing the skills failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
8011
8394
  return EXIT.notGraded;
8012
8395
  }
8013
- console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8396
+ if (interactive)
8397
+ step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
8398
+ else
8399
+ console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8014
8400
  const ignored = spawnSync2("git", ["check-ignore", "-q", ".claude/skills"], {
8015
8401
  cwd
8016
8402
  });
8017
8403
  if (ignored.status === 0)
8018
8404
  console.error("warning: git ignores .claude/skills, so teammates and CI won't get the skills. Commit them with: git add -f .claude/skills");
8019
- }
8020
- console.log(interactive ? `
8021
- Paste this into your coding agent:
8022
-
8023
- ${AGENT_PROMPT}
8024
- ` : `
8405
+ } else if (interactive)
8406
+ step(3, "firegoat-onboarding skill", "skipped");
8407
+ if (!interactive) {
8408
+ console.log(`
8025
8409
  Prompt for your coding agent: ${AGENT_PROMPT}`);
8026
- if (interactive && copyToClipboard(AGENT_PROMPT))
8027
- console.log("\x1B[32mPrompt copied!\x1B[0m");
8410
+ return EXIT.pass;
8411
+ }
8412
+ console.log(rule);
8413
+ console.log(` next: hand this to your coding agent
8414
+ `);
8415
+ console.log(AGENT_PROMPT);
8416
+ console.log("");
8417
+ const copied = copyToClipboard(AGENT_PROMPT);
8418
+ console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}then: ${paint("36", "npx firegoat eval")}`);
8028
8419
  return EXIT.pass;
8029
8420
  }
8030
8421
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"