firegoat 0.14.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 +21 -6
- package/dist/cli.js +205 -33
- 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,6 +82,21 @@ 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
|
|
@@ -99,19 +114,19 @@ tests:
|
|
|
99
114
|
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
100
115
|
```
|
|
101
116
|
|
|
102
|
-
`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), `{{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
|
|
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:`.
|
|
103
118
|
|
|
104
119
|
## What eval does
|
|
105
120
|
|
|
106
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.
|
|
107
|
-
- 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`.
|
|
108
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.
|
|
109
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.
|
|
110
|
-
- 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.
|
|
111
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.
|
|
112
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.
|
|
113
128
|
|
|
114
|
-
`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`.
|
|
115
130
|
|
|
116
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).
|
|
117
132
|
|
|
@@ -130,7 +145,7 @@ Something in firegoat got in your way (or your agent's)? Tell us from the termin
|
|
|
130
145
|
npx firegoat feedback "eval hid claude's spend-limit error" --type bug --body "ran npx firegoat eval, got 'not graded' x3"
|
|
131
146
|
```
|
|
132
147
|
|
|
133
|
-
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.
|
|
134
149
|
|
|
135
150
|
## Develop
|
|
136
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)
|
|
@@ -7175,6 +7194,8 @@ function parseEnv(raw, path) {
|
|
|
7175
7194
|
const p = `${path}.${k}`;
|
|
7176
7195
|
if (!VAR_KEY.test(k))
|
|
7177
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)");
|
|
7178
7199
|
if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
|
|
7179
7200
|
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7180
7201
|
if (typeof v !== "string")
|
|
@@ -7298,7 +7319,12 @@ async function runClaude(opts) {
|
|
|
7298
7319
|
let pending = "";
|
|
7299
7320
|
const exited = await run({
|
|
7300
7321
|
cmd: opts.bin,
|
|
7301
|
-
args: [
|
|
7322
|
+
args: [
|
|
7323
|
+
"-p",
|
|
7324
|
+
opts.prompt,
|
|
7325
|
+
...CLAUDE_FLAGS,
|
|
7326
|
+
...opts.model === undefined ? [] : ["--model", opts.model]
|
|
7327
|
+
],
|
|
7302
7328
|
cwd: opts.cwd,
|
|
7303
7329
|
env: opts.env,
|
|
7304
7330
|
timeoutMs: opts.timeoutMs,
|
|
@@ -7322,6 +7348,7 @@ async function runClaude(opts) {
|
|
|
7322
7348
|
if (exited.error)
|
|
7323
7349
|
return {
|
|
7324
7350
|
events: [],
|
|
7351
|
+
claudeModel: null,
|
|
7325
7352
|
ok: false,
|
|
7326
7353
|
reason: `could not start ${opts.bin}: ${exited.error.message}`
|
|
7327
7354
|
};
|
|
@@ -7339,15 +7366,18 @@ async function runClaude(opts) {
|
|
|
7339
7366
|
`)[0]?.slice(0, 300);
|
|
7340
7367
|
return {
|
|
7341
7368
|
events,
|
|
7369
|
+
claudeModel: null,
|
|
7342
7370
|
ok: false,
|
|
7343
7371
|
reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
|
|
7344
7372
|
};
|
|
7345
7373
|
}
|
|
7346
7374
|
const sessionId = String(init.session_id);
|
|
7375
|
+
const claudeModel = typeof init.model === "string" ? init.model : null;
|
|
7347
7376
|
const result = events.findLast((e) => e?.type === "result");
|
|
7348
7377
|
if (exited.timedOut)
|
|
7349
7378
|
return {
|
|
7350
7379
|
events,
|
|
7380
|
+
claudeModel,
|
|
7351
7381
|
ok: false,
|
|
7352
7382
|
reason: `timed out after ${opts.timeoutMs / 1000}s`,
|
|
7353
7383
|
result,
|
|
@@ -7356,6 +7386,7 @@ async function runClaude(opts) {
|
|
|
7356
7386
|
if (!result)
|
|
7357
7387
|
return {
|
|
7358
7388
|
events,
|
|
7389
|
+
claudeModel,
|
|
7359
7390
|
ok: false,
|
|
7360
7391
|
reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
|
|
7361
7392
|
sessionId
|
|
@@ -7363,12 +7394,13 @@ async function runClaude(opts) {
|
|
|
7363
7394
|
if (result.is_error === true || result.subtype !== "success")
|
|
7364
7395
|
return {
|
|
7365
7396
|
events,
|
|
7397
|
+
claudeModel,
|
|
7366
7398
|
ok: false,
|
|
7367
7399
|
reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
|
|
7368
7400
|
result,
|
|
7369
7401
|
sessionId
|
|
7370
7402
|
};
|
|
7371
|
-
return { events, ok: true, result, sessionId };
|
|
7403
|
+
return { events, claudeModel, ok: true, result, sessionId };
|
|
7372
7404
|
}
|
|
7373
7405
|
async function runShell(opts) {
|
|
7374
7406
|
opts.log.write(`$ ${opts.run}
|
|
@@ -7420,8 +7452,9 @@ var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
|
7420
7452
|
// src/firegoat-dir.ts
|
|
7421
7453
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7422
7454
|
import { join } from "node:path";
|
|
7423
|
-
var GITIGNORE =
|
|
7424
|
-
|
|
7455
|
+
var GITIGNORE = `*
|
|
7456
|
+
!fixtures/
|
|
7457
|
+
!fixtures/**
|
|
7425
7458
|
`;
|
|
7426
7459
|
function ensureFiregoatDir(root) {
|
|
7427
7460
|
const dir = join(root, ".firegoat");
|
|
@@ -7487,7 +7520,7 @@ function topHelp(version) {
|
|
|
7487
7520
|
folder, with none of your claude config; firegoat saves the session and runs your
|
|
7488
7521
|
script checks.
|
|
7489
7522
|
|
|
7490
|
-
npx firegoat init create firegoat.yaml and .firegoat/, install the
|
|
7523
|
+
npx firegoat init create firegoat.yaml and .firegoat/, install the skill
|
|
7491
7524
|
npx firegoat eval run the tests (claude must be logged in: run \`claude\` once)
|
|
7492
7525
|
npx firegoat feedback "<what got in the way>" tell the firegoat team
|
|
7493
7526
|
|
|
@@ -7504,16 +7537,20 @@ ${EXIT_CODES}`;
|
|
|
7504
7537
|
var INIT_HELP = `Usage: firegoat init [--force] [--no-skills]
|
|
7505
7538
|
|
|
7506
7539
|
In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
|
|
7507
|
-
firegoat-onboarding
|
|
7540
|
+
firegoat-onboarding skill into .claude/skills/ (npx skills
|
|
7508
7541
|
add firegoat-ai/skills; asks first in a terminal), then prints a prompt to paste into
|
|
7509
|
-
your coding agent. In .firegoat/, git ignores
|
|
7542
|
+
your coding agent. In .firegoat/, git ignores everything but fixtures/: keep test fixtures
|
|
7510
7543
|
(helper scripts, sample data) in .firegoat/fixtures/ and commit them.
|
|
7511
7544
|
|
|
7512
7545
|
--force replace an existing firegoat.yaml
|
|
7513
|
-
--no-skills don't install the
|
|
7546
|
+
--no-skills don't install the skill
|
|
7514
7547
|
|
|
7515
7548
|
firegoat.yaml reference:
|
|
7516
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
|
|
7517
7554
|
setup: <sh> | [<sh>, ...] once per eval, in this folder; what it writes to
|
|
7518
7555
|
$FIREGOAT_OUT is first on the agent's PATH
|
|
7519
7556
|
workdir_setup: <sh> | [...] in each fresh folder before the agent starts
|
|
@@ -7526,7 +7563,11 @@ firegoat.yaml reference:
|
|
|
7526
7563
|
(no other $ expansion: $HOME stays literal).
|
|
7527
7564
|
setup gets only the entries that use nothing but
|
|
7528
7565
|
{{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
|
|
7529
|
-
PATH, CLAUDE_CONFIG_DIR
|
|
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}}"
|
|
7530
7571
|
tests:
|
|
7531
7572
|
- name: quickstart letters, digits, '.', '_', '-'; unique
|
|
7532
7573
|
prompt: ... what a user would ask the agent; one concrete outcome
|
|
@@ -7565,9 +7606,9 @@ Example:
|
|
|
7565
7606
|
- run: |
|
|
7566
7607
|
! grep -Eq '$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
|
|
7567
7608
|
|
|
7568
|
-
Exit codes: 0 done, 2 the
|
|
7569
|
-
var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--
|
|
7570
|
-
[--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]
|
|
7571
7612
|
|
|
7572
7613
|
Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
|
|
7573
7614
|
in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with setup:'s
|
|
@@ -7584,7 +7625,11 @@ fixtures and setup: stay in the current folder.
|
|
|
7584
7625
|
|
|
7585
7626
|
test.. only these tests (by name)
|
|
7586
7627
|
--runs N runs per test (overrides runs: in firegoat.yaml)
|
|
7587
|
-
--
|
|
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
|
|
7588
7633
|
--var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
|
|
7589
7634
|
--json the summary as JSON on stdout (or {error, exitCode} if eval
|
|
7590
7635
|
stops early); progress goes to stderr
|
|
@@ -7595,8 +7640,10 @@ Output: .firegoat/runs/<runId>/
|
|
|
7595
7640
|
git {branch, commit, dirty} (no git outside a repo)
|
|
7596
7641
|
../index.jsonl one line per eval: runId, startedAt, dir, branch,
|
|
7597
7642
|
commit, passed, failed, notGraded, cost
|
|
7598
|
-
<test>/<n>/run.json one run: status and reason (from the checks),
|
|
7599
|
-
|
|
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,
|
|
7600
7647
|
transcript, session (claude's session file), stderr,
|
|
7601
7648
|
checksLog
|
|
7602
7649
|
<test>/<n>/transcript.txt what the agent did: each tool call with its command,
|
|
@@ -7610,6 +7657,8 @@ yours to judge: read transcript.txt first (a few KB); the session file has every
|
|
|
7610
7657
|
Examples:
|
|
7611
7658
|
npx firegoat eval --json all tests, JSON summary on stdout
|
|
7612
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
|
|
7613
7662
|
npx firegoat eval --var SHA=$(git rev-parse HEAD)
|
|
7614
7663
|
|
|
7615
7664
|
A run can take minutes (default timeout 600s per test).
|
|
@@ -7627,7 +7676,9 @@ title, then what you ran and what happened in --body (- reads stdin).
|
|
|
7627
7676
|
|
|
7628
7677
|
Sent along: the firegoat version, your OS and node version, and which agent you are
|
|
7629
7678
|
(claude-code/codex/cursor, from the environment). Don't include secrets: a report that
|
|
7630
|
-
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.
|
|
7631
7682
|
|
|
7632
7683
|
Example:
|
|
7633
7684
|
npx firegoat feedback "checks can't grep commands with quotes" --type bug \\
|
|
@@ -7849,7 +7900,8 @@ function plainUI(write) {
|
|
|
7849
7900
|
printed.add(r);
|
|
7850
7901
|
const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
|
|
7851
7902
|
const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
|
|
7852
|
-
|
|
7903
|
+
const model = r.model === null ? "" : ` [${r.model}]`;
|
|
7904
|
+
write(`${label} ${r.test}#${r.n}${model} ${r.reason} (${u})`);
|
|
7853
7905
|
}
|
|
7854
7906
|
},
|
|
7855
7907
|
done: ({ runsDir, rows: all }) => {
|
|
@@ -7916,10 +7968,12 @@ function ttyUI(out, opts) {
|
|
|
7916
7968
|
const mark = setup.state === "ok" ? p(C.green, "✓") : setup.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
|
|
7917
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` : ""}`);
|
|
7918
7970
|
}
|
|
7971
|
+
const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
|
|
7919
7972
|
rows.forEach((r, i) => {
|
|
7920
7973
|
out.push(p(C.gray, " │"));
|
|
7921
7974
|
const num = String(i + 1).padStart(2, "0");
|
|
7922
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))}` : "";
|
|
7923
7977
|
let detail = "";
|
|
7924
7978
|
if (r.state === "running")
|
|
7925
7979
|
detail = p(C.gray, `turn ${r.turns}`);
|
|
@@ -7927,7 +7981,7 @@ function ttyUI(out, opts) {
|
|
|
7927
7981
|
detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
|
|
7928
7982
|
else if (r.state === "error")
|
|
7929
7983
|
detail = p(C.gray, usage(r));
|
|
7930
|
-
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}`);
|
|
7931
7985
|
if (r.state === "running" && r.command)
|
|
7932
7986
|
out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
|
|
7933
7987
|
if (r.state === "fail")
|
|
@@ -8026,6 +8080,7 @@ class UsageError extends Error {
|
|
|
8026
8080
|
function parseArgs(args) {
|
|
8027
8081
|
const opts = {
|
|
8028
8082
|
names: [],
|
|
8083
|
+
models: [],
|
|
8029
8084
|
parallel: 4,
|
|
8030
8085
|
json: false,
|
|
8031
8086
|
vars: {},
|
|
@@ -8059,6 +8114,13 @@ function parseArgs(args) {
|
|
|
8059
8114
|
if (!/^\d+$/.test(v) || n < 1)
|
|
8060
8115
|
throw new UsageError(`--runs must be a positive integer, got ${v}`);
|
|
8061
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);
|
|
8062
8124
|
} else if (flag === "--parallel") {
|
|
8063
8125
|
const v = value();
|
|
8064
8126
|
const n = Number(v);
|
|
@@ -8085,6 +8147,10 @@ function findSession(configDir, sessionId) {
|
|
|
8085
8147
|
}
|
|
8086
8148
|
return null;
|
|
8087
8149
|
}
|
|
8150
|
+
function grepsSessionJson(run) {
|
|
8151
|
+
const unescaped = run.replace(/\\/g, "");
|
|
8152
|
+
return unescaped.includes("FIREGOAT_SESSION") && /"command":"|"name":"Write"/.test(unescaped);
|
|
8153
|
+
}
|
|
8088
8154
|
function newRunId() {
|
|
8089
8155
|
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
|
|
8090
8156
|
return `${ts}-${randomBytes(2).toString("hex")}`;
|
|
@@ -8098,6 +8164,8 @@ async function runOne(opts) {
|
|
|
8098
8164
|
const r = {
|
|
8099
8165
|
test: test.name,
|
|
8100
8166
|
n,
|
|
8167
|
+
model: opts.model,
|
|
8168
|
+
claudeModel: null,
|
|
8101
8169
|
status: "error",
|
|
8102
8170
|
reason: "",
|
|
8103
8171
|
prompt: render(test.prompt, opts.vars),
|
|
@@ -8148,14 +8216,16 @@ async function runOne(opts) {
|
|
|
8148
8216
|
env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
|
|
8149
8217
|
stderr,
|
|
8150
8218
|
timeoutMs: test.timeout * 1000,
|
|
8219
|
+
model: opts.model ?? undefined,
|
|
8151
8220
|
onEvent: opts.onEvent
|
|
8152
8221
|
});
|
|
8222
|
+
r.claudeModel = outcome.claudeModel;
|
|
8153
8223
|
await new Promise((res) => stderr.end(res));
|
|
8154
8224
|
if (outcome.sessionId)
|
|
8155
8225
|
r.session = findSession(opts.configDir, outcome.sessionId);
|
|
8156
8226
|
writeTranscript({
|
|
8157
8227
|
path: r.transcript,
|
|
8158
|
-
title: `${test.name}#${n}`,
|
|
8228
|
+
title: `${test.name}#${n}${opts.model ? ` (model ${opts.model})` : ""}`,
|
|
8159
8229
|
prompt: r.prompt,
|
|
8160
8230
|
events: outcome.events,
|
|
8161
8231
|
workdir
|
|
@@ -8227,7 +8297,10 @@ async function evalCommand(args, root) {
|
|
|
8227
8297
|
return fatal(EXIT.usage, `unknown test ${unknown.map((n) => JSON.stringify(n)).join(", ")} (known: ${[...known].join(", ")})`);
|
|
8228
8298
|
tests = tests.filter((t) => opts.names.includes(t.name));
|
|
8229
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.');
|
|
8230
8302
|
const runs = opts.runs ?? config.runs;
|
|
8303
|
+
const models = opts.models.length > 0 ? opts.models : config.models;
|
|
8231
8304
|
const vars = { ...config.vars, ...opts.vars };
|
|
8232
8305
|
const missing = [
|
|
8233
8306
|
...new Set(tests.flatMap((t) => [
|
|
@@ -8270,12 +8343,14 @@ async function evalCommand(args, root) {
|
|
|
8270
8343
|
FIREGOAT_OUT: out,
|
|
8271
8344
|
PATH: [out, process.env.PATH ?? ""].join(delimiter2)
|
|
8272
8345
|
};
|
|
8273
|
-
const
|
|
8346
|
+
const matrix = models.length > 0 ? models : [null];
|
|
8347
|
+
const rows = tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
|
|
8274
8348
|
test: t.name,
|
|
8275
|
-
n: i + 1,
|
|
8349
|
+
n: m * runs + i + 1,
|
|
8350
|
+
model,
|
|
8276
8351
|
state: "queued",
|
|
8277
8352
|
turns: 0
|
|
8278
|
-
})));
|
|
8353
|
+
}))));
|
|
8279
8354
|
if (config.setup.length > 0) {
|
|
8280
8355
|
const setupStarted = Date.now();
|
|
8281
8356
|
ui.setup("running");
|
|
@@ -8318,6 +8393,7 @@ async function evalCommand(args, root) {
|
|
|
8318
8393
|
},
|
|
8319
8394
|
test,
|
|
8320
8395
|
n: row.n,
|
|
8396
|
+
model: row.model,
|
|
8321
8397
|
runId,
|
|
8322
8398
|
runDir,
|
|
8323
8399
|
claudeBin: claudePath,
|
|
@@ -8385,7 +8461,68 @@ async function evalCommand(args, root) {
|
|
|
8385
8461
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
8386
8462
|
|
|
8387
8463
|
// node_modules/@firegoat/feedback/dist/index.js
|
|
8388
|
-
|
|
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";
|
|
8389
8526
|
var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
|
|
8390
8527
|
var TIMEOUT_MS = 15000;
|
|
8391
8528
|
function envBaseUrl() {
|
|
@@ -8414,6 +8551,7 @@ class FeedbackClient {
|
|
|
8414
8551
|
baseUrl;
|
|
8415
8552
|
token;
|
|
8416
8553
|
fetchImpl;
|
|
8554
|
+
device;
|
|
8417
8555
|
constructor(options) {
|
|
8418
8556
|
if (!options.domain?.trim()) {
|
|
8419
8557
|
throw new Error("FeedbackClient: `domain` is required (your product's domain, e.g. example.com)");
|
|
@@ -8422,6 +8560,7 @@ class FeedbackClient {
|
|
|
8422
8560
|
this.baseUrl = (options.baseUrl ?? envBaseUrl() ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
8423
8561
|
this.token = options.token;
|
|
8424
8562
|
this.fetchImpl = options.fetch ?? fetch;
|
|
8563
|
+
this.device = options.token || options.device === false ? undefined : options.device ?? defaultDeviceStore();
|
|
8425
8564
|
}
|
|
8426
8565
|
async registerDevice() {
|
|
8427
8566
|
const data = await this.post("/api/feedback/device", undefined);
|
|
@@ -8429,6 +8568,7 @@ class FeedbackClient {
|
|
|
8429
8568
|
throw new FeedbackError(201, "invalid_response", "server response missing token");
|
|
8430
8569
|
}
|
|
8431
8570
|
this.token = data.token;
|
|
8571
|
+
await this.device?.save(this.baseUrl, data.token);
|
|
8432
8572
|
return data.token;
|
|
8433
8573
|
}
|
|
8434
8574
|
async send(input) {
|
|
@@ -8441,18 +8581,43 @@ class FeedbackClient {
|
|
|
8441
8581
|
cliVersion: `@firegoat/feedback@${VERSION}`,
|
|
8442
8582
|
feedbackType: input.type
|
|
8443
8583
|
};
|
|
8444
|
-
const
|
|
8584
|
+
const payload = {
|
|
8445
8585
|
domain: this.domain,
|
|
8446
8586
|
title,
|
|
8447
8587
|
body: input.body ?? "",
|
|
8448
8588
|
meta,
|
|
8449
8589
|
maskSecrets: input.maskSecrets ?? false
|
|
8450
|
-
}
|
|
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
|
+
}
|
|
8451
8603
|
if (typeof data.id !== "string" || !data.id) {
|
|
8452
8604
|
throw new FeedbackError(201, "invalid_response", "server response missing id");
|
|
8453
8605
|
}
|
|
8454
8606
|
return { id: data.id, similarCount: data.similarCount ?? 0 };
|
|
8455
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
|
+
}
|
|
8456
8621
|
async post(path, body) {
|
|
8457
8622
|
const headers = {
|
|
8458
8623
|
"content-type": "application/json"
|
|
@@ -8478,6 +8643,9 @@ class FeedbackClient {
|
|
|
8478
8643
|
return data;
|
|
8479
8644
|
}
|
|
8480
8645
|
}
|
|
8646
|
+
function isInvalidToken(err) {
|
|
8647
|
+
return err instanceof FeedbackError && err.code === "invalid_token";
|
|
8648
|
+
}
|
|
8481
8649
|
|
|
8482
8650
|
// src/feedback.ts
|
|
8483
8651
|
var TYPES = ["bug", "feature_request", "feedback"];
|
|
@@ -8567,8 +8735,6 @@ var SKILLS_ARGV = [
|
|
|
8567
8735
|
"firegoat-ai/skills",
|
|
8568
8736
|
"--skill",
|
|
8569
8737
|
"firegoat-onboarding",
|
|
8570
|
-
"--skill",
|
|
8571
|
-
"suggesting-eval-prompts",
|
|
8572
8738
|
"--agent",
|
|
8573
8739
|
"claude-code",
|
|
8574
8740
|
"--copy",
|
|
@@ -8582,6 +8748,10 @@ function template(product) {
|
|
|
8582
8748
|
# How many times to run each test.
|
|
8583
8749
|
runs: 1
|
|
8584
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
|
+
|
|
8585
8755
|
# Optional: test your local build instead of the published package.
|
|
8586
8756
|
# setup: runs once per eval, here; what it writes to $FIREGOAT_OUT is put on the
|
|
8587
8757
|
# agent's PATH. workdir_setup: runs in each fresh folder before the agent starts.
|
|
@@ -8607,8 +8777,10 @@ runs: 1
|
|
|
8607
8777
|
# run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
|
|
8608
8778
|
# {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
|
|
8609
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.
|
|
8610
8782
|
# env:
|
|
8611
|
-
# MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
|
|
8783
|
+
# MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{test.name}}-{{run.n}}"
|
|
8612
8784
|
# UV_FIND_LINKS: "$FIREGOAT_OUT"
|
|
8613
8785
|
|
|
8614
8786
|
tests:
|
|
@@ -8710,18 +8882,18 @@ ${INIT_HELP}`);
|
|
|
8710
8882
|
});
|
|
8711
8883
|
if (r.error || r.status !== 0) {
|
|
8712
8884
|
process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
8713
|
-
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(" ")}`);
|
|
8714
8886
|
return EXIT.notGraded;
|
|
8715
8887
|
}
|
|
8716
8888
|
if (interactive)
|
|
8717
8889
|
step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
|
|
8718
8890
|
else
|
|
8719
|
-
console.log("installed
|
|
8891
|
+
console.log("installed skill firegoat-onboarding in .claude/skills/");
|
|
8720
8892
|
const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
|
|
8721
8893
|
cwd
|
|
8722
8894
|
});
|
|
8723
8895
|
if (ignored.status === 0)
|
|
8724
|
-
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");
|
|
8725
8897
|
} else if (interactive)
|
|
8726
8898
|
step(3, "firegoat-onboarding skill", "skipped");
|
|
8727
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"
|