firegoat 0.13.0 → 0.15.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 -6
- package/dist/cli.js +322 -57
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Status: early (v0.11). See `plan.md`.
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx firegoat init # creates firegoat.yaml and .firegoat/, installs the
|
|
10
|
+
npx firegoat init # creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
|
|
11
11
|
# edit firegoat.yaml (or paste the prompt into your coding agent and let it)
|
|
12
12
|
npx firegoat eval # runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
|
|
13
13
|
```
|
|
@@ -82,12 +82,28 @@ tests:
|
|
|
82
82
|
npx firegoat eval --var SHA=$(git rev-parse HEAD)
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
### Same tests on several models
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
model: [claude-haiku-5-5, claude-sonnet-5-5]
|
|
89
|
+
runs: 2
|
|
90
|
+
tests:
|
|
91
|
+
- name: quickstart
|
|
92
|
+
prompt: Get the acme SDK's hello-world example running and save its output to output.txt.
|
|
93
|
+
end_goal: output.txt holds the example's output.
|
|
94
|
+
checks:
|
|
95
|
+
- run: test -s output.txt
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`model:` is passed to claude as `--model`. A list is a matrix: each test runs `runs` times per model, numbered `1..runs×models` (here `quickstart/1`, `2` on haiku, `3`, `4` on sonnet). `npx firegoat eval --model claude-opus-5-5` replaces it for one eval; repeat the flag for a matrix. Unset, firegoat passes no `--model` and claude uses its default. Each run's `run.json` records `model` (what firegoat asked for, or null) and `claudeModel` (what claude reported starting with), and the output labels each run with its model.
|
|
99
|
+
|
|
85
100
|
### Env vars for the agent: a home dir per run, a feature flag
|
|
86
101
|
|
|
87
102
|
```yaml
|
|
88
103
|
env:
|
|
89
104
|
ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # sessions/config never collide across runs
|
|
90
105
|
ACME_TELEMETRY: "off"
|
|
106
|
+
UV_FIND_LINKS: "$FIREGOAT_OUT" # resolve the wheel setup built
|
|
91
107
|
tests:
|
|
92
108
|
- name: login
|
|
93
109
|
prompt: Log in to acme with the CLI and print the current user.
|
|
@@ -98,18 +114,19 @@ tests:
|
|
|
98
114
|
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
99
115
|
```
|
|
100
116
|
|
|
101
|
-
`env:` goes to the agent, and to `workdir_setup` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs), `{{
|
|
117
|
+
`env:` goes to the agent, and to `workdir_setup` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` (also `${...}`), the same dirs `setup`, `workdir_setup` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `setup` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`.
|
|
102
118
|
|
|
103
119
|
## What eval does
|
|
104
120
|
|
|
105
121
|
- claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
|
|
106
|
-
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores
|
|
122
|
+
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores everything except `fixtures/`: keep test fixtures (scripts, sample data) in `.firegoat/fixtures/` and commit them with `firegoat.yaml`.
|
|
123
|
+
- In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `setup:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost`) to tell runs from different branches apart.
|
|
107
124
|
- The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, setup, workdir_setup, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it.
|
|
108
|
-
- Runs (tests × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
|
|
125
|
+
- Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
|
|
109
126
|
- Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
|
|
110
127
|
- Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
|
|
111
128
|
|
|
112
|
-
`firegoat.yaml` keys: `runs`, `setup`, `workdir_setup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
129
|
+
`firegoat.yaml` keys: `runs`, `model`, `setup`, `workdir_setup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
113
130
|
|
|
114
131
|
Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See [`examples/local-npm-registry`](examples/local-npm-registry/README.md).
|
|
115
132
|
|
|
@@ -128,7 +145,7 @@ Something in firegoat got in your way (or your agent's)? Tell us from the termin
|
|
|
128
145
|
npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
|
|
129
146
|
```
|
|
130
147
|
|
|
131
|
-
It goes to the firegoat team with the firegoat version, your OS and node version, and which coding agent sent it. Don't include secrets: a report that looks like it has one is refused.
|
|
148
|
+
It goes to the firegoat team with the firegoat version, your OS and node version, and which coding agent sent it. Don't include secrets: a report that looks like it has one is refused. The first send registers your device and saves its token to `~/.config/firegoat/feedback-device.json` (`$XDG_CONFIG_HOME` respected), which raises the daily report limit.
|
|
132
149
|
|
|
133
150
|
## Develop
|
|
134
151
|
|
package/dist/cli.js
CHANGED
|
@@ -6947,14 +6947,14 @@ 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.15.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
|
|
6954
6954
|
},
|
|
6955
6955
|
devDependencies: {
|
|
6956
6956
|
"@biomejs/biome": "^2.5.15",
|
|
6957
|
-
"@firegoat/feedback": "0.
|
|
6957
|
+
"@firegoat/feedback": "0.2.0",
|
|
6958
6958
|
"@types/bun": "latest",
|
|
6959
6959
|
typescript: "^7",
|
|
6960
6960
|
yaml: "^2.9.1"
|
|
@@ -7075,6 +7075,7 @@ function parseConfig(text) {
|
|
|
7075
7075
|
fail("(root)", "must be a mapping");
|
|
7076
7076
|
onlyKeys(raw, "(root)", [
|
|
7077
7077
|
"runs",
|
|
7078
|
+
"model",
|
|
7078
7079
|
"setup",
|
|
7079
7080
|
"workdir_setup",
|
|
7080
7081
|
"vars",
|
|
@@ -7082,6 +7083,7 @@ function parseConfig(text) {
|
|
|
7082
7083
|
"tests"
|
|
7083
7084
|
]);
|
|
7084
7085
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7086
|
+
const models = parseModels(raw.model);
|
|
7085
7087
|
const setup = commands(raw.setup, "setup");
|
|
7086
7088
|
const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
|
|
7087
7089
|
const vars = parseVars(raw.vars);
|
|
@@ -7122,6 +7124,7 @@ function parseConfig(text) {
|
|
|
7122
7124
|
});
|
|
7123
7125
|
return {
|
|
7124
7126
|
runs,
|
|
7127
|
+
models,
|
|
7125
7128
|
setup,
|
|
7126
7129
|
workdirSetup,
|
|
7127
7130
|
vars,
|
|
@@ -7139,6 +7142,22 @@ function commands(raw, path) {
|
|
|
7139
7142
|
fail(path, "must be a shell command or a list of them");
|
|
7140
7143
|
return raw.map((c, i) => str(c, `${path}[${i}]`));
|
|
7141
7144
|
}
|
|
7145
|
+
function parseModels(raw) {
|
|
7146
|
+
if (raw === undefined)
|
|
7147
|
+
return [];
|
|
7148
|
+
if (typeof raw === "string")
|
|
7149
|
+
return [str(raw, "model")];
|
|
7150
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
7151
|
+
fail("model", "must be a model name or a non-empty list of them");
|
|
7152
|
+
const seen = new Set;
|
|
7153
|
+
return raw.map((m, i) => {
|
|
7154
|
+
const name = str(m, `model[${i}]`);
|
|
7155
|
+
if (seen.has(name))
|
|
7156
|
+
fail(`model[${i}]`, `duplicate model ${JSON.stringify(name)}`);
|
|
7157
|
+
seen.add(name);
|
|
7158
|
+
return name;
|
|
7159
|
+
});
|
|
7160
|
+
}
|
|
7142
7161
|
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7143
7162
|
function parseVars(raw) {
|
|
7144
7163
|
if (raw === undefined)
|
|
@@ -7160,9 +7179,11 @@ var RESERVED_ENV = new Set([
|
|
|
7160
7179
|
"CLAUDE_CONFIG_DIR",
|
|
7161
7180
|
"CLAUDE_SECURESTORAGE_CONFIG_DIR"
|
|
7162
7181
|
]);
|
|
7163
|
-
var RUN_PLACEHOLDERS = ["run.id", "run.n", "
|
|
7182
|
+
var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
|
|
7164
7183
|
var EVAL_PLACEHOLDERS = ["run.id"];
|
|
7165
7184
|
var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
|
7185
|
+
var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
|
|
7186
|
+
var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
|
|
7166
7187
|
function parseEnv(raw, path) {
|
|
7167
7188
|
if (raw === undefined)
|
|
7168
7189
|
return {};
|
|
@@ -7173,6 +7194,8 @@ function parseEnv(raw, path) {
|
|
|
7173
7194
|
const p = `${path}.${k}`;
|
|
7174
7195
|
if (!VAR_KEY.test(k))
|
|
7175
7196
|
fail(p, "name must be letters, digits and _, not starting with a digit");
|
|
7197
|
+
if (k === "ANTHROPIC_MODEL")
|
|
7198
|
+
fail(p, "is reserved: choose the model with top-level model: (or eval --model)");
|
|
7176
7199
|
if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
|
|
7177
7200
|
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7178
7201
|
if (typeof v !== "string")
|
|
@@ -7187,7 +7210,8 @@ function parseEnv(raw, path) {
|
|
|
7187
7210
|
return out;
|
|
7188
7211
|
}
|
|
7189
7212
|
function isEvalLevel(value) {
|
|
7190
|
-
|
|
7213
|
+
const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
|
|
7214
|
+
return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
|
|
7191
7215
|
const name = m[1];
|
|
7192
7216
|
return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
|
|
7193
7217
|
});
|
|
@@ -7195,8 +7219,9 @@ function isEvalLevel(value) {
|
|
|
7195
7219
|
function renderEnv(env, vars, run) {
|
|
7196
7220
|
const out = {};
|
|
7197
7221
|
for (const [k, v] of Object.entries(env))
|
|
7198
|
-
out[k] = v.replace(
|
|
7199
|
-
const
|
|
7222
|
+
out[k] = v.replace(ENV_REF, (whole, name, braced, bare) => {
|
|
7223
|
+
const key = name ?? braced ?? bare;
|
|
7224
|
+
const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
|
|
7200
7225
|
if (filled === undefined)
|
|
7201
7226
|
throw new ConfigError(`env.${k}: no value for ${whole}`);
|
|
7202
7227
|
return filled;
|
|
@@ -7225,13 +7250,14 @@ function loadConfig(dir) {
|
|
|
7225
7250
|
// src/eval.ts
|
|
7226
7251
|
import { randomBytes } from "node:crypto";
|
|
7227
7252
|
import {
|
|
7253
|
+
appendFileSync,
|
|
7228
7254
|
createWriteStream as createWriteStream2,
|
|
7229
7255
|
existsSync as existsSync2,
|
|
7230
7256
|
mkdirSync as mkdirSync2,
|
|
7231
7257
|
readdirSync as readdirSync2,
|
|
7232
7258
|
writeFileSync as writeFileSync3
|
|
7233
7259
|
} from "node:fs";
|
|
7234
|
-
import { basename, delimiter as delimiter2, join as join4 } from "node:path";
|
|
7260
|
+
import { basename as basename2, delimiter as delimiter2, join as join4 } from "node:path";
|
|
7235
7261
|
|
|
7236
7262
|
// src/claude.ts
|
|
7237
7263
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -7293,7 +7319,12 @@ async function runClaude(opts) {
|
|
|
7293
7319
|
let pending = "";
|
|
7294
7320
|
const exited = await run({
|
|
7295
7321
|
cmd: opts.bin,
|
|
7296
|
-
args: [
|
|
7322
|
+
args: [
|
|
7323
|
+
"-p",
|
|
7324
|
+
opts.prompt,
|
|
7325
|
+
...CLAUDE_FLAGS,
|
|
7326
|
+
...opts.model === undefined ? [] : ["--model", opts.model]
|
|
7327
|
+
],
|
|
7297
7328
|
cwd: opts.cwd,
|
|
7298
7329
|
env: opts.env,
|
|
7299
7330
|
timeoutMs: opts.timeoutMs,
|
|
@@ -7317,6 +7348,7 @@ async function runClaude(opts) {
|
|
|
7317
7348
|
if (exited.error)
|
|
7318
7349
|
return {
|
|
7319
7350
|
events: [],
|
|
7351
|
+
claudeModel: null,
|
|
7320
7352
|
ok: false,
|
|
7321
7353
|
reason: `could not start ${opts.bin}: ${exited.error.message}`
|
|
7322
7354
|
};
|
|
@@ -7334,15 +7366,18 @@ async function runClaude(opts) {
|
|
|
7334
7366
|
`)[0]?.slice(0, 300);
|
|
7335
7367
|
return {
|
|
7336
7368
|
events,
|
|
7369
|
+
claudeModel: null,
|
|
7337
7370
|
ok: false,
|
|
7338
7371
|
reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
|
|
7339
7372
|
};
|
|
7340
7373
|
}
|
|
7341
7374
|
const sessionId = String(init.session_id);
|
|
7375
|
+
const claudeModel = typeof init.model === "string" ? init.model : null;
|
|
7342
7376
|
const result = events.findLast((e) => e?.type === "result");
|
|
7343
7377
|
if (exited.timedOut)
|
|
7344
7378
|
return {
|
|
7345
7379
|
events,
|
|
7380
|
+
claudeModel,
|
|
7346
7381
|
ok: false,
|
|
7347
7382
|
reason: `timed out after ${opts.timeoutMs / 1000}s`,
|
|
7348
7383
|
result,
|
|
@@ -7351,6 +7386,7 @@ async function runClaude(opts) {
|
|
|
7351
7386
|
if (!result)
|
|
7352
7387
|
return {
|
|
7353
7388
|
events,
|
|
7389
|
+
claudeModel,
|
|
7354
7390
|
ok: false,
|
|
7355
7391
|
reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
|
|
7356
7392
|
sessionId
|
|
@@ -7358,12 +7394,13 @@ async function runClaude(opts) {
|
|
|
7358
7394
|
if (result.is_error === true || result.subtype !== "success")
|
|
7359
7395
|
return {
|
|
7360
7396
|
events,
|
|
7397
|
+
claudeModel,
|
|
7361
7398
|
ok: false,
|
|
7362
7399
|
reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
|
|
7363
7400
|
result,
|
|
7364
7401
|
sessionId
|
|
7365
7402
|
};
|
|
7366
|
-
return { events, ok: true, result, sessionId };
|
|
7403
|
+
return { events, claudeModel, ok: true, result, sessionId };
|
|
7367
7404
|
}
|
|
7368
7405
|
async function runShell(opts) {
|
|
7369
7406
|
opts.log.write(`$ ${opts.run}
|
|
@@ -7415,8 +7452,9 @@ var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
|
7415
7452
|
// src/firegoat-dir.ts
|
|
7416
7453
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7417
7454
|
import { join } from "node:path";
|
|
7418
|
-
var GITIGNORE =
|
|
7419
|
-
|
|
7455
|
+
var GITIGNORE = `*
|
|
7456
|
+
!fixtures/
|
|
7457
|
+
!fixtures/**
|
|
7420
7458
|
`;
|
|
7421
7459
|
function ensureFiregoatDir(root) {
|
|
7422
7460
|
const dir = join(root, ".firegoat");
|
|
@@ -7425,6 +7463,51 @@ function ensureFiregoatDir(root) {
|
|
|
7425
7463
|
return dir;
|
|
7426
7464
|
}
|
|
7427
7465
|
|
|
7466
|
+
// src/git.ts
|
|
7467
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
7468
|
+
import { basename, dirname } from "node:path";
|
|
7469
|
+
function git(dir, args) {
|
|
7470
|
+
const r = spawnSync2("git", args, { cwd: dir, encoding: "utf8" });
|
|
7471
|
+
if (r.error)
|
|
7472
|
+
throw r.error;
|
|
7473
|
+
return { code: r.status ?? -1, stdout: r.stdout.trim() };
|
|
7474
|
+
}
|
|
7475
|
+
function gitRepo(dir) {
|
|
7476
|
+
const r = spawnSync2("git", ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"], { cwd: dir, encoding: "utf8" });
|
|
7477
|
+
if (r.error?.code === "ENOENT")
|
|
7478
|
+
return null;
|
|
7479
|
+
if (r.error)
|
|
7480
|
+
throw r.error;
|
|
7481
|
+
if (r.status !== 0) {
|
|
7482
|
+
if (/not a git repository/.test(r.stderr))
|
|
7483
|
+
return null;
|
|
7484
|
+
throw new Error(`git rev-parse in ${dir} failed: ${r.stderr.trim()}`);
|
|
7485
|
+
}
|
|
7486
|
+
const [gitDir, commonDir] = r.stdout.trim().split(`
|
|
7487
|
+
`);
|
|
7488
|
+
if (!gitDir || !commonDir)
|
|
7489
|
+
throw new Error(`git rev-parse in ${dir} printed ${r.stdout}`);
|
|
7490
|
+
let mainCheckout = null;
|
|
7491
|
+
if (gitDir !== commonDir) {
|
|
7492
|
+
if (basename(commonDir) !== ".git")
|
|
7493
|
+
throw new Error(`${dir} is a linked git worktree, but its common git dir ${commonDir} is not a checkout's .git (a bare repo?), so there is no main checkout to keep .firegoat/runs in`);
|
|
7494
|
+
mainCheckout = dirname(commonDir);
|
|
7495
|
+
}
|
|
7496
|
+
const branch = git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
7497
|
+
const commit = git(dir, ["rev-parse", "-q", "--verify", "HEAD"]);
|
|
7498
|
+
const status = git(dir, ["status", "--porcelain"]);
|
|
7499
|
+
if (status.code !== 0)
|
|
7500
|
+
throw new Error(`git status in ${dir} failed`);
|
|
7501
|
+
return {
|
|
7502
|
+
info: {
|
|
7503
|
+
branch: branch.code === 0 ? branch.stdout : null,
|
|
7504
|
+
commit: commit.code === 0 ? commit.stdout : null,
|
|
7505
|
+
dirty: status.stdout !== ""
|
|
7506
|
+
},
|
|
7507
|
+
mainCheckout
|
|
7508
|
+
};
|
|
7509
|
+
}
|
|
7510
|
+
|
|
7428
7511
|
// src/help.ts
|
|
7429
7512
|
var EXIT_CODES = `Exit codes:
|
|
7430
7513
|
0 every run's script checks passed
|
|
@@ -7437,7 +7520,7 @@ function topHelp(version) {
|
|
|
7437
7520
|
folder, with none of your claude config; firegoat saves the session and runs your
|
|
7438
7521
|
script checks.
|
|
7439
7522
|
|
|
7440
|
-
npx firegoat init create firegoat.yaml and .firegoat/, install the
|
|
7523
|
+
npx firegoat init create firegoat.yaml and .firegoat/, install the skill
|
|
7441
7524
|
npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
|
|
7442
7525
|
npx firegoat feedback "<what got in the way>" tell the firegoat team
|
|
7443
7526
|
|
|
@@ -7454,16 +7537,20 @@ ${EXIT_CODES}`;
|
|
|
7454
7537
|
var INIT_HELP = `Usage: firegoat init [--force] [--no-skills]
|
|
7455
7538
|
|
|
7456
7539
|
In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
|
|
7457
|
-
firegoat-onboarding
|
|
7540
|
+
firegoat-onboarding skill into .claude/skills/ (npx skills
|
|
7458
7541
|
add firegoat-ai/skills; asks first in a terminal), then prints a prompt to paste into
|
|
7459
|
-
your coding agent. In .firegoat/, git ignores
|
|
7542
|
+
your coding agent. In .firegoat/, git ignores everything but fixtures/: keep test fixtures
|
|
7460
7543
|
(helper scripts, sample data) in .firegoat/fixtures/ and commit them.
|
|
7461
7544
|
|
|
7462
7545
|
--force replace an existing firegoat.yaml
|
|
7463
|
-
--no-skills don't install the
|
|
7546
|
+
--no-skills don't install the skill
|
|
7464
7547
|
|
|
7465
7548
|
firegoat.yaml reference:
|
|
7466
7549
|
runs: 1 runs per test
|
|
7550
|
+
model: <name> | [<name>, ...]
|
|
7551
|
+
claude --model; a list runs every test runs times
|
|
7552
|
+
per model (n counts 1..runs x models). Unset: no
|
|
7553
|
+
--model, claude's default. eval --model overrides
|
|
7467
7554
|
setup: <sh> | [<sh>, ...] once per eval, in this folder; what it writes to
|
|
7468
7555
|
$FIREGOAT_OUT is first on the agent's PATH
|
|
7469
7556
|
workdir_setup: <sh> | [...] in each fresh folder before the agent starts
|
|
@@ -7472,9 +7559,15 @@ firegoat.yaml reference:
|
|
|
7472
7559
|
env: {NAME: value} env vars for the agent, and for workdir_setup and
|
|
7473
7560
|
checks of the same run; values are strings and can
|
|
7474
7561
|
use {{vars.KEY}}, {{run.id}}, {{run.n}},
|
|
7475
|
-
{{
|
|
7476
|
-
|
|
7477
|
-
|
|
7562
|
+
{{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
|
|
7563
|
+
(no other $ expansion: $HOME stays literal).
|
|
7564
|
+
setup gets only the entries that use nothing but
|
|
7565
|
+
{{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
|
|
7566
|
+
PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
|
|
7567
|
+
ANTHROPIC_MODEL (use model:) are reserved.
|
|
7568
|
+
Runs go 4 at a time: give a tool with a global home
|
|
7569
|
+
a per-run one (or use eval --parallel 1), e.g.
|
|
7570
|
+
MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
|
|
7478
7571
|
tests:
|
|
7479
7572
|
- name: quickstart letters, digits, '.', '_', '-'; unique
|
|
7480
7573
|
prompt: ... what a user would ask the agent; one concrete outcome
|
|
@@ -7513,9 +7606,9 @@ Example:
|
|
|
7513
7606
|
- run: |
|
|
7514
7607
|
! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
|
|
7515
7608
|
|
|
7516
|
-
Exit codes: 0 done, 2 the
|
|
7517
|
-
var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--
|
|
7518
|
-
[--json] [--claude-bin PATH]
|
|
7609
|
+
Exit codes: 0 done, 2 the skill install failed, 3 usage error.`;
|
|
7610
|
+
var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--parallel N]
|
|
7611
|
+
[--var KEY=value].. [--json] [--claude-bin PATH]
|
|
7519
7612
|
|
|
7520
7613
|
Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
|
|
7521
7614
|
in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with setup:'s
|
|
@@ -7526,18 +7619,31 @@ with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
|
|
|
7526
7619
|
plugins, skills or MCP servers. claude saves its session there. There is no
|
|
7527
7620
|
sandbox: the agent can reach the rest of your machine, including the repo.
|
|
7528
7621
|
|
|
7622
|
+
In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
|
|
7623
|
+
live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
|
|
7624
|
+
fixtures and setup: stay in the current folder.
|
|
7625
|
+
|
|
7529
7626
|
test.. only these tests (by name)
|
|
7530
7627
|
--runs N runs per test (overrides runs: in firegoat.yaml)
|
|
7531
|
-
--
|
|
7628
|
+
--model NAME claude --model, replacing model: in firegoat.yaml; repeat it
|
|
7629
|
+
(--model a --model b) to run each test runs times per model
|
|
7630
|
+
--parallel N runs at once (default 4; 1 runs them one after another).
|
|
7631
|
+
Tools with global on-disk state collide: give each run its
|
|
7632
|
+
own home via env: ({{run.id}}-{{test.name}}-{{run.n}}) or use 1
|
|
7532
7633
|
--var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
|
|
7533
7634
|
--json the summary as JSON on stdout (or {error, exitCode} if eval
|
|
7534
7635
|
stops early); progress goes to stderr
|
|
7535
7636
|
--claude-bin PATH claude binary (env FIREGOAT_CLAUDE_BIN; default: claude on PATH)
|
|
7536
7637
|
|
|
7537
7638
|
Output: .firegoat/runs/<runId>/
|
|
7538
|
-
summary.json every run, plus totals
|
|
7539
|
-
|
|
7540
|
-
|
|
7639
|
+
summary.json every run, plus totals, dir (where eval ran) and
|
|
7640
|
+
git {branch, commit, dirty} (no git outside a repo)
|
|
7641
|
+
../index.jsonl one line per eval: runId, startedAt, dir, branch,
|
|
7642
|
+
commit, passed, failed, notGraded, cost
|
|
7643
|
+
<test>/<n>/run.json one run: status and reason (from the checks), model
|
|
7644
|
+
(asked for, or null) and claudeModel (what claude's
|
|
7645
|
+
init line reported), prompt, end_goal,
|
|
7646
|
+
checks [{run, code}], and paths: workdir,
|
|
7541
7647
|
transcript, session (claude's session file), stderr,
|
|
7542
7648
|
checksLog
|
|
7543
7649
|
<test>/<n>/transcript.txt what the agent did: each tool call with its command,
|
|
@@ -7551,6 +7657,8 @@ yours to judge: read transcript.txt first (a few KB); the session file has every
|
|
|
7551
7657
|
Examples:
|
|
7552
7658
|
npx firegoat eval --json all tests, JSON summary on stdout
|
|
7553
7659
|
npx firegoat eval quickstart --runs 3 one test, three runs
|
|
7660
|
+
npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
|
|
7661
|
+
each test on both models
|
|
7554
7662
|
npx firegoat eval --var SHA=$(git rev-parse HEAD)
|
|
7555
7663
|
|
|
7556
7664
|
A run can take minutes (default timeout 600s per test).
|
|
@@ -7568,7 +7676,9 @@ title, then what you ran and what happened in --body (- reads stdin).
|
|
|
7568
7676
|
|
|
7569
7677
|
Sent along: the firegoat version, your OS and node version, and which agent you are
|
|
7570
7678
|
(claude-code/codex/cursor, from the environment). Don't include secrets: a report that
|
|
7571
|
-
looks like it contains one is refused.
|
|
7679
|
+
looks like it contains one is refused. The first send registers this device and saves
|
|
7680
|
+
its token to ~/.config/firegoat/feedback-device.json ($XDG_CONFIG_HOME respected),
|
|
7681
|
+
which raises the daily report limit.
|
|
7572
7682
|
|
|
7573
7683
|
Example:
|
|
7574
7684
|
npx firegoat feedback "checks can't grep commands with quotes" --type bug \\
|
|
@@ -7692,10 +7802,14 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
|
|
|
7692
7802
|
}
|
|
7693
7803
|
|
|
7694
7804
|
// src/ui.ts
|
|
7695
|
-
import { relative as relative2 } from "node:path";
|
|
7805
|
+
import { isAbsolute, relative as relative2 } from "node:path";
|
|
7696
7806
|
var RAMP = "▁▂▃▄▅▆▇";
|
|
7697
7807
|
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
7698
7808
|
var SPIN = ["◐", "◓", "◑", "◒"];
|
|
7809
|
+
function shortPath(path) {
|
|
7810
|
+
const rel = relative2(process.cwd(), path);
|
|
7811
|
+
return rel.startsWith("..") || isAbsolute(rel) ? path : rel;
|
|
7812
|
+
}
|
|
7699
7813
|
function painter(color) {
|
|
7700
7814
|
return (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
7701
7815
|
}
|
|
@@ -7786,7 +7900,8 @@ function plainUI(write) {
|
|
|
7786
7900
|
printed.add(r);
|
|
7787
7901
|
const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
|
|
7788
7902
|
const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
|
|
7789
|
-
|
|
7903
|
+
const model = r.model === null ? "" : ` [${r.model}]`;
|
|
7904
|
+
write(`${label} ${r.test}#${r.n}${model} ${r.reason} (${u})`);
|
|
7790
7905
|
}
|
|
7791
7906
|
},
|
|
7792
7907
|
done: ({ runsDir, rows: all }) => {
|
|
@@ -7853,10 +7968,12 @@ function ttyUI(out, opts) {
|
|
|
7853
7968
|
const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
|
|
7854
7969
|
out.push(` ${setup.state === "running" ? p(C.ember, "◐") : p(C.green, "●")} setup${" ".repeat(Math.max(1, w - 20))}${mark}${setup.seconds !== undefined ? ` ${setup.seconds}s` : ""}`);
|
|
7855
7970
|
}
|
|
7971
|
+
const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
|
|
7856
7972
|
rows.forEach((r, i) => {
|
|
7857
7973
|
out.push(p(C.gray, " │"));
|
|
7858
7974
|
const num = String(i + 1).padStart(2, "0");
|
|
7859
7975
|
const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
|
|
7976
|
+
const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
|
|
7860
7977
|
let detail = "";
|
|
7861
7978
|
if (r.state === "running")
|
|
7862
7979
|
detail = p(C.gray, `turn ${r.turns}`);
|
|
@@ -7864,7 +7981,7 @@ function ttyUI(out, opts) {
|
|
|
7864
7981
|
detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
|
|
7865
7982
|
else if (r.state === "error")
|
|
7866
7983
|
detail = p(C.gray, usage(r));
|
|
7867
|
-
out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
|
|
7984
|
+
out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)}${model} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
|
|
7868
7985
|
if (r.state === "running" && r.command)
|
|
7869
7986
|
out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
|
|
7870
7987
|
if (r.state === "fail")
|
|
@@ -7891,7 +8008,7 @@ function ttyUI(out, opts) {
|
|
|
7891
8008
|
const right = goated ? `GOATED · tier ${tr}` : tr ? `tier ${tr}` : "";
|
|
7892
8009
|
out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
|
|
7893
8010
|
out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
|
|
7894
|
-
out.push(p(C.gray, ` runs ${
|
|
8011
|
+
out.push(p(C.gray, ` runs ${shortPath(finished.runsDir)}/`));
|
|
7895
8012
|
out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
|
|
7896
8013
|
return out;
|
|
7897
8014
|
};
|
|
@@ -7963,6 +8080,7 @@ class UsageError extends Error {
|
|
|
7963
8080
|
function parseArgs(args) {
|
|
7964
8081
|
const opts = {
|
|
7965
8082
|
names: [],
|
|
8083
|
+
models: [],
|
|
7966
8084
|
parallel: 4,
|
|
7967
8085
|
json: false,
|
|
7968
8086
|
vars: {},
|
|
@@ -7996,6 +8114,13 @@ function parseArgs(args) {
|
|
|
7996
8114
|
if (!/^\d+$/.test(v) || n < 1)
|
|
7997
8115
|
throw new UsageError(`--runs must be a positive integer, got ${v}`);
|
|
7998
8116
|
opts.runs = n;
|
|
8117
|
+
} else if (flag === "--model") {
|
|
8118
|
+
const v = value();
|
|
8119
|
+
if (v.trim() === "")
|
|
8120
|
+
throw new UsageError("--model needs a model name");
|
|
8121
|
+
if (opts.models.includes(v))
|
|
8122
|
+
throw new UsageError(`--model ${v} given twice`);
|
|
8123
|
+
opts.models.push(v);
|
|
7999
8124
|
} else if (flag === "--parallel") {
|
|
8000
8125
|
const v = value();
|
|
8001
8126
|
const n = Number(v);
|
|
@@ -8022,6 +8147,10 @@ function findSession(configDir, sessionId) {
|
|
|
8022
8147
|
}
|
|
8023
8148
|
return null;
|
|
8024
8149
|
}
|
|
8150
|
+
function grepsSessionJson(run) {
|
|
8151
|
+
const unescaped = run.replace(/\\/g, "");
|
|
8152
|
+
return unescaped.includes("FIREGOAT_SESSION") && /"command":"|"name":"Write"/.test(unescaped);
|
|
8153
|
+
}
|
|
8025
8154
|
function newRunId() {
|
|
8026
8155
|
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
|
|
8027
8156
|
return `${ts}-${randomBytes(2).toString("hex")}`;
|
|
@@ -8035,6 +8164,8 @@ async function runOne(opts) {
|
|
|
8035
8164
|
const r = {
|
|
8036
8165
|
test: test.name,
|
|
8037
8166
|
n,
|
|
8167
|
+
model: opts.model,
|
|
8168
|
+
claudeModel: null,
|
|
8038
8169
|
status: "error",
|
|
8039
8170
|
reason: "",
|
|
8040
8171
|
prompt: render(test.prompt, opts.vars),
|
|
@@ -8061,8 +8192,9 @@ async function runOne(opts) {
|
|
|
8061
8192
|
const env = renderEnv(test.env, opts.vars, {
|
|
8062
8193
|
"run.id": opts.runId,
|
|
8063
8194
|
"run.n": String(n),
|
|
8064
|
-
"
|
|
8065
|
-
|
|
8195
|
+
"test.name": test.name,
|
|
8196
|
+
FIREGOAT_OUT: opts.out,
|
|
8197
|
+
FIREGOAT_WORKDIR: workdir
|
|
8066
8198
|
});
|
|
8067
8199
|
const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
|
|
8068
8200
|
if (opts.workdirSetup.length > 0) {
|
|
@@ -8084,14 +8216,16 @@ async function runOne(opts) {
|
|
|
8084
8216
|
env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
|
|
8085
8217
|
stderr,
|
|
8086
8218
|
timeoutMs: test.timeout * 1000,
|
|
8219
|
+
model: opts.model ?? undefined,
|
|
8087
8220
|
onEvent: opts.onEvent
|
|
8088
8221
|
});
|
|
8222
|
+
r.claudeModel = outcome.claudeModel;
|
|
8089
8223
|
await new Promise((res) => stderr.end(res));
|
|
8090
8224
|
if (outcome.sessionId)
|
|
8091
8225
|
r.session = findSession(opts.configDir, outcome.sessionId);
|
|
8092
8226
|
writeTranscript({
|
|
8093
8227
|
path: r.transcript,
|
|
8094
|
-
title: `${test.name}#${n}`,
|
|
8228
|
+
title: `${test.name}#${n}${opts.model ? ` (model ${opts.model})` : ""}`,
|
|
8095
8229
|
prompt: r.prompt,
|
|
8096
8230
|
events: outcome.events,
|
|
8097
8231
|
workdir
|
|
@@ -8146,7 +8280,7 @@ async function evalCommand(args, root) {
|
|
|
8146
8280
|
}
|
|
8147
8281
|
const say = opts.json ? console.error : console.log;
|
|
8148
8282
|
const stream = opts.json ? process.stderr : process.stdout;
|
|
8149
|
-
const ui = stream.isTTY ? ttyUI(stream, { project:
|
|
8283
|
+
const ui = stream.isTTY ? ttyUI(stream, { project: basename2(root), color: !process.env.NO_COLOR }) : plainUI(say);
|
|
8150
8284
|
let config;
|
|
8151
8285
|
try {
|
|
8152
8286
|
config = loadConfig(root);
|
|
@@ -8163,7 +8297,10 @@ async function evalCommand(args, root) {
|
|
|
8163
8297
|
return fatal(EXIT.usage, `unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
|
|
8164
8298
|
tests = tests.filter((t) => opts.names.includes(t.name));
|
|
8165
8299
|
}
|
|
8300
|
+
if (tests.some((t) => t.checks.some((c) => grepsSessionJson(c.run))))
|
|
8301
|
+
console.error('firegoat eval: warning: a check greps $FIREGOAT_SESSION for session JSON ("command":" / "name":"Write"), which breaks on escaping. Grep $FIREGOAT_TRANSCRIPT instead: one "[n] $ <command>" line per shell call, "[n] Write <path>" per file write.');
|
|
8166
8302
|
const runs = opts.runs ?? config.runs;
|
|
8303
|
+
const models = opts.models.length > 0 ? opts.models : config.models;
|
|
8167
8304
|
const vars = { ...config.vars, ...opts.vars };
|
|
8168
8305
|
const missing = [
|
|
8169
8306
|
...new Set(tests.flatMap((t) => [
|
|
@@ -8177,7 +8314,13 @@ async function evalCommand(args, root) {
|
|
|
8177
8314
|
const claudePath = resolveBin(opts.claudeBin);
|
|
8178
8315
|
if (!claudePath)
|
|
8179
8316
|
return fatal(EXIT.notGraded, `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.`);
|
|
8180
|
-
|
|
8317
|
+
let repo;
|
|
8318
|
+
try {
|
|
8319
|
+
repo = gitRepo(root);
|
|
8320
|
+
} catch (e) {
|
|
8321
|
+
return fatal(EXIT.usage, e.message);
|
|
8322
|
+
}
|
|
8323
|
+
const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
|
|
8181
8324
|
const configDir = join4(firegoatDir, ".claude");
|
|
8182
8325
|
mkdirSync2(configDir, { recursive: true });
|
|
8183
8326
|
ui.auth(process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login");
|
|
@@ -8190,7 +8333,8 @@ async function evalCommand(args, root) {
|
|
|
8190
8333
|
return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
|
|
8191
8334
|
const evalStarted = Date.now();
|
|
8192
8335
|
const runId = newRunId();
|
|
8193
|
-
const
|
|
8336
|
+
const runsRoot = join4(firegoatDir, "runs");
|
|
8337
|
+
const runDir = join4(runsRoot, runId);
|
|
8194
8338
|
const out = join4(runDir, "out");
|
|
8195
8339
|
mkdirSync2(out, { recursive: true });
|
|
8196
8340
|
const stepEnv = {
|
|
@@ -8199,12 +8343,14 @@ async function evalCommand(args, root) {
|
|
|
8199
8343
|
FIREGOAT_OUT: out,
|
|
8200
8344
|
PATH: [out, process.env.PATH ?? ""].join(delimiter2)
|
|
8201
8345
|
};
|
|
8202
|
-
const
|
|
8346
|
+
const matrix = models.length > 0 ? models : [null];
|
|
8347
|
+
const rows = tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
|
|
8203
8348
|
test: t.name,
|
|
8204
|
-
n: i + 1,
|
|
8349
|
+
n: m * runs + i + 1,
|
|
8350
|
+
model,
|
|
8205
8351
|
state: "queued",
|
|
8206
8352
|
turns: 0
|
|
8207
|
-
})));
|
|
8353
|
+
}))));
|
|
8208
8354
|
if (config.setup.length > 0) {
|
|
8209
8355
|
const setupStarted = Date.now();
|
|
8210
8356
|
ui.setup("running");
|
|
@@ -8214,7 +8360,7 @@ async function evalCommand(args, root) {
|
|
|
8214
8360
|
cwd: root,
|
|
8215
8361
|
env: {
|
|
8216
8362
|
...stepEnv,
|
|
8217
|
-
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
|
|
8363
|
+
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
|
|
8218
8364
|
},
|
|
8219
8365
|
logPath: join4(runDir, "setup.txt")
|
|
8220
8366
|
});
|
|
@@ -8247,11 +8393,13 @@ async function evalCommand(args, root) {
|
|
|
8247
8393
|
},
|
|
8248
8394
|
test,
|
|
8249
8395
|
n: row.n,
|
|
8396
|
+
model: row.model,
|
|
8250
8397
|
runId,
|
|
8251
8398
|
runDir,
|
|
8252
8399
|
claudeBin: claudePath,
|
|
8253
8400
|
configDir,
|
|
8254
8401
|
workdirSetup: config.workdirSetup,
|
|
8402
|
+
out,
|
|
8255
8403
|
stepEnv,
|
|
8256
8404
|
vars
|
|
8257
8405
|
});
|
|
@@ -8276,8 +8424,27 @@ async function evalCommand(args, root) {
|
|
|
8276
8424
|
const totals = { pass: 0, fail: 0, error: 0 };
|
|
8277
8425
|
for (const r of results)
|
|
8278
8426
|
totals[r.status]++;
|
|
8279
|
-
const doc = {
|
|
8427
|
+
const doc = {
|
|
8428
|
+
runId,
|
|
8429
|
+
runsDir: runDir,
|
|
8430
|
+
dir: root,
|
|
8431
|
+
...repo && { git: repo.info },
|
|
8432
|
+
totals,
|
|
8433
|
+
runs: results
|
|
8434
|
+
};
|
|
8280
8435
|
writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
|
|
8436
|
+
`);
|
|
8437
|
+
appendFileSync(join4(runsRoot, "index.jsonl"), `${JSON.stringify({
|
|
8438
|
+
runId,
|
|
8439
|
+
startedAt: new Date(evalStarted).toISOString(),
|
|
8440
|
+
dir: root,
|
|
8441
|
+
branch: repo?.info.branch ?? null,
|
|
8442
|
+
commit: repo?.info.commit ?? null,
|
|
8443
|
+
passed: totals.pass,
|
|
8444
|
+
failed: totals.fail,
|
|
8445
|
+
notGraded: totals.error,
|
|
8446
|
+
cost: results.reduce((sum, r) => sum + (r.cost ?? 0), 0)
|
|
8447
|
+
})}
|
|
8281
8448
|
`);
|
|
8282
8449
|
ui.done({
|
|
8283
8450
|
runId,
|
|
@@ -8294,7 +8461,68 @@ async function evalCommand(args, root) {
|
|
|
8294
8461
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
8295
8462
|
|
|
8296
8463
|
// node_modules/@firegoat/feedback/dist/index.js
|
|
8297
|
-
|
|
8464
|
+
function memoryDeviceStore() {
|
|
8465
|
+
const tokens = {};
|
|
8466
|
+
return {
|
|
8467
|
+
load: async (baseUrl) => tokens[baseUrl],
|
|
8468
|
+
save: async (baseUrl, token) => {
|
|
8469
|
+
tokens[baseUrl] = token;
|
|
8470
|
+
},
|
|
8471
|
+
clear: async (baseUrl) => {
|
|
8472
|
+
delete tokens[baseUrl];
|
|
8473
|
+
}
|
|
8474
|
+
};
|
|
8475
|
+
}
|
|
8476
|
+
async function defaultDevicePath() {
|
|
8477
|
+
const { homedir } = await import("node:os");
|
|
8478
|
+
const { join } = await import("node:path");
|
|
8479
|
+
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
8480
|
+
return join(configHome, "firegoat", "feedback-device.json");
|
|
8481
|
+
}
|
|
8482
|
+
function fileDeviceStore(path) {
|
|
8483
|
+
const resolvePath = async () => path ?? await defaultDevicePath();
|
|
8484
|
+
async function read() {
|
|
8485
|
+
const { readFile } = await import("node:fs/promises");
|
|
8486
|
+
const file = await resolvePath();
|
|
8487
|
+
let text;
|
|
8488
|
+
try {
|
|
8489
|
+
text = await readFile(file, "utf8");
|
|
8490
|
+
} catch (err) {
|
|
8491
|
+
if (err.code === "ENOENT")
|
|
8492
|
+
return {};
|
|
8493
|
+
throw err;
|
|
8494
|
+
}
|
|
8495
|
+
try {
|
|
8496
|
+
return JSON.parse(text);
|
|
8497
|
+
} catch {
|
|
8498
|
+
throw new Error(`@firegoat/feedback: ${file} is not valid JSON; delete it to register a new device`);
|
|
8499
|
+
}
|
|
8500
|
+
}
|
|
8501
|
+
async function write(tokens) {
|
|
8502
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
8503
|
+
const { dirname } = await import("node:path");
|
|
8504
|
+
const file = await resolvePath();
|
|
8505
|
+
await mkdir(dirname(file), { recursive: true });
|
|
8506
|
+
await writeFile(file, `${JSON.stringify(tokens, null, 2)}
|
|
8507
|
+
`, {
|
|
8508
|
+
mode: 384
|
|
8509
|
+
});
|
|
8510
|
+
}
|
|
8511
|
+
return {
|
|
8512
|
+
load: async (baseUrl) => (await read())[baseUrl],
|
|
8513
|
+
save: async (baseUrl, token) => write({ ...await read(), [baseUrl]: token }),
|
|
8514
|
+
clear: async (baseUrl) => {
|
|
8515
|
+
const tokens = await read();
|
|
8516
|
+
delete tokens[baseUrl];
|
|
8517
|
+
await write(tokens);
|
|
8518
|
+
}
|
|
8519
|
+
};
|
|
8520
|
+
}
|
|
8521
|
+
function defaultDeviceStore() {
|
|
8522
|
+
const isNode = Boolean(globalThis.process?.versions?.node);
|
|
8523
|
+
return isNode ? fileDeviceStore() : memoryDeviceStore();
|
|
8524
|
+
}
|
|
8525
|
+
var VERSION = "0.2.0";
|
|
8298
8526
|
var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
|
|
8299
8527
|
var TIMEOUT_MS = 15000;
|
|
8300
8528
|
function envBaseUrl() {
|
|
@@ -8323,6 +8551,7 @@ class FeedbackClient {
|
|
|
8323
8551
|
baseUrl;
|
|
8324
8552
|
token;
|
|
8325
8553
|
fetchImpl;
|
|
8554
|
+
device;
|
|
8326
8555
|
constructor(options) {
|
|
8327
8556
|
if (!options.domain?.trim()) {
|
|
8328
8557
|
throw new Error("FeedbackClient: `domain` is required (your product's domain, e.g. example.com)");
|
|
@@ -8331,6 +8560,7 @@ class FeedbackClient {
|
|
|
8331
8560
|
this.baseUrl = (options.baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
8332
8561
|
this.token = options.token;
|
|
8333
8562
|
this.fetchImpl = options.fetch ?? fetch;
|
|
8563
|
+
this.device = options.token || options.device === false ? undefined : options.device ?? defaultDeviceStore();
|
|
8334
8564
|
}
|
|
8335
8565
|
async registerDevice() {
|
|
8336
8566
|
const data = await this.post("/api/feedback/device", undefined);
|
|
@@ -8338,6 +8568,7 @@ class FeedbackClient {
|
|
|
8338
8568
|
throw new FeedbackError(201, "invalid_response", "server response missing token");
|
|
8339
8569
|
}
|
|
8340
8570
|
this.token = data.token;
|
|
8571
|
+
await this.device?.save(this.baseUrl, data.token);
|
|
8341
8572
|
return data.token;
|
|
8342
8573
|
}
|
|
8343
8574
|
async send(input) {
|
|
@@ -8350,18 +8581,43 @@ class FeedbackClient {
|
|
|
8350
8581
|
cliVersion: `@firegoat/feedback@${VERSION}`,
|
|
8351
8582
|
feedbackType: input.type
|
|
8352
8583
|
};
|
|
8353
|
-
const
|
|
8584
|
+
const payload = {
|
|
8354
8585
|
domain: this.domain,
|
|
8355
8586
|
title,
|
|
8356
8587
|
body: input.body ?? "",
|
|
8357
8588
|
meta,
|
|
8358
8589
|
maskSecrets: input.maskSecrets ?? false
|
|
8359
|
-
}
|
|
8590
|
+
};
|
|
8591
|
+
await this.resolveToken();
|
|
8592
|
+
let data;
|
|
8593
|
+
try {
|
|
8594
|
+
data = await this.post("/api/feedback", payload);
|
|
8595
|
+
} catch (err) {
|
|
8596
|
+
if (!(this.device && isInvalidToken(err)))
|
|
8597
|
+
throw err;
|
|
8598
|
+
await this.device.clear(this.baseUrl);
|
|
8599
|
+
this.token = undefined;
|
|
8600
|
+
await this.registerDevice();
|
|
8601
|
+
data = await this.post("/api/feedback", payload);
|
|
8602
|
+
}
|
|
8360
8603
|
if (typeof data.id !== "string" || !data.id) {
|
|
8361
8604
|
throw new FeedbackError(201, "invalid_response", "server response missing id");
|
|
8362
8605
|
}
|
|
8363
8606
|
return { id: data.id, similarCount: data.similarCount ?? 0 };
|
|
8364
8607
|
}
|
|
8608
|
+
async resolveToken() {
|
|
8609
|
+
if (this.token || !this.device)
|
|
8610
|
+
return;
|
|
8611
|
+
this.token = await this.device.load(this.baseUrl);
|
|
8612
|
+
if (this.token)
|
|
8613
|
+
return;
|
|
8614
|
+
try {
|
|
8615
|
+
await this.registerDevice();
|
|
8616
|
+
} catch (err) {
|
|
8617
|
+
if (!(err instanceof FeedbackError && err.code === "rate_limited"))
|
|
8618
|
+
throw err;
|
|
8619
|
+
}
|
|
8620
|
+
}
|
|
8365
8621
|
async post(path, body) {
|
|
8366
8622
|
const headers = {
|
|
8367
8623
|
"content-type": "application/json"
|
|
@@ -8387,6 +8643,9 @@ class FeedbackClient {
|
|
|
8387
8643
|
return data;
|
|
8388
8644
|
}
|
|
8389
8645
|
}
|
|
8646
|
+
function isInvalidToken(err) {
|
|
8647
|
+
return err instanceof FeedbackError && err.code === "invalid_token";
|
|
8648
|
+
}
|
|
8390
8649
|
|
|
8391
8650
|
// src/feedback.ts
|
|
8392
8651
|
var TYPES = ["bug", "feature_request", "feedback"];
|
|
@@ -8466,9 +8725,9 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
|
|
|
8466
8725
|
}
|
|
8467
8726
|
|
|
8468
8727
|
// src/init.ts
|
|
8469
|
-
import { spawnSync as
|
|
8728
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
8470
8729
|
import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
8471
|
-
import { basename as
|
|
8730
|
+
import { basename as basename3, join as join5 } from "node:path";
|
|
8472
8731
|
import { createInterface } from "node:readline/promises";
|
|
8473
8732
|
var SKILLS_ARGV = [
|
|
8474
8733
|
"skills",
|
|
@@ -8476,8 +8735,6 @@ var SKILLS_ARGV = [
|
|
|
8476
8735
|
"firegoat-ai/skills",
|
|
8477
8736
|
"--skill",
|
|
8478
8737
|
"firegoat-onboarding",
|
|
8479
|
-
"--skill",
|
|
8480
|
-
"suggesting-eval-prompts",
|
|
8481
8738
|
"--agent",
|
|
8482
8739
|
"claude-code",
|
|
8483
8740
|
"--copy",
|
|
@@ -8491,6 +8748,10 @@ function template(product) {
|
|
|
8491
8748
|
# How many times to run each test.
|
|
8492
8749
|
runs: 1
|
|
8493
8750
|
|
|
8751
|
+
# Optional: claude's --model. A list runs every test \`runs\` times per model.
|
|
8752
|
+
# Unset: claude's default. eval --model NAME overrides it.
|
|
8753
|
+
# model: [claude-haiku-5-5, claude-sonnet-5-5]
|
|
8754
|
+
|
|
8494
8755
|
# Optional: test your local build instead of the published package.
|
|
8495
8756
|
# setup: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
|
|
8496
8757
|
# agent's PATH. workdir_setup: runs in each fresh folder before the agent starts.
|
|
@@ -8514,9 +8775,13 @@ runs: 1
|
|
|
8514
8775
|
|
|
8515
8776
|
# Optional: env vars for the agent (and for workdir_setup and checks of the same
|
|
8516
8777
|
# run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
|
|
8517
|
-
# {{run.id}}, {{run.n}}, {{
|
|
8778
|
+
# {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
|
|
8779
|
+
# other $ expansion: $HOME stays literal). A test can add its own env:.
|
|
8780
|
+
# Runs go 4 at a time (--parallel), so give a tool with a global home (~/.mytool)
|
|
8781
|
+
# a per-run one, as below, or use --parallel 1.
|
|
8518
8782
|
# env:
|
|
8519
|
-
# MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
|
|
8783
|
+
# MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
|
|
8784
|
+
# UV_FIND_LINKS: "$FIREGOAT_OUT"
|
|
8520
8785
|
|
|
8521
8786
|
tests:
|
|
8522
8787
|
- name: quickstart
|
|
@@ -8545,7 +8810,7 @@ var CLIPBOARD = [
|
|
|
8545
8810
|
];
|
|
8546
8811
|
function copyToClipboard(text) {
|
|
8547
8812
|
return CLIPBOARD.some(([cmd, args]) => {
|
|
8548
|
-
const r =
|
|
8813
|
+
const r = spawnSync3(cmd, args, { input: text });
|
|
8549
8814
|
return !r.error && r.status === 0;
|
|
8550
8815
|
});
|
|
8551
8816
|
}
|
|
@@ -8592,7 +8857,7 @@ ${INIT_HELP}`);
|
|
|
8592
8857
|
const configPath = join5(cwd, CONFIG_FILE);
|
|
8593
8858
|
const keep = existsSync3(configPath) && !force;
|
|
8594
8859
|
if (!keep)
|
|
8595
|
-
writeFileSync4(configPath, template(
|
|
8860
|
+
writeFileSync4(configPath, template(basename3(cwd)));
|
|
8596
8861
|
if (interactive) {
|
|
8597
8862
|
step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
|
|
8598
8863
|
console.log(paint("90", " │"));
|
|
@@ -8609,7 +8874,7 @@ ${INIT_HELP}`);
|
|
|
8609
8874
|
console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
|
|
8610
8875
|
const env = { ...process.env };
|
|
8611
8876
|
delete env.npm_config_package;
|
|
8612
|
-
const r =
|
|
8877
|
+
const r = spawnSync3("npx", ["-y", ...SKILLS_ARGV], {
|
|
8613
8878
|
cwd,
|
|
8614
8879
|
env,
|
|
8615
8880
|
stdio: "pipe",
|
|
@@ -8617,18 +8882,18 @@ ${INIT_HELP}`);
|
|
|
8617
8882
|
});
|
|
8618
8883
|
if (r.error || r.status !== 0) {
|
|
8619
8884
|
process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
8620
|
-
console.error(`firegoat init: installing the
|
|
8885
|
+
console.error(`firegoat init: installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
|
|
8621
8886
|
return EXIT.notGraded;
|
|
8622
8887
|
}
|
|
8623
8888
|
if (interactive)
|
|
8624
8889
|
step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
|
|
8625
8890
|
else
|
|
8626
|
-
console.log("installed
|
|
8627
|
-
const ignored =
|
|
8891
|
+
console.log("installed skill firegoat-onboarding in .claude/skills/");
|
|
8892
|
+
const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
|
|
8628
8893
|
cwd
|
|
8629
8894
|
});
|
|
8630
8895
|
if (ignored.status === 0)
|
|
8631
|
-
console.error("warning: git ignores .claude/skills, so teammates and CI won't get the
|
|
8896
|
+
console.error("warning: git ignores .claude/skills, so teammates and CI won't get the skill. Commit them with: git add -f .claude/skills");
|
|
8632
8897
|
} else if (interactive)
|
|
8633
8898
|
step(3, "firegoat-onboarding skill", "skipped");
|
|
8634
8899
|
if (!interactive) {
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firegoat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/firegoat-ai/firegoat-cli.git"
|
|
7
7
|
},
|
|
8
8
|
"devDependencies": {
|
|
9
9
|
"@biomejs/biome": "^2.5.15",
|
|
10
|
-
"@firegoat/feedback": "0.
|
|
10
|
+
"@firegoat/feedback": "0.2.0",
|
|
11
11
|
"@types/bun": "latest",
|
|
12
12
|
"typescript": "^7",
|
|
13
13
|
"yaml": "^2.9.1"
|