firegoat 0.2.0 → 0.3.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,16 +2,37 @@
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.2). See `plan.md`.
5
+ Status: early (v0.3). See `plan.md`.
6
6
 
7
7
  ## Use
8
8
 
9
9
  ```bash
10
10
  npx firegoat init # writes firegoat.yaml with starter evals; --skills adds the onboarding skill
11
11
  npx firegoat login # once per machine: signs claude in to ~/.firegoat/claude
12
- npx firegoat eval # runs each test with your local `claude`, sandboxed, in a fresh empty folder
12
+ npx firegoat eval # runs each test with your local `claude`, sandboxed, in a fresh folder
13
+ npx firegoat open # opens the latest run's folder (--print to only print it)
13
14
  ```
14
15
 
16
+ ### Local builds
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.
19
+
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: ...
30
+ ```
31
+
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`.
35
+
15
36
  ### Isolation
16
37
 
17
38
  On macOS, `eval` runs `claude` inside a Seatbelt (`sandbox-exec`) profile:
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.2.0",
6950
+ version: "0.3.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/team2027/firegoat-cli.git"
@@ -7081,6 +7081,8 @@ 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);
7085
+ const vars = parseVars(raw.vars);
7084
7086
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7085
7087
  fail("tests", "must be a non-empty list");
7086
7088
  const names = new Set;
@@ -7089,7 +7091,7 @@ function parseConfig(text, warn = console.error) {
7089
7091
  if (!isRecord(t))
7090
7092
  fail(p, "must be a mapping");
7091
7093
  const name = str(t.name, `${p}.name`);
7092
- if (!/^[A-Za-z0-9._-]+$/.test(name))
7094
+ if (!NAME.test(name))
7093
7095
  fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
7094
7096
  if (names.has(name))
7095
7097
  fail(`${p}.name`, `duplicate test name ${JSON.stringify(name)}`);
@@ -7116,7 +7118,71 @@ function parseConfig(text, warn = console.error) {
7116
7118
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
7117
7119
  };
7118
7120
  });
7119
- return { agents, runs, tests };
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 };
7130
+ }
7131
+ var NAME = /^[A-Za-z0-9._-]+$/;
7132
+ function parseBuild(raw) {
7133
+ if (raw === undefined)
7134
+ 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
+ });
7155
+ }
7156
+ var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7157
+ function parseVars(raw) {
7158
+ if (raw === undefined)
7159
+ return {};
7160
+ if (!isRecord(raw))
7161
+ fail("vars", "must be a mapping of KEY to value");
7162
+ const out = {};
7163
+ for (const [k, v] of Object.entries(raw)) {
7164
+ if (!VAR_KEY.test(k))
7165
+ fail(`vars.${k}`, "key must be letters, digits and _, not starting with a digit");
7166
+ if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean")
7167
+ fail(`vars.${k}`, "must be a string, number or boolean");
7168
+ out[k] = String(v);
7169
+ }
7170
+ return out;
7171
+ }
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
+ }));
7178
+ }
7179
+ function render(text, values) {
7180
+ return text.replace(PLACEHOLDER, (_, kind, key) => {
7181
+ const v = values[kind][key];
7182
+ if (v === undefined)
7183
+ throw new ConfigError(`no value for {{${kind}.${key}}}`);
7184
+ return v;
7185
+ });
7120
7186
  }
7121
7187
  function loadConfig(dir, warn) {
7122
7188
  const file = `${dir}/${CONFIG_FILE}`;
@@ -7139,19 +7205,28 @@ function findConfigDir(cwd) {
7139
7205
  // src/eval.ts
7140
7206
  import { randomBytes } from "node:crypto";
7141
7207
  import {
7142
- accessSync,
7143
- constants,
7144
- createWriteStream as createWriteStream2,
7145
- existsSync as existsSync3,
7146
- mkdirSync as mkdirSync2,
7208
+ createWriteStream as createWriteStream3,
7209
+ existsSync as existsSync4,
7210
+ mkdirSync as mkdirSync3,
7147
7211
  mkdtempSync,
7148
7212
  readFileSync as readFileSync2,
7149
7213
  realpathSync as realpathSync2,
7150
- statSync,
7151
- writeFileSync
7214
+ writeFileSync as writeFileSync2
7152
7215
  } from "node:fs";
7153
7216
  import { tmpdir } from "node:os";
7154
- import { delimiter, join as join3, resolve } from "node:path";
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
+ writeFileSync
7228
+ } from "node:fs";
7229
+ import { delimiter as delimiter2, join as join4, resolve as resolve2 } from "node:path";
7155
7230
 
7156
7231
  // src/claude.ts
7157
7232
  import { spawn, spawnSync as spawnSync2 } from "node:child_process";
@@ -7185,6 +7260,7 @@ var TOOLCHAINS = [
7185
7260
  ".local/share/mise",
7186
7261
  ".local/share/fnm",
7187
7262
  ".local/share/claude",
7263
+ ".local/share/uv",
7188
7264
  "go",
7189
7265
  "Library/pnpm"
7190
7266
  ];
@@ -7514,7 +7590,7 @@ async function runClaude(opts) {
7514
7590
  };
7515
7591
  return { ok: true, result };
7516
7592
  }
7517
- async function runCheck(opts) {
7593
+ async function runShell(opts) {
7518
7594
  opts.log.write(`$ ${opts.run}
7519
7595
  `);
7520
7596
  const r = await spawnWatched({
@@ -7564,6 +7640,142 @@ function checkLogin(opts) {
7564
7640
  return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
7565
7641
  }
7566
7642
 
7643
+ // src/which.ts
7644
+ import { accessSync, constants, statSync } from "node:fs";
7645
+ import { delimiter, join as join3, resolve } from "node:path";
7646
+ function resolveBin(bin) {
7647
+ const executable = (p) => {
7648
+ try {
7649
+ accessSync(p, constants.X_OK);
7650
+ return statSync(p).isFile();
7651
+ } catch {
7652
+ return false;
7653
+ }
7654
+ };
7655
+ if (bin.includes("/"))
7656
+ 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);
7660
+ }
7661
+ return;
7662
+ }
7663
+
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
+
7567
7779
  // src/exit.ts
7568
7780
  var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7569
7781
 
@@ -7577,6 +7789,7 @@ function parseArgs(args) {
7577
7789
  names: [],
7578
7790
  json: false,
7579
7791
  host: false,
7792
+ vars: {},
7580
7793
  claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
7581
7794
  };
7582
7795
  for (let i = 0;i < args.length; i++) {
@@ -7596,7 +7809,14 @@ function parseArgs(args) {
7596
7809
  opts.json = true;
7597
7810
  else if (flag === "--host")
7598
7811
  opts.host = true;
7599
- else if (flag === "--runs") {
7812
+ else if (flag === "--var") {
7813
+ const kv = value();
7814
+ const at = kv.indexOf("=");
7815
+ const key = kv.slice(0, at);
7816
+ if (at < 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
7817
+ throw new UsageError(`--var needs KEY=value, got ${kv}`);
7818
+ opts.vars[key] = kv.slice(at + 1);
7819
+ } else if (flag === "--runs") {
7600
7820
  const v = value();
7601
7821
  const n = Number(v);
7602
7822
  if (!/^\d+$/.test(v) || n < 1)
@@ -7616,33 +7836,16 @@ function newRunId() {
7616
7836
  return `${ts}-${randomBytes(2).toString("hex")}`;
7617
7837
  }
7618
7838
  function gitignoreHasFiregoat(cwd) {
7619
- const file = join3(cwd, ".gitignore");
7620
- if (!existsSync3(file))
7839
+ const file = join5(cwd, ".gitignore");
7840
+ if (!existsSync4(file))
7621
7841
  return false;
7622
7842
  return readFileSync2(file, "utf8").split(`
7623
7843
  `).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
7624
7844
  }
7625
- function resolveBin(bin) {
7626
- const executable = (p) => {
7627
- try {
7628
- accessSync(p, constants.X_OK);
7629
- return statSync(p).isFile();
7630
- } catch {
7631
- return false;
7632
- }
7633
- };
7634
- if (bin.includes("/"))
7635
- return executable(bin) ? resolve(bin) : undefined;
7636
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
7637
- if (dir && executable(join3(dir, bin)))
7638
- return join3(dir, bin);
7639
- }
7640
- return;
7641
- }
7642
7845
  function newRunRoot(label) {
7643
- const runRoot = realpathSync2(mkdtempSync(join3(tmpdir(), `firegoat-${label}-`)));
7644
- const workdir = join3(runRoot, "work");
7645
- mkdirSync2(workdir);
7846
+ const runRoot = realpathSync2(mkdtempSync(join5(tmpdir(), `firegoat-${label}-`)));
7847
+ const workdir = join5(runRoot, "work");
7848
+ mkdirSync3(workdir);
7646
7849
  return { runRoot, workdir };
7647
7850
  }
7648
7851
  function isolate(iso, claudeBin, runRoot, workdir) {
@@ -7660,9 +7863,9 @@ function isolate(iso, claudeBin, runRoot, workdir) {
7660
7863
  async function runOne(opts) {
7661
7864
  const { test, n } = opts;
7662
7865
  const started = Date.now();
7663
- const dir = join3(opts.runDir, test.name, String(n));
7664
- mkdirSync2(dir, { recursive: true });
7665
- const session = join3(dir, "session.jsonl");
7866
+ const dir = join5(opts.runDir, test.name, String(n));
7867
+ mkdirSync3(dir, { recursive: true });
7868
+ const session = join5(dir, "session.jsonl");
7666
7869
  const summary = {
7667
7870
  test: test.name,
7668
7871
  n,
@@ -7686,14 +7889,18 @@ async function runOne(opts) {
7686
7889
  const sandbox = isolate(opts.iso, opts.claudeBin, runRoot, workdir);
7687
7890
  if ("error" in sandbox)
7688
7891
  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 };
7689
7896
  const outcome = await runClaude({
7690
- env: agentEnv(runRoot),
7897
+ env: { ...agentEnv(runRoot), ...installed.env },
7691
7898
  profile: sandbox.profile,
7692
7899
  bin: opts.claudeBin,
7693
- prompt: test.prompt,
7900
+ prompt: render(test.prompt, values),
7694
7901
  cwd: workdir,
7695
7902
  sessionPath: session,
7696
- stderrPath: join3(dir, "stderr.txt"),
7903
+ stderrPath: join5(dir, "stderr.txt"),
7697
7904
  timeoutMs: test.timeout * 1000
7698
7905
  });
7699
7906
  if (outcome.result) {
@@ -7706,17 +7913,18 @@ async function runOne(opts) {
7706
7913
  opts.warn(`warning: ${test.name} has no checks, so its goal is unverified; counted as pass`);
7707
7914
  return done("pass", "no checks (goal unverified)");
7708
7915
  }
7709
- const log = createWriteStream2(join3(dir, "checks.txt"));
7916
+ const log = createWriteStream3(join5(dir, "checks.txt"));
7710
7917
  const env = {
7711
7918
  ...process.env,
7712
- FIREGOAT_SESSION: resolve(session),
7919
+ ...installed.env,
7920
+ FIREGOAT_SESSION: resolve3(session),
7713
7921
  FIREGOAT_WORKDIR: workdir
7714
7922
  };
7715
7923
  const results = [];
7716
7924
  try {
7717
7925
  for (const c of test.checks) {
7718
- results.push(await runCheck({
7719
- run: c.run,
7926
+ results.push(await runShell({
7927
+ run: render(c.run, values),
7720
7928
  cwd: workdir,
7721
7929
  env,
7722
7930
  timeoutMs: CHECK_TIMEOUT_MS,
@@ -7761,6 +7969,14 @@ async function evalCommand(args, cwd) {
7761
7969
  tests = tests.filter((t) => opts.names.includes(t.name));
7762
7970
  }
7763
7971
  const runs = opts.runs ?? config.runs;
7972
+ const vars = { ...config.vars, ...opts.vars };
7973
+ 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))
7975
+ ];
7976
+ if (missing.length > 0) {
7977
+ 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`);
7978
+ return EXIT.usage;
7979
+ }
7764
7980
  const claudePath = resolveBin(opts.claudeBin);
7765
7981
  if (!claudePath) {
7766
7982
  console.error(`firegoat eval: 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.`);
@@ -7768,16 +7984,16 @@ async function evalCommand(args, cwd) {
7768
7984
  }
7769
7985
  if (opts.host)
7770
7986
  warn("warning: --host: the agent runs without isolation and can read and write your files");
7771
- else if (process.platform !== "darwin" || !existsSync3(SANDBOX_EXEC)) {
7987
+ else if (process.platform !== "darwin" || !existsSync4(SANDBOX_EXEC)) {
7772
7988
  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.`);
7773
7989
  return EXIT.usage;
7774
7990
  }
7775
7991
  const iso = {
7776
7992
  host: opts.host,
7777
7993
  repo: root,
7778
- repoFile: join3(root, "firegoat.yaml")
7994
+ repoFile: join5(root, "firegoat.yaml")
7779
7995
  };
7780
- mkdirSync2(claudeConfigDir(), { recursive: true });
7996
+ mkdirSync3(claudeConfigDir(), { recursive: true });
7781
7997
  const pre = newRunRoot("preflight");
7782
7998
  const preSandbox = isolate(iso, claudePath, pre.runRoot, pre.workdir);
7783
7999
  const loginProblem = "error" in preSandbox ? preSandbox.error : checkLogin({
@@ -7790,13 +8006,26 @@ async function evalCommand(args, cwd) {
7790
8006
  console.error(`firegoat eval: not graded: ${loginProblem}`);
7791
8007
  return EXIT.notGraded;
7792
8008
  }
7793
- mkdirSync2(join3(root, ".firegoat"), { recursive: true });
8009
+ mkdirSync3(join5(root, ".firegoat"), { recursive: true });
7794
8010
  if (!gitignoreHasFiregoat(root))
7795
8011
  warn("hint: add .firegoat/ to .gitignore (it holds run traces)");
7796
8012
  const runId = newRunId();
7797
- const runDir = join3(root, ".firegoat", "runs", runId);
7798
- mkdirSync2(runDir, { recursive: true });
7799
- const summaryPath = join3(runDir, "summary.json");
8013
+ const runDir = join5(root, ".firegoat", "runs", runId);
8014
+ mkdirSync3(runDir, { recursive: true });
8015
+ 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
8022
+ });
8023
+ if ("error" in b) {
8024
+ console.error(`firegoat eval: not graded: ${b.error}`);
8025
+ return EXIT.notGraded;
8026
+ }
8027
+ built = b.built;
8028
+ }
7800
8029
  const results = [];
7801
8030
  const writeSummary = () => {
7802
8031
  const totals = { pass: 0, fail: 0, error: 0 };
@@ -7806,10 +8035,11 @@ async function evalCommand(args, cwd) {
7806
8035
  runId,
7807
8036
  runsDir: runDir,
7808
8037
  isolation: opts.host ? "host" : "seatbelt",
8038
+ builds: built.map((b) => ({ name: b.name, kind: b.kind })),
7809
8039
  totals,
7810
8040
  runs: results
7811
8041
  };
7812
- writeFileSync(summaryPath, `${JSON.stringify(doc, null, 2)}
8042
+ writeFileSync2(summaryPath, `${JSON.stringify(doc, null, 2)}
7813
8043
  `);
7814
8044
  return doc;
7815
8045
  };
@@ -7823,6 +8053,8 @@ async function evalCommand(args, cwd) {
7823
8053
  runDir,
7824
8054
  claudeBin: claudePath,
7825
8055
  iso,
8056
+ built,
8057
+ vars,
7826
8058
  warn
7827
8059
  });
7828
8060
  } catch (e) {
@@ -7832,7 +8064,7 @@ async function evalCommand(args, cwd) {
7832
8064
  status: "error",
7833
8065
  reason: `not graded: firegoat crashed: ${e.message}`,
7834
8066
  workdir: null,
7835
- session: join3(runDir, test.name, String(n), "session.jsonl"),
8067
+ session: join5(runDir, test.name, String(n), "session.jsonl"),
7836
8068
  checks: [],
7837
8069
  num_turns: null,
7838
8070
  cost: null,
@@ -7855,8 +8087,8 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
7855
8087
  }
7856
8088
 
7857
8089
  // src/init.ts
7858
- import { cpSync, existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
7859
- import { basename as basename2, join as join4 } from "node:path";
8090
+ import { cpSync as cpSync2, existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
8091
+ import { basename as basename2, join as join6 } from "node:path";
7860
8092
  import { createInterface as createInterface2 } from "node:readline/promises";
7861
8093
  import { fileURLToPath } from "node:url";
7862
8094
  var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills]
@@ -7895,9 +8127,9 @@ function tomlSections(text) {
7895
8127
  return sections;
7896
8128
  }
7897
8129
  function detect(cwd) {
7898
- const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join4(cwd, f)));
8130
+ const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync5(join6(cwd, f)));
7899
8131
  const fallback = { name: basename2(cwd), library: true, readme };
7900
- const pkgText = readText(join4(cwd, "package.json"));
8132
+ const pkgText = readText(join6(cwd, "package.json"));
7901
8133
  if (pkgText !== undefined) {
7902
8134
  try {
7903
8135
  const pkg = JSON.parse(pkgText);
@@ -7912,7 +8144,7 @@ function detect(cwd) {
7912
8144
  return { ecosystem: "npm", name, bin, library, readme, docsUrl };
7913
8145
  } catch {}
7914
8146
  }
7915
- const pyText = readText(join4(cwd, "pyproject.toml"));
8147
+ const pyText = readText(join6(cwd, "pyproject.toml"));
7916
8148
  if (pyText !== undefined) {
7917
8149
  const s = tomlSections(pyText);
7918
8150
  const name = s.get("project")?.get("name") ?? s.get("tool.poetry")?.get("name") ?? fallback.name;
@@ -7922,12 +8154,12 @@ function detect(cwd) {
7922
8154
  const docsUrl = urls?.get("Documentation") ?? urls?.get("documentation") ?? urls?.get("Homepage") ?? urls?.get("homepage");
7923
8155
  return { ecosystem: "python", name, bin, library: true, readme, docsUrl };
7924
8156
  }
7925
- const goText = readText(join4(cwd, "go.mod"));
8157
+ const goText = readText(join6(cwd, "go.mod"));
7926
8158
  if (goText !== undefined) {
7927
8159
  const name = goText.match(/^module\s+(\S+)/m)?.[1] ?? fallback.name;
7928
8160
  return { ecosystem: "go", name, library: true, readme };
7929
8161
  }
7930
- const cargoText = readText(join4(cwd, "Cargo.toml"));
8162
+ const cargoText = readText(join6(cwd, "Cargo.toml"));
7931
8163
  if (cargoText !== undefined) {
7932
8164
  const name = tomlSections(cargoText).get("package")?.get("name");
7933
8165
  return {
@@ -8030,10 +8262,10 @@ function renderYaml(p, tests) {
8030
8262
  `;
8031
8263
  }
8032
8264
  function ensureGitignore(cwd) {
8033
- const file = join4(cwd, ".gitignore");
8265
+ const file = join6(cwd, ".gitignore");
8034
8266
  const text = readText(file);
8035
8267
  if (text === undefined) {
8036
- writeFileSync2(file, `${GITIGNORE_LINE}
8268
+ writeFileSync3(file, `${GITIGNORE_LINE}
8037
8269
  `);
8038
8270
  return `created .gitignore with ${GITIGNORE_LINE}`;
8039
8271
  }
@@ -8043,7 +8275,7 @@ function ensureGitignore(cwd) {
8043
8275
  const sep = text === "" || text.endsWith(`
8044
8276
  `) ? "" : `
8045
8277
  `;
8046
- writeFileSync2(file, `${text}${sep}${GITIGNORE_LINE}
8278
+ writeFileSync3(file, `${text}${sep}${GITIGNORE_LINE}
8047
8279
  `);
8048
8280
  return `added ${GITIGNORE_LINE} to .gitignore`;
8049
8281
  }
@@ -8097,8 +8329,8 @@ ${INIT_USAGE}`);
8097
8329
  return EXIT.usage;
8098
8330
  }
8099
8331
  }
8100
- const configPath = join4(cwd, CONFIG_FILE);
8101
- const keepConfig = existsSync4(configPath) && !force;
8332
+ const configPath = join6(cwd, CONFIG_FILE);
8333
+ const keepConfig = existsSync5(configPath) && !force;
8102
8334
  if (keepConfig && !skills) {
8103
8335
  console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
8104
8336
  return EXIT.usage;
@@ -8109,7 +8341,7 @@ ${INIT_USAGE}`);
8109
8341
  const project = detect(cwd);
8110
8342
  const tests = templates(project);
8111
8343
  console.log(`detected: ${describe(project)}`);
8112
- writeFileSync2(configPath, renderYaml(project, tests));
8344
+ writeFileSync3(configPath, renderYaml(project, tests));
8113
8345
  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`);
8114
8346
  }
8115
8347
  console.log(ensureGitignore(cwd));
@@ -8117,8 +8349,8 @@ ${INIT_USAGE}`);
8117
8349
  if (!skills && interactive)
8118
8350
  skills = await askYesNo(`Add the ${SKILL} skill to .claude/skills/ (helps your agent draft better evals)?`);
8119
8351
  if (skills) {
8120
- const dest = join4(cwd, ".claude", "skills", SKILL);
8121
- cpSync(skillSource(), dest, { recursive: true });
8352
+ const dest = join6(cwd, ".claude", "skills", SKILL);
8353
+ cpSync2(skillSource(), dest, { recursive: true });
8122
8354
  console.log(`copied ${SKILL} skill to .claude/skills/${SKILL}/`);
8123
8355
  } else {
8124
8356
  console.log(`hint: \`npx firegoat init --skills\` adds the ${SKILL} skill to .claude/skills/ (keeps your ${CONFIG_FILE})`);
@@ -8129,7 +8361,7 @@ ${INIT_USAGE}`);
8129
8361
 
8130
8362
  // src/login.ts
8131
8363
  import { spawnSync as spawnSync3 } from "node:child_process";
8132
- import { mkdirSync as mkdirSync3 } from "node:fs";
8364
+ import { mkdirSync as mkdirSync4 } from "node:fs";
8133
8365
  function loginCommand(args) {
8134
8366
  let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
8135
8367
  for (let i = 0;i < args.length; i++) {
@@ -8144,7 +8376,7 @@ function loginCommand(args) {
8144
8376
  }
8145
8377
  }
8146
8378
  const configDir = claudeConfigDir();
8147
- mkdirSync3(configDir, { recursive: true });
8379
+ mkdirSync4(configDir, { recursive: true });
8148
8380
  const env = {
8149
8381
  ...process.env,
8150
8382
  CLAUDE_CONFIG_DIR: configDir
@@ -8164,19 +8396,69 @@ function loginCommand(args) {
8164
8396
  return EXIT.pass;
8165
8397
  }
8166
8398
 
8399
+ // src/open.ts
8400
+ import { spawnSync as spawnSync4 } from "node:child_process";
8401
+ import { existsSync as existsSync6, readdirSync as readdirSync2 } from "node:fs";
8402
+ import { join as join7 } from "node:path";
8403
+ var OPENER = {
8404
+ darwin: "open",
8405
+ linux: "xdg-open"
8406
+ };
8407
+ function openCommand(args, cwd) {
8408
+ let print = false;
8409
+ const ids = [];
8410
+ for (const a of args) {
8411
+ if (a === "--print")
8412
+ print = true;
8413
+ else if (a.startsWith("-")) {
8414
+ console.error(`firegoat open: unknown flag: ${a}`);
8415
+ return EXIT.usage;
8416
+ } else
8417
+ ids.push(a);
8418
+ }
8419
+ if (ids.length > 1) {
8420
+ console.error("firegoat open: takes at most one run id");
8421
+ return EXIT.usage;
8422
+ }
8423
+ const base = join7(findConfigDir(cwd), ".firegoat", "runs");
8424
+ const runs = existsSync6(base) ? readdirSync2(base).sort() : [];
8425
+ const id = ids[0] ?? runs.at(-1);
8426
+ if (!id) {
8427
+ console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
8428
+ return EXIT.usage;
8429
+ }
8430
+ if (!runs.includes(id)) {
8431
+ console.error(`firegoat open: no run ${JSON.stringify(id)} in ${base} (latest: ${runs.at(-1) ?? "none"})`);
8432
+ return EXIT.usage;
8433
+ }
8434
+ const dir = join7(base, id);
8435
+ console.log(dir);
8436
+ const opener = OPENER[process.platform];
8437
+ if (print || !opener)
8438
+ return EXIT.pass;
8439
+ const r = spawnSync4(opener, [dir], { stdio: "inherit" });
8440
+ if (r.error || r.status !== 0) {
8441
+ console.error(`firegoat open: ${opener} failed (${r.error?.message ?? `exit ${r.status}`}); the run is at the path above`);
8442
+ return EXIT.notGraded;
8443
+ }
8444
+ return EXIT.pass;
8445
+ }
8446
+
8167
8447
  // src/cli.ts
8168
8448
  var USAGE = `firegoat ${package_default.version}: local-first AX evals
8169
8449
 
8170
8450
  Usage:
8171
8451
  firegoat init [--yes] [--force] [--skills] write firegoat.yaml with starter evals
8172
8452
  firegoat login [--claude-bin PATH] sign claude in to firegoat's own config dir (once)
8173
- firegoat eval [test..] [--runs N] [--json] [--host] [--claude-bin PATH]
8174
- run evals with claude in a sandboxed empty workdir
8453
+ firegoat eval [test..] [--runs N] [--var KEY=value].. [--json] [--host] [--claude-bin PATH]
8454
+ run evals with claude in a sandboxed fresh workdir
8455
+ firegoat open [runId] [--print] open a run's folder (default: the latest)
8175
8456
  firegoat --version
8176
8457
 
8177
8458
  eval options:
8178
8459
  test.. only run these tests (by name)
8179
8460
  --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
8180
8462
  --json JSON summary on stdout, progress on stderr
8181
8463
  --host no sandbox (needed off macOS; use a throwaway container)
8182
8464
  --claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
@@ -8207,6 +8489,8 @@ async function main(argv) {
8207
8489
  return loginCommand(rest);
8208
8490
  case "eval":
8209
8491
  return evalCommand(rest, process.cwd());
8492
+ case "open":
8493
+ return openCommand(rest, process.cwd());
8210
8494
  default:
8211
8495
  console.error(`unknown command: ${cmd}
8212
8496
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/team2027/firegoat-cli.git"
@@ -73,6 +73,12 @@ 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
80
+ vars: # optional: defaults for {{vars.KEY}}; `--var KEY=value` overrides
81
+ SHA: main
76
82
  tests:
77
83
  - name: quickstart # letters, digits, '.', '_', '-'; unique
78
84
  prompt: >-
@@ -83,6 +89,9 @@ tests:
83
89
  timeout: 600 # optional, seconds
84
90
  ```
85
91
 
92
+ Add `build:` when the repo ships a package or CLI whose local build the tests should use;
93
+ then the prompt can say "The package is already installed from the build under test."
94
+
86
95
  If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append
87
96
  yours, and rename yours (`-2` suffix) if a name collides. Never delete or rewrite user tests.
88
97
  Quote prompts with `>-` folded blocks so colons and quotes stay valid YAML.