firegoat 0.2.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,16 +2,40 @@
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.4). 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: setup
17
+
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
+
20
+ ```yaml
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
31
+ ```
32
+
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`.
38
+
15
39
  ### Isolation
16
40
 
17
41
  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.4.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/team2027/firegoat-cli.git"
@@ -7081,6 +7081,11 @@ function parseConfig(text, warn = console.error) {
7081
7081
  }
7082
7082
  });
7083
7083
  const runs = posInt(raw.runs, "runs", 1);
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");
7088
+ const vars = parseVars(raw.vars);
7084
7089
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7085
7090
  fail("tests", "must be a non-empty list");
7086
7091
  const names = new Set;
@@ -7089,7 +7094,7 @@ function parseConfig(text, warn = console.error) {
7089
7094
  if (!isRecord(t))
7090
7095
  fail(p, "must be a mapping");
7091
7096
  const name = str(t.name, `${p}.name`);
7092
- if (!/^[A-Za-z0-9._-]+$/.test(name))
7097
+ if (!NAME.test(name))
7093
7098
  fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
7094
7099
  if (names.has(name))
7095
7100
  fail(`${p}.name`, `duplicate test name ${JSON.stringify(name)}`);
@@ -7116,7 +7121,52 @@ function parseConfig(text, warn = console.error) {
7116
7121
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
7117
7122
  };
7118
7123
  });
7119
- return { agents, runs, tests };
7124
+ return {
7125
+ agents,
7126
+ runs,
7127
+ setup,
7128
+ workdirSetup,
7129
+ vars,
7130
+ tests
7131
+ };
7132
+ }
7133
+ var NAME = /^[A-Za-z0-9._-]+$/;
7134
+ function commands(raw, path) {
7135
+ if (raw === undefined)
7136
+ return [];
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}]`));
7142
+ }
7143
+ var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7144
+ function parseVars(raw) {
7145
+ if (raw === undefined)
7146
+ return {};
7147
+ if (!isRecord(raw))
7148
+ fail("vars", "must be a mapping of KEY to value");
7149
+ const out = {};
7150
+ for (const [k, v] of Object.entries(raw)) {
7151
+ if (!VAR_KEY.test(k))
7152
+ fail(`vars.${k}`, "key must be letters, digits and _, not starting with a digit");
7153
+ if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean")
7154
+ fail(`vars.${k}`, "must be a string, number or boolean");
7155
+ out[k] = String(v);
7156
+ }
7157
+ return out;
7158
+ }
7159
+ var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
7160
+ function varRefs(text) {
7161
+ return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
7162
+ }
7163
+ function render(text, vars) {
7164
+ return text.replace(PLACEHOLDER, (_, key) => {
7165
+ const v = vars[key];
7166
+ if (v === undefined)
7167
+ throw new ConfigError(`no value for {{vars.${key}}}`);
7168
+ return v;
7169
+ });
7120
7170
  }
7121
7171
  function loadConfig(dir, warn) {
7122
7172
  const file = `${dir}/${CONFIG_FILE}`;
@@ -7139,19 +7189,16 @@ function findConfigDir(cwd) {
7139
7189
  // src/eval.ts
7140
7190
  import { randomBytes } from "node:crypto";
7141
7191
  import {
7142
- accessSync,
7143
- constants,
7144
- createWriteStream as createWriteStream2,
7192
+ createWriteStream as createWriteStream3,
7145
7193
  existsSync as existsSync3,
7146
- mkdirSync as mkdirSync2,
7194
+ mkdirSync as mkdirSync3,
7147
7195
  mkdtempSync,
7148
7196
  readFileSync as readFileSync2,
7149
7197
  realpathSync as realpathSync2,
7150
- statSync,
7151
7198
  writeFileSync
7152
7199
  } from "node:fs";
7153
7200
  import { tmpdir } from "node:os";
7154
- import { delimiter, join as join3, resolve } from "node:path";
7201
+ import { join as join5, resolve as resolve2 } from "node:path";
7155
7202
 
7156
7203
  // src/claude.ts
7157
7204
  import { spawn, spawnSync as spawnSync2 } from "node:child_process";
@@ -7185,6 +7232,7 @@ var TOOLCHAINS = [
7185
7232
  ".local/share/mise",
7186
7233
  ".local/share/fnm",
7187
7234
  ".local/share/claude",
7235
+ ".local/share/uv",
7188
7236
  "go",
7189
7237
  "Library/pnpm"
7190
7238
  ];
@@ -7514,7 +7562,7 @@ async function runClaude(opts) {
7514
7562
  };
7515
7563
  return { ok: true, result };
7516
7564
  }
7517
- async function runCheck(opts) {
7565
+ async function runShell(opts) {
7518
7566
  opts.log.write(`$ ${opts.run}
7519
7567
  `);
7520
7568
  const r = await spawnWatched({
@@ -7567,6 +7615,70 @@ function checkLogin(opts) {
7567
7615
  // src/exit.ts
7568
7616
  var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7569
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
+
7661
+ // src/which.ts
7662
+ import { accessSync, constants, statSync } from "node:fs";
7663
+ import { delimiter as delimiter2, join as join4, resolve } from "node:path";
7664
+ function resolveBin(bin) {
7665
+ const executable = (p) => {
7666
+ try {
7667
+ accessSync(p, constants.X_OK);
7668
+ return statSync(p).isFile();
7669
+ } catch {
7670
+ return false;
7671
+ }
7672
+ };
7673
+ if (bin.includes("/"))
7674
+ return executable(bin) ? resolve(bin) : undefined;
7675
+ for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
7676
+ if (dir && executable(join4(dir, bin)))
7677
+ return join4(dir, bin);
7678
+ }
7679
+ return;
7680
+ }
7681
+
7570
7682
  // src/eval.ts
7571
7683
  var CHECK_TIMEOUT_MS = 120000;
7572
7684
 
@@ -7577,6 +7689,7 @@ function parseArgs(args) {
7577
7689
  names: [],
7578
7690
  json: false,
7579
7691
  host: false,
7692
+ vars: {},
7580
7693
  claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
7581
7694
  };
7582
7695
  for (let i = 0;i < args.length; i++) {
@@ -7596,7 +7709,14 @@ function parseArgs(args) {
7596
7709
  opts.json = true;
7597
7710
  else if (flag === "--host")
7598
7711
  opts.host = true;
7599
- else if (flag === "--runs") {
7712
+ else if (flag === "--var") {
7713
+ const kv = value();
7714
+ const at = kv.indexOf("=");
7715
+ const key = kv.slice(0, at);
7716
+ if (at < 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
7717
+ throw new UsageError(`--var needs KEY=value, got ${kv}`);
7718
+ opts.vars[key] = kv.slice(at + 1);
7719
+ } else if (flag === "--runs") {
7600
7720
  const v = value();
7601
7721
  const n = Number(v);
7602
7722
  if (!/^\d+$/.test(v) || n < 1)
@@ -7616,33 +7736,16 @@ function newRunId() {
7616
7736
  return `${ts}-${randomBytes(2).toString("hex")}`;
7617
7737
  }
7618
7738
  function gitignoreHasFiregoat(cwd) {
7619
- const file = join3(cwd, ".gitignore");
7739
+ const file = join5(cwd, ".gitignore");
7620
7740
  if (!existsSync3(file))
7621
7741
  return false;
7622
7742
  return readFileSync2(file, "utf8").split(`
7623
7743
  `).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
7624
7744
  }
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
7745
  function newRunRoot(label) {
7643
- const runRoot = realpathSync2(mkdtempSync(join3(tmpdir(), `firegoat-${label}-`)));
7644
- const workdir = join3(runRoot, "work");
7645
- mkdirSync2(workdir);
7746
+ const runRoot = realpathSync2(mkdtempSync(join5(tmpdir(), `firegoat-${label}-`)));
7747
+ const workdir = join5(runRoot, "work");
7748
+ mkdirSync3(workdir);
7646
7749
  return { runRoot, workdir };
7647
7750
  }
7648
7751
  function isolate(iso, claudeBin, runRoot, workdir) {
@@ -7660,9 +7763,9 @@ function isolate(iso, claudeBin, runRoot, workdir) {
7660
7763
  async function runOne(opts) {
7661
7764
  const { test, n } = opts;
7662
7765
  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");
7766
+ const dir = join5(opts.runDir, test.name, String(n));
7767
+ mkdirSync3(dir, { recursive: true });
7768
+ const session = join5(dir, "session.jsonl");
7666
7769
  const summary = {
7667
7770
  test: test.name,
7668
7771
  n,
@@ -7686,14 +7789,31 @@ async function runOne(opts) {
7686
7789
  const sandbox = isolate(opts.iso, opts.claudeBin, runRoot, workdir);
7687
7790
  if ("error" in sandbox)
7688
7791
  return done("error", `not graded: ${sandbox.error}`);
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 } : {};
7689
7809
  const outcome = await runClaude({
7690
- env: agentEnv(runRoot),
7810
+ env: { ...agentEnv(runRoot), ...agentPath },
7691
7811
  profile: sandbox.profile,
7692
7812
  bin: opts.claudeBin,
7693
- prompt: test.prompt,
7813
+ prompt: render(test.prompt, opts.vars),
7694
7814
  cwd: workdir,
7695
7815
  sessionPath: session,
7696
- stderrPath: join3(dir, "stderr.txt"),
7816
+ stderrPath: join5(dir, "stderr.txt"),
7697
7817
  timeoutMs: test.timeout * 1000
7698
7818
  });
7699
7819
  if (outcome.result) {
@@ -7706,17 +7826,19 @@ async function runOne(opts) {
7706
7826
  opts.warn(`warning: ${test.name} has no checks, so its goal is unverified; counted as pass`);
7707
7827
  return done("pass", "no checks (goal unverified)");
7708
7828
  }
7709
- const log = createWriteStream2(join3(dir, "checks.txt"));
7829
+ const log = createWriteStream3(join5(dir, "checks.txt"));
7710
7830
  const env = {
7711
7831
  ...process.env,
7712
- FIREGOAT_SESSION: resolve(session),
7832
+ ...hostEnv,
7833
+ ...extra,
7834
+ FIREGOAT_SESSION: resolve2(session),
7713
7835
  FIREGOAT_WORKDIR: workdir
7714
7836
  };
7715
7837
  const results = [];
7716
7838
  try {
7717
7839
  for (const c of test.checks) {
7718
- results.push(await runCheck({
7719
- run: c.run,
7840
+ results.push(await runShell({
7841
+ run: render(c.run, opts.vars),
7720
7842
  cwd: workdir,
7721
7843
  env,
7722
7844
  timeoutMs: CHECK_TIMEOUT_MS,
@@ -7761,6 +7883,14 @@ async function evalCommand(args, cwd) {
7761
7883
  tests = tests.filter((t) => opts.names.includes(t.name));
7762
7884
  }
7763
7885
  const runs = opts.runs ?? config.runs;
7886
+ const vars = { ...config.vars, ...opts.vars };
7887
+ const missing = [
7888
+ ...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(varRefs).filter((k) => !(k in vars)))
7889
+ ];
7890
+ if (missing.length > 0) {
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`);
7892
+ return EXIT.usage;
7893
+ }
7764
7894
  const claudePath = resolveBin(opts.claudeBin);
7765
7895
  if (!claudePath) {
7766
7896
  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.`);
@@ -7775,9 +7905,9 @@ async function evalCommand(args, cwd) {
7775
7905
  const iso = {
7776
7906
  host: opts.host,
7777
7907
  repo: root,
7778
- repoFile: join3(root, "firegoat.yaml")
7908
+ repoFile: join5(root, "firegoat.yaml")
7779
7909
  };
7780
- mkdirSync2(claudeConfigDir(), { recursive: true });
7910
+ mkdirSync3(claudeConfigDir(), { recursive: true });
7781
7911
  const pre = newRunRoot("preflight");
7782
7912
  const preSandbox = isolate(iso, claudePath, pre.runRoot, pre.workdir);
7783
7913
  const loginProblem = "error" in preSandbox ? preSandbox.error : checkLogin({
@@ -7790,13 +7920,29 @@ async function evalCommand(args, cwd) {
7790
7920
  console.error(`firegoat eval: not graded: ${loginProblem}`);
7791
7921
  return EXIT.notGraded;
7792
7922
  }
7793
- mkdirSync2(join3(root, ".firegoat"), { recursive: true });
7923
+ mkdirSync3(join5(root, ".firegoat"), { recursive: true });
7794
7924
  if (!gitignoreHasFiregoat(root))
7795
7925
  warn("hint: add .firegoat/ to .gitignore (it holds run traces)");
7796
7926
  const runId = newRunId();
7797
- const runDir = join3(root, ".firegoat", "runs", runId);
7798
- mkdirSync2(runDir, { recursive: true });
7799
- const summaryPath = join3(runDir, "summary.json");
7927
+ const runDir = join5(root, ".firegoat", "runs", runId);
7928
+ mkdirSync3(runDir, { recursive: true });
7929
+ const summaryPath = join5(runDir, "summary.json");
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")
7940
+ });
7941
+ if (error) {
7942
+ console.error(`firegoat eval: not graded: ${error}`);
7943
+ return EXIT.notGraded;
7944
+ }
7945
+ }
7800
7946
  const results = [];
7801
7947
  const writeSummary = () => {
7802
7948
  const totals = { pass: 0, fail: 0, error: 0 };
@@ -7823,6 +7969,9 @@ async function evalCommand(args, cwd) {
7823
7969
  runDir,
7824
7970
  claudeBin: claudePath,
7825
7971
  iso,
7972
+ out,
7973
+ workdirSetup: config.workdirSetup,
7974
+ vars,
7826
7975
  warn
7827
7976
  });
7828
7977
  } catch (e) {
@@ -7832,7 +7981,7 @@ async function evalCommand(args, cwd) {
7832
7981
  status: "error",
7833
7982
  reason: `not graded: firegoat crashed: ${e.message}`,
7834
7983
  workdir: null,
7835
- session: join3(runDir, test.name, String(n), "session.jsonl"),
7984
+ session: join5(runDir, test.name, String(n), "session.jsonl"),
7836
7985
  checks: [],
7837
7986
  num_turns: null,
7838
7987
  cost: null,
@@ -7855,8 +8004,8 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
7855
8004
  }
7856
8005
 
7857
8006
  // 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";
8007
+ import { cpSync as cpSync2, existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
8008
+ import { basename as basename2, join as join6 } from "node:path";
7860
8009
  import { createInterface as createInterface2 } from "node:readline/promises";
7861
8010
  import { fileURLToPath } from "node:url";
7862
8011
  var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills]
@@ -7895,9 +8044,9 @@ function tomlSections(text) {
7895
8044
  return sections;
7896
8045
  }
7897
8046
  function detect(cwd) {
7898
- const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join4(cwd, f)));
8047
+ const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join6(cwd, f)));
7899
8048
  const fallback = { name: basename2(cwd), library: true, readme };
7900
- const pkgText = readText(join4(cwd, "package.json"));
8049
+ const pkgText = readText(join6(cwd, "package.json"));
7901
8050
  if (pkgText !== undefined) {
7902
8051
  try {
7903
8052
  const pkg = JSON.parse(pkgText);
@@ -7912,7 +8061,7 @@ function detect(cwd) {
7912
8061
  return { ecosystem: "npm", name, bin, library, readme, docsUrl };
7913
8062
  } catch {}
7914
8063
  }
7915
- const pyText = readText(join4(cwd, "pyproject.toml"));
8064
+ const pyText = readText(join6(cwd, "pyproject.toml"));
7916
8065
  if (pyText !== undefined) {
7917
8066
  const s = tomlSections(pyText);
7918
8067
  const name = s.get("project")?.get("name") ?? s.get("tool.poetry")?.get("name") ?? fallback.name;
@@ -7922,12 +8071,12 @@ function detect(cwd) {
7922
8071
  const docsUrl = urls?.get("Documentation") ?? urls?.get("documentation") ?? urls?.get("Homepage") ?? urls?.get("homepage");
7923
8072
  return { ecosystem: "python", name, bin, library: true, readme, docsUrl };
7924
8073
  }
7925
- const goText = readText(join4(cwd, "go.mod"));
8074
+ const goText = readText(join6(cwd, "go.mod"));
7926
8075
  if (goText !== undefined) {
7927
8076
  const name = goText.match(/^module\s+(\S+)/m)?.[1] ?? fallback.name;
7928
8077
  return { ecosystem: "go", name, library: true, readme };
7929
8078
  }
7930
- const cargoText = readText(join4(cwd, "Cargo.toml"));
8079
+ const cargoText = readText(join6(cwd, "Cargo.toml"));
7931
8080
  if (cargoText !== undefined) {
7932
8081
  const name = tomlSections(cargoText).get("package")?.get("name");
7933
8082
  return {
@@ -8020,6 +8169,28 @@ function renderYaml(p, tests) {
8020
8169
  "# How many times to run each test.",
8021
8170
  "runs: 1",
8022
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
+ "",
8023
8194
  "tests:"
8024
8195
  ];
8025
8196
  for (const t of tests) {
@@ -8030,7 +8201,7 @@ function renderYaml(p, tests) {
8030
8201
  `;
8031
8202
  }
8032
8203
  function ensureGitignore(cwd) {
8033
- const file = join4(cwd, ".gitignore");
8204
+ const file = join6(cwd, ".gitignore");
8034
8205
  const text = readText(file);
8035
8206
  if (text === undefined) {
8036
8207
  writeFileSync2(file, `${GITIGNORE_LINE}
@@ -8097,7 +8268,7 @@ ${INIT_USAGE}`);
8097
8268
  return EXIT.usage;
8098
8269
  }
8099
8270
  }
8100
- const configPath = join4(cwd, CONFIG_FILE);
8271
+ const configPath = join6(cwd, CONFIG_FILE);
8101
8272
  const keepConfig = existsSync4(configPath) && !force;
8102
8273
  if (keepConfig && !skills) {
8103
8274
  console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
@@ -8117,8 +8288,8 @@ ${INIT_USAGE}`);
8117
8288
  if (!skills && interactive)
8118
8289
  skills = await askYesNo(`Add the ${SKILL} skill to .claude/skills/ (helps your agent draft better evals)?`);
8119
8290
  if (skills) {
8120
- const dest = join4(cwd, ".claude", "skills", SKILL);
8121
- cpSync(skillSource(), dest, { recursive: true });
8291
+ const dest = join6(cwd, ".claude", "skills", SKILL);
8292
+ cpSync2(skillSource(), dest, { recursive: true });
8122
8293
  console.log(`copied ${SKILL} skill to .claude/skills/${SKILL}/`);
8123
8294
  } else {
8124
8295
  console.log(`hint: \`npx firegoat init --skills\` adds the ${SKILL} skill to .claude/skills/ (keeps your ${CONFIG_FILE})`);
@@ -8129,7 +8300,7 @@ ${INIT_USAGE}`);
8129
8300
 
8130
8301
  // src/login.ts
8131
8302
  import { spawnSync as spawnSync3 } from "node:child_process";
8132
- import { mkdirSync as mkdirSync3 } from "node:fs";
8303
+ import { mkdirSync as mkdirSync4 } from "node:fs";
8133
8304
  function loginCommand(args) {
8134
8305
  let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
8135
8306
  for (let i = 0;i < args.length; i++) {
@@ -8144,7 +8315,7 @@ function loginCommand(args) {
8144
8315
  }
8145
8316
  }
8146
8317
  const configDir = claudeConfigDir();
8147
- mkdirSync3(configDir, { recursive: true });
8318
+ mkdirSync4(configDir, { recursive: true });
8148
8319
  const env = {
8149
8320
  ...process.env,
8150
8321
  CLAUDE_CONFIG_DIR: configDir
@@ -8164,19 +8335,69 @@ function loginCommand(args) {
8164
8335
  return EXIT.pass;
8165
8336
  }
8166
8337
 
8338
+ // src/open.ts
8339
+ import { spawnSync as spawnSync4 } from "node:child_process";
8340
+ import { existsSync as existsSync5, readdirSync } from "node:fs";
8341
+ import { join as join7 } from "node:path";
8342
+ var OPENER = {
8343
+ darwin: "open",
8344
+ linux: "xdg-open"
8345
+ };
8346
+ function openCommand(args, cwd) {
8347
+ let print = false;
8348
+ const ids = [];
8349
+ for (const a of args) {
8350
+ if (a === "--print")
8351
+ print = true;
8352
+ else if (a.startsWith("-")) {
8353
+ console.error(`firegoat open: unknown flag: ${a}`);
8354
+ return EXIT.usage;
8355
+ } else
8356
+ ids.push(a);
8357
+ }
8358
+ if (ids.length > 1) {
8359
+ console.error("firegoat open: takes at most one run id");
8360
+ return EXIT.usage;
8361
+ }
8362
+ const base = join7(findConfigDir(cwd), ".firegoat", "runs");
8363
+ const runs = existsSync5(base) ? readdirSync(base).sort() : [];
8364
+ const id = ids[0] ?? runs.at(-1);
8365
+ if (!id) {
8366
+ console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
8367
+ return EXIT.usage;
8368
+ }
8369
+ if (!runs.includes(id)) {
8370
+ console.error(`firegoat open: no run ${JSON.stringify(id)} in ${base} (latest: ${runs.at(-1) ?? "none"})`);
8371
+ return EXIT.usage;
8372
+ }
8373
+ const dir = join7(base, id);
8374
+ console.log(dir);
8375
+ const opener = OPENER[process.platform];
8376
+ if (print || !opener)
8377
+ return EXIT.pass;
8378
+ const r = spawnSync4(opener, [dir], { stdio: "inherit" });
8379
+ if (r.error || r.status !== 0) {
8380
+ console.error(`firegoat open: ${opener} failed (${r.error?.message ?? `exit ${r.status}`}); the run is at the path above`);
8381
+ return EXIT.notGraded;
8382
+ }
8383
+ return EXIT.pass;
8384
+ }
8385
+
8167
8386
  // src/cli.ts
8168
8387
  var USAGE = `firegoat ${package_default.version}: local-first AX evals
8169
8388
 
8170
8389
  Usage:
8171
8390
  firegoat init [--yes] [--force] [--skills] write firegoat.yaml with starter evals
8172
8391
  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
8392
+ firegoat eval [test..] [--runs N] [--var KEY=value].. [--json] [--host] [--claude-bin PATH]
8393
+ run evals with claude in a sandboxed fresh workdir
8394
+ firegoat open [runId] [--print] open a run's folder (default: the latest)
8175
8395
  firegoat --version
8176
8396
 
8177
8397
  eval options:
8178
8398
  test.. only run these tests (by name)
8179
8399
  --runs N runs per test (overrides runs: in firegoat.yaml)
8400
+ --var KEY=value fills {{vars.KEY}} in prompts and checks; env for setup, workdir_setup and checks
8180
8401
  --json JSON summary on stdout, progress on stderr
8181
8402
  --host no sandbox (needed off macOS; use a throwaway container)
8182
8403
  --claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
@@ -8207,6 +8428,8 @@ async function main(argv) {
8207
8428
  return loginCommand(rest);
8208
8429
  case "eval":
8209
8430
  return evalCommand(rest, process.cwd());
8431
+ case "open":
8432
+ return openCommand(rest, process.cwd());
8210
8433
  default:
8211
8434
  console.error(`unknown command: ${cmd}
8212
8435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.2.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,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
+ 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
+ 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,10 @@ tests:
83
89
  timeout: 600 # optional, seconds
84
90
  ```
85
91
 
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`);
94
+ then the prompt can say "The package is already installed from the build under test."
95
+
86
96
  If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append
87
97
  yours, and rename yours (`-2` suffix) if a name collides. Never delete or rewrite user tests.
88
98
  Quote prompts with `>-` folded blocks so colons and quotes stay valid YAML.