firegoat 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local-first AX evals: how well coding agents use your SDK, CLI, API and docs.
4
4
 
5
- Status: early (v0.3). See `plan.md`.
5
+ Status: early (v0.4). See `plan.md`.
6
6
 
7
7
  ## Use
8
8
 
@@ -13,25 +13,28 @@ npx firegoat eval # runs each test with your local `claude`, sandboxe
13
13
  npx firegoat open # opens the latest run's folder (--print to only print it)
14
14
  ```
15
15
 
16
- ### Local builds
16
+ ### Local builds: setup
17
17
 
18
- `build:` builds your packages once per `eval` (on the host, from source) and preinstalls them into every run's fresh folder. The agent gets the build, never the source.
18
+ Plain shell, any language. `setup:` runs once per `eval` in the repo, on your machine; whatever it writes to `$FIREGOAT_OUT` is copied into each run's folder and put on the agent's PATH. `workdir_setup:` runs in each fresh workdir before the agent starts. The agent gets the build, never the source. Each takes one command or a list.
19
19
 
20
20
  ```yaml
21
- build:
22
- sdk: ./packages/sdk # package.json: npm pack, then npm install into node_modules
23
- py: ./python # pyproject.toml: wheel (uv build, or python3 -m build) into .venv, on PATH
24
- cli: # anything else: your command writes outputs to $FIREGOAT_OUT,
25
- run: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme # which go on the agent's PATH
26
- tests:
27
- - name: quickstart
28
- prompt: "Deploy commit {{vars.SHA}} with the acme CLI. The SDK is already installed; don't install it from the registry."
29
- goal: ...
21
+ # npm
22
+ setup: npm pack --pack-destination "$FIREGOAT_OUT"
23
+ workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
24
+
25
+ # python
26
+ setup: uv build --wheel --out-dir "$FIREGOAT_OUT"
27
+ workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
28
+
29
+ # a CLI binary: on PATH as is
30
+ setup: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme
30
31
  ```
31
32
 
32
- - `{{build.NAME}}` in a prompt or check is the path of that build's artifact inside the run's folder.
33
- - `{{vars.KEY}}` comes from `--var KEY=value` or `vars:` in `firegoat.yaml`; vars are also in the env of `build:` commands. A missing var is a usage error. Other `{{...}}` text is left alone.
34
- - A failed build or install means not graded (exit 2); logs are in `.firegoat/runs/<runId>/build/build.txt` and `<test>/<n>/setup.txt`.
33
+ Then say so in the prompt: "The package is already installed from the build under test."
34
+
35
+ - `{{vars.KEY}}` in prompts and checks comes from `--var KEY=value` or `vars:` in `firegoat.yaml`. Vars are also env vars for `setup:`, `workdir_setup:` and checks, not for the agent. A missing var is a usage error; other `{{...}}` text is left alone.
36
+ - Checks also get `$FIREGOAT_OUT` (the run's copy).
37
+ - A failed `setup:` or `workdir_setup:` means not graded (exit 2); logs are `.firegoat/runs/<runId>/setup.txt` and `<test>/<n>/workdir_setup.txt`.
35
38
 
36
39
  ### Isolation
37
40
 
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.3.0",
6950
+ version: "0.4.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/team2027/firegoat-cli.git"
@@ -7081,7 +7081,10 @@ function parseConfig(text, warn = console.error) {
7081
7081
  }
7082
7082
  });
7083
7083
  const runs = posInt(raw.runs, "runs", 1);
7084
- const build = parseBuild(raw.build);
7084
+ if (raw.build !== undefined)
7085
+ fail("build", "was replaced by setup: in firegoat 0.4.0: a shell command (or list) that writes what the agent gets to $FIREGOAT_OUT");
7086
+ const setup = commands(raw.setup, "setup");
7087
+ const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
7085
7088
  const vars = parseVars(raw.vars);
7086
7089
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7087
7090
  fail("tests", "must be a non-empty list");
@@ -7118,40 +7121,24 @@ function parseConfig(text, warn = console.error) {
7118
7121
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
7119
7122
  };
7120
7123
  });
7121
- const builds = new Set(build.map((b) => b.name));
7122
- tests.forEach((t, i) => {
7123
- const texts = [t.prompt, ...t.checks.map((c) => c.run)];
7124
- for (const ref of texts.flatMap(placeholders)) {
7125
- if (ref.kind === "build" && !builds.has(ref.key))
7126
- fail(`tests[${i}]`, `{{build.${ref.key}}} names no entry under build: (known: ${[...builds].join(", ") || "none"})`);
7127
- }
7128
- });
7129
- return { agents, runs, build, vars, tests };
7124
+ return {
7125
+ agents,
7126
+ runs,
7127
+ setup,
7128
+ workdirSetup,
7129
+ vars,
7130
+ tests
7131
+ };
7130
7132
  }
7131
7133
  var NAME = /^[A-Za-z0-9._-]+$/;
7132
- function parseBuild(raw) {
7134
+ function commands(raw, path) {
7133
7135
  if (raw === undefined)
7134
7136
  return [];
7135
- if (!isRecord(raw))
7136
- fail("build", "must be a mapping of name to path or {path, run}");
7137
- return Object.entries(raw).map(([name, v]) => {
7138
- const p = `build.${name}`;
7139
- if (!NAME.test(name))
7140
- fail(p, "name may only contain letters, digits, '.', '_' and '-'");
7141
- if (typeof v === "string")
7142
- return { name, path: str(v, p) };
7143
- if (!isRecord(v))
7144
- fail(p, "must be a path or a mapping with path and/or run");
7145
- if (v.path === undefined && v.run === undefined)
7146
- fail(p, "needs path, run, or both");
7147
- const b = {
7148
- name,
7149
- path: v.path === undefined ? "." : str(v.path, `${p}.path`)
7150
- };
7151
- if (v.run !== undefined)
7152
- b.run = str(v.run, `${p}.run`);
7153
- return b;
7154
- });
7137
+ if (typeof raw === "string")
7138
+ return [str(raw, path)];
7139
+ if (!Array.isArray(raw))
7140
+ fail(path, "must be a shell command or a list of them");
7141
+ return raw.map((c, i) => str(c, `${path}[${i}]`));
7155
7142
  }
7156
7143
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7157
7144
  function parseVars(raw) {
@@ -7169,18 +7156,15 @@ function parseVars(raw) {
7169
7156
  }
7170
7157
  return out;
7171
7158
  }
7172
- var PLACEHOLDER = /\{\{\s*(build|vars)\.([A-Za-z0-9._-]+)\s*\}\}/g;
7173
- function placeholders(text) {
7174
- return [...text.matchAll(PLACEHOLDER)].map((m) => ({
7175
- kind: m[1],
7176
- key: m[2]
7177
- }));
7159
+ var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
7160
+ function varRefs(text) {
7161
+ return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
7178
7162
  }
7179
- function render(text, values) {
7180
- return text.replace(PLACEHOLDER, (_, kind, key) => {
7181
- const v = values[kind][key];
7163
+ function render(text, vars) {
7164
+ return text.replace(PLACEHOLDER, (_, key) => {
7165
+ const v = vars[key];
7182
7166
  if (v === undefined)
7183
- throw new ConfigError(`no value for {{${kind}.${key}}}`);
7167
+ throw new ConfigError(`no value for {{vars.${key}}}`);
7184
7168
  return v;
7185
7169
  });
7186
7170
  }
@@ -7206,27 +7190,15 @@ function findConfigDir(cwd) {
7206
7190
  import { randomBytes } from "node:crypto";
7207
7191
  import {
7208
7192
  createWriteStream as createWriteStream3,
7209
- existsSync as existsSync4,
7193
+ existsSync as existsSync3,
7210
7194
  mkdirSync as mkdirSync3,
7211
7195
  mkdtempSync,
7212
7196
  readFileSync as readFileSync2,
7213
7197
  realpathSync as realpathSync2,
7214
- writeFileSync as writeFileSync2
7215
- } from "node:fs";
7216
- import { tmpdir } from "node:os";
7217
- import { join as join5, resolve as resolve3 } from "node:path";
7218
-
7219
- // src/build.ts
7220
- import {
7221
- cpSync,
7222
- createWriteStream as createWriteStream2,
7223
- existsSync as existsSync3,
7224
- mkdirSync as mkdirSync2,
7225
- readdirSync,
7226
- statSync as statSync2,
7227
7198
  writeFileSync
7228
7199
  } from "node:fs";
7229
- import { delimiter as delimiter2, join as join4, resolve as resolve2 } from "node:path";
7200
+ import { tmpdir } from "node:os";
7201
+ import { join as join5, resolve as resolve2 } from "node:path";
7230
7202
 
7231
7203
  // src/claude.ts
7232
7204
  import { spawn, spawnSync as spawnSync2 } from "node:child_process";
@@ -7640,9 +7612,55 @@ function checkLogin(opts) {
7640
7612
  return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
7641
7613
  }
7642
7614
 
7615
+ // src/exit.ts
7616
+ var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7617
+
7618
+ // src/setup.ts
7619
+ import { cpSync, createWriteStream as createWriteStream2, mkdirSync as mkdirSync2 } from "node:fs";
7620
+ import { delimiter, join as join3 } from "node:path";
7621
+ var SETUP_TIMEOUT_MS = 600000;
7622
+ async function runAll(commands, cwd, env, logPath, what) {
7623
+ const log = createWriteStream2(logPath);
7624
+ try {
7625
+ for (const run of commands) {
7626
+ const r = await runShell({
7627
+ run,
7628
+ cwd,
7629
+ env,
7630
+ timeoutMs: SETUP_TIMEOUT_MS,
7631
+ log
7632
+ });
7633
+ if (r.code !== 0)
7634
+ return `${what} failed (${r.error ?? `exit ${r.code}`}): ${run} (log: ${logPath})`;
7635
+ }
7636
+ return;
7637
+ } finally {
7638
+ await new Promise((r) => log.end(r));
7639
+ }
7640
+ }
7641
+ async function setupOnce(opts) {
7642
+ mkdirSync2(opts.out, { recursive: true });
7643
+ return runAll(opts.commands, opts.root, { ...opts.env, FIREGOAT_OUT: opts.out }, opts.logPath, "setup");
7644
+ }
7645
+ async function prepareRun(opts) {
7646
+ const out = join3(opts.runRoot, "out");
7647
+ if (opts.out === null)
7648
+ mkdirSync2(out);
7649
+ else
7650
+ cpSync(opts.out, out, { recursive: true });
7651
+ const path = [out, opts.env.PATH ?? ""].join(delimiter);
7652
+ const error = await runAll(opts.commands, opts.workdir, {
7653
+ ...opts.env,
7654
+ PATH: path,
7655
+ FIREGOAT_OUT: out,
7656
+ FIREGOAT_WORKDIR: opts.workdir
7657
+ }, opts.logPath, "workdir_setup");
7658
+ return error ? { error } : { out, path };
7659
+ }
7660
+
7643
7661
  // src/which.ts
7644
7662
  import { accessSync, constants, statSync } from "node:fs";
7645
- import { delimiter, join as join3, resolve } from "node:path";
7663
+ import { delimiter as delimiter2, join as join4, resolve } from "node:path";
7646
7664
  function resolveBin(bin) {
7647
7665
  const executable = (p) => {
7648
7666
  try {
@@ -7654,131 +7672,13 @@ function resolveBin(bin) {
7654
7672
  };
7655
7673
  if (bin.includes("/"))
7656
7674
  return executable(bin) ? resolve(bin) : undefined;
7657
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
7658
- if (dir && executable(join3(dir, bin)))
7659
- return join3(dir, bin);
7675
+ for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
7676
+ if (dir && executable(join4(dir, bin)))
7677
+ return join4(dir, bin);
7660
7678
  }
7661
7679
  return;
7662
7680
  }
7663
7681
 
7664
- // src/build.ts
7665
- var BUILD_TIMEOUT_MS = 600000;
7666
-
7667
- class BuildError extends Error {
7668
- }
7669
- function kindOf(b, dir) {
7670
- if (b.run)
7671
- return "run";
7672
- if (existsSync3(join4(dir, "package.json")))
7673
- return "npm";
7674
- if (existsSync3(join4(dir, "pyproject.toml")))
7675
- return "python";
7676
- throw new BuildError(`build.${b.name}: no package.json or pyproject.toml in ${dir}; add run: with a build command that writes to $FIREGOAT_OUT`);
7677
- }
7678
- function pythonBuildCommand() {
7679
- if (resolveBin("uv"))
7680
- return 'uv build --wheel --out-dir "$FIREGOAT_OUT"';
7681
- return 'python3 -m build --wheel --outdir "$FIREGOAT_OUT"';
7682
- }
7683
- var BUILD_COMMAND = {
7684
- npm: () => 'npm pack --ignore-scripts=false --pack-destination "$FIREGOAT_OUT"',
7685
- python: pythonBuildCommand
7686
- };
7687
- var ARTIFACT = { npm: ".tgz", python: ".whl" };
7688
- async function sh(run, cwd, env, log, what) {
7689
- const r = await runShell({ run, cwd, env, timeoutMs: BUILD_TIMEOUT_MS, log });
7690
- if (r.code !== 0)
7691
- throw new BuildError(`${what} failed (${r.error ?? `exit ${r.code}`}): ${run}`);
7692
- }
7693
- async function buildOne(b, root, buildDir, env, log) {
7694
- const dir = resolve2(root, b.path);
7695
- if (!existsSync3(dir) || !statSync2(dir).isDirectory())
7696
- throw new BuildError(`build.${b.name}: ${dir} is not a directory`);
7697
- const kind = kindOf(b, dir);
7698
- const out = join4(buildDir, b.name);
7699
- mkdirSync2(out, { recursive: true });
7700
- const run = kind === "run" ? b.run : BUILD_COMMAND[kind]();
7701
- log.write(`## build.${b.name} (${kind}) in ${dir}
7702
- `);
7703
- await sh(run, dir, { ...env, FIREGOAT_OUT: out }, log, `build.${b.name}`);
7704
- const files = readdirSync(out);
7705
- if (kind !== "run") {
7706
- const ext = ARTIFACT[kind];
7707
- const hits = files.filter((f) => f.endsWith(ext));
7708
- if (hits.length !== 1)
7709
- throw new BuildError(`build.${b.name}: expected one ${ext} in ${out}, found ${hits.length ? hits.join(", ") : "none"}`);
7710
- } else if (files.length === 0)
7711
- throw new BuildError(`build.${b.name}: run: wrote nothing to $FIREGOAT_OUT (${out})`);
7712
- return { name: b.name, kind, out };
7713
- }
7714
- async function buildAll(builds, root, buildDir, env) {
7715
- mkdirSync2(buildDir, { recursive: true });
7716
- const log = createWriteStream2(join4(buildDir, "build.txt"));
7717
- try {
7718
- const built = [];
7719
- for (const b of builds)
7720
- built.push(await buildOne(b, root, buildDir, env, log));
7721
- return { built };
7722
- } catch (e) {
7723
- if (e instanceof BuildError)
7724
- return { error: `${e.message} (log: ${join4(buildDir, "build.txt")})` };
7725
- throw e;
7726
- } finally {
7727
- await new Promise((r) => log.end(r));
7728
- }
7729
- }
7730
- async function install(built, runRoot, workdir, env, logPath) {
7731
- const result = { paths: {}, env: {} };
7732
- if (built.length === 0)
7733
- return result;
7734
- const log = createWriteStream2(logPath);
7735
- const path = [];
7736
- const artifacts = { npm: [], python: [] };
7737
- try {
7738
- for (const b of built) {
7739
- const copy = join4(runRoot, "packages", b.name);
7740
- cpSync(b.out, copy, { recursive: true });
7741
- const files = readdirSync(copy);
7742
- if (b.kind === "run") {
7743
- result.paths[b.name] = files.length === 1 ? join4(copy, files[0]) : copy;
7744
- path.push(copy);
7745
- } else {
7746
- const ext = ARTIFACT[b.kind];
7747
- const file = join4(copy, files.find((f) => f.endsWith(ext)));
7748
- result.paths[b.name] = file;
7749
- artifacts[b.kind].push(file);
7750
- }
7751
- }
7752
- if (artifacts.npm.length > 0) {
7753
- writeFileSync(join4(workdir, "package.json"), `${JSON.stringify({ name: "app", version: "0.0.0", private: true }, null, 2)}
7754
- `);
7755
- await sh(`npm install --no-audit --no-fund ${artifacts.npm.map(q2).join(" ")}`, workdir, env, log, "npm install of the build");
7756
- path.push(join4(workdir, "node_modules", ".bin"));
7757
- }
7758
- if (artifacts.python.length > 0) {
7759
- const venv = join4(workdir, ".venv");
7760
- const wheels = artifacts.python.map(q2).join(" ");
7761
- await sh(resolveBin("uv") ? `uv venv .venv && uv pip install --python .venv/bin/python ${wheels}` : `python3 -m venv .venv && .venv/bin/pip install ${wheels}`, workdir, env, log, "pip install of the build");
7762
- path.push(join4(venv, "bin"));
7763
- result.env.VIRTUAL_ENV = venv;
7764
- }
7765
- result.env.PATH = [...path, env.PATH ?? ""].join(delimiter2);
7766
- return result;
7767
- } catch (e) {
7768
- if (e instanceof BuildError)
7769
- return { error: `${e.message} (log: ${logPath})` };
7770
- throw e;
7771
- } finally {
7772
- await new Promise((r) => log.end(r));
7773
- }
7774
- }
7775
- function q2(s) {
7776
- return `'${s.replaceAll("'", `'\\''`)}'`;
7777
- }
7778
-
7779
- // src/exit.ts
7780
- var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7781
-
7782
7682
  // src/eval.ts
7783
7683
  var CHECK_TIMEOUT_MS = 120000;
7784
7684
 
@@ -7837,7 +7737,7 @@ function newRunId() {
7837
7737
  }
7838
7738
  function gitignoreHasFiregoat(cwd) {
7839
7739
  const file = join5(cwd, ".gitignore");
7840
- if (!existsSync4(file))
7740
+ if (!existsSync3(file))
7841
7741
  return false;
7842
7742
  return readFileSync2(file, "utf8").split(`
7843
7743
  `).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
@@ -7889,15 +7789,28 @@ async function runOne(opts) {
7889
7789
  const sandbox = isolate(opts.iso, opts.claudeBin, runRoot, workdir);
7890
7790
  if ("error" in sandbox)
7891
7791
  return done("error", `not graded: ${sandbox.error}`);
7892
- const installed = await install(opts.built, runRoot, workdir, process.env, join5(dir, "setup.txt"));
7893
- if ("error" in installed)
7894
- return done("error", `not graded: setup: ${installed.error}`);
7895
- const values = { build: installed.paths, vars: opts.vars };
7792
+ const hostEnv = { ...process.env, ...opts.vars };
7793
+ let extra = {};
7794
+ if (opts.out !== null || opts.workdirSetup.length > 0) {
7795
+ const prepared = await prepareRun({
7796
+ out: opts.out,
7797
+ runRoot,
7798
+ workdir,
7799
+ commands: opts.workdirSetup,
7800
+ env: hostEnv,
7801
+ logPath: join5(dir, "workdir_setup.txt")
7802
+ });
7803
+ if ("error" in prepared)
7804
+ return done("error", `not graded: ${prepared.error}`);
7805
+ if (opts.out !== null)
7806
+ extra = { PATH: prepared.path, FIREGOAT_OUT: prepared.out };
7807
+ }
7808
+ const agentPath = extra.PATH ? { PATH: extra.PATH } : {};
7896
7809
  const outcome = await runClaude({
7897
- env: { ...agentEnv(runRoot), ...installed.env },
7810
+ env: { ...agentEnv(runRoot), ...agentPath },
7898
7811
  profile: sandbox.profile,
7899
7812
  bin: opts.claudeBin,
7900
- prompt: render(test.prompt, values),
7813
+ prompt: render(test.prompt, opts.vars),
7901
7814
  cwd: workdir,
7902
7815
  sessionPath: session,
7903
7816
  stderrPath: join5(dir, "stderr.txt"),
@@ -7916,15 +7829,16 @@ async function runOne(opts) {
7916
7829
  const log = createWriteStream3(join5(dir, "checks.txt"));
7917
7830
  const env = {
7918
7831
  ...process.env,
7919
- ...installed.env,
7920
- FIREGOAT_SESSION: resolve3(session),
7832
+ ...hostEnv,
7833
+ ...extra,
7834
+ FIREGOAT_SESSION: resolve2(session),
7921
7835
  FIREGOAT_WORKDIR: workdir
7922
7836
  };
7923
7837
  const results = [];
7924
7838
  try {
7925
7839
  for (const c of test.checks) {
7926
7840
  results.push(await runShell({
7927
- run: render(c.run, values),
7841
+ run: render(c.run, opts.vars),
7928
7842
  cwd: workdir,
7929
7843
  env,
7930
7844
  timeoutMs: CHECK_TIMEOUT_MS,
@@ -7971,7 +7885,7 @@ async function evalCommand(args, cwd) {
7971
7885
  const runs = opts.runs ?? config.runs;
7972
7886
  const vars = { ...config.vars, ...opts.vars };
7973
7887
  const missing = [
7974
- ...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(placeholders).filter((p) => p.kind === "vars" && !(p.key in vars)).map((p) => p.key))
7888
+ ...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(varRefs).filter((k) => !(k in vars)))
7975
7889
  ];
7976
7890
  if (missing.length > 0) {
7977
7891
  console.error(`firegoat eval: no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
@@ -7984,7 +7898,7 @@ async function evalCommand(args, cwd) {
7984
7898
  }
7985
7899
  if (opts.host)
7986
7900
  warn("warning: --host: the agent runs without isolation and can read and write your files");
7987
- else if (process.platform !== "darwin" || !existsSync4(SANDBOX_EXEC)) {
7901
+ else if (process.platform !== "darwin" || !existsSync3(SANDBOX_EXEC)) {
7988
7902
  console.error(`firegoat eval: isolation needs macOS (${SANDBOX_EXEC}); this is ${process.platform}. In CI or Linux, run inside a throwaway container and pass --host.`);
7989
7903
  return EXIT.usage;
7990
7904
  }
@@ -8013,18 +7927,21 @@ async function evalCommand(args, cwd) {
8013
7927
  const runDir = join5(root, ".firegoat", "runs", runId);
8014
7928
  mkdirSync3(runDir, { recursive: true });
8015
7929
  const summaryPath = join5(runDir, "summary.json");
8016
- let built = [];
8017
- if (config.build.length > 0) {
8018
- say(`building ${config.build.map((b) => b.name).join(", ")}...`);
8019
- const b = await buildAll(config.build, root, join5(runDir, "build"), {
8020
- ...process.env,
8021
- ...vars
7930
+ let out = null;
7931
+ if (config.setup.length > 0) {
7932
+ say("setup...");
7933
+ out = join5(runDir, "out");
7934
+ const error = await setupOnce({
7935
+ commands: config.setup,
7936
+ root,
7937
+ out,
7938
+ env: { ...process.env, ...vars },
7939
+ logPath: join5(runDir, "setup.txt")
8022
7940
  });
8023
- if ("error" in b) {
8024
- console.error(`firegoat eval: not graded: ${b.error}`);
7941
+ if (error) {
7942
+ console.error(`firegoat eval: not graded: ${error}`);
8025
7943
  return EXIT.notGraded;
8026
7944
  }
8027
- built = b.built;
8028
7945
  }
8029
7946
  const results = [];
8030
7947
  const writeSummary = () => {
@@ -8035,11 +7952,10 @@ async function evalCommand(args, cwd) {
8035
7952
  runId,
8036
7953
  runsDir: runDir,
8037
7954
  isolation: opts.host ? "host" : "seatbelt",
8038
- builds: built.map((b) => ({ name: b.name, kind: b.kind })),
8039
7955
  totals,
8040
7956
  runs: results
8041
7957
  };
8042
- writeFileSync2(summaryPath, `${JSON.stringify(doc, null, 2)}
7958
+ writeFileSync(summaryPath, `${JSON.stringify(doc, null, 2)}
8043
7959
  `);
8044
7960
  return doc;
8045
7961
  };
@@ -8053,7 +7969,8 @@ async function evalCommand(args, cwd) {
8053
7969
  runDir,
8054
7970
  claudeBin: claudePath,
8055
7971
  iso,
8056
- built,
7972
+ out,
7973
+ workdirSetup: config.workdirSetup,
8057
7974
  vars,
8058
7975
  warn
8059
7976
  });
@@ -8087,7 +8004,7 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
8087
8004
  }
8088
8005
 
8089
8006
  // src/init.ts
8090
- import { cpSync as cpSync2, existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
8007
+ import { cpSync as cpSync2, existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
8091
8008
  import { basename as basename2, join as join6 } from "node:path";
8092
8009
  import { createInterface as createInterface2 } from "node:readline/promises";
8093
8010
  import { fileURLToPath } from "node:url";
@@ -8127,7 +8044,7 @@ function tomlSections(text) {
8127
8044
  return sections;
8128
8045
  }
8129
8046
  function detect(cwd) {
8130
- const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync5(join6(cwd, f)));
8047
+ const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join6(cwd, f)));
8131
8048
  const fallback = { name: basename2(cwd), library: true, readme };
8132
8049
  const pkgText = readText(join6(cwd, "package.json"));
8133
8050
  if (pkgText !== undefined) {
@@ -8252,6 +8169,28 @@ function renderYaml(p, tests) {
8252
8169
  "# How many times to run each test.",
8253
8170
  "runs: 1",
8254
8171
  "",
8172
+ "# Optional: test your local build instead of the published package.",
8173
+ "# setup: runs once per eval, in this folder, on your machine. Whatever it writes to",
8174
+ "# $FIREGOAT_OUT is copied into each run's folder and put on the agent's PATH.",
8175
+ "# workdir_setup: runs in each fresh workdir before the agent starts. Then tell the",
8176
+ '# agent in the prompt: "The package is already installed from the build under test."',
8177
+ "#",
8178
+ "# npm:",
8179
+ '# setup: npm pack --pack-destination "$FIREGOAT_OUT"',
8180
+ '# workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz',
8181
+ "#",
8182
+ "# python:",
8183
+ '# setup: uv build --wheel --out-dir "$FIREGOAT_OUT"',
8184
+ '# workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl',
8185
+ "#",
8186
+ "# a CLI binary (lands on PATH as is):",
8187
+ '# setup: go build -o "$FIREGOAT_OUT/mycli" ./cmd/mycli',
8188
+ "#",
8189
+ "# Values for {{vars.KEY}} in prompts and checks, overridden by `--var KEY=value`;",
8190
+ "# setup commands and checks also get them as env vars.",
8191
+ "# vars:",
8192
+ "# SHA: main",
8193
+ "",
8255
8194
  "tests:"
8256
8195
  ];
8257
8196
  for (const t of tests) {
@@ -8265,7 +8204,7 @@ function ensureGitignore(cwd) {
8265
8204
  const file = join6(cwd, ".gitignore");
8266
8205
  const text = readText(file);
8267
8206
  if (text === undefined) {
8268
- writeFileSync3(file, `${GITIGNORE_LINE}
8207
+ writeFileSync2(file, `${GITIGNORE_LINE}
8269
8208
  `);
8270
8209
  return `created .gitignore with ${GITIGNORE_LINE}`;
8271
8210
  }
@@ -8275,7 +8214,7 @@ function ensureGitignore(cwd) {
8275
8214
  const sep = text === "" || text.endsWith(`
8276
8215
  `) ? "" : `
8277
8216
  `;
8278
- writeFileSync3(file, `${text}${sep}${GITIGNORE_LINE}
8217
+ writeFileSync2(file, `${text}${sep}${GITIGNORE_LINE}
8279
8218
  `);
8280
8219
  return `added ${GITIGNORE_LINE} to .gitignore`;
8281
8220
  }
@@ -8330,7 +8269,7 @@ ${INIT_USAGE}`);
8330
8269
  }
8331
8270
  }
8332
8271
  const configPath = join6(cwd, CONFIG_FILE);
8333
- const keepConfig = existsSync5(configPath) && !force;
8272
+ const keepConfig = existsSync4(configPath) && !force;
8334
8273
  if (keepConfig && !skills) {
8335
8274
  console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
8336
8275
  return EXIT.usage;
@@ -8341,7 +8280,7 @@ ${INIT_USAGE}`);
8341
8280
  const project = detect(cwd);
8342
8281
  const tests = templates(project);
8343
8282
  console.log(`detected: ${describe(project)}`);
8344
- writeFileSync3(configPath, renderYaml(project, tests));
8283
+ writeFileSync2(configPath, renderYaml(project, tests));
8345
8284
  console.log(`wrote ${CONFIG_FILE} (${tests.length} test${tests.length === 1 ? "" : "s"}: ${tests.map((t) => t.name).join(", ")}); edit the prompts to fit your users`);
8346
8285
  }
8347
8286
  console.log(ensureGitignore(cwd));
@@ -8398,7 +8337,7 @@ function loginCommand(args) {
8398
8337
 
8399
8338
  // src/open.ts
8400
8339
  import { spawnSync as spawnSync4 } from "node:child_process";
8401
- import { existsSync as existsSync6, readdirSync as readdirSync2 } from "node:fs";
8340
+ import { existsSync as existsSync5, readdirSync } from "node:fs";
8402
8341
  import { join as join7 } from "node:path";
8403
8342
  var OPENER = {
8404
8343
  darwin: "open",
@@ -8421,7 +8360,7 @@ function openCommand(args, cwd) {
8421
8360
  return EXIT.usage;
8422
8361
  }
8423
8362
  const base = join7(findConfigDir(cwd), ".firegoat", "runs");
8424
- const runs = existsSync6(base) ? readdirSync2(base).sort() : [];
8363
+ const runs = existsSync5(base) ? readdirSync(base).sort() : [];
8425
8364
  const id = ids[0] ?? runs.at(-1);
8426
8365
  if (!id) {
8427
8366
  console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
@@ -8458,7 +8397,7 @@ Usage:
8458
8397
  eval options:
8459
8398
  test.. only run these tests (by name)
8460
8399
  --runs N runs per test (overrides runs: in firegoat.yaml)
8461
- --var KEY=value fills {{vars.KEY}} in prompts and checks, and is in the env of build: commands
8400
+ --var KEY=value fills {{vars.KEY}} in prompts and checks; env for setup, workdir_setup and checks
8462
8401
  --json JSON summary on stdout, progress on stderr
8463
8402
  --host no sandbox (needed off macOS; use a throwaway container)
8464
8403
  --claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/team2027/firegoat-cli.git"
@@ -73,10 +73,10 @@ Schema (v1):
73
73
  ```yaml
74
74
  agents: [claude-code] # only claude-code in v1
75
75
  runs: 1 # runs per test, positive integer
76
- build: # optional: local packages preinstalled in the agent's workdir
77
- sdk: ./packages/sdk # package.json -> node_modules, pyproject.toml -> .venv on PATH
78
- cli: # anything else: a command that writes outputs to $FIREGOAT_OUT
79
- run: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme # outputs go on PATH
76
+ setup: npm pack --pack-destination "$FIREGOAT_OUT" # optional: once per eval, in the repo;
77
+ # $FIREGOAT_OUT is copied into each run and put on PATH
78
+ workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
79
+ # optional: in each fresh workdir before the agent starts
80
80
  vars: # optional: defaults for {{vars.KEY}}; `--var KEY=value` overrides
81
81
  SHA: main
82
82
  tests:
@@ -89,7 +89,8 @@ tests:
89
89
  timeout: 600 # optional, seconds
90
90
  ```
91
91
 
92
- Add `build:` when the repo ships a package or CLI whose local build the tests should use;
92
+ Add `setup:` / `workdir_setup:` when the tests should use the repo's local build (python:
93
+ `uv build --wheel --out-dir "$FIREGOAT_OUT"` and `uv venv && uv pip install "$FIREGOAT_OUT"/*.whl`);
93
94
  then the prompt can say "The package is already installed from the build under test."
94
95
 
95
96
  If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append