firegoat 0.9.4 → 0.11.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 +4 -4
  2. package/dist/cli.js +485 -75
  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.9). See `plan.md`.
5
+ Status: early (v0.11). See `plan.md`.
6
6
 
7
7
  ## Quickstart
8
8
 
@@ -30,7 +30,7 @@ tests:
30
30
  and print the event id. Credentials are in ACME_API_KEY. Keep it minimal.
31
31
  end_goal: A script sends one event with the SDK's client and prints its id.
32
32
  checks:
33
- - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_SESSION"
33
+ - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
34
34
  ```
35
35
 
36
36
  `setup` builds your package once; `workdir_setup` installs that build into each fresh folder, so the agent uses your local code, not the registry.
@@ -44,7 +44,7 @@ tests:
44
44
  prompt: Deploy the hello-world template to a preview environment with the acme CLI and print the URL.
45
45
  end_goal: The agent finds the right acme commands and ends with a working preview URL.
46
46
  checks:
47
- - run: grep -q '"command":"acme deploy' "$FIREGOAT_SESSION"
47
+ - run: grep -q '\$ acme deploy' "$FIREGOAT_TRANSCRIPT"
48
48
  ```
49
49
 
50
50
  Whatever `setup` writes to `$FIREGOAT_OUT` is first on the agent's `PATH`. That only helps for binaries called by bare name: if users run your CLI via `npx`/`uvx`, pack and install it like the npm example, or `npx` fetches the registry release.
@@ -87,7 +87,7 @@ npx firegoat eval --var SHA=$(git rev-parse HEAD)
87
87
  - 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
88
  - 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`.
89
89
  - 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
- - Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. `summary.json` covers the whole eval; `--json` prints it.
90
+ - 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
91
 
92
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`.
93
93
 
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.9.4",
6950
+ version: "0.11.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7169,10 +7169,10 @@ import {
7169
7169
  createWriteStream as createWriteStream2,
7170
7170
  existsSync as existsSync2,
7171
7171
  mkdirSync as mkdirSync2,
7172
- readdirSync,
7173
- writeFileSync as writeFileSync2
7172
+ readdirSync as readdirSync2,
7173
+ writeFileSync as writeFileSync3
7174
7174
  } from "node:fs";
7175
- import { delimiter as delimiter2, join as join3 } from "node:path";
7175
+ import { basename, delimiter as delimiter2, join as join4 } from "node:path";
7176
7176
 
7177
7177
  // src/claude.ts
7178
7178
  import { spawn, spawnSync } from "node:child_process";
@@ -7226,6 +7226,7 @@ function run(opts) {
7226
7226
  }
7227
7227
  async function runClaude(opts) {
7228
7228
  let stdout = "";
7229
+ let pending = "";
7229
7230
  const exited = await run({
7230
7231
  cmd: opts.bin,
7231
7232
  args: ["-p", opts.prompt, ...CLAUDE_FLAGS],
@@ -7233,12 +7234,25 @@ async function runClaude(opts) {
7233
7234
  env: opts.env,
7234
7235
  timeoutMs: opts.timeoutMs,
7235
7236
  onStdout: (c) => {
7236
- stdout += c.toString();
7237
+ const text = c.toString();
7238
+ stdout += text;
7239
+ if (!opts.onEvent)
7240
+ return;
7241
+ pending += text;
7242
+ const lines = pending.split(`
7243
+ `);
7244
+ pending = lines.pop() ?? "";
7245
+ for (const line of lines) {
7246
+ try {
7247
+ opts.onEvent(JSON.parse(line));
7248
+ } catch {}
7249
+ }
7237
7250
  },
7238
7251
  onStderr: (c) => opts.stderr.write(c)
7239
7252
  });
7240
7253
  if (exited.error)
7241
7254
  return {
7255
+ events: [],
7242
7256
  ok: false,
7243
7257
  reason: `could not start ${opts.bin}: ${exited.error.message}`
7244
7258
  };
@@ -7255,6 +7269,7 @@ async function runClaude(opts) {
7255
7269
  const said = stdout.trim().split(`
7256
7270
  `)[0]?.slice(0, 300);
7257
7271
  return {
7272
+ events,
7258
7273
  ok: false,
7259
7274
  reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7260
7275
  };
@@ -7263,6 +7278,7 @@ async function runClaude(opts) {
7263
7278
  const result = events.findLast((e) => e?.type === "result");
7264
7279
  if (exited.timedOut)
7265
7280
  return {
7281
+ events,
7266
7282
  ok: false,
7267
7283
  reason: `timed out after ${opts.timeoutMs / 1000}s`,
7268
7284
  result,
@@ -7270,18 +7286,20 @@ async function runClaude(opts) {
7270
7286
  };
7271
7287
  if (!result)
7272
7288
  return {
7289
+ events,
7273
7290
  ok: false,
7274
7291
  reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7275
7292
  sessionId
7276
7293
  };
7277
7294
  if (result.is_error === true || result.subtype !== "success")
7278
7295
  return {
7296
+ events,
7279
7297
  ok: false,
7280
7298
  reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7281
7299
  result,
7282
7300
  sessionId
7283
7301
  };
7284
- return { ok: true, result, sessionId };
7302
+ return { events, ok: true, result, sessionId };
7285
7303
  }
7286
7304
  async function runShell(opts) {
7287
7305
  opts.log.write(`$ ${opts.run}
@@ -7397,8 +7415,10 @@ firegoat.yaml reference:
7397
7415
 
7398
7416
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7399
7417
  .firegoat/fixtures/ and copy them in setup or workdir_setup. setup, workdir_setup and checks get the vars as env vars,
7400
- plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; checks also get $FIREGOAT_SESSION (the
7401
- claude session file, one JSON event per line).
7418
+ plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short
7419
+ plain-text transcript ("[1] $ <command>" per shell call, "[2] Write <path>", then
7420
+ each output), and
7421
+ $FIREGOAT_SESSION (claude's full session file, JSON lines).
7402
7422
 
7403
7423
  Example:
7404
7424
  setup: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -7412,9 +7432,9 @@ Example:
7412
7432
  A script sends one event with the SDK's client (not raw HTTP) and prints its
7413
7433
  id, without installing acme from the registry.
7414
7434
  checks:
7415
- - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_SESSION"
7435
+ - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
7416
7436
  - run: |
7417
- ! grep -Eq 'npm (install|i) [^"]*acme' "$FIREGOAT_SESSION"
7437
+ ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7418
7438
 
7419
7439
  Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7420
7440
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--var KEY=value].. [--json]
@@ -7440,12 +7460,15 @@ Output: .firegoat/runs/<runId>/
7440
7460
  summary.json every run, plus totals
7441
7461
  <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7442
7462
  end_goal, checks [{run, code}], and paths: workdir,
7443
- session (claude's session file), stderr, checksLog
7463
+ transcript, session (claude's session file), stderr,
7464
+ checksLog
7465
+ <test>/<n>/transcript.txt what the agent did: each tool call with its command,
7466
+ short output, its final answer, files it made
7444
7467
  setup.txt, <test>/<n>/workdir_setup.txt, checks.txt logs
7445
7468
 
7446
7469
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
7447
- yours to judge. The session is one JSON event per line: "assistant" messages hold
7448
- "tool_use" items (the agent's commands), "user" messages their output.
7470
+ yours to judge: read transcript.txt first (a few KB); the session file has everything
7471
+ (often 50x larger).
7449
7472
 
7450
7473
  Examples:
7451
7474
  npx firegoat eval --json all tests, JSON summary on stdout
@@ -7479,14 +7502,337 @@ async function runSteps(opts) {
7479
7502
  }
7480
7503
  }
7481
7504
 
7505
+ // src/transcript.ts
7506
+ import { readdirSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
7507
+ import { join as join2, relative } from "node:path";
7508
+ var OUTPUT_LINES = 15;
7509
+ var OUTPUT_CHARS = 1500;
7510
+ var TEXT_CHARS = 1500;
7511
+ var SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "__pycache__"]);
7512
+ function truncate(text, maxLines, maxChars) {
7513
+ let out = text.length > maxChars ? text.slice(0, maxChars) : text;
7514
+ const lines = out.split(`
7515
+ `);
7516
+ if (lines.length > maxLines)
7517
+ out = lines.slice(0, maxLines).join(`
7518
+ `);
7519
+ return out.length < text.length ? `${out}
7520
+ … (${text.length - out.length} more chars)` : out;
7521
+ }
7522
+ function indent(text) {
7523
+ return text.split(`
7524
+ `).map((l) => ` ${l}`).join(`
7525
+ `);
7526
+ }
7527
+ function describeCall(name, input) {
7528
+ if (typeof input.command === "string")
7529
+ return `$ ${input.command}`;
7530
+ for (const key of ["file_path", "path", "url", "pattern", "query"])
7531
+ if (typeof input[key] === "string")
7532
+ return `${name} ${input[key]}`;
7533
+ return `${name} ${truncate(JSON.stringify(input), 1, 300)}`;
7534
+ }
7535
+ function resultText(content) {
7536
+ if (typeof content === "string")
7537
+ return content;
7538
+ if (Array.isArray(content))
7539
+ return content.map((c) => typeof c.text === "string" ? c.text : `[${c.type}]`).join(`
7540
+ `);
7541
+ return "";
7542
+ }
7543
+ function workdirFiles(dir) {
7544
+ const out = [];
7545
+ const walk = (d) => {
7546
+ for (const name of readdirSync(d)) {
7547
+ if (SKIP_DIRS.has(name))
7548
+ continue;
7549
+ const p = join2(d, name);
7550
+ if (statSync(p).isDirectory())
7551
+ walk(p);
7552
+ else
7553
+ out.push(relative(dir, p));
7554
+ }
7555
+ };
7556
+ walk(dir);
7557
+ return out.sort();
7558
+ }
7559
+ function writeTranscript(opts) {
7560
+ const out = [`# ${opts.title}`, "", "## prompt", opts.prompt, "", "## steps"];
7561
+ let step = 0;
7562
+ let final = "";
7563
+ let lastText = -1;
7564
+ for (const e of opts.events) {
7565
+ const message = e?.message;
7566
+ const content = Array.isArray(message?.content) ? message.content : [];
7567
+ if (e?.type === "assistant")
7568
+ for (const b of content) {
7569
+ if (b.type === "tool_use")
7570
+ out.push(`[${++step}] ${describeCall(String(b.name), b.input ?? {})}`);
7571
+ else if (b.type === "text" && typeof b.text === "string") {
7572
+ lastText = out.length;
7573
+ out.push(`agent: ${truncate(b.text, 30, TEXT_CHARS)}`);
7574
+ }
7575
+ }
7576
+ else if (e?.type === "user")
7577
+ for (const b of content) {
7578
+ if (b.type !== "tool_result")
7579
+ continue;
7580
+ const text = resultText(b.content).trimEnd();
7581
+ const status = b.is_error === true ? "error" : "ok";
7582
+ out.push(text ? ` → ${status}
7583
+ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
7584
+ }
7585
+ else if (e?.type === "result" && typeof e.result === "string")
7586
+ final = e.result;
7587
+ }
7588
+ if (lastText === out.length - 1 && final && out[lastText] === `agent: ${truncate(final, 30, TEXT_CHARS)}`)
7589
+ out.pop();
7590
+ out.push("", "## final answer", final || "(none)");
7591
+ out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
7592
+ writeFileSync2(opts.path, `${out.join(`
7593
+ `)}
7594
+ `);
7595
+ }
7596
+
7597
+ // src/ui.ts
7598
+ import { relative as relative2 } from "node:path";
7599
+ var RAMP = "▁▂▃▄▅▆▇";
7600
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7601
+ var SPIN = ["◐", "◓", "◑", "◒"];
7602
+ function painter(color) {
7603
+ return (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
7604
+ }
7605
+ var C = {
7606
+ ember: "38;5;208",
7607
+ green: "32",
7608
+ red: "31",
7609
+ amber: "33",
7610
+ gray: "90",
7611
+ bold: "1",
7612
+ cyan: "36"
7613
+ };
7614
+ function tier(rows) {
7615
+ const graded = rows.filter((r) => r.state === "pass" || r.state === "fail");
7616
+ if (graded.length === 0)
7617
+ return null;
7618
+ const share = graded.filter((r) => r.state === "pass").length / graded.length;
7619
+ return share === 1 ? "S" : share >= 0.75 ? "A" : share >= 0.5 ? "B" : "C";
7620
+ }
7621
+ function notGradedWhy(reason) {
7622
+ const said = /claude result is an error \(subtype=[^)]*\): (.*)$/s.exec(reason);
7623
+ if (said)
7624
+ return `claude said: "${said[1]}"`;
7625
+ return reason.replace(/^not graded: /, "");
7626
+ }
7627
+ function ramp(r) {
7628
+ const blocks = Array.from({ length: Math.min(r.turns, 7) }, (_, i) => RAMP[i]).join("");
7629
+ const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
7630
+ return (blocks + end).padEnd(8);
7631
+ }
7632
+ function usage(r) {
7633
+ const parts = [];
7634
+ if (r.cost !== null && r.cost !== undefined)
7635
+ parts.push(`$${r.cost.toFixed(2)}`);
7636
+ parts.push(`${r.turns} turns`);
7637
+ if (r.seconds !== undefined)
7638
+ parts.push(`${r.seconds}s`);
7639
+ return parts.join(" ");
7640
+ }
7641
+ function checksPassed(r) {
7642
+ const all = r.checks ?? [];
7643
+ return `${all.filter((c) => c.code === 0).length}/${all.length} checks`;
7644
+ }
7645
+ function total(rows) {
7646
+ const cost = rows.reduce((s, r) => s + (r.cost ?? 0), 0);
7647
+ 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 };
7651
+ }
7652
+ function counts(rows) {
7653
+ return {
7654
+ pass: rows.filter((r) => r.state === "pass").length,
7655
+ fail: rows.filter((r) => r.state === "fail").length,
7656
+ error: rows.filter((r) => r.state === "error").length
7657
+ };
7658
+ }
7659
+ function plainUI(write) {
7660
+ let rows = [];
7661
+ const printed = new Set;
7662
+ return {
7663
+ auth: (text) => write(`auth: ${text}`),
7664
+ setup: (state, seconds) => {
7665
+ if (state === "running")
7666
+ write("setup...");
7667
+ else if (state === "fail")
7668
+ write(`setup failed (${seconds}s)`);
7669
+ },
7670
+ runs: (r) => {
7671
+ rows = r;
7672
+ },
7673
+ update: () => {
7674
+ for (const r of rows) {
7675
+ if (printed.has(r) || r.state === "queued" || r.state === "running")
7676
+ continue;
7677
+ printed.add(r);
7678
+ const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
7679
+ const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
7680
+ write(`${label} ${r.test}#${r.n} ${r.reason} (${u})`);
7681
+ }
7682
+ },
7683
+ done: ({ runsDir, rows: all }) => {
7684
+ const c = counts(all);
7685
+ const t = total(all);
7686
+ const tr = tier(all);
7687
+ write("");
7688
+ write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, $${t.cost.toFixed(2)})${tr ? `, tier ${tr}` : ""}`);
7689
+ write(`runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
7690
+ }
7691
+ };
7692
+ }
7693
+ function ttyUI(out, opts) {
7694
+ const p = painter(opts.color);
7695
+ let rows = [];
7696
+ let setup = null;
7697
+ let authText = "";
7698
+ let frame = 0;
7699
+ let drawn = 0;
7700
+ let finished = null;
7701
+ const width = () => Math.min(out.columns || 80, 100);
7702
+ const cut = (text, max) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
7703
+ const statusWord = (r) => {
7704
+ switch (r.state) {
7705
+ case "pass":
7706
+ return p(C.green, "PASS ");
7707
+ case "fail":
7708
+ return p(C.red, "FAIL ");
7709
+ case "error":
7710
+ return p(C.amber, "NOT GRADED");
7711
+ case "running":
7712
+ return p(C.ember, "running ");
7713
+ default:
7714
+ return p(C.gray, "queued ");
7715
+ }
7716
+ };
7717
+ const glyph = (r) => {
7718
+ switch (r.state) {
7719
+ case "pass":
7720
+ return p(C.green, "●");
7721
+ case "fail":
7722
+ return p(C.red, "✗");
7723
+ case "error":
7724
+ return p(C.amber, "?");
7725
+ case "running":
7726
+ return p(C.ember, SPIN[frame % SPIN.length]);
7727
+ default:
7728
+ return p(C.gray, "○");
7729
+ }
7730
+ };
7731
+ const lines = () => {
7732
+ const w = width();
7733
+ const rule = p(C.gray, ` ${"─".repeat(w - 2)}`);
7734
+ const c = counts(rows);
7735
+ const doneCount = c.pass + c.fail + c.error;
7736
+ const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
7737
+ const title = ` \uD83E\uDD8B firegoat eval · ${opts.project}`;
7738
+ 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)}`,
7740
+ rule
7741
+ ];
7742
+ if (authText)
7743
+ out.push(p(C.gray, ` auth ${authText}`));
7744
+ if (setup) {
7745
+ const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
7746
+ 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` : ""}`);
7747
+ }
7748
+ rows.forEach((r, i) => {
7749
+ out.push(p(C.gray, " │"));
7750
+ const num = String(i + 1).padStart(2, "0");
7751
+ const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
7752
+ let detail = "";
7753
+ if (r.state === "running")
7754
+ detail = p(C.gray, `turn ${r.turns}`);
7755
+ else if (r.state === "pass" || r.state === "fail")
7756
+ detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
7757
+ else if (r.state === "error")
7758
+ detail = p(C.gray, usage(r));
7759
+ out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
7760
+ if (r.state === "running" && r.command)
7761
+ out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
7762
+ if (r.state === "fail")
7763
+ for (const ck of (r.checks ?? []).filter((x) => x.code !== 0)) {
7764
+ out.push(` ${p(C.gray, "│")} ${p(C.red, "✗")} ${cut(ck.run, w - 14)}`);
7765
+ out.push(p(C.gray, ` │ ${ck.error ?? `exit ${ck.code}`}`));
7766
+ }
7767
+ if (r.state === "error")
7768
+ out.push(p(C.amber, ` ${cut(notGradedWhy(r.reason ?? ""), w - 12)}`));
7769
+ });
7770
+ out.push(rule);
7771
+ const t = total(rows);
7772
+ if (!finished) {
7773
+ const track = Math.max(10, w - 34);
7774
+ 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`)}`);
7777
+ return out;
7778
+ }
7779
+ const tr = tier(rows);
7780
+ const goated = c.pass === rows.length && rows.length > 0;
7781
+ 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}`));
7785
+ out.push(p(C.gray, ` runs ${relative2(process.cwd(), finished.runsDir)}/`));
7786
+ out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
7787
+ return out;
7788
+ };
7789
+ const rowsOf = (line) => {
7790
+ const visible = line.replace(ANSI, "").replace(/🐐|🦋/g, "gg");
7791
+ return Math.max(1, Math.ceil(visible.length / (out.columns || 80)));
7792
+ };
7793
+ const render = () => {
7794
+ const text = lines();
7795
+ let s = drawn > 0 ? `\x1B[${drawn}A\x1B[J` : "";
7796
+ s += `${text.join(`
7797
+ `)}
7798
+ `;
7799
+ out.write(s);
7800
+ drawn = text.reduce((n, l) => n + rowsOf(l), 0);
7801
+ };
7802
+ const timer = setInterval(() => {
7803
+ frame++;
7804
+ render();
7805
+ }, 120);
7806
+ timer.unref();
7807
+ return {
7808
+ auth: (text) => {
7809
+ authText = text;
7810
+ },
7811
+ setup: (state, seconds) => {
7812
+ setup = { state, seconds };
7813
+ render();
7814
+ },
7815
+ runs: (r) => {
7816
+ rows = r;
7817
+ render();
7818
+ },
7819
+ update: render,
7820
+ done: ({ runId, runsDir }) => {
7821
+ clearInterval(timer);
7822
+ finished = { runId, runsDir };
7823
+ render();
7824
+ }
7825
+ };
7826
+ }
7827
+
7482
7828
  // src/which.ts
7483
- import { accessSync, constants, statSync } from "node:fs";
7484
- import { delimiter, join as join2, resolve } from "node:path";
7829
+ import { accessSync, constants, statSync as statSync2 } from "node:fs";
7830
+ import { delimiter, join as join3, resolve } from "node:path";
7485
7831
  function resolveBin(bin) {
7486
7832
  const executable = (p) => {
7487
7833
  try {
7488
7834
  accessSync(p, constants.X_OK);
7489
- return statSync(p).isFile();
7835
+ return statSync2(p).isFile();
7490
7836
  } catch {
7491
7837
  return false;
7492
7838
  }
@@ -7494,8 +7840,8 @@ function resolveBin(bin) {
7494
7840
  if (bin.includes("/"))
7495
7841
  return executable(bin) ? resolve(bin) : undefined;
7496
7842
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
7497
- if (dir && executable(join2(dir, bin)))
7498
- return join2(dir, bin);
7843
+ if (dir && executable(join3(dir, bin)))
7844
+ return join3(dir, bin);
7499
7845
  }
7500
7846
  return;
7501
7847
  }
@@ -7550,25 +7896,16 @@ function parseArgs(args) {
7550
7896
  return opts;
7551
7897
  }
7552
7898
  function findSession(configDir, sessionId) {
7553
- const projects = join3(configDir, "projects");
7899
+ const projects = join4(configDir, "projects");
7554
7900
  if (!existsSync2(projects))
7555
7901
  return null;
7556
- for (const d of readdirSync(projects)) {
7557
- const f = join3(projects, d, `${sessionId}.jsonl`);
7902
+ for (const d of readdirSync2(projects)) {
7903
+ const f = join4(projects, d, `${sessionId}.jsonl`);
7558
7904
  if (existsSync2(f))
7559
7905
  return f;
7560
7906
  }
7561
7907
  return null;
7562
7908
  }
7563
- function usage(r) {
7564
- const parts = [];
7565
- if (r.cost !== null)
7566
- parts.push(`$${r.cost.toFixed(2)}`);
7567
- if (r.num_turns !== null)
7568
- parts.push(`${r.num_turns} turns`);
7569
- parts.push(`${Math.round(r.duration_ms / 1000)}s`);
7570
- return parts.join(", ");
7571
- }
7572
7909
  function newRunId() {
7573
7910
  const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
7574
7911
  return `${ts}-${randomBytes(2).toString("hex")}`;
@@ -7576,8 +7913,8 @@ function newRunId() {
7576
7913
  async function runOne(opts) {
7577
7914
  const { test, n } = opts;
7578
7915
  const started = Date.now();
7579
- const dir = join3(opts.runDir, test.name, String(n));
7580
- const workdir = join3(dir, "work");
7916
+ const dir = join4(opts.runDir, test.name, String(n));
7917
+ const workdir = join4(dir, "work");
7581
7918
  mkdirSync2(workdir, { recursive: true });
7582
7919
  const r = {
7583
7920
  test: test.name,
@@ -7590,8 +7927,9 @@ async function runOne(opts) {
7590
7927
  dir,
7591
7928
  workdir,
7592
7929
  session: null,
7593
- stderr: join3(dir, "stderr.txt"),
7594
- checksLog: join3(dir, "checks.txt"),
7930
+ transcript: join4(dir, "transcript.txt"),
7931
+ stderr: join4(dir, "stderr.txt"),
7932
+ checksLog: join4(dir, "checks.txt"),
7595
7933
  num_turns: null,
7596
7934
  cost: null,
7597
7935
  duration_ms: 0
@@ -7600,7 +7938,7 @@ async function runOne(opts) {
7600
7938
  r.status = status;
7601
7939
  r.reason = reason;
7602
7940
  r.duration_ms = Date.now() - started;
7603
- writeFileSync2(join3(dir, "run.json"), `${JSON.stringify(r, null, 2)}
7941
+ writeFileSync3(join4(dir, "run.json"), `${JSON.stringify(r, null, 2)}
7604
7942
  `);
7605
7943
  return r;
7606
7944
  };
@@ -7611,7 +7949,7 @@ async function runOne(opts) {
7611
7949
  commands: opts.workdirSetup,
7612
7950
  cwd: workdir,
7613
7951
  env: stepEnv,
7614
- logPath: join3(dir, "workdir_setup.txt")
7952
+ logPath: join4(dir, "workdir_setup.txt")
7615
7953
  });
7616
7954
  if (error)
7617
7955
  return done("error", `not graded: ${error}`);
@@ -7623,11 +7961,19 @@ async function runOne(opts) {
7623
7961
  cwd: workdir,
7624
7962
  env: { ...claudeEnv(opts.configDir), PATH: opts.stepEnv.PATH },
7625
7963
  stderr,
7626
- timeoutMs: test.timeout * 1000
7964
+ timeoutMs: test.timeout * 1000,
7965
+ onEvent: opts.onEvent
7627
7966
  });
7628
7967
  await new Promise((res) => stderr.end(res));
7629
7968
  if (outcome.sessionId)
7630
7969
  r.session = findSession(opts.configDir, outcome.sessionId);
7970
+ writeTranscript({
7971
+ path: r.transcript,
7972
+ title: `${test.name}#${n}`,
7973
+ prompt: r.prompt,
7974
+ events: outcome.events,
7975
+ workdir
7976
+ });
7631
7977
  r.num_turns = outcome.result?.num_turns ?? null;
7632
7978
  r.cost = outcome.result?.total_cost_usd ?? null;
7633
7979
  if (!outcome.ok)
@@ -7640,7 +7986,11 @@ async function runOne(opts) {
7640
7986
  r.checks.push(await runShell({
7641
7987
  run: render(c.run, opts.vars),
7642
7988
  cwd: workdir,
7643
- env: { ...stepEnv, FIREGOAT_SESSION: r.session },
7989
+ env: {
7990
+ ...stepEnv,
7991
+ FIREGOAT_SESSION: r.session,
7992
+ FIREGOAT_TRANSCRIPT: r.transcript
7993
+ },
7644
7994
  timeoutMs: CHECK_TIMEOUT_MS,
7645
7995
  log
7646
7996
  }));
@@ -7673,6 +8023,8 @@ async function evalCommand(args, root) {
7673
8023
  throw e;
7674
8024
  }
7675
8025
  const say = opts.json ? console.error : console.log;
8026
+ const stream = opts.json ? process.stderr : process.stdout;
8027
+ const ui = stream.isTTY ? ttyUI(stream, { project: basename(root), color: !process.env.NO_COLOR }) : plainUI(say);
7676
8028
  let config;
7677
8029
  try {
7678
8030
  config = loadConfig(root);
@@ -7700,9 +8052,9 @@ async function evalCommand(args, root) {
7700
8052
  if (!claudePath)
7701
8053
  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.`);
7702
8054
  const firegoatDir = ensureFiregoatDir(root);
7703
- const configDir = join3(firegoatDir, ".claude");
8055
+ const configDir = join4(firegoatDir, ".claude");
7704
8056
  mkdirSync2(configDir, { recursive: true });
7705
- 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");
8057
+ 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");
7706
8058
  const loginProblem = checkLogin({
7707
8059
  bin: claudePath,
7708
8060
  env: claudeEnv(configDir),
@@ -7711,8 +8063,8 @@ async function evalCommand(args, root) {
7711
8063
  if (loginProblem)
7712
8064
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
7713
8065
  const runId = newRunId();
7714
- const runDir = join3(firegoatDir, "runs", runId);
7715
- const out = join3(runDir, "out");
8066
+ const runDir = join4(firegoatDir, "runs", runId);
8067
+ const out = join4(runDir, "out");
7716
8068
  mkdirSync2(out, { recursive: true });
7717
8069
  const stepEnv = {
7718
8070
  ...process.env,
@@ -7720,22 +8072,49 @@ async function evalCommand(args, root) {
7720
8072
  FIREGOAT_OUT: out,
7721
8073
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
7722
8074
  };
8075
+ const rows = tests.flatMap((t) => Array.from({ length: runs }, (_, i) => ({
8076
+ test: t.name,
8077
+ n: i + 1,
8078
+ state: "queued",
8079
+ turns: 0
8080
+ })));
7723
8081
  if (config.setup.length > 0) {
7724
- say("setup...");
8082
+ const setupStarted = Date.now();
8083
+ ui.setup("running");
7725
8084
  const error = await runSteps({
7726
8085
  what: "setup",
7727
8086
  commands: config.setup,
7728
8087
  cwd: root,
7729
8088
  env: stepEnv,
7730
- logPath: join3(runDir, "setup.txt")
8089
+ logPath: join4(runDir, "setup.txt")
7731
8090
  });
8091
+ const secs = Math.round((Date.now() - setupStarted) / 1000);
8092
+ ui.setup(error ? "fail" : "ok", secs);
7732
8093
  if (error)
7733
8094
  return fatal(EXIT.notGraded, `not graded: ${error}`);
7734
8095
  }
8096
+ ui.runs(rows);
7735
8097
  const results = [];
7736
8098
  for (const test of tests)
7737
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();
7738
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(`
8115
+ `)[0];
8116
+ }
8117
+ },
7739
8118
  test,
7740
8119
  n,
7741
8120
  runDir,
@@ -7746,18 +8125,24 @@ async function evalCommand(args, root) {
7746
8125
  vars
7747
8126
  });
7748
8127
  results.push(r);
7749
- say(`${r.status.toUpperCase()} ${r.test}#${r.n} ${r.reason} (${usage(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();
7750
8138
  }
7751
8139
  const totals = { pass: 0, fail: 0, error: 0 };
7752
8140
  for (const r of results)
7753
8141
  totals[r.status]++;
7754
8142
  const doc = { runId, runsDir: runDir, totals, runs: results };
7755
- writeFileSync2(join3(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
8143
+ writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
7756
8144
  `);
7757
- const cost = results.reduce((sum, r) => sum + (r.cost ?? 0), 0);
7758
- say(`
7759
- ${totals.pass} passed, ${totals.fail} failed, ${totals.error} not graded (${results.length} runs, $${cost.toFixed(2)})`);
7760
- say(`runs: ${runDir} (end_goal is for you to judge: see run.json per run)`);
8145
+ ui.done({ runId, runsDir: runDir, rows });
7761
8146
  if (opts.json)
7762
8147
  console.log(JSON.stringify(doc, null, 2));
7763
8148
  return totals.error > 0 ? EXIT.notGraded : totals.fail > 0 ? EXIT.fail : EXIT.pass;
@@ -7765,8 +8150,8 @@ ${totals.pass} passed, ${totals.fail} failed, ${totals.error} not graded (${resu
7765
8150
 
7766
8151
  // src/init.ts
7767
8152
  import { spawnSync as spawnSync2 } from "node:child_process";
7768
- import { existsSync as existsSync3, writeFileSync as writeFileSync3 } from "node:fs";
7769
- import { basename, join as join4 } from "node:path";
8153
+ import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
8154
+ import { basename as basename2, join as join5 } from "node:path";
7770
8155
  import { createInterface } from "node:readline/promises";
7771
8156
  var SKILLS_ARGV = [
7772
8157
  "skills",
@@ -7869,48 +8254,73 @@ ${INIT_HELP}`);
7869
8254
  return EXIT.usage;
7870
8255
  }
7871
8256
  }
8257
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
8258
+ const color = interactive && !process.env.NO_COLOR;
8259
+ const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
8260
+ const width = Math.min(process.stdout.columns || 80, 80);
8261
+ const rule = paint("90", ` ${"─".repeat(width - 2)}`);
8262
+ const step = (n, label, status) => console.log(` ${paint("32", "●")} ${paint("90", String(n).padStart(2, "0"))} ${label.padEnd(28)}${paint("90", status)}`);
8263
+ 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)}`);
8267
+ console.log(rule);
8268
+ }
7872
8269
  ensureFiregoatDir(cwd);
7873
- const configPath = join4(cwd, CONFIG_FILE);
7874
- if (existsSync3(configPath) && !force)
7875
- console.log(`kept ${CONFIG_FILE} (--force replaces it)`);
7876
- else {
7877
- writeFileSync3(configPath, template(basename(cwd)));
7878
- console.log(`wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
7879
- }
7880
- const interactive = process.stdin.isTTY && process.stdout.isTTY;
7881
- if (skills && interactive)
7882
- skills = await askYes("Install the firegoat-onboarding skill for your coding agent?");
8270
+ const configPath = join5(cwd, CONFIG_FILE);
8271
+ const keep = existsSync3(configPath) && !force;
8272
+ if (!keep)
8273
+ writeFileSync4(configPath, template(basename2(cwd)));
8274
+ if (interactive) {
8275
+ step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
8276
+ console.log(paint("90", " │"));
8277
+ step(2, ".firegoat/", "ready");
8278
+ } else
8279
+ console.log(keep ? `kept ${CONFIG_FILE} (--force replaces it)` : `wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
8280
+ if (skills && interactive) {
8281
+ console.log(paint("90", " │"));
8282
+ skills = await askYes(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill?`);
8283
+ process.stdout.write("\x1B[1A\x1B[2K");
8284
+ }
7883
8285
  if (skills) {
7884
- console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
8286
+ if (!interactive)
8287
+ console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
7885
8288
  const env = { ...process.env };
7886
8289
  delete env.npm_config_package;
7887
8290
  const r = spawnSync2("npx", ["-y", ...SKILLS_ARGV], {
7888
8291
  cwd,
7889
8292
  env,
7890
- stdio: interactive ? "inherit" : "pipe",
8293
+ stdio: "pipe",
7891
8294
  encoding: "utf8"
7892
8295
  });
7893
8296
  if (r.error || r.status !== 0) {
7894
- if (!interactive)
7895
- process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
8297
+ process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
7896
8298
  console.error(`firegoat init: installing the skills failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
7897
8299
  return EXIT.notGraded;
7898
8300
  }
7899
- console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8301
+ if (interactive)
8302
+ step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
8303
+ else
8304
+ console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
7900
8305
  const ignored = spawnSync2("git", ["check-ignore", "-q", ".claude/skills"], {
7901
8306
  cwd
7902
8307
  });
7903
8308
  if (ignored.status === 0)
7904
8309
  console.error("warning: git ignores .claude/skills, so teammates and CI won't get the skills. Commit them with: git add -f .claude/skills");
7905
- }
7906
- console.log(interactive ? `
7907
- Paste this into your coding agent:
7908
-
7909
- ${AGENT_PROMPT}
7910
- ` : `
8310
+ } else if (interactive)
8311
+ step(3, "firegoat-onboarding skill", "skipped");
8312
+ if (!interactive) {
8313
+ console.log(`
7911
8314
  Prompt for your coding agent: ${AGENT_PROMPT}`);
7912
- if (interactive && copyToClipboard(AGENT_PROMPT))
7913
- console.log("\x1B[32mPrompt copied!\x1B[0m");
8315
+ return EXIT.pass;
8316
+ }
8317
+ console.log(rule);
8318
+ console.log(` next: hand this to your coding agent
8319
+ `);
8320
+ console.log(AGENT_PROMPT);
8321
+ console.log("");
8322
+ const copied = copyToClipboard(AGENT_PROMPT);
8323
+ console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}then: ${paint("36", "npx firegoat eval")}`);
7914
8324
  return EXIT.pass;
7915
8325
  }
7916
8326
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.9.4",
3
+ "version": "0.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"