firegoat 0.9.3 → 0.10.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 +157 -40
  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.10). 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.3",
6950
+ version: "0.10.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 { 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";
@@ -7239,6 +7239,7 @@ async function runClaude(opts) {
7239
7239
  });
7240
7240
  if (exited.error)
7241
7241
  return {
7242
+ events: [],
7242
7243
  ok: false,
7243
7244
  reason: `could not start ${opts.bin}: ${exited.error.message}`
7244
7245
  };
@@ -7250,16 +7251,21 @@ async function runClaude(opts) {
7250
7251
  return;
7251
7252
  }
7252
7253
  });
7253
- const init = events[0];
7254
- if (init?.type !== "system" || init.subtype !== "init")
7254
+ const init = events.find((e) => e?.type === "system" && e.subtype === "init");
7255
+ if (!init) {
7256
+ const said = stdout.trim().split(`
7257
+ `)[0]?.slice(0, 300);
7255
7258
  return {
7259
+ events,
7256
7260
  ok: false,
7257
- reason: `claude did not start a session (exit ${exited.code ?? "by signal"}), see stderr.txt`
7261
+ reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7258
7262
  };
7263
+ }
7259
7264
  const sessionId = String(init.session_id);
7260
7265
  const result = events.findLast((e) => e?.type === "result");
7261
7266
  if (exited.timedOut)
7262
7267
  return {
7268
+ events,
7263
7269
  ok: false,
7264
7270
  reason: `timed out after ${opts.timeoutMs / 1000}s`,
7265
7271
  result,
@@ -7267,18 +7273,20 @@ async function runClaude(opts) {
7267
7273
  };
7268
7274
  if (!result)
7269
7275
  return {
7276
+ events,
7270
7277
  ok: false,
7271
7278
  reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7272
7279
  sessionId
7273
7280
  };
7274
7281
  if (result.is_error === true || result.subtype !== "success")
7275
7282
  return {
7283
+ events,
7276
7284
  ok: false,
7277
- reason: `claude result is an error (subtype=${result.subtype})`,
7285
+ reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7278
7286
  result,
7279
7287
  sessionId
7280
7288
  };
7281
- return { ok: true, result, sessionId };
7289
+ return { events, ok: true, result, sessionId };
7282
7290
  }
7283
7291
  async function runShell(opts) {
7284
7292
  opts.log.write(`$ ${opts.run}
@@ -7394,8 +7402,10 @@ firegoat.yaml reference:
7394
7402
 
7395
7403
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7396
7404
  .firegoat/fixtures/ and copy them in setup or workdir_setup. setup, workdir_setup and checks get the vars as env vars,
7397
- plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; checks also get $FIREGOAT_SESSION (the
7398
- claude session file, one JSON event per line).
7405
+ plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short
7406
+ plain-text transcript ("[1] $ <command>" per shell call, "[2] Write <path>", then
7407
+ each output), and
7408
+ $FIREGOAT_SESSION (claude's full session file, JSON lines).
7399
7409
 
7400
7410
  Example:
7401
7411
  setup: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -7409,9 +7419,9 @@ Example:
7409
7419
  A script sends one event with the SDK's client (not raw HTTP) and prints its
7410
7420
  id, without installing acme from the registry.
7411
7421
  checks:
7412
- - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_SESSION"
7422
+ - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
7413
7423
  - run: |
7414
- ! grep -Eq 'npm (install|i) [^"]*acme' "$FIREGOAT_SESSION"
7424
+ ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7415
7425
 
7416
7426
  Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7417
7427
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--var KEY=value].. [--json]
@@ -7437,12 +7447,15 @@ Output: .firegoat/runs/<runId>/
7437
7447
  summary.json every run, plus totals
7438
7448
  <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7439
7449
  end_goal, checks [{run, code}], and paths: workdir,
7440
- session (claude's session file), stderr, checksLog
7450
+ transcript, session (claude's session file), stderr,
7451
+ checksLog
7452
+ <test>/<n>/transcript.txt what the agent did: each tool call with its command,
7453
+ short output, its final answer, files it made
7441
7454
  setup.txt, <test>/<n>/workdir_setup.txt, checks.txt logs
7442
7455
 
7443
7456
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
7444
- yours to judge. The session is one JSON event per line: "assistant" messages hold
7445
- "tool_use" items (the agent's commands), "user" messages their output.
7457
+ yours to judge: read transcript.txt first (a few KB); the session file has everything
7458
+ (often 50x larger).
7446
7459
 
7447
7460
  Examples:
7448
7461
  npx firegoat eval --json all tests, JSON summary on stdout
@@ -7476,14 +7489,106 @@ async function runSteps(opts) {
7476
7489
  }
7477
7490
  }
7478
7491
 
7492
+ // src/transcript.ts
7493
+ import { readdirSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
7494
+ import { join as join2, relative } from "node:path";
7495
+ var OUTPUT_LINES = 15;
7496
+ var OUTPUT_CHARS = 1500;
7497
+ var TEXT_CHARS = 1500;
7498
+ var SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "__pycache__"]);
7499
+ function truncate(text, maxLines, maxChars) {
7500
+ let out = text.length > maxChars ? text.slice(0, maxChars) : text;
7501
+ const lines = out.split(`
7502
+ `);
7503
+ if (lines.length > maxLines)
7504
+ out = lines.slice(0, maxLines).join(`
7505
+ `);
7506
+ return out.length < text.length ? `${out}
7507
+ … (${text.length - out.length} more chars)` : out;
7508
+ }
7509
+ function indent(text) {
7510
+ return text.split(`
7511
+ `).map((l) => ` ${l}`).join(`
7512
+ `);
7513
+ }
7514
+ function describeCall(name, input) {
7515
+ if (typeof input.command === "string")
7516
+ return `$ ${input.command}`;
7517
+ for (const key of ["file_path", "path", "url", "pattern", "query"])
7518
+ if (typeof input[key] === "string")
7519
+ return `${name} ${input[key]}`;
7520
+ return `${name} ${truncate(JSON.stringify(input), 1, 300)}`;
7521
+ }
7522
+ function resultText(content) {
7523
+ if (typeof content === "string")
7524
+ return content;
7525
+ if (Array.isArray(content))
7526
+ return content.map((c) => typeof c.text === "string" ? c.text : `[${c.type}]`).join(`
7527
+ `);
7528
+ return "";
7529
+ }
7530
+ function workdirFiles(dir) {
7531
+ const out = [];
7532
+ const walk = (d) => {
7533
+ for (const name of readdirSync(d)) {
7534
+ if (SKIP_DIRS.has(name))
7535
+ continue;
7536
+ const p = join2(d, name);
7537
+ if (statSync(p).isDirectory())
7538
+ walk(p);
7539
+ else
7540
+ out.push(relative(dir, p));
7541
+ }
7542
+ };
7543
+ walk(dir);
7544
+ return out.sort();
7545
+ }
7546
+ function writeTranscript(opts) {
7547
+ const out = [`# ${opts.title}`, "", "## prompt", opts.prompt, "", "## steps"];
7548
+ let step = 0;
7549
+ let final = "";
7550
+ let lastText = -1;
7551
+ for (const e of opts.events) {
7552
+ const message = e?.message;
7553
+ const content = Array.isArray(message?.content) ? message.content : [];
7554
+ if (e?.type === "assistant")
7555
+ for (const b of content) {
7556
+ if (b.type === "tool_use")
7557
+ out.push(`[${++step}] ${describeCall(String(b.name), b.input ?? {})}`);
7558
+ else if (b.type === "text" && typeof b.text === "string") {
7559
+ lastText = out.length;
7560
+ out.push(`agent: ${truncate(b.text, 30, TEXT_CHARS)}`);
7561
+ }
7562
+ }
7563
+ else if (e?.type === "user")
7564
+ for (const b of content) {
7565
+ if (b.type !== "tool_result")
7566
+ continue;
7567
+ const text = resultText(b.content).trimEnd();
7568
+ const status = b.is_error === true ? "error" : "ok";
7569
+ out.push(text ? ` → ${status}
7570
+ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
7571
+ }
7572
+ else if (e?.type === "result" && typeof e.result === "string")
7573
+ final = e.result;
7574
+ }
7575
+ if (lastText === out.length - 1 && final && out[lastText] === `agent: ${truncate(final, 30, TEXT_CHARS)}`)
7576
+ out.pop();
7577
+ out.push("", "## final answer", final || "(none)");
7578
+ out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
7579
+ writeFileSync2(opts.path, `${out.join(`
7580
+ `)}
7581
+ `);
7582
+ }
7583
+
7479
7584
  // src/which.ts
7480
- import { accessSync, constants, statSync } from "node:fs";
7481
- import { delimiter, join as join2, resolve } from "node:path";
7585
+ import { accessSync, constants, statSync as statSync2 } from "node:fs";
7586
+ import { delimiter, join as join3, resolve } from "node:path";
7482
7587
  function resolveBin(bin) {
7483
7588
  const executable = (p) => {
7484
7589
  try {
7485
7590
  accessSync(p, constants.X_OK);
7486
- return statSync(p).isFile();
7591
+ return statSync2(p).isFile();
7487
7592
  } catch {
7488
7593
  return false;
7489
7594
  }
@@ -7491,8 +7596,8 @@ function resolveBin(bin) {
7491
7596
  if (bin.includes("/"))
7492
7597
  return executable(bin) ? resolve(bin) : undefined;
7493
7598
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
7494
- if (dir && executable(join2(dir, bin)))
7495
- return join2(dir, bin);
7599
+ if (dir && executable(join3(dir, bin)))
7600
+ return join3(dir, bin);
7496
7601
  }
7497
7602
  return;
7498
7603
  }
@@ -7547,11 +7652,11 @@ function parseArgs(args) {
7547
7652
  return opts;
7548
7653
  }
7549
7654
  function findSession(configDir, sessionId) {
7550
- const projects = join3(configDir, "projects");
7655
+ const projects = join4(configDir, "projects");
7551
7656
  if (!existsSync2(projects))
7552
7657
  return null;
7553
- for (const d of readdirSync(projects)) {
7554
- const f = join3(projects, d, `${sessionId}.jsonl`);
7658
+ for (const d of readdirSync2(projects)) {
7659
+ const f = join4(projects, d, `${sessionId}.jsonl`);
7555
7660
  if (existsSync2(f))
7556
7661
  return f;
7557
7662
  }
@@ -7573,8 +7678,8 @@ function newRunId() {
7573
7678
  async function runOne(opts) {
7574
7679
  const { test, n } = opts;
7575
7680
  const started = Date.now();
7576
- const dir = join3(opts.runDir, test.name, String(n));
7577
- const workdir = join3(dir, "work");
7681
+ const dir = join4(opts.runDir, test.name, String(n));
7682
+ const workdir = join4(dir, "work");
7578
7683
  mkdirSync2(workdir, { recursive: true });
7579
7684
  const r = {
7580
7685
  test: test.name,
@@ -7587,8 +7692,9 @@ async function runOne(opts) {
7587
7692
  dir,
7588
7693
  workdir,
7589
7694
  session: null,
7590
- stderr: join3(dir, "stderr.txt"),
7591
- checksLog: join3(dir, "checks.txt"),
7695
+ transcript: join4(dir, "transcript.txt"),
7696
+ stderr: join4(dir, "stderr.txt"),
7697
+ checksLog: join4(dir, "checks.txt"),
7592
7698
  num_turns: null,
7593
7699
  cost: null,
7594
7700
  duration_ms: 0
@@ -7597,7 +7703,7 @@ async function runOne(opts) {
7597
7703
  r.status = status;
7598
7704
  r.reason = reason;
7599
7705
  r.duration_ms = Date.now() - started;
7600
- writeFileSync2(join3(dir, "run.json"), `${JSON.stringify(r, null, 2)}
7706
+ writeFileSync3(join4(dir, "run.json"), `${JSON.stringify(r, null, 2)}
7601
7707
  `);
7602
7708
  return r;
7603
7709
  };
@@ -7608,7 +7714,7 @@ async function runOne(opts) {
7608
7714
  commands: opts.workdirSetup,
7609
7715
  cwd: workdir,
7610
7716
  env: stepEnv,
7611
- logPath: join3(dir, "workdir_setup.txt")
7717
+ logPath: join4(dir, "workdir_setup.txt")
7612
7718
  });
7613
7719
  if (error)
7614
7720
  return done("error", `not graded: ${error}`);
@@ -7625,6 +7731,13 @@ async function runOne(opts) {
7625
7731
  await new Promise((res) => stderr.end(res));
7626
7732
  if (outcome.sessionId)
7627
7733
  r.session = findSession(opts.configDir, outcome.sessionId);
7734
+ writeTranscript({
7735
+ path: r.transcript,
7736
+ title: `${test.name}#${n}`,
7737
+ prompt: r.prompt,
7738
+ events: outcome.events,
7739
+ workdir
7740
+ });
7628
7741
  r.num_turns = outcome.result?.num_turns ?? null;
7629
7742
  r.cost = outcome.result?.total_cost_usd ?? null;
7630
7743
  if (!outcome.ok)
@@ -7637,7 +7750,11 @@ async function runOne(opts) {
7637
7750
  r.checks.push(await runShell({
7638
7751
  run: render(c.run, opts.vars),
7639
7752
  cwd: workdir,
7640
- env: { ...stepEnv, FIREGOAT_SESSION: r.session },
7753
+ env: {
7754
+ ...stepEnv,
7755
+ FIREGOAT_SESSION: r.session,
7756
+ FIREGOAT_TRANSCRIPT: r.transcript
7757
+ },
7641
7758
  timeoutMs: CHECK_TIMEOUT_MS,
7642
7759
  log
7643
7760
  }));
@@ -7697,7 +7814,7 @@ async function evalCommand(args, root) {
7697
7814
  if (!claudePath)
7698
7815
  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.`);
7699
7816
  const firegoatDir = ensureFiregoatDir(root);
7700
- const configDir = join3(firegoatDir, ".claude");
7817
+ const configDir = join4(firegoatDir, ".claude");
7701
7818
  mkdirSync2(configDir, { recursive: true });
7702
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");
7703
7820
  const loginProblem = checkLogin({
@@ -7708,8 +7825,8 @@ async function evalCommand(args, root) {
7708
7825
  if (loginProblem)
7709
7826
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
7710
7827
  const runId = newRunId();
7711
- const runDir = join3(firegoatDir, "runs", runId);
7712
- const out = join3(runDir, "out");
7828
+ const runDir = join4(firegoatDir, "runs", runId);
7829
+ const out = join4(runDir, "out");
7713
7830
  mkdirSync2(out, { recursive: true });
7714
7831
  const stepEnv = {
7715
7832
  ...process.env,
@@ -7724,7 +7841,7 @@ async function evalCommand(args, root) {
7724
7841
  commands: config.setup,
7725
7842
  cwd: root,
7726
7843
  env: stepEnv,
7727
- logPath: join3(runDir, "setup.txt")
7844
+ logPath: join4(runDir, "setup.txt")
7728
7845
  });
7729
7846
  if (error)
7730
7847
  return fatal(EXIT.notGraded, `not graded: ${error}`);
@@ -7749,7 +7866,7 @@ async function evalCommand(args, root) {
7749
7866
  for (const r of results)
7750
7867
  totals[r.status]++;
7751
7868
  const doc = { runId, runsDir: runDir, totals, runs: results };
7752
- writeFileSync2(join3(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
7869
+ writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
7753
7870
  `);
7754
7871
  const cost = results.reduce((sum, r) => sum + (r.cost ?? 0), 0);
7755
7872
  say(`
@@ -7762,8 +7879,8 @@ ${totals.pass} passed, ${totals.fail} failed, ${totals.error} not graded (${resu
7762
7879
 
7763
7880
  // src/init.ts
7764
7881
  import { spawnSync as spawnSync2 } from "node:child_process";
7765
- import { existsSync as existsSync3, writeFileSync as writeFileSync3 } from "node:fs";
7766
- import { basename, join as join4 } from "node:path";
7882
+ import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
7883
+ import { basename, join as join5 } from "node:path";
7767
7884
  import { createInterface } from "node:readline/promises";
7768
7885
  var SKILLS_ARGV = [
7769
7886
  "skills",
@@ -7867,11 +7984,11 @@ ${INIT_HELP}`);
7867
7984
  }
7868
7985
  }
7869
7986
  ensureFiregoatDir(cwd);
7870
- const configPath = join4(cwd, CONFIG_FILE);
7987
+ const configPath = join5(cwd, CONFIG_FILE);
7871
7988
  if (existsSync3(configPath) && !force)
7872
7989
  console.log(`kept ${CONFIG_FILE} (--force replaces it)`);
7873
7990
  else {
7874
- writeFileSync3(configPath, template(basename(cwd)));
7991
+ writeFileSync4(configPath, template(basename(cwd)));
7875
7992
  console.log(`wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
7876
7993
  }
7877
7994
  const interactive = process.stdin.isTTY && process.stdout.isTTY;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"