firegoat 0.21.0 → 0.22.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 +12 -2
  2. package/dist/cli.js +118 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs th
12
12
  npx firegoat@latest eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
13
13
  ```
14
14
 
15
- That's it: two commands. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat X.Y.Z`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
15
+ That's it: two commands. `npx firegoat@latest eval --dry-run` checks `firegoat.yaml` and lists the runs it would start (`<test>#<n> [model]`, with step counts) without building, running claude or uploading; config errors exit 3 as in a real eval. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat X.Y.Z`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
16
16
 
17
17
  `init` installs the firegoat-onboarding skill globally, into `~/.claude/skills/` (in a terminal it asks: global, this repo, or none). `--local-skills` puts it in this repo's `.claude/skills/` instead (commit it so teammates and CI get it); `--no-skills` skips it.
18
18
 
@@ -224,7 +224,17 @@ Limits:
224
224
  - 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, build, install, 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. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too (claude then gets the run's own `FIREGOAT_OUT`, `FIREGOAT_WORKDIR` and `FIREGOAT_HOME`, for MCP configs; the agent's commands see them too), and so are the vars a parent Claude Code session sets (when firegoat runs from claude, or its agent runs firegoat), so the claude under test doesn't act as a nested session: `CLAUDECODE`, `CLAUDE_CODE_*`, `CLAUDE_PID`, `CLAUDE_ENV_FILE`, `CLAUDE_SESSION_ID`, `CLAUDE_PROJECT_DIR`, `CLAUDE_EFFORT`, `CLAUDE_AGENT_SDK_*`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA`, `CLAUDE_SKILL_DIR`. Login and provider vars stay: `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN`, `CLAUDE_CODE_USE_*` (Bedrock, Vertex, ...), `CLAUDE_CODE_SKIP_*_AUTH`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE`.
225
225
  - Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session and HOME; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
226
226
  - 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.
227
- - 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`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, `home/` and `home.env` (the run's HOME), 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.
227
+ - 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`; format below), `run.json` (status, prompt, end_goal, check results, paths), `work/`, `home/` and `home.env` (the run's HOME), 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.
228
+
229
+ ### The transcript
230
+
231
+ `transcript.txt` is greppable line by line; `n` counts the agent's tool calls:
232
+
233
+ - `[n] $ <command>`: a shell call, on every line of the command (heredocs and multi-line scripts too), never cut. So `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands.
234
+ - `[n] <Tool> <target>`: a tool (MCP ones too) whose input has a `file_path`, `path`, `url`, `pattern` or `query`: just that value, e.g. `[2] Write src/app.ts`. The written body is not in the transcript: check the file in the workdir.
235
+ - `[n] <Tool> <json>`: any other tool, MCP tools included, with its whole input as one line of JSON, cut at 4000 chars: `[3] mcp__acme__push_html {"canvas":"demo","html":"<p>Hi</p>"}`. So `grep -Eq '^\[[0-9]+\] mcp__acme__push_html .*<h1>' "$FIREGOAT_TRANSCRIPT"` checks what the agent pushed.
236
+ - ` → ok` or ` → error`, then the tool's output indented 4 spaces, cut at 15 lines or 1500 chars (`… (N more chars)`).
237
+ - `agent: ` on every line of the agent's text (cut at 30 lines or 1500 chars) and of its final answer (uncut), then the files in the workdir (without `node_modules`, `.git`, `.venv`, `__pycache__`).
228
238
 
229
239
  `firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `cleanup`, `vars`, `env`, `claude_args`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`, `install`, `cleanup`, `claude_args`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
230
240
 
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.21.0",
6950
+ version: "0.22.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -8010,11 +8010,9 @@ firegoat.yaml reference:
8010
8010
  vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
8011
8011
  get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
8012
8012
  Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
8013
- transcript, greppable line by line: "[1] $ <command>" on every line of a shell
8014
- call (one line per line of the command, same n), "[2] Write <path>" for other
8015
- tools, each output indented, and "agent: " on every line of the agent's text and
8016
- final answer. So '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also
8017
- get $FIREGOAT_SESSION (claude's full session file, JSON lines).
8013
+ transcript, greppable line by line (format: eval --help, Transcript). So
8014
+ '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also get
8015
+ $FIREGOAT_SESSION (claude's full session file, JSON lines).
8018
8016
 
8019
8017
  The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
8020
8018
  (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
@@ -8098,7 +8096,8 @@ page.
8098
8096
  Exit codes: 0 logged out (or was not logged in), 3 the file or ~/.firegoat is a
8099
8097
  symlink, not a regular file/directory, or someone else's (left alone).`;
8100
8098
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--parallel N]
8101
- [--var KEY=value].. [--json] [--no-upload] [--claude-bin PATH]
8099
+ [--var KEY=value].. [--json] [--no-upload] [--dry-run]
8100
+ [--claude-bin PATH]
8102
8101
 
8103
8102
  Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
8104
8103
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
@@ -8152,6 +8151,14 @@ fixtures and build: stay in the current folder.
8152
8151
  --json The summary as JSON on stdout (or {error, exitCode} if eval
8153
8152
  stops early); progress goes to stderr
8154
8153
  --no-upload Keep runs local even when logged in to firegoat.ai
8154
+ --dry-run Check firegoat.yaml as eval would (test names, --var,
8155
+ --model, missing vars), print each planned run
8156
+ ("<test>#<n> [model]" with its install, checks and cleanup
8157
+ counts and rendered claude_args, paths under
8158
+ runs/<runId>/) and exit 0. Builds nothing, starts no
8159
+ claude, uploads nothing, writes no files. Config errors
8160
+ exit 3. With --json: {dryRun, build, runs: [{test, n,
8161
+ model, claudeArgs, install, checks, cleanup}]}
8155
8162
  --claude-bin PATH Path to claude (default: claude on PATH)
8156
8163
 
8157
8164
  Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
@@ -8181,14 +8188,25 @@ Output: .firegoat/runs/<runId>/
8181
8188
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8182
8189
  url} | {status: "failed", error} | {status:
8183
8190
  "skipped", reason}, null when not uploading)
8184
- <test>/<n>/transcript.txt What the agent did: each tool call with its command
8185
- ("[n] $ " on every line of a shell call), short output
8186
- (indented), its text and final answer ("agent: " on
8187
- every line), files it made
8191
+ <test>/<n>/transcript.txt What the agent did, below
8188
8192
  <test>/<n>/home/, home.env The run's HOME, and the file claude sources
8189
8193
  before the agent's Bash commands
8190
8194
  build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
8191
8195
 
8196
+ Transcript ($FIREGOAT_TRANSCRIPT), greppable line by line; n counts tool calls:
8197
+ [n] $ <command> A shell call, on every line of the command, uncut
8198
+ [n] <Tool> <target> A tool (MCP too) with a file_path, path, url, pattern
8199
+ or query input: that value, e.g. "[2] Write src/a.ts"
8200
+ [n] <Tool> <json> Any other tool, MCP ones included: its input as one
8201
+ line of JSON, e.g. [3] mcp__<server>__<tool> {"q":"x"};
8202
+ cut at 4000 chars
8203
+ → ok | → error Then the tool's output, indented 4 spaces, cut at 15
8204
+ lines or 1500 chars ("… (N more chars)")
8205
+ agent: <text> Every line of the agent's text (cut at 30 lines or
8206
+ 1500 chars) and of its final answer (uncut)
8207
+ Then the files in the workdir (not node_modules, .git, .venv, __pycache__).
8208
+ A written file's body is not in the transcript: check the file in the workdir.
8209
+
8192
8210
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
8193
8211
  yours to judge: read transcript.txt first (a few KB); the session file has everything
8194
8212
  (often 50x larger).
@@ -8199,6 +8217,7 @@ Examples:
8199
8217
  npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
8200
8218
  Each test on both models
8201
8219
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
8220
+ npx firegoat eval --dry-run Validate firegoat.yaml, list the runs
8202
8221
 
8203
8222
  A run can take minutes (default timeout 600s per test).
8204
8223
 
@@ -8295,6 +8314,7 @@ import { join as join4, relative } from "node:path";
8295
8314
  var OUTPUT_LINES = 15;
8296
8315
  var OUTPUT_CHARS = 1500;
8297
8316
  var TEXT_CHARS = 1500;
8317
+ var INPUT_CHARS = 4000;
8298
8318
  var SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "__pycache__"]);
8299
8319
  function truncate(text, maxLines, maxChars) {
8300
8320
  let out = text.length > maxChars ? text.slice(0, maxChars) : text;
@@ -8319,7 +8339,7 @@ function describeCall(step, name, input) {
8319
8339
  for (const key of ["file_path", "path", "url", "pattern", "query"])
8320
8340
  if (typeof input[key] === "string")
8321
8341
  return `[${step}] ${name} ${input[key]}`;
8322
- return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
8342
+ return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, INPUT_CHARS)}`;
8323
8343
  }
8324
8344
  function describeToolUse(step, b) {
8325
8345
  if (typeof b.name !== "string" || b.name === "")
@@ -8879,6 +8899,7 @@ function parseArgs(args) {
8879
8899
  parallel: 4,
8880
8900
  json: false,
8881
8901
  noUpload: false,
8902
+ dryRun: false,
8882
8903
  vars: {},
8883
8904
  claudeBin: "claude"
8884
8905
  };
@@ -8899,6 +8920,8 @@ function parseArgs(args) {
8899
8920
  opts.json = true;
8900
8921
  else if (flag === "--no-upload")
8901
8922
  opts.noUpload = true;
8923
+ else if (flag === "--dry-run")
8924
+ opts.dryRun = true;
8902
8925
  else if (flag === "--var") {
8903
8926
  const kv = value();
8904
8927
  const at = kv.indexOf("=");
@@ -8989,6 +9012,51 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
8989
9012
  };
8990
9013
  }
8991
9014
  }
9015
+ function runFill(runId, n, testName, out, dir) {
9016
+ return {
9017
+ "run.id": runId,
9018
+ "run.n": String(n),
9019
+ "test.name": testName,
9020
+ FIREGOAT_OUT: out,
9021
+ FIREGOAT_WORKDIR: join6(dir, "work"),
9022
+ FIREGOAT_HOME: join6(dir, "home")
9023
+ };
9024
+ }
9025
+ function renderClaudeArgs(test, vars, fill) {
9026
+ return test.claudeArgs.map((a, i) => renderValue(a, vars, fill, `claude_args[${i}]`));
9027
+ }
9028
+ function planRuns(tests, models, runs) {
9029
+ const matrix = models.length > 0 ? models : [null];
9030
+ return tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
9031
+ test: t.name,
9032
+ n: m * runs + i + 1,
9033
+ model
9034
+ }))));
9035
+ }
9036
+ var DRY_RUN_ID = "<runId>";
9037
+ function dryRun(opts) {
9038
+ const out = join6(opts.runsRoot, DRY_RUN_ID, "out");
9039
+ const runs = opts.plan.map((p) => {
9040
+ const test = opts.tests.find((t) => t.name === p.test);
9041
+ const fill = runFill(DRY_RUN_ID, p.n, test.name, out, join6(opts.runsRoot, DRY_RUN_ID, test.name, String(p.n)));
9042
+ renderEnv(test.env, opts.vars, fill);
9043
+ return {
9044
+ ...p,
9045
+ claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9046
+ install: [...opts.config.install, ...test.install],
9047
+ checks: test.checks.map((c) => render(c.run, opts.vars)),
9048
+ cleanup: [...test.cleanup, ...opts.config.cleanup]
9049
+ };
9050
+ });
9051
+ if (opts.json) {
9052
+ console.log(JSON.stringify({ dryRun: true, build: opts.config.build, runs }, null, 2));
9053
+ return;
9054
+ }
9055
+ const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
9056
+ opts.say(`Dry run: ${count(runs.length, "run")}, build ${count(opts.config.build.length, "step")}. Nothing was started.`);
9057
+ for (const r of runs)
9058
+ opts.say(`${r.test}#${r.n}${r.model === null ? "" : ` [${r.model}]`} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.claudeArgs.length > 0 ? `, claude_args ${JSON.stringify(r.claudeArgs)}` : ""}`);
9059
+ }
8992
9060
  async function runOne(opts) {
8993
9061
  const { test, n } = opts;
8994
9062
  const started = Date.now();
@@ -8996,20 +9064,13 @@ async function runOne(opts) {
8996
9064
  const workdir = join6(dir, "work");
8997
9065
  mkdirSync4(workdir, { recursive: true });
8998
9066
  const { home, envFile } = makeRunHome(dir);
8999
- const fill = {
9000
- "run.id": opts.runId,
9001
- "run.n": String(n),
9002
- "test.name": test.name,
9003
- FIREGOAT_OUT: opts.out,
9004
- FIREGOAT_WORKDIR: workdir,
9005
- FIREGOAT_HOME: home
9006
- };
9067
+ const fill = runFill(opts.runId, n, test.name, opts.out, dir);
9007
9068
  const r = {
9008
9069
  test: test.name,
9009
9070
  n,
9010
9071
  model: opts.model,
9011
9072
  claudeModel: null,
9012
- claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
9073
+ claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9013
9074
  status: "error",
9014
9075
  reason: "",
9015
9076
  prompt: render(test.prompt, opts.vars),
@@ -9206,6 +9267,30 @@ async function evalCommand(args, root) {
9206
9267
  ];
9207
9268
  if (missing.length > 0)
9208
9269
  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`);
9270
+ if (opts.dryRun) {
9271
+ let repo;
9272
+ try {
9273
+ repo = gitRepo(root);
9274
+ } catch (e) {
9275
+ return fatal(EXIT.usage, e.message);
9276
+ }
9277
+ try {
9278
+ dryRun({
9279
+ config,
9280
+ tests,
9281
+ plan: planRuns(tests, models, runs),
9282
+ vars,
9283
+ runsRoot: join6(repo?.mainCheckout ?? root, ".firegoat", "runs"),
9284
+ json: opts.json,
9285
+ say
9286
+ });
9287
+ } catch (e) {
9288
+ if (e instanceof ConfigError)
9289
+ return fatal(EXIT.usage, e.message);
9290
+ throw e;
9291
+ }
9292
+ return EXIT.pass;
9293
+ }
9209
9294
  const claudePath = resolveBin(opts.claudeBin);
9210
9295
  if (!claudePath)
9211
9296
  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.`);
@@ -9249,14 +9334,11 @@ async function evalCommand(args, root) {
9249
9334
  FIREGOAT_OUT: out,
9250
9335
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
9251
9336
  };
9252
- const matrix = models.length > 0 ? models : [null];
9253
- const rows = tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
9254
- test: t.name,
9255
- n: m * runs + i + 1,
9256
- model,
9337
+ const rows = planRuns(tests, models, runs).map((p) => ({
9338
+ ...p,
9257
9339
  state: "queued",
9258
9340
  turns: 0
9259
- }))));
9341
+ }));
9260
9342
  if (config.build.length > 0) {
9261
9343
  const buildStarted = Date.now();
9262
9344
  ui.build("running");
@@ -9664,7 +9746,8 @@ var TIMEOUT_MS2 = 30000;
9664
9746
  var MAX_EXPIRES_S = 900;
9665
9747
  var MAX_INTERVAL_S = 60;
9666
9748
  var FORM = { "Content-Type": "application/x-www-form-urlencoded" };
9667
- var UPLOAD_NOTICE = "Each eval uploads every agent session (prompts, tool outputs, file contents, possibly secrets from your env or repo) to firegoat.ai as private traces; --no-upload keeps runs local. The agent runs as you and can read ~/.firegoat (and a FIREGOAT_API_KEY in firegoat's own process env).";
9749
+ var UPLOADS_ON = "Eval uploads each session to firegoat.ai as a private trace; --no-upload keeps runs local.";
9750
+ var UPLOAD_NOTICE = `${UPLOADS_ON} A session holds prompts, tool outputs and file contents: possibly secrets from your env or repo. The agent runs as you and can read ~/.firegoat (and a FIREGOAT_API_KEY in firegoat's own process env).`;
9668
9751
  var TERMS = "By logging in, you agree to the firegoat.ai Terms of Service (https://firegoat.ai/terms) and Privacy Policy (https://firegoat.ai/privacy).";
9669
9752
  function str2(o, k, step) {
9670
9753
  const v = o?.[k];
@@ -9967,13 +10050,16 @@ async function askScope(question) {
9967
10050
  var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
9968
10051
  async function loginStep(interactive, done, paint) {
9969
10052
  const label = "firegoat.ai login";
10053
+ const uploadsOn = () => console.log(` ${paint("90", UPLOADS_ON)}`);
9970
10054
  const key = resolveKey(baseUrl());
9971
10055
  if (key) {
9972
10056
  const who = key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}`;
9973
- if (interactive)
10057
+ if (interactive) {
9974
10058
  done(label, who);
9975
- else
9976
- console.log(who);
10059
+ uploadsOn();
10060
+ } else
10061
+ console.log(`${who}
10062
+ ${UPLOADS_ON}`);
9977
10063
  return EXIT.pass;
9978
10064
  }
9979
10065
  if (!interactive) {
@@ -9989,6 +10075,7 @@ async function loginStep(interactive, done, paint) {
9989
10075
  try {
9990
10076
  const email = await loginFlow((l) => console.log(` ${l}`), true);
9991
10077
  done(label, `Logged in as ${email}`);
10078
+ uploadsOn();
9992
10079
  return EXIT.pass;
9993
10080
  } catch (e) {
9994
10081
  if (!(e instanceof ApiError))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"