firegoat 0.9.4 → 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 +150 -36
  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.4",
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
  };
@@ -7255,6 +7256,7 @@ async function runClaude(opts) {
7255
7256
  const said = stdout.trim().split(`
7256
7257
  `)[0]?.slice(0, 300);
7257
7258
  return {
7259
+ events,
7258
7260
  ok: false,
7259
7261
  reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7260
7262
  };
@@ -7263,6 +7265,7 @@ async function runClaude(opts) {
7263
7265
  const result = events.findLast((e) => e?.type === "result");
7264
7266
  if (exited.timedOut)
7265
7267
  return {
7268
+ events,
7266
7269
  ok: false,
7267
7270
  reason: `timed out after ${opts.timeoutMs / 1000}s`,
7268
7271
  result,
@@ -7270,18 +7273,20 @@ async function runClaude(opts) {
7270
7273
  };
7271
7274
  if (!result)
7272
7275
  return {
7276
+ events,
7273
7277
  ok: false,
7274
7278
  reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7275
7279
  sessionId
7276
7280
  };
7277
7281
  if (result.is_error === true || result.subtype !== "success")
7278
7282
  return {
7283
+ events,
7279
7284
  ok: false,
7280
7285
  reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7281
7286
  result,
7282
7287
  sessionId
7283
7288
  };
7284
- return { ok: true, result, sessionId };
7289
+ return { events, ok: true, result, sessionId };
7285
7290
  }
7286
7291
  async function runShell(opts) {
7287
7292
  opts.log.write(`$ ${opts.run}
@@ -7397,8 +7402,10 @@ firegoat.yaml reference:
7397
7402
 
7398
7403
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7399
7404
  .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).
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).
7402
7409
 
7403
7410
  Example:
7404
7411
  setup: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -7412,9 +7419,9 @@ Example:
7412
7419
  A script sends one event with the SDK's client (not raw HTTP) and prints its
7413
7420
  id, without installing acme from the registry.
7414
7421
  checks:
7415
- - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_SESSION"
7422
+ - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
7416
7423
  - run: |
7417
- ! grep -Eq 'npm (install|i) [^"]*acme' "$FIREGOAT_SESSION"
7424
+ ! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
7418
7425
 
7419
7426
  Exit codes: 0 done, 2 the skills install failed, 3 usage error.`;
7420
7427
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--var KEY=value].. [--json]
@@ -7440,12 +7447,15 @@ Output: .firegoat/runs/<runId>/
7440
7447
  summary.json every run, plus totals
7441
7448
  <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7442
7449
  end_goal, checks [{run, code}], and paths: workdir,
7443
- 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
7444
7454
  setup.txt, <test>/<n>/workdir_setup.txt, checks.txt logs
7445
7455
 
7446
7456
  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.
7457
+ yours to judge: read transcript.txt first (a few KB); the session file has everything
7458
+ (often 50x larger).
7449
7459
 
7450
7460
  Examples:
7451
7461
  npx firegoat eval --json all tests, JSON summary on stdout
@@ -7479,14 +7489,106 @@ async function runSteps(opts) {
7479
7489
  }
7480
7490
  }
7481
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
+
7482
7584
  // src/which.ts
7483
- import { accessSync, constants, statSync } from "node:fs";
7484
- 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";
7485
7587
  function resolveBin(bin) {
7486
7588
  const executable = (p) => {
7487
7589
  try {
7488
7590
  accessSync(p, constants.X_OK);
7489
- return statSync(p).isFile();
7591
+ return statSync2(p).isFile();
7490
7592
  } catch {
7491
7593
  return false;
7492
7594
  }
@@ -7494,8 +7596,8 @@ function resolveBin(bin) {
7494
7596
  if (bin.includes("/"))
7495
7597
  return executable(bin) ? resolve(bin) : undefined;
7496
7598
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
7497
- if (dir && executable(join2(dir, bin)))
7498
- return join2(dir, bin);
7599
+ if (dir && executable(join3(dir, bin)))
7600
+ return join3(dir, bin);
7499
7601
  }
7500
7602
  return;
7501
7603
  }
@@ -7550,11 +7652,11 @@ function parseArgs(args) {
7550
7652
  return opts;
7551
7653
  }
7552
7654
  function findSession(configDir, sessionId) {
7553
- const projects = join3(configDir, "projects");
7655
+ const projects = join4(configDir, "projects");
7554
7656
  if (!existsSync2(projects))
7555
7657
  return null;
7556
- for (const d of readdirSync(projects)) {
7557
- const f = join3(projects, d, `${sessionId}.jsonl`);
7658
+ for (const d of readdirSync2(projects)) {
7659
+ const f = join4(projects, d, `${sessionId}.jsonl`);
7558
7660
  if (existsSync2(f))
7559
7661
  return f;
7560
7662
  }
@@ -7576,8 +7678,8 @@ function newRunId() {
7576
7678
  async function runOne(opts) {
7577
7679
  const { test, n } = opts;
7578
7680
  const started = Date.now();
7579
- const dir = join3(opts.runDir, test.name, String(n));
7580
- const workdir = join3(dir, "work");
7681
+ const dir = join4(opts.runDir, test.name, String(n));
7682
+ const workdir = join4(dir, "work");
7581
7683
  mkdirSync2(workdir, { recursive: true });
7582
7684
  const r = {
7583
7685
  test: test.name,
@@ -7590,8 +7692,9 @@ async function runOne(opts) {
7590
7692
  dir,
7591
7693
  workdir,
7592
7694
  session: null,
7593
- stderr: join3(dir, "stderr.txt"),
7594
- checksLog: join3(dir, "checks.txt"),
7695
+ transcript: join4(dir, "transcript.txt"),
7696
+ stderr: join4(dir, "stderr.txt"),
7697
+ checksLog: join4(dir, "checks.txt"),
7595
7698
  num_turns: null,
7596
7699
  cost: null,
7597
7700
  duration_ms: 0
@@ -7600,7 +7703,7 @@ async function runOne(opts) {
7600
7703
  r.status = status;
7601
7704
  r.reason = reason;
7602
7705
  r.duration_ms = Date.now() - started;
7603
- writeFileSync2(join3(dir, "run.json"), `${JSON.stringify(r, null, 2)}
7706
+ writeFileSync3(join4(dir, "run.json"), `${JSON.stringify(r, null, 2)}
7604
7707
  `);
7605
7708
  return r;
7606
7709
  };
@@ -7611,7 +7714,7 @@ async function runOne(opts) {
7611
7714
  commands: opts.workdirSetup,
7612
7715
  cwd: workdir,
7613
7716
  env: stepEnv,
7614
- logPath: join3(dir, "workdir_setup.txt")
7717
+ logPath: join4(dir, "workdir_setup.txt")
7615
7718
  });
7616
7719
  if (error)
7617
7720
  return done("error", `not graded: ${error}`);
@@ -7628,6 +7731,13 @@ async function runOne(opts) {
7628
7731
  await new Promise((res) => stderr.end(res));
7629
7732
  if (outcome.sessionId)
7630
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
+ });
7631
7741
  r.num_turns = outcome.result?.num_turns ?? null;
7632
7742
  r.cost = outcome.result?.total_cost_usd ?? null;
7633
7743
  if (!outcome.ok)
@@ -7640,7 +7750,11 @@ async function runOne(opts) {
7640
7750
  r.checks.push(await runShell({
7641
7751
  run: render(c.run, opts.vars),
7642
7752
  cwd: workdir,
7643
- env: { ...stepEnv, FIREGOAT_SESSION: r.session },
7753
+ env: {
7754
+ ...stepEnv,
7755
+ FIREGOAT_SESSION: r.session,
7756
+ FIREGOAT_TRANSCRIPT: r.transcript
7757
+ },
7644
7758
  timeoutMs: CHECK_TIMEOUT_MS,
7645
7759
  log
7646
7760
  }));
@@ -7700,7 +7814,7 @@ async function evalCommand(args, root) {
7700
7814
  if (!claudePath)
7701
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.`);
7702
7816
  const firegoatDir = ensureFiregoatDir(root);
7703
- const configDir = join3(firegoatDir, ".claude");
7817
+ const configDir = join4(firegoatDir, ".claude");
7704
7818
  mkdirSync2(configDir, { recursive: true });
7705
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");
7706
7820
  const loginProblem = checkLogin({
@@ -7711,8 +7825,8 @@ async function evalCommand(args, root) {
7711
7825
  if (loginProblem)
7712
7826
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
7713
7827
  const runId = newRunId();
7714
- const runDir = join3(firegoatDir, "runs", runId);
7715
- const out = join3(runDir, "out");
7828
+ const runDir = join4(firegoatDir, "runs", runId);
7829
+ const out = join4(runDir, "out");
7716
7830
  mkdirSync2(out, { recursive: true });
7717
7831
  const stepEnv = {
7718
7832
  ...process.env,
@@ -7727,7 +7841,7 @@ async function evalCommand(args, root) {
7727
7841
  commands: config.setup,
7728
7842
  cwd: root,
7729
7843
  env: stepEnv,
7730
- logPath: join3(runDir, "setup.txt")
7844
+ logPath: join4(runDir, "setup.txt")
7731
7845
  });
7732
7846
  if (error)
7733
7847
  return fatal(EXIT.notGraded, `not graded: ${error}`);
@@ -7752,7 +7866,7 @@ async function evalCommand(args, root) {
7752
7866
  for (const r of results)
7753
7867
  totals[r.status]++;
7754
7868
  const doc = { runId, runsDir: runDir, totals, runs: results };
7755
- writeFileSync2(join3(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
7869
+ writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
7756
7870
  `);
7757
7871
  const cost = results.reduce((sum, r) => sum + (r.cost ?? 0), 0);
7758
7872
  say(`
@@ -7765,8 +7879,8 @@ ${totals.pass} passed, ${totals.fail} failed, ${totals.error} not graded (${resu
7765
7879
 
7766
7880
  // src/init.ts
7767
7881
  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";
7882
+ import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
7883
+ import { basename, join as join5 } from "node:path";
7770
7884
  import { createInterface } from "node:readline/promises";
7771
7885
  var SKILLS_ARGV = [
7772
7886
  "skills",
@@ -7870,11 +7984,11 @@ ${INIT_HELP}`);
7870
7984
  }
7871
7985
  }
7872
7986
  ensureFiregoatDir(cwd);
7873
- const configPath = join4(cwd, CONFIG_FILE);
7987
+ const configPath = join5(cwd, CONFIG_FILE);
7874
7988
  if (existsSync3(configPath) && !force)
7875
7989
  console.log(`kept ${CONFIG_FILE} (--force replaces it)`);
7876
7990
  else {
7877
- writeFileSync3(configPath, template(basename(cwd)));
7991
+ writeFileSync4(configPath, template(basename(cwd)));
7878
7992
  console.log(`wrote ${CONFIG_FILE}: edit the quickstart test to fit your users`);
7879
7993
  }
7880
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.4",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"