firegoat 0.3.0 → 0.5.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 +19 -16
- package/dist/cli.js +187 -228
- package/package.json +2 -3
- package/skills/firegoat-onboarding/SKILL.md +0 -114
- package/skills/firegoat-onboarding/references/examples.md +0 -114
package/README.md
CHANGED
|
@@ -2,36 +2,39 @@
|
|
|
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
|
-
npx firegoat init # writes firegoat.yaml with starter evals; --skills adds the onboarding skill
|
|
10
|
+
npx firegoat init # writes firegoat.yaml with starter evals; --skills adds the firegoat-onboarding skill (from team2027/skills)
|
|
11
11
|
npx firegoat login # once per machine: signs claude in to ~/.firegoat/claude
|
|
12
12
|
npx firegoat eval # runs each test with your local `claude`, sandboxed, in a fresh folder
|
|
13
13
|
npx firegoat open # opens the latest run's folder (--print to only print it)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
### Local builds
|
|
16
|
+
### Local builds: setup
|
|
17
17
|
|
|
18
|
-
`
|
|
18
|
+
Plain shell, any language. `setup:` runs once per `eval` in the repo, on your machine; whatever it writes to `$FIREGOAT_OUT` is copied into each run's folder and put on the agent's PATH. `workdir_setup:` runs in each fresh workdir before the agent starts. The agent gets the build, never the source. Each takes one command or a list.
|
|
19
19
|
|
|
20
20
|
```yaml
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
# npm
|
|
22
|
+
setup: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
23
|
+
workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
24
|
+
|
|
25
|
+
# python
|
|
26
|
+
setup: uv build --wheel --out-dir "$FIREGOAT_OUT"
|
|
27
|
+
workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
|
|
28
|
+
|
|
29
|
+
# a CLI binary: on PATH as is
|
|
30
|
+
setup: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
33
|
+
Then say so in the prompt: "The package is already installed from the build under test."
|
|
34
|
+
|
|
35
|
+
- `{{vars.KEY}}` in prompts and checks comes from `--var KEY=value` or `vars:` in `firegoat.yaml`. Vars are also env vars for `setup:`, `workdir_setup:` and checks, not for the agent. A missing var is a usage error; other `{{...}}` text is left alone.
|
|
36
|
+
- Checks also get `$FIREGOAT_OUT` (the run's copy).
|
|
37
|
+
- A failed `setup:` or `workdir_setup:` means not graded (exit 2); logs are `.firegoat/runs/<runId>/setup.txt` and `<test>/<n>/workdir_setup.txt`.
|
|
35
38
|
|
|
36
39
|
### Isolation
|
|
37
40
|
|
package/dist/cli.js
CHANGED
|
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS(function(exports) {
|
|
|
6947
6947
|
// package.json
|
|
6948
6948
|
var package_default = {
|
|
6949
6949
|
name: "firegoat",
|
|
6950
|
-
version: "0.
|
|
6950
|
+
version: "0.5.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/team2027/firegoat-cli.git"
|
|
@@ -6968,8 +6968,7 @@ var package_default = {
|
|
|
6968
6968
|
node: ">=18"
|
|
6969
6969
|
},
|
|
6970
6970
|
files: [
|
|
6971
|
-
"dist"
|
|
6972
|
-
"skills"
|
|
6971
|
+
"dist"
|
|
6973
6972
|
],
|
|
6974
6973
|
homepage: "https://github.com/team2027/firegoat-cli#readme",
|
|
6975
6974
|
license: "MIT",
|
|
@@ -7081,7 +7080,10 @@ function parseConfig(text, warn = console.error) {
|
|
|
7081
7080
|
}
|
|
7082
7081
|
});
|
|
7083
7082
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7084
|
-
|
|
7083
|
+
if (raw.build !== undefined)
|
|
7084
|
+
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");
|
|
7085
|
+
const setup = commands(raw.setup, "setup");
|
|
7086
|
+
const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
|
|
7085
7087
|
const vars = parseVars(raw.vars);
|
|
7086
7088
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
7087
7089
|
fail("tests", "must be a non-empty list");
|
|
@@ -7118,40 +7120,24 @@ function parseConfig(text, warn = console.error) {
|
|
|
7118
7120
|
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
|
|
7119
7121
|
};
|
|
7120
7122
|
});
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
}
|
|
7129
|
-
return { agents, runs, build, vars, tests };
|
|
7123
|
+
return {
|
|
7124
|
+
agents,
|
|
7125
|
+
runs,
|
|
7126
|
+
setup,
|
|
7127
|
+
workdirSetup,
|
|
7128
|
+
vars,
|
|
7129
|
+
tests
|
|
7130
|
+
};
|
|
7130
7131
|
}
|
|
7131
7132
|
var NAME = /^[A-Za-z0-9._-]+$/;
|
|
7132
|
-
function
|
|
7133
|
+
function commands(raw, path) {
|
|
7133
7134
|
if (raw === undefined)
|
|
7134
7135
|
return [];
|
|
7135
|
-
if (
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
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
|
-
});
|
|
7136
|
+
if (typeof raw === "string")
|
|
7137
|
+
return [str(raw, path)];
|
|
7138
|
+
if (!Array.isArray(raw))
|
|
7139
|
+
fail(path, "must be a shell command or a list of them");
|
|
7140
|
+
return raw.map((c, i) => str(c, `${path}[${i}]`));
|
|
7155
7141
|
}
|
|
7156
7142
|
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7157
7143
|
function parseVars(raw) {
|
|
@@ -7169,18 +7155,15 @@ function parseVars(raw) {
|
|
|
7169
7155
|
}
|
|
7170
7156
|
return out;
|
|
7171
7157
|
}
|
|
7172
|
-
var PLACEHOLDER = /\{\{\s*
|
|
7173
|
-
function
|
|
7174
|
-
return [...text.matchAll(PLACEHOLDER)].map((m) =>
|
|
7175
|
-
kind: m[1],
|
|
7176
|
-
key: m[2]
|
|
7177
|
-
}));
|
|
7158
|
+
var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
|
|
7159
|
+
function varRefs(text) {
|
|
7160
|
+
return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
|
|
7178
7161
|
}
|
|
7179
|
-
function render(text,
|
|
7180
|
-
return text.replace(PLACEHOLDER, (_,
|
|
7181
|
-
const v =
|
|
7162
|
+
function render(text, vars) {
|
|
7163
|
+
return text.replace(PLACEHOLDER, (_, key) => {
|
|
7164
|
+
const v = vars[key];
|
|
7182
7165
|
if (v === undefined)
|
|
7183
|
-
throw new ConfigError(`no value for {{
|
|
7166
|
+
throw new ConfigError(`no value for {{vars.${key}}}`);
|
|
7184
7167
|
return v;
|
|
7185
7168
|
});
|
|
7186
7169
|
}
|
|
@@ -7206,27 +7189,15 @@ function findConfigDir(cwd) {
|
|
|
7206
7189
|
import { randomBytes } from "node:crypto";
|
|
7207
7190
|
import {
|
|
7208
7191
|
createWriteStream as createWriteStream3,
|
|
7209
|
-
existsSync as
|
|
7192
|
+
existsSync as existsSync3,
|
|
7210
7193
|
mkdirSync as mkdirSync3,
|
|
7211
7194
|
mkdtempSync,
|
|
7212
7195
|
readFileSync as readFileSync2,
|
|
7213
7196
|
realpathSync as realpathSync2,
|
|
7214
|
-
writeFileSync as writeFileSync2
|
|
7215
|
-
} from "node:fs";
|
|
7216
|
-
import { tmpdir } from "node:os";
|
|
7217
|
-
import { join as join5, resolve as resolve3 } from "node:path";
|
|
7218
|
-
|
|
7219
|
-
// src/build.ts
|
|
7220
|
-
import {
|
|
7221
|
-
cpSync,
|
|
7222
|
-
createWriteStream as createWriteStream2,
|
|
7223
|
-
existsSync as existsSync3,
|
|
7224
|
-
mkdirSync as mkdirSync2,
|
|
7225
|
-
readdirSync,
|
|
7226
|
-
statSync as statSync2,
|
|
7227
7197
|
writeFileSync
|
|
7228
7198
|
} from "node:fs";
|
|
7229
|
-
import {
|
|
7199
|
+
import { tmpdir } from "node:os";
|
|
7200
|
+
import { join as join5, resolve as resolve2 } from "node:path";
|
|
7230
7201
|
|
|
7231
7202
|
// src/claude.ts
|
|
7232
7203
|
import { spawn, spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -7640,9 +7611,55 @@ function checkLogin(opts) {
|
|
|
7640
7611
|
return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
|
|
7641
7612
|
}
|
|
7642
7613
|
|
|
7614
|
+
// src/exit.ts
|
|
7615
|
+
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7616
|
+
|
|
7617
|
+
// src/setup.ts
|
|
7618
|
+
import { cpSync, createWriteStream as createWriteStream2, mkdirSync as mkdirSync2 } from "node:fs";
|
|
7619
|
+
import { delimiter, join as join3 } from "node:path";
|
|
7620
|
+
var SETUP_TIMEOUT_MS = 600000;
|
|
7621
|
+
async function runAll(commands, cwd, env, logPath, what) {
|
|
7622
|
+
const log = createWriteStream2(logPath);
|
|
7623
|
+
try {
|
|
7624
|
+
for (const run of commands) {
|
|
7625
|
+
const r = await runShell({
|
|
7626
|
+
run,
|
|
7627
|
+
cwd,
|
|
7628
|
+
env,
|
|
7629
|
+
timeoutMs: SETUP_TIMEOUT_MS,
|
|
7630
|
+
log
|
|
7631
|
+
});
|
|
7632
|
+
if (r.code !== 0)
|
|
7633
|
+
return `${what} failed (${r.error ?? `exit ${r.code}`}): ${run} (log: ${logPath})`;
|
|
7634
|
+
}
|
|
7635
|
+
return;
|
|
7636
|
+
} finally {
|
|
7637
|
+
await new Promise((r) => log.end(r));
|
|
7638
|
+
}
|
|
7639
|
+
}
|
|
7640
|
+
async function setupOnce(opts) {
|
|
7641
|
+
mkdirSync2(opts.out, { recursive: true });
|
|
7642
|
+
return runAll(opts.commands, opts.root, { ...opts.env, FIREGOAT_OUT: opts.out }, opts.logPath, "setup");
|
|
7643
|
+
}
|
|
7644
|
+
async function prepareRun(opts) {
|
|
7645
|
+
const out = join3(opts.runRoot, "out");
|
|
7646
|
+
if (opts.out === null)
|
|
7647
|
+
mkdirSync2(out);
|
|
7648
|
+
else
|
|
7649
|
+
cpSync(opts.out, out, { recursive: true });
|
|
7650
|
+
const path = [out, opts.env.PATH ?? ""].join(delimiter);
|
|
7651
|
+
const error = await runAll(opts.commands, opts.workdir, {
|
|
7652
|
+
...opts.env,
|
|
7653
|
+
PATH: path,
|
|
7654
|
+
FIREGOAT_OUT: out,
|
|
7655
|
+
FIREGOAT_WORKDIR: opts.workdir
|
|
7656
|
+
}, opts.logPath, "workdir_setup");
|
|
7657
|
+
return error ? { error } : { out, path };
|
|
7658
|
+
}
|
|
7659
|
+
|
|
7643
7660
|
// src/which.ts
|
|
7644
7661
|
import { accessSync, constants, statSync } from "node:fs";
|
|
7645
|
-
import { delimiter, join as
|
|
7662
|
+
import { delimiter as delimiter2, join as join4, resolve } from "node:path";
|
|
7646
7663
|
function resolveBin(bin) {
|
|
7647
7664
|
const executable = (p) => {
|
|
7648
7665
|
try {
|
|
@@ -7654,131 +7671,13 @@ function resolveBin(bin) {
|
|
|
7654
7671
|
};
|
|
7655
7672
|
if (bin.includes("/"))
|
|
7656
7673
|
return executable(bin) ? resolve(bin) : undefined;
|
|
7657
|
-
for (const dir of (process.env.PATH ?? "").split(
|
|
7658
|
-
if (dir && executable(
|
|
7659
|
-
return
|
|
7674
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
|
|
7675
|
+
if (dir && executable(join4(dir, bin)))
|
|
7676
|
+
return join4(dir, bin);
|
|
7660
7677
|
}
|
|
7661
7678
|
return;
|
|
7662
7679
|
}
|
|
7663
7680
|
|
|
7664
|
-
// src/build.ts
|
|
7665
|
-
var BUILD_TIMEOUT_MS = 600000;
|
|
7666
|
-
|
|
7667
|
-
class BuildError extends Error {
|
|
7668
|
-
}
|
|
7669
|
-
function kindOf(b, dir) {
|
|
7670
|
-
if (b.run)
|
|
7671
|
-
return "run";
|
|
7672
|
-
if (existsSync3(join4(dir, "package.json")))
|
|
7673
|
-
return "npm";
|
|
7674
|
-
if (existsSync3(join4(dir, "pyproject.toml")))
|
|
7675
|
-
return "python";
|
|
7676
|
-
throw new BuildError(`build.${b.name}: no package.json or pyproject.toml in ${dir}; add run: with a build command that writes to $FIREGOAT_OUT`);
|
|
7677
|
-
}
|
|
7678
|
-
function pythonBuildCommand() {
|
|
7679
|
-
if (resolveBin("uv"))
|
|
7680
|
-
return 'uv build --wheel --out-dir "$FIREGOAT_OUT"';
|
|
7681
|
-
return 'python3 -m build --wheel --outdir "$FIREGOAT_OUT"';
|
|
7682
|
-
}
|
|
7683
|
-
var BUILD_COMMAND = {
|
|
7684
|
-
npm: () => 'npm pack --ignore-scripts=false --pack-destination "$FIREGOAT_OUT"',
|
|
7685
|
-
python: pythonBuildCommand
|
|
7686
|
-
};
|
|
7687
|
-
var ARTIFACT = { npm: ".tgz", python: ".whl" };
|
|
7688
|
-
async function sh(run, cwd, env, log, what) {
|
|
7689
|
-
const r = await runShell({ run, cwd, env, timeoutMs: BUILD_TIMEOUT_MS, log });
|
|
7690
|
-
if (r.code !== 0)
|
|
7691
|
-
throw new BuildError(`${what} failed (${r.error ?? `exit ${r.code}`}): ${run}`);
|
|
7692
|
-
}
|
|
7693
|
-
async function buildOne(b, root, buildDir, env, log) {
|
|
7694
|
-
const dir = resolve2(root, b.path);
|
|
7695
|
-
if (!existsSync3(dir) || !statSync2(dir).isDirectory())
|
|
7696
|
-
throw new BuildError(`build.${b.name}: ${dir} is not a directory`);
|
|
7697
|
-
const kind = kindOf(b, dir);
|
|
7698
|
-
const out = join4(buildDir, b.name);
|
|
7699
|
-
mkdirSync2(out, { recursive: true });
|
|
7700
|
-
const run = kind === "run" ? b.run : BUILD_COMMAND[kind]();
|
|
7701
|
-
log.write(`## build.${b.name} (${kind}) in ${dir}
|
|
7702
|
-
`);
|
|
7703
|
-
await sh(run, dir, { ...env, FIREGOAT_OUT: out }, log, `build.${b.name}`);
|
|
7704
|
-
const files = readdirSync(out);
|
|
7705
|
-
if (kind !== "run") {
|
|
7706
|
-
const ext = ARTIFACT[kind];
|
|
7707
|
-
const hits = files.filter((f) => f.endsWith(ext));
|
|
7708
|
-
if (hits.length !== 1)
|
|
7709
|
-
throw new BuildError(`build.${b.name}: expected one ${ext} in ${out}, found ${hits.length ? hits.join(", ") : "none"}`);
|
|
7710
|
-
} else if (files.length === 0)
|
|
7711
|
-
throw new BuildError(`build.${b.name}: run: wrote nothing to $FIREGOAT_OUT (${out})`);
|
|
7712
|
-
return { name: b.name, kind, out };
|
|
7713
|
-
}
|
|
7714
|
-
async function buildAll(builds, root, buildDir, env) {
|
|
7715
|
-
mkdirSync2(buildDir, { recursive: true });
|
|
7716
|
-
const log = createWriteStream2(join4(buildDir, "build.txt"));
|
|
7717
|
-
try {
|
|
7718
|
-
const built = [];
|
|
7719
|
-
for (const b of builds)
|
|
7720
|
-
built.push(await buildOne(b, root, buildDir, env, log));
|
|
7721
|
-
return { built };
|
|
7722
|
-
} catch (e) {
|
|
7723
|
-
if (e instanceof BuildError)
|
|
7724
|
-
return { error: `${e.message} (log: ${join4(buildDir, "build.txt")})` };
|
|
7725
|
-
throw e;
|
|
7726
|
-
} finally {
|
|
7727
|
-
await new Promise((r) => log.end(r));
|
|
7728
|
-
}
|
|
7729
|
-
}
|
|
7730
|
-
async function install(built, runRoot, workdir, env, logPath) {
|
|
7731
|
-
const result = { paths: {}, env: {} };
|
|
7732
|
-
if (built.length === 0)
|
|
7733
|
-
return result;
|
|
7734
|
-
const log = createWriteStream2(logPath);
|
|
7735
|
-
const path = [];
|
|
7736
|
-
const artifacts = { npm: [], python: [] };
|
|
7737
|
-
try {
|
|
7738
|
-
for (const b of built) {
|
|
7739
|
-
const copy = join4(runRoot, "packages", b.name);
|
|
7740
|
-
cpSync(b.out, copy, { recursive: true });
|
|
7741
|
-
const files = readdirSync(copy);
|
|
7742
|
-
if (b.kind === "run") {
|
|
7743
|
-
result.paths[b.name] = files.length === 1 ? join4(copy, files[0]) : copy;
|
|
7744
|
-
path.push(copy);
|
|
7745
|
-
} else {
|
|
7746
|
-
const ext = ARTIFACT[b.kind];
|
|
7747
|
-
const file = join4(copy, files.find((f) => f.endsWith(ext)));
|
|
7748
|
-
result.paths[b.name] = file;
|
|
7749
|
-
artifacts[b.kind].push(file);
|
|
7750
|
-
}
|
|
7751
|
-
}
|
|
7752
|
-
if (artifacts.npm.length > 0) {
|
|
7753
|
-
writeFileSync(join4(workdir, "package.json"), `${JSON.stringify({ name: "app", version: "0.0.0", private: true }, null, 2)}
|
|
7754
|
-
`);
|
|
7755
|
-
await sh(`npm install --no-audit --no-fund ${artifacts.npm.map(q2).join(" ")}`, workdir, env, log, "npm install of the build");
|
|
7756
|
-
path.push(join4(workdir, "node_modules", ".bin"));
|
|
7757
|
-
}
|
|
7758
|
-
if (artifacts.python.length > 0) {
|
|
7759
|
-
const venv = join4(workdir, ".venv");
|
|
7760
|
-
const wheels = artifacts.python.map(q2).join(" ");
|
|
7761
|
-
await sh(resolveBin("uv") ? `uv venv .venv && uv pip install --python .venv/bin/python ${wheels}` : `python3 -m venv .venv && .venv/bin/pip install ${wheels}`, workdir, env, log, "pip install of the build");
|
|
7762
|
-
path.push(join4(venv, "bin"));
|
|
7763
|
-
result.env.VIRTUAL_ENV = venv;
|
|
7764
|
-
}
|
|
7765
|
-
result.env.PATH = [...path, env.PATH ?? ""].join(delimiter2);
|
|
7766
|
-
return result;
|
|
7767
|
-
} catch (e) {
|
|
7768
|
-
if (e instanceof BuildError)
|
|
7769
|
-
return { error: `${e.message} (log: ${logPath})` };
|
|
7770
|
-
throw e;
|
|
7771
|
-
} finally {
|
|
7772
|
-
await new Promise((r) => log.end(r));
|
|
7773
|
-
}
|
|
7774
|
-
}
|
|
7775
|
-
function q2(s) {
|
|
7776
|
-
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
7777
|
-
}
|
|
7778
|
-
|
|
7779
|
-
// src/exit.ts
|
|
7780
|
-
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7781
|
-
|
|
7782
7681
|
// src/eval.ts
|
|
7783
7682
|
var CHECK_TIMEOUT_MS = 120000;
|
|
7784
7683
|
|
|
@@ -7837,7 +7736,7 @@ function newRunId() {
|
|
|
7837
7736
|
}
|
|
7838
7737
|
function gitignoreHasFiregoat(cwd) {
|
|
7839
7738
|
const file = join5(cwd, ".gitignore");
|
|
7840
|
-
if (!
|
|
7739
|
+
if (!existsSync3(file))
|
|
7841
7740
|
return false;
|
|
7842
7741
|
return readFileSync2(file, "utf8").split(`
|
|
7843
7742
|
`).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
|
|
@@ -7889,15 +7788,28 @@ async function runOne(opts) {
|
|
|
7889
7788
|
const sandbox = isolate(opts.iso, opts.claudeBin, runRoot, workdir);
|
|
7890
7789
|
if ("error" in sandbox)
|
|
7891
7790
|
return done("error", `not graded: ${sandbox.error}`);
|
|
7892
|
-
const
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7791
|
+
const hostEnv = { ...process.env, ...opts.vars };
|
|
7792
|
+
let extra = {};
|
|
7793
|
+
if (opts.out !== null || opts.workdirSetup.length > 0) {
|
|
7794
|
+
const prepared = await prepareRun({
|
|
7795
|
+
out: opts.out,
|
|
7796
|
+
runRoot,
|
|
7797
|
+
workdir,
|
|
7798
|
+
commands: opts.workdirSetup,
|
|
7799
|
+
env: hostEnv,
|
|
7800
|
+
logPath: join5(dir, "workdir_setup.txt")
|
|
7801
|
+
});
|
|
7802
|
+
if ("error" in prepared)
|
|
7803
|
+
return done("error", `not graded: ${prepared.error}`);
|
|
7804
|
+
if (opts.out !== null)
|
|
7805
|
+
extra = { PATH: prepared.path, FIREGOAT_OUT: prepared.out };
|
|
7806
|
+
}
|
|
7807
|
+
const agentPath = extra.PATH ? { PATH: extra.PATH } : {};
|
|
7896
7808
|
const outcome = await runClaude({
|
|
7897
|
-
env: { ...agentEnv(runRoot), ...
|
|
7809
|
+
env: { ...agentEnv(runRoot), ...agentPath },
|
|
7898
7810
|
profile: sandbox.profile,
|
|
7899
7811
|
bin: opts.claudeBin,
|
|
7900
|
-
prompt: render(test.prompt,
|
|
7812
|
+
prompt: render(test.prompt, opts.vars),
|
|
7901
7813
|
cwd: workdir,
|
|
7902
7814
|
sessionPath: session,
|
|
7903
7815
|
stderrPath: join5(dir, "stderr.txt"),
|
|
@@ -7916,15 +7828,16 @@ async function runOne(opts) {
|
|
|
7916
7828
|
const log = createWriteStream3(join5(dir, "checks.txt"));
|
|
7917
7829
|
const env = {
|
|
7918
7830
|
...process.env,
|
|
7919
|
-
...
|
|
7920
|
-
|
|
7831
|
+
...hostEnv,
|
|
7832
|
+
...extra,
|
|
7833
|
+
FIREGOAT_SESSION: resolve2(session),
|
|
7921
7834
|
FIREGOAT_WORKDIR: workdir
|
|
7922
7835
|
};
|
|
7923
7836
|
const results = [];
|
|
7924
7837
|
try {
|
|
7925
7838
|
for (const c of test.checks) {
|
|
7926
7839
|
results.push(await runShell({
|
|
7927
|
-
run: render(c.run,
|
|
7840
|
+
run: render(c.run, opts.vars),
|
|
7928
7841
|
cwd: workdir,
|
|
7929
7842
|
env,
|
|
7930
7843
|
timeoutMs: CHECK_TIMEOUT_MS,
|
|
@@ -7971,7 +7884,7 @@ async function evalCommand(args, cwd) {
|
|
|
7971
7884
|
const runs = opts.runs ?? config.runs;
|
|
7972
7885
|
const vars = { ...config.vars, ...opts.vars };
|
|
7973
7886
|
const missing = [
|
|
7974
|
-
...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(
|
|
7887
|
+
...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(varRefs).filter((k) => !(k in vars)))
|
|
7975
7888
|
];
|
|
7976
7889
|
if (missing.length > 0) {
|
|
7977
7890
|
console.error(`firegoat eval: no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
|
|
@@ -7984,7 +7897,7 @@ async function evalCommand(args, cwd) {
|
|
|
7984
7897
|
}
|
|
7985
7898
|
if (opts.host)
|
|
7986
7899
|
warn("warning: --host: the agent runs without isolation and can read and write your files");
|
|
7987
|
-
else if (process.platform !== "darwin" || !
|
|
7900
|
+
else if (process.platform !== "darwin" || !existsSync3(SANDBOX_EXEC)) {
|
|
7988
7901
|
console.error(`firegoat eval: isolation needs macOS (${SANDBOX_EXEC}); this is ${process.platform}. In CI or Linux, run inside a throwaway container and pass --host.`);
|
|
7989
7902
|
return EXIT.usage;
|
|
7990
7903
|
}
|
|
@@ -8013,18 +7926,21 @@ async function evalCommand(args, cwd) {
|
|
|
8013
7926
|
const runDir = join5(root, ".firegoat", "runs", runId);
|
|
8014
7927
|
mkdirSync3(runDir, { recursive: true });
|
|
8015
7928
|
const summaryPath = join5(runDir, "summary.json");
|
|
8016
|
-
let
|
|
8017
|
-
if (config.
|
|
8018
|
-
say(
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
7929
|
+
let out = null;
|
|
7930
|
+
if (config.setup.length > 0) {
|
|
7931
|
+
say("setup...");
|
|
7932
|
+
out = join5(runDir, "out");
|
|
7933
|
+
const error = await setupOnce({
|
|
7934
|
+
commands: config.setup,
|
|
7935
|
+
root,
|
|
7936
|
+
out,
|
|
7937
|
+
env: { ...process.env, ...vars },
|
|
7938
|
+
logPath: join5(runDir, "setup.txt")
|
|
8022
7939
|
});
|
|
8023
|
-
if (
|
|
8024
|
-
console.error(`firegoat eval: not graded: ${
|
|
7940
|
+
if (error) {
|
|
7941
|
+
console.error(`firegoat eval: not graded: ${error}`);
|
|
8025
7942
|
return EXIT.notGraded;
|
|
8026
7943
|
}
|
|
8027
|
-
built = b.built;
|
|
8028
7944
|
}
|
|
8029
7945
|
const results = [];
|
|
8030
7946
|
const writeSummary = () => {
|
|
@@ -8035,11 +7951,10 @@ async function evalCommand(args, cwd) {
|
|
|
8035
7951
|
runId,
|
|
8036
7952
|
runsDir: runDir,
|
|
8037
7953
|
isolation: opts.host ? "host" : "seatbelt",
|
|
8038
|
-
builds: built.map((b) => ({ name: b.name, kind: b.kind })),
|
|
8039
7954
|
totals,
|
|
8040
7955
|
runs: results
|
|
8041
7956
|
};
|
|
8042
|
-
|
|
7957
|
+
writeFileSync(summaryPath, `${JSON.stringify(doc, null, 2)}
|
|
8043
7958
|
`);
|
|
8044
7959
|
return doc;
|
|
8045
7960
|
};
|
|
@@ -8053,7 +7968,8 @@ async function evalCommand(args, cwd) {
|
|
|
8053
7968
|
runDir,
|
|
8054
7969
|
claudeBin: claudePath,
|
|
8055
7970
|
iso,
|
|
8056
|
-
|
|
7971
|
+
out,
|
|
7972
|
+
workdirSetup: config.workdirSetup,
|
|
8057
7973
|
vars,
|
|
8058
7974
|
warn
|
|
8059
7975
|
});
|
|
@@ -8087,17 +8003,27 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
|
|
|
8087
8003
|
}
|
|
8088
8004
|
|
|
8089
8005
|
// src/init.ts
|
|
8090
|
-
import {
|
|
8006
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
8007
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
8091
8008
|
import { basename as basename2, join as join6 } from "node:path";
|
|
8092
8009
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
8093
|
-
import { fileURLToPath } from "node:url";
|
|
8094
8010
|
var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills]
|
|
8095
8011
|
|
|
8096
8012
|
--yes, -y don't ask questions (default when stdin is not a terminal)
|
|
8097
8013
|
--force, -f overwrite an existing ${CONFIG_FILE}
|
|
8098
|
-
--skills
|
|
8014
|
+
--skills install the firegoat-onboarding and suggesting-eval-prompts skills
|
|
8015
|
+
into .claude/skills/ (npx skills add team2027/skills)
|
|
8099
8016
|
`;
|
|
8100
|
-
var
|
|
8017
|
+
var SKILLS = ["firegoat-onboarding", "suggesting-eval-prompts"];
|
|
8018
|
+
var SKILLS_ARGV = [
|
|
8019
|
+
"skills",
|
|
8020
|
+
"add",
|
|
8021
|
+
"team2027/skills",
|
|
8022
|
+
...SKILLS.flatMap((s) => ["--skill", s]),
|
|
8023
|
+
"--agent",
|
|
8024
|
+
"claude-code",
|
|
8025
|
+
"-y"
|
|
8026
|
+
];
|
|
8101
8027
|
var GITIGNORE_LINE = ".firegoat/";
|
|
8102
8028
|
function readText(file) {
|
|
8103
8029
|
try {
|
|
@@ -8127,7 +8053,7 @@ function tomlSections(text) {
|
|
|
8127
8053
|
return sections;
|
|
8128
8054
|
}
|
|
8129
8055
|
function detect(cwd) {
|
|
8130
|
-
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) =>
|
|
8056
|
+
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join6(cwd, f)));
|
|
8131
8057
|
const fallback = { name: basename2(cwd), library: true, readme };
|
|
8132
8058
|
const pkgText = readText(join6(cwd, "package.json"));
|
|
8133
8059
|
if (pkgText !== undefined) {
|
|
@@ -8252,6 +8178,28 @@ function renderYaml(p, tests) {
|
|
|
8252
8178
|
"# How many times to run each test.",
|
|
8253
8179
|
"runs: 1",
|
|
8254
8180
|
"",
|
|
8181
|
+
"# Optional: test your local build instead of the published package.",
|
|
8182
|
+
"# setup: runs once per eval, in this folder, on your machine. Whatever it writes to",
|
|
8183
|
+
"# $FIREGOAT_OUT is copied into each run's folder and put on the agent's PATH.",
|
|
8184
|
+
"# workdir_setup: runs in each fresh workdir before the agent starts. Then tell the",
|
|
8185
|
+
'# agent in the prompt: "The package is already installed from the build under test."',
|
|
8186
|
+
"#",
|
|
8187
|
+
"# npm:",
|
|
8188
|
+
'# setup: npm pack --pack-destination "$FIREGOAT_OUT"',
|
|
8189
|
+
'# workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz',
|
|
8190
|
+
"#",
|
|
8191
|
+
"# python:",
|
|
8192
|
+
'# setup: uv build --wheel --out-dir "$FIREGOAT_OUT"',
|
|
8193
|
+
'# workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl',
|
|
8194
|
+
"#",
|
|
8195
|
+
"# a CLI binary (lands on PATH as is):",
|
|
8196
|
+
'# setup: go build -o "$FIREGOAT_OUT/mycli" ./cmd/mycli',
|
|
8197
|
+
"#",
|
|
8198
|
+
"# Values for {{vars.KEY}} in prompts and checks, overridden by `--var KEY=value`;",
|
|
8199
|
+
"# setup commands and checks also get them as env vars.",
|
|
8200
|
+
"# vars:",
|
|
8201
|
+
"# SHA: main",
|
|
8202
|
+
"",
|
|
8255
8203
|
"tests:"
|
|
8256
8204
|
];
|
|
8257
8205
|
for (const t of tests) {
|
|
@@ -8265,7 +8213,7 @@ function ensureGitignore(cwd) {
|
|
|
8265
8213
|
const file = join6(cwd, ".gitignore");
|
|
8266
8214
|
const text = readText(file);
|
|
8267
8215
|
if (text === undefined) {
|
|
8268
|
-
|
|
8216
|
+
writeFileSync2(file, `${GITIGNORE_LINE}
|
|
8269
8217
|
`);
|
|
8270
8218
|
return `created .gitignore with ${GITIGNORE_LINE}`;
|
|
8271
8219
|
}
|
|
@@ -8275,12 +8223,20 @@ function ensureGitignore(cwd) {
|
|
|
8275
8223
|
const sep = text === "" || text.endsWith(`
|
|
8276
8224
|
`) ? "" : `
|
|
8277
8225
|
`;
|
|
8278
|
-
|
|
8226
|
+
writeFileSync2(file, `${text}${sep}${GITIGNORE_LINE}
|
|
8279
8227
|
`);
|
|
8280
8228
|
return `added ${GITIGNORE_LINE} to .gitignore`;
|
|
8281
8229
|
}
|
|
8282
|
-
function
|
|
8283
|
-
|
|
8230
|
+
function installSkills(cwd) {
|
|
8231
|
+
const r = spawnSync3("npx", ["-y", ...SKILLS_ARGV], {
|
|
8232
|
+
cwd,
|
|
8233
|
+
stdio: "inherit"
|
|
8234
|
+
});
|
|
8235
|
+
if (r.error)
|
|
8236
|
+
return r.error.message;
|
|
8237
|
+
if (r.status !== 0)
|
|
8238
|
+
return `exit ${r.status ?? "by signal"}`;
|
|
8239
|
+
return;
|
|
8284
8240
|
}
|
|
8285
8241
|
async function askYesNo(question) {
|
|
8286
8242
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
@@ -8330,7 +8286,7 @@ ${INIT_USAGE}`);
|
|
|
8330
8286
|
}
|
|
8331
8287
|
}
|
|
8332
8288
|
const configPath = join6(cwd, CONFIG_FILE);
|
|
8333
|
-
const keepConfig =
|
|
8289
|
+
const keepConfig = existsSync4(configPath) && !force;
|
|
8334
8290
|
if (keepConfig && !skills) {
|
|
8335
8291
|
console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
|
|
8336
8292
|
return EXIT.usage;
|
|
@@ -8341,26 +8297,29 @@ ${INIT_USAGE}`);
|
|
|
8341
8297
|
const project = detect(cwd);
|
|
8342
8298
|
const tests = templates(project);
|
|
8343
8299
|
console.log(`detected: ${describe(project)}`);
|
|
8344
|
-
|
|
8300
|
+
writeFileSync2(configPath, renderYaml(project, tests));
|
|
8345
8301
|
console.log(`wrote ${CONFIG_FILE} (${tests.length} test${tests.length === 1 ? "" : "s"}: ${tests.map((t) => t.name).join(", ")}); edit the prompts to fit your users`);
|
|
8346
8302
|
}
|
|
8347
8303
|
console.log(ensureGitignore(cwd));
|
|
8348
8304
|
const interactive = !yes && process.stdin.isTTY && process.stdout.isTTY;
|
|
8349
8305
|
if (!skills && interactive)
|
|
8350
|
-
skills = await askYesNo(`Add the ${
|
|
8306
|
+
skills = await askYesNo(`Add the ${SKILLS.join(" and ")} skills to .claude/skills/ (help your agent draft better evals)?`);
|
|
8351
8307
|
if (skills) {
|
|
8352
|
-
|
|
8353
|
-
|
|
8354
|
-
|
|
8308
|
+
console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
|
|
8309
|
+
const failed = installSkills(cwd);
|
|
8310
|
+
if (failed) {
|
|
8311
|
+
console.error(`firegoat init: installing the skills failed (${failed}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
|
|
8312
|
+
return EXIT.notGraded;
|
|
8313
|
+
}
|
|
8355
8314
|
} else {
|
|
8356
|
-
console.log(`hint: \`npx firegoat init --skills\` adds the ${
|
|
8315
|
+
console.log(`hint: \`npx firegoat init --skills\` adds the ${SKILLS.join(" and ")} skills to .claude/skills/ (keeps your ${CONFIG_FILE})`);
|
|
8357
8316
|
}
|
|
8358
8317
|
console.log("next: npx firegoat eval");
|
|
8359
8318
|
return 0;
|
|
8360
8319
|
}
|
|
8361
8320
|
|
|
8362
8321
|
// src/login.ts
|
|
8363
|
-
import { spawnSync as
|
|
8322
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
8364
8323
|
import { mkdirSync as mkdirSync4 } from "node:fs";
|
|
8365
8324
|
function loginCommand(args) {
|
|
8366
8325
|
let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
|
|
@@ -8383,7 +8342,7 @@ function loginCommand(args) {
|
|
|
8383
8342
|
};
|
|
8384
8343
|
delete env.ANTHROPIC_API_KEY;
|
|
8385
8344
|
console.error(`Signing claude in to ${configDir}. Evals run with this login, never your own ~/.claude.`);
|
|
8386
|
-
const r =
|
|
8345
|
+
const r = spawnSync4(claudeBin, ["auth", "login"], { env, stdio: "inherit" });
|
|
8387
8346
|
if (r.error) {
|
|
8388
8347
|
console.error(`firegoat login: could not run ${claudeBin}: ${r.error.message}`);
|
|
8389
8348
|
return EXIT.notGraded;
|
|
@@ -8397,8 +8356,8 @@ function loginCommand(args) {
|
|
|
8397
8356
|
}
|
|
8398
8357
|
|
|
8399
8358
|
// src/open.ts
|
|
8400
|
-
import { spawnSync as
|
|
8401
|
-
import { existsSync as
|
|
8359
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
8360
|
+
import { existsSync as existsSync5, readdirSync } from "node:fs";
|
|
8402
8361
|
import { join as join7 } from "node:path";
|
|
8403
8362
|
var OPENER = {
|
|
8404
8363
|
darwin: "open",
|
|
@@ -8421,7 +8380,7 @@ function openCommand(args, cwd) {
|
|
|
8421
8380
|
return EXIT.usage;
|
|
8422
8381
|
}
|
|
8423
8382
|
const base = join7(findConfigDir(cwd), ".firegoat", "runs");
|
|
8424
|
-
const runs =
|
|
8383
|
+
const runs = existsSync5(base) ? readdirSync(base).sort() : [];
|
|
8425
8384
|
const id = ids[0] ?? runs.at(-1);
|
|
8426
8385
|
if (!id) {
|
|
8427
8386
|
console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
|
|
@@ -8436,7 +8395,7 @@ function openCommand(args, cwd) {
|
|
|
8436
8395
|
const opener = OPENER[process.platform];
|
|
8437
8396
|
if (print || !opener)
|
|
8438
8397
|
return EXIT.pass;
|
|
8439
|
-
const r =
|
|
8398
|
+
const r = spawnSync5(opener, [dir], { stdio: "inherit" });
|
|
8440
8399
|
if (r.error || r.status !== 0) {
|
|
8441
8400
|
console.error(`firegoat open: ${opener} failed (${r.error?.message ?? `exit ${r.status}`}); the run is at the path above`);
|
|
8442
8401
|
return EXIT.notGraded;
|
|
@@ -8458,7 +8417,7 @@ Usage:
|
|
|
8458
8417
|
eval options:
|
|
8459
8418
|
test.. only run these tests (by name)
|
|
8460
8419
|
--runs N runs per test (overrides runs: in firegoat.yaml)
|
|
8461
|
-
--var KEY=value fills {{vars.KEY}} in prompts and checks
|
|
8420
|
+
--var KEY=value fills {{vars.KEY}} in prompts and checks; env for setup, workdir_setup and checks
|
|
8462
8421
|
--json JSON summary on stdout, progress on stderr
|
|
8463
8422
|
--host no sandbox (needed off macOS; use a throwaway container)
|
|
8464
8423
|
--claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firegoat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/team2027/firegoat-cli.git"
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
|
-
"dist"
|
|
25
|
-
"skills"
|
|
24
|
+
"dist"
|
|
26
25
|
],
|
|
27
26
|
"homepage": "https://github.com/team2027/firegoat-cli#readme",
|
|
28
27
|
"license": "MIT",
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: firegoat-onboarding
|
|
3
|
-
description: Draft 2-3 firegoat evals (firegoat.yaml) for the current repo by reading its README, docs and package manifests, phrasing each test as a real user's problem with a gradeable goal and script checks. Use when the user says "write firegoat evals", "draft evals", "set up firegoat", "firegoat init", or asks for agent-experience (AX) evals of their SDK, CLI, API or docs.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Draft firegoat evals
|
|
7
|
-
|
|
8
|
-
firegoat runs a coding agent (Claude Code) in an empty, isolated workdir with one prompt,
|
|
9
|
-
then grades the run. Your job: write 2-3 tests into `firegoat.yaml` that tell the
|
|
10
|
-
maintainer whether an agent can succeed with their product the way a new user would.
|
|
11
|
-
|
|
12
|
-
## 1. Learn the product (read, don't run)
|
|
13
|
-
|
|
14
|
-
Read, in this order, and stop when you can answer the questions below:
|
|
15
|
-
- `README*`, then the docs dir (`docs/`, `website/`, `*.mdx`), quickstart/getting-started pages first.
|
|
16
|
-
- Package manifests: `package.json` (`name`, `bin`, `exports`), `pyproject.toml`, `go.mod`,
|
|
17
|
-
`Cargo.toml`, `*.gemspec`, `Package.swift`. They tell you the install name and entry points.
|
|
18
|
-
- `examples/`, `templates/`, `samples/`: the shapes users actually build.
|
|
19
|
-
- An existing `firegoat.yaml`, if any: keep its tests (see step 5).
|
|
20
|
-
|
|
21
|
-
Answer: What is it (SDK, CLI, API, framework)? Who uses it? What is the first thing a new
|
|
22
|
-
user wants to have working? What is the public docs URL? Which credentials does it need
|
|
23
|
-
(env var names only)?
|
|
24
|
-
|
|
25
|
-
## 2. Pick 2-3 outcomes
|
|
26
|
-
|
|
27
|
-
Choose the most important first-time user outcomes, e.g. "first successful API call",
|
|
28
|
-
"the core feature end to end", "the thing users most often ask about". Prefer outcomes
|
|
29
|
-
that leave observable proof. Skip admin, billing and anything needing a browser login
|
|
30
|
-
unless credentials can come from env vars.
|
|
31
|
-
|
|
32
|
-
## 3. Write each prompt as a user's problem
|
|
33
|
-
|
|
34
|
-
A prompt is what a user would type to their agent, not a recipe. Each prompt has:
|
|
35
|
-
- **What they want, not how.** No numbered implementation steps, no "use template X, add package Y".
|
|
36
|
-
The eval measures whether the agent finds its way with your docs.
|
|
37
|
-
- **One concrete outcome**, small enough to finish in a few minutes.
|
|
38
|
-
- **Observable proof**: print a marker to stdout, write a file, or take a screenshot.
|
|
39
|
-
- **"Keep it minimal."** so the agent doesn't build a demo app.
|
|
40
|
-
- **The docs URL as the only hint** (omit it if the test is about discovery).
|
|
41
|
-
- **Credentials as env var names**, never values: "Credentials are in env vars ACME_API_KEY and ACME_API_URL."
|
|
42
|
-
- If the test should use the build under test, say so: "The package is already installed
|
|
43
|
-
from the build under test. Do not install or upgrade it from the registry."
|
|
44
|
-
|
|
45
|
-
## 4. Write the goal and checks
|
|
46
|
-
|
|
47
|
-
`goal`: one sentence a judge could check from the transcript and workdir, e.g. "A minimal
|
|
48
|
-
project runs and prints the answer, a numeric score and a reason."
|
|
49
|
-
|
|
50
|
-
`checks`: shell scripts, run in the agent's workdir after the run. Exit code 0 = pass.
|
|
51
|
-
Env: `FIREGOAT_WORKDIR` (the workdir) and `FIREGOAT_SESSION` (the agent's stream-json
|
|
52
|
-
transcript, one JSON event per line). Checks should prove the outcome and catch cheating:
|
|
53
|
-
- Output exists with the expected marker: `grep -rq hello-from-acme "$FIREGOAT_WORKDIR"`
|
|
54
|
-
or `test -s "$FIREGOAT_WORKDIR/screenshot.png"`.
|
|
55
|
-
- The real thing was used, not faked: the transcript shows the SDK/CLI being called,
|
|
56
|
-
e.g. `grep -q '"command":"[^"]*acme ' "$FIREGOAT_SESSION"`.
|
|
57
|
-
- Cleanup happened: the transcript shows the delete call after the create call.
|
|
58
|
-
- The local build was used, not a registry install:
|
|
59
|
-
`! grep -Eq '"command":"[^"]*(pip install|npm (install|i) [^"]*acme@)' "$FIREGOAT_SESSION"`.
|
|
60
|
-
- Only local docs were used (when that's the point): `! grep -q '"name":"WebFetch"' "$FIREGOAT_SESSION"`.
|
|
61
|
-
|
|
62
|
-
Match tool-call JSON (`"command":"...`) rather than plain words, so the prompt text or the
|
|
63
|
-
agent saying "I won't pip install" doesn't trip the check. Checks run in the agent's
|
|
64
|
-
workdir, which does not contain the repo, so keep them self-contained: one-liners, or a
|
|
65
|
-
`run: |` block for longer scripts. Never reference repo files by relative path.
|
|
66
|
-
|
|
67
|
-
Do not emit `judge:` checks: v1 skips them with a warning.
|
|
68
|
-
|
|
69
|
-
## 5. Write firegoat.yaml
|
|
70
|
-
|
|
71
|
-
Schema (v1):
|
|
72
|
-
|
|
73
|
-
```yaml
|
|
74
|
-
agents: [claude-code] # only claude-code in v1
|
|
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
|
|
82
|
-
tests:
|
|
83
|
-
- name: quickstart # letters, digits, '.', '_', '-'; unique
|
|
84
|
-
prompt: >-
|
|
85
|
-
...
|
|
86
|
-
goal: ...
|
|
87
|
-
checks: # optional
|
|
88
|
-
- run: grep -rq some-marker "$FIREGOAT_WORKDIR"
|
|
89
|
-
timeout: 600 # optional, seconds
|
|
90
|
-
```
|
|
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
|
-
|
|
95
|
-
If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append
|
|
96
|
-
yours, and rename yours (`-2` suffix) if a name collides. Never delete or rewrite user tests.
|
|
97
|
-
Quote prompts with `>-` folded blocks so colons and quotes stay valid YAML.
|
|
98
|
-
|
|
99
|
-
Before finishing, re-read the file and check: it parses as YAML, every test has a non-empty
|
|
100
|
-
`name`, `prompt` and `goal`, names are unique and match `^[A-Za-z0-9._-]+$`, and no credential
|
|
101
|
-
values appear anywhere.
|
|
102
|
-
|
|
103
|
-
## 6. Hand off
|
|
104
|
-
|
|
105
|
-
Tell the user, in a few lines: which tests you added and why, which env vars they must export,
|
|
106
|
-
and to run `npx firegoat eval`.
|
|
107
|
-
|
|
108
|
-
## Examples
|
|
109
|
-
|
|
110
|
-
See `references/examples.md` for good and bad tests with their YAML. Short version:
|
|
111
|
-
|
|
112
|
-
Good: "Create a new app - a coffee timer. Run it on the simulator and get the screenshot."
|
|
113
|
-
Bad: "Create a sandbox. https://docs.acme.dev" with no goal and no checks: every run passes,
|
|
114
|
-
so it measures nothing.
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# Example firegoat tests
|
|
2
|
-
|
|
3
|
-
Product names are fictional. Copy the shape, not the words.
|
|
4
|
-
|
|
5
|
-
## Good
|
|
6
|
-
|
|
7
|
-
### 1. Core feature end to end, printed proof
|
|
8
|
-
|
|
9
|
-
An agent framework with scorers. The prompt names the outcome and what to print; the agent
|
|
10
|
-
has to find how in the docs.
|
|
11
|
-
|
|
12
|
-
```yaml
|
|
13
|
-
tests:
|
|
14
|
-
- name: agent-with-scorer
|
|
15
|
-
prompt: >-
|
|
16
|
-
Build an agent with a scorer using https://docs.acme.dev/agents. Ask the agent
|
|
17
|
-
to explain recursion in one sentence, then run a scorer that grades whether the
|
|
18
|
-
answer is concise and accurate. Print the agent answer, the numeric score and
|
|
19
|
-
the scorer reason to stdout. Keep it minimal.
|
|
20
|
-
goal: >-
|
|
21
|
-
A minimal project runs and prints the agent's answer, a numeric score and the
|
|
22
|
-
scorer's reason.
|
|
23
|
-
checks:
|
|
24
|
-
- run: grep -Eq '"command":"[^"]*(node|npx|tsx|bun) ' "$FIREGOAT_SESSION"
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### 2. Multi-step workflow, one realistic input
|
|
28
|
-
|
|
29
|
-
```yaml
|
|
30
|
-
tests:
|
|
31
|
-
- name: support-workflow
|
|
32
|
-
prompt: >-
|
|
33
|
-
Using https://docs.acme.dev/workflows, build a three-step workflow: extract the
|
|
34
|
-
issue type from a support message, have an agent draft a response, then add a
|
|
35
|
-
priority label. Run it once on a customer asking for a refund and print the
|
|
36
|
-
final result. Keep it minimal.
|
|
37
|
-
goal: >-
|
|
38
|
-
The workflow runs once on a refund request and prints a result containing the
|
|
39
|
-
issue type, a drafted response and a priority label.
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### 3. Local build under test, cleanup, no registry install
|
|
43
|
-
|
|
44
|
-
```yaml
|
|
45
|
-
tests:
|
|
46
|
-
- name: sandbox-hello
|
|
47
|
-
prompt: >-
|
|
48
|
-
Using the Acme Python SDK, create a sandbox, run `echo hello-from-acme` inside
|
|
49
|
-
it, print the output, then delete the sandbox. The package is already installed
|
|
50
|
-
from the build under test. Do not install or upgrade it from PyPI.
|
|
51
|
-
Docs: https://docs.acme.dev/python. Credentials are in env vars ACME_API_KEY
|
|
52
|
-
and ACME_API_URL.
|
|
53
|
-
goal: >-
|
|
54
|
-
The script prints hello-from-acme from inside a sandbox and deletes the sandbox
|
|
55
|
-
at the end.
|
|
56
|
-
checks:
|
|
57
|
-
- run: grep -Eq '\.(delete|remove)\(' -r "$FIREGOAT_WORKDIR" --include='*.py'
|
|
58
|
-
- run: "! grep -Eq '\"command\":\"[^\"]*pip3? install[^\"]*acme' \"$FIREGOAT_SESSION\""
|
|
59
|
-
timeout: 900
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### 4. Pure user need, discovery
|
|
63
|
-
|
|
64
|
-
No docs URL, no package names: the test is whether the agent finds the right packages.
|
|
65
|
-
|
|
66
|
-
```yaml
|
|
67
|
-
tests:
|
|
68
|
-
- name: which-packages
|
|
69
|
-
prompt: >-
|
|
70
|
-
I'm building a mobile app with Acme. It needs a local database, maps, age
|
|
71
|
-
gating, localization, Sign in with Apple and a small on-device model. Does Acme
|
|
72
|
-
offer packages for these? Which ones would you use?
|
|
73
|
-
goal: >-
|
|
74
|
-
The answer names the right Acme package for each requirement and relies on the
|
|
75
|
-
docs shipped in the project, not web search.
|
|
76
|
-
checks:
|
|
77
|
-
- run: "! grep -q '\"name\":\"WebFetch\"' \"$FIREGOAT_SESSION\""
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### 5. Shortest
|
|
81
|
-
|
|
82
|
-
```yaml
|
|
83
|
-
tests:
|
|
84
|
-
- name: coffee-timer
|
|
85
|
-
prompt: Create a new app - a coffee timer. Run it on the simulator and get the screenshot.
|
|
86
|
-
goal: A screenshot shows a working coffee timer app running on the simulator.
|
|
87
|
-
checks:
|
|
88
|
-
- run: find "$FIREGOAT_WORKDIR" -name '*.png' -size +1k | grep -q .
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Bad
|
|
92
|
-
|
|
93
|
-
### No goal, no checks
|
|
94
|
-
|
|
95
|
-
```text
|
|
96
|
-
prompt: "Create a sandbox. https://docs.acme.dev"
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Ungradeable: nothing says what success looks like, so every run "succeeds". Add an
|
|
100
|
-
observable outcome ("run echo hello-from-acme inside it and print the output"), a goal
|
|
101
|
-
and a check.
|
|
102
|
-
|
|
103
|
-
### Implementation steps
|
|
104
|
-
|
|
105
|
-
```text
|
|
106
|
-
prompt: >-
|
|
107
|
-
1. Create a project from the acme-starter template.
|
|
108
|
-
2. Add the acme-maps package.
|
|
109
|
-
3. Render a map centered on Berlin.
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
This tests instruction-following, not whether an agent finds its way with your docs.
|
|
113
|
-
Rewrite as the user's need: "Show a map centered on Berlin in a new Acme app. Keep it
|
|
114
|
-
minimal. Take a screenshot."
|