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 +26 -2
- package/dist/cli.js +287 -64
- package/package.json +1 -1
- package/skills/firegoat-onboarding/SKILL.md +10 -0
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.
|
|
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
|
|
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.
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
7143
|
-
constants,
|
|
7144
|
-
createWriteStream as createWriteStream2,
|
|
7192
|
+
createWriteStream as createWriteStream3,
|
|
7145
7193
|
existsSync as existsSync3,
|
|
7146
|
-
mkdirSync as
|
|
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 {
|
|
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
|
|
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 === "--
|
|
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 =
|
|
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(
|
|
7644
|
-
const workdir =
|
|
7645
|
-
|
|
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 =
|
|
7664
|
-
|
|
7665
|
-
const session =
|
|
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:
|
|
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 =
|
|
7829
|
+
const log = createWriteStream3(join5(dir, "checks.txt"));
|
|
7710
7830
|
const env = {
|
|
7711
7831
|
...process.env,
|
|
7712
|
-
|
|
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
|
|
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:
|
|
7908
|
+
repoFile: join5(root, "firegoat.yaml")
|
|
7779
7909
|
};
|
|
7780
|
-
|
|
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
|
-
|
|
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 =
|
|
7798
|
-
|
|
7799
|
-
const summaryPath =
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
8121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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.
|