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