firegoat 0.23.0 → 0.24.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.
Files changed (2) hide show
  1. package/dist/cli.js +343 -132
  2. package/package.json +1 -1
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.23.0",
6950
+ version: "0.24.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7086,9 +7086,9 @@ function parseConfig(text) {
7086
7086
  ]);
7087
7087
  const runs = posInt(raw.runs, "runs", 1);
7088
7088
  const models = parseModels(raw.model);
7089
- const build = commands(raw.build, "build");
7090
- const install = commands(raw.install, "install");
7091
- const cleanup = commands(raw.cleanup, "cleanup");
7089
+ const build = commands(raw.build, "build", EVAL_PLACEHOLDERS);
7090
+ const install = commands(raw.install, "install", RUN_PLACEHOLDERS);
7091
+ const cleanup = commands(raw.cleanup, "cleanup", RUN_PLACEHOLDERS);
7092
7092
  const vars = parseVars(raw.vars);
7093
7093
  const env = parseEnv(raw.env, "env");
7094
7094
  const claudeArgs = parseClaudeArgs(raw.claude_args, "claude_args");
@@ -7125,7 +7125,9 @@ function parseConfig(text) {
7125
7125
  if (!isRecord(c))
7126
7126
  fail(cp, "must be a mapping");
7127
7127
  onlyKeys(c, cp, ["run"]);
7128
- checks.push({ run: str(c.run, `${cp}.run`) });
7128
+ const run = str(c.run, `${cp}.run`);
7129
+ checkPlaceholders(run, `${cp}.run`, RUN_PLACEHOLDERS);
7130
+ checks.push({ run });
7129
7131
  });
7130
7132
  return {
7131
7133
  name,
@@ -7134,8 +7136,8 @@ function parseConfig(text) {
7134
7136
  checks,
7135
7137
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
7136
7138
  env: { ...env, ...parseEnv(t.env, `${p}.env`) },
7137
- install: commands(t.install, `${p}.install`),
7138
- cleanup: commands(t.cleanup, `${p}.cleanup`),
7139
+ install: commands(t.install, `${p}.install`, RUN_PLACEHOLDERS),
7140
+ cleanup: commands(t.cleanup, `${p}.cleanup`, RUN_PLACEHOLDERS),
7139
7141
  claudeArgs: [
7140
7142
  ...claudeArgs,
7141
7143
  ...parseClaudeArgs(t.claude_args, `${p}.claude_args`)
@@ -7154,14 +7156,18 @@ function parseConfig(text) {
7154
7156
  };
7155
7157
  }
7156
7158
  var NAME = /^[A-Za-z0-9._-]+$/;
7157
- function commands(raw, path) {
7159
+ function commands(raw, path, allowed) {
7158
7160
  if (raw === undefined)
7159
7161
  return [];
7160
- if (typeof raw === "string")
7161
- return [str(raw, path)];
7162
- if (!Array.isArray(raw))
7162
+ if (typeof raw !== "string" && !Array.isArray(raw))
7163
7163
  fail(path, "must be a shell command or a list of them");
7164
- return raw.map((c, i) => str(c, `${path}[${i}]`));
7164
+ const list = typeof raw === "string" ? [raw] : raw;
7165
+ return list.map((c, i) => {
7166
+ const p = typeof raw === "string" ? path : `${path}[${i}]`;
7167
+ const cmd = str(c, p);
7168
+ checkPlaceholders(cmd, p, allowed);
7169
+ return cmd;
7170
+ });
7165
7171
  }
7166
7172
  function parseModels(raw) {
7167
7173
  if (raw === undefined)
@@ -7211,7 +7217,7 @@ function parseClaudeArgs(raw, path) {
7211
7217
  for (const f of flags)
7212
7218
  if (arg === f || arg.startsWith(`${f}=`))
7213
7219
  fail(p, `${f} is not allowed: ${why}`);
7214
- checkPlaceholders(arg, p);
7220
+ checkPlaceholders(arg, p, RUN_PLACEHOLDERS);
7215
7221
  return arg;
7216
7222
  });
7217
7223
  }
@@ -7267,16 +7273,16 @@ function parseEnv(raw, path) {
7267
7273
  fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7268
7274
  if (typeof v !== "string")
7269
7275
  fail(p, "must be a string (quote numbers and booleans)");
7270
- checkPlaceholders(v, p);
7276
+ checkPlaceholders(v, p, RUN_PLACEHOLDERS);
7271
7277
  out[k] = v;
7272
7278
  }
7273
7279
  return out;
7274
7280
  }
7275
- function checkPlaceholders(v, p) {
7281
+ function checkPlaceholders(v, p, allowed) {
7276
7282
  for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7277
7283
  const name = m[1];
7278
- if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7279
- fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7284
+ if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !allowed.includes(name))
7285
+ fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${allowed.map((n) => `{{${n}}}`).join(", ")})`);
7280
7286
  }
7281
7287
  }
7282
7288
  function isEvalLevel(value) {
@@ -7287,13 +7293,16 @@ function isEvalLevel(value) {
7287
7293
  });
7288
7294
  }
7289
7295
  function renderValue(value, vars, run, where) {
7290
- return value.replace(ENV_REF, (whole, name, braced, bare) => {
7291
- const key = name ?? braced ?? bare;
7292
- const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7293
- if (filled === undefined)
7294
- throw new ConfigError(`${where}: no value for ${whole}`);
7295
- return filled;
7296
- });
7296
+ return value.replace(ENV_REF, (whole, name, braced, bare) => lookup(whole, name ?? braced ?? bare, vars, run, where));
7297
+ }
7298
+ function renderStep(cmd, vars, run, where) {
7299
+ return cmd.replace(ANY_PLACEHOLDER, (whole, name) => lookup(whole, name, vars, run, where));
7300
+ }
7301
+ function lookup(whole, key, vars, run, where) {
7302
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7303
+ if (filled === undefined)
7304
+ throw new ConfigError(`${where}: no value for ${whole}`);
7305
+ return filled;
7297
7306
  }
7298
7307
  function renderEnv(env, vars, run) {
7299
7308
  const out = {};
@@ -8071,8 +8080,9 @@ firegoat.yaml reference:
8071
8080
  what the agent left running (servers, daemons).
8072
8081
  Every step runs; a failure is a warning (run.json
8073
8082
  cleanup, <test>/<n>/cleanup.txt), never the verdict
8074
- vars: {KEY: value} Defaults for {{vars.KEY}} in prompts and checks;
8075
- --var KEY=value overrides
8083
+ vars: {KEY: value} Defaults for {{vars.KEY}} in prompts, checks and
8084
+ build, install and cleanup steps; --var KEY=value
8085
+ overrides. A missing value is a config error
8076
8086
  env: {NAME: value} Env vars for the agent, and for install, checks
8077
8087
  and cleanup of the same run; values are strings and can
8078
8088
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
@@ -8123,6 +8133,11 @@ firegoat.yaml reference:
8123
8133
  from a template project, commit it as e.g. fixtures/starter-app/, then:
8124
8134
  build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
8125
8135
  install: cp -R "$FIREGOAT_OUT/starter-app/." .
8136
+ Placeholders in build, install, cleanup and checks: {{vars.KEY}}, {{run.id}},
8137
+ {{run.n}}, {{test.name}} (build: only {{vars.KEY}} and {{run.id}}); others are
8138
+ config errors. Values are pasted into the shell text as-is, unquoted: quote
8139
+ them yourself ('{{vars.KEY}}'), or use the env var below ("$KEY"), which is safe
8140
+ for any value.
8126
8141
  The build, install and cleanup commands and the checks get the vars as env
8127
8142
  vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
8128
8143
  get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
@@ -8269,10 +8284,12 @@ fixtures and build: stay in the current folder.
8269
8284
  --parallel N Runs at once (default 4; 1 runs them one after another).
8270
8285
  Each run has its own HOME; state outside it (a fixed port,
8271
8286
  /tmp paths) can still collide: use 1 then
8272
- --var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
8273
- cleanup
8287
+ --var KEY=value Fills {{vars.KEY}} (prompts, checks, build, install,
8288
+ cleanup); env for build, install, checks and cleanup
8274
8289
  --json The summary as JSON on stdout (or {error, exitCode} if eval
8275
- stops early); progress goes to stderr
8290
+ stops early); progress goes to stderr. The verdict comes
8291
+ last, after runs: totals {pass, fail, error}, status
8292
+ ("pass", "fail" or "error" = not graded) and exitCode
8276
8293
  --no-upload Keep runs local even when logged in to firegoat.ai
8277
8294
  --dry-run Check firegoat.yaml as eval would (test names, --var,
8278
8295
  --model, missing vars), print each planned run
@@ -8281,7 +8298,8 @@ fixtures and build: stay in the current folder.
8281
8298
  runs/<runId>/) and exit 0. Builds nothing, starts no
8282
8299
  claude, uploads nothing, writes no files. Config errors
8283
8300
  exit 3. With --json: {dryRun, build, runs: [{test, n,
8284
- model, claudeArgs, install, checks, cleanup}]}
8301
+ model, claudeArgs, install, checks, cleanup}]}, steps
8302
+ and checks with their placeholders filled
8285
8303
  --claude-bin PATH Path to claude (default: claude on PATH)
8286
8304
 
8287
8305
  Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
@@ -8295,9 +8313,11 @@ Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
8295
8313
  (default https://firegoat.ai/evals). See firegoat login --help.
8296
8314
 
8297
8315
  Output: .firegoat/runs/<runId>/
8298
- summary.json Every run, plus totals, dir (where eval ran),
8299
- git {branch, commit, dirty} (no git outside a repo)
8300
- and upload {origin, email} (null when not uploading)
8316
+ summary.json firegoatVersion, runId, runsDir, dir (where eval
8317
+ ran), git {branch, commit, dirty} (no git outside a
8318
+ repo), upload {origin, email} (null when not
8319
+ uploading), runs (every run.json), then the verdict:
8320
+ totals, status and exitCode
8301
8321
  ../index.jsonl One line per eval: runId, startedAt, dir, branch,
8302
8322
  commit, passed, failed, notGraded, cost (null when
8303
8323
  any run's cost is unknown), uploaded, uploadFailed
@@ -9212,6 +9232,19 @@ function runFill(runId, n, testName, out, dir) {
9212
9232
  function renderClaudeArgs(test, vars, fill) {
9213
9233
  return test.claudeArgs.map((a, i) => renderValue(a, vars, fill, `claude_args[${i}]`));
9214
9234
  }
9235
+ function renderSteps(what, commands, vars, fill) {
9236
+ return commands.map((c, i) => renderStep(c, vars, fill, `${what}[${i}]`));
9237
+ }
9238
+ function renderRunSteps(test, top, vars, fill) {
9239
+ return {
9240
+ install: renderSteps("install", [...top.install, ...test.install], vars, fill),
9241
+ checks: renderSteps("checks", test.checks.map((c) => c.run), vars, fill),
9242
+ cleanup: renderSteps("cleanup", [...test.cleanup, ...top.cleanup], vars, fill)
9243
+ };
9244
+ }
9245
+ function renderBuild(config, vars, runId) {
9246
+ return renderSteps("build", config.build, vars, { "run.id": runId });
9247
+ }
9215
9248
  function planRuns(tests, models, runs) {
9216
9249
  const matrix = models.length > 0 ? models : [null];
9217
9250
  return tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
@@ -9230,13 +9263,12 @@ function dryRun(opts) {
9230
9263
  return {
9231
9264
  ...p,
9232
9265
  claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9233
- install: [...opts.config.install, ...test.install],
9234
- checks: test.checks.map((c) => render(c.run, opts.vars)),
9235
- cleanup: [...test.cleanup, ...opts.config.cleanup]
9266
+ ...renderRunSteps(test, opts.config, opts.vars, fill)
9236
9267
  };
9237
9268
  });
9269
+ const build = renderBuild(opts.config, opts.vars, DRY_RUN_ID);
9238
9270
  if (opts.json) {
9239
- console.log(JSON.stringify({ dryRun: true, build: opts.config.build, runs }, null, 2));
9271
+ console.log(JSON.stringify({ dryRun: true, build, runs }, null, 2));
9240
9272
  return;
9241
9273
  }
9242
9274
  const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
@@ -9286,11 +9318,11 @@ async function runOne(opts) {
9286
9318
  FIREGOAT_WORKDIR: workdir,
9287
9319
  FIREGOAT_HOME: home
9288
9320
  };
9289
- const [status, reason] = await attempt(opts, r, env, stepEnv, envFile);
9290
- const cleanup = [...test.cleanup, ...opts.cleanup];
9291
- if (cleanup.length > 0)
9321
+ const steps = renderRunSteps(test, opts, opts.vars, fill);
9322
+ const [status, reason] = await attempt(opts, r, steps, env, stepEnv, envFile);
9323
+ if (steps.cleanup.length > 0)
9292
9324
  r.cleanup = await runAllSteps({
9293
- commands: cleanup,
9325
+ commands: steps.cleanup,
9294
9326
  cwd: workdir,
9295
9327
  env: stepEnv,
9296
9328
  logPath: r.cleanupLog
@@ -9301,14 +9333,13 @@ async function runOne(opts) {
9301
9333
  writeRunJson(r);
9302
9334
  return r;
9303
9335
  }
9304
- async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9336
+ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
9305
9337
  const { test, n } = opts;
9306
9338
  const { dir, workdir } = r;
9307
- const install = [...opts.install, ...test.install];
9308
- if (install.length > 0) {
9339
+ if (steps.install.length > 0) {
9309
9340
  const error = await runSteps({
9310
9341
  what: "install",
9311
- commands: install,
9342
+ commands: steps.install,
9312
9343
  cwd: workdir,
9313
9344
  env: stepEnv,
9314
9345
  logPath: join7(dir, "install.txt")
@@ -9362,9 +9393,9 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9362
9393
  ];
9363
9394
  const log = createWriteStream2(r.checksLog);
9364
9395
  try {
9365
- for (const c of test.checks)
9396
+ for (const run of steps.checks)
9366
9397
  r.checks.push(await runShell({
9367
- run: render(c.run, opts.vars),
9398
+ run,
9368
9399
  cwd: workdir,
9369
9400
  env: {
9370
9401
  ...stepEnv,
@@ -9462,8 +9493,10 @@ async function evalCommand(args, root) {
9462
9493
  t.prompt,
9463
9494
  ...t.checks.map((c) => c.run),
9464
9495
  ...Object.values(t.env),
9465
- ...t.claudeArgs
9466
- ]).flatMap(varRefs).filter((k) => !(k in vars)))
9496
+ ...t.claudeArgs,
9497
+ ...t.install,
9498
+ ...t.cleanup
9499
+ ]).concat(config.build, config.install, config.cleanup).flatMap(varRefs).filter((k) => !(k in vars)))
9467
9500
  ];
9468
9501
  if (missing.length > 0)
9469
9502
  return fatal(EXIT.usage, `No value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`, "config");
@@ -9546,7 +9579,7 @@ async function evalCommand(args, root) {
9546
9579
  ui.build("running");
9547
9580
  const error = await runSteps({
9548
9581
  what: "build",
9549
- commands: config.build,
9582
+ commands: renderBuild(config, vars, runId),
9550
9583
  cwd: root,
9551
9584
  env: {
9552
9585
  ...stepEnv,
@@ -9624,6 +9657,12 @@ async function evalCommand(args, root) {
9624
9657
  uploaded,
9625
9658
  uploadFailed: uploadsFailed
9626
9659
  });
9660
+ const status = totals.error > 0 ? "error" : totals.fail > 0 ? "fail" : "pass";
9661
+ const exitCode = {
9662
+ error: EXIT.notGraded,
9663
+ fail: EXIT.fail,
9664
+ pass: EXIT.pass
9665
+ }[status];
9627
9666
  const doc = {
9628
9667
  firegoatVersion: package_default.version,
9629
9668
  runId,
@@ -9631,8 +9670,10 @@ async function evalCommand(args, root) {
9631
9670
  dir: root,
9632
9671
  ...repo && { git: repo.info },
9633
9672
  upload: upload && { origin: upload.origin, email: upload.email },
9673
+ runs: results,
9634
9674
  totals,
9635
- runs: results
9675
+ status,
9676
+ exitCode
9636
9677
  };
9637
9678
  writeFileSync4(join7(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
9638
9679
  `);
@@ -9666,11 +9707,11 @@ async function evalCommand(args, root) {
9666
9707
  console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
9667
9708
  if (opts.json)
9668
9709
  console.log(JSON.stringify(doc, null, 2));
9669
- if (totals.error > 0)
9710
+ if (status === "error")
9670
9711
  failWith("not_graded");
9671
- else if (totals.fail > 0)
9712
+ else if (status === "fail")
9672
9713
  failWith("checks_failed");
9673
- return totals.error > 0 ? EXIT.notGraded : totals.fail > 0 ? EXIT.fail : EXIT.pass;
9714
+ return exitCode;
9674
9715
  }
9675
9716
 
9676
9717
  // src/feedback.ts
@@ -9946,10 +9987,9 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9946
9987
  }
9947
9988
 
9948
9989
  // src/init.ts
9949
- import { spawnSync as spawnSync3 } from "node:child_process";
9990
+ import { spawn as spawn3, spawnSync as spawnSync3 } from "node:child_process";
9950
9991
  import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
9951
9992
  import { basename as basename4, join as join8 } from "node:path";
9952
- import { createInterface } from "node:readline/promises";
9953
9993
 
9954
9994
  // src/login.ts
9955
9995
  import { spawn as spawn2 } from "node:child_process";
@@ -9979,7 +10019,7 @@ function openBrowser(url) {
9979
10019
  } catch {}
9980
10020
  }
9981
10021
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9982
- async function loginFlow(say, interactive) {
10022
+ async function loginFlow(show, interactive) {
9983
10023
  const base = baseUrl();
9984
10024
  const origin = new URL(base).origin;
9985
10025
  const cfgStep = "GET /api/traces/oauth/config";
@@ -10024,8 +10064,11 @@ async function loginFlow(say, interactive) {
10024
10064
  let interval = Math.min(Math.max(rawInterval ?? 5, 5), MAX_INTERVAL_S);
10025
10065
  const expiresS = Math.min(expiresIn, MAX_EXPIRES_S);
10026
10066
  const deadline = Date.now() + expiresS * 1000;
10027
- say(TERMS);
10028
- say(`Open ${printable(page.href, 500)} and enter code ${userCode} (expires in ${Math.max(1, Math.round(expiresS / 60))}m).`);
10067
+ show({
10068
+ page: printable(page.href, 500),
10069
+ code: userCode,
10070
+ minutes: Math.max(1, Math.round(expiresS / 60))
10071
+ });
10029
10072
  if (interactive)
10030
10073
  openBrowser(page.href);
10031
10074
  const tokStep = "Token request";
@@ -10093,7 +10136,10 @@ ${LOGIN_HELP}`);
10093
10136
  console.error("Note: FIREGOAT_API_KEY is set, so eval uses it instead of this login.");
10094
10137
  try {
10095
10138
  console.log(UPLOAD_NOTICE);
10096
- const email = await loginFlow((l) => console.log(l), Boolean(process.stdout.isTTY));
10139
+ const email = await loginFlow(({ page, code, minutes }) => {
10140
+ console.log(TERMS);
10141
+ console.log(`Open ${page} and enter code ${code} (expires in ${minutes}m).`);
10142
+ }, Boolean(process.stdout.isTTY));
10097
10143
  console.log(`Logged in as ${email}. Key saved to ${credentialsPath()}.`);
10098
10144
  return EXIT.pass;
10099
10145
  } catch (e) {
@@ -10130,6 +10176,102 @@ ${LOGOUT_HELP}`);
10130
10176
  return EXIT.pass;
10131
10177
  }
10132
10178
 
10179
+ // src/tty.ts
10180
+ import { emitKeypressEvents } from "node:readline";
10181
+ var ANSI2 = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
10182
+ var visible = (s) => s.replace(ANSI2, "");
10183
+ function liveBlock(out = process.stdout) {
10184
+ let drawn = 0;
10185
+ const rowsOf = (line) => Math.max(1, Math.ceil(visible(line).length / (out.columns || 80)));
10186
+ return {
10187
+ draw(lines) {
10188
+ out.write(`${drawn > 0 ? `\x1B[${drawn}A\x1B[J` : ""}${lines.join(`
10189
+ `)}
10190
+ `);
10191
+ drawn = lines.reduce((n, l) => n + rowsOf(l), 0);
10192
+ }
10193
+ };
10194
+ }
10195
+ function animate(block, render) {
10196
+ let frame = 0;
10197
+ const draw = () => block.draw(render(SPIN[frame++ % SPIN.length]));
10198
+ draw();
10199
+ const timer = setInterval(draw, 120);
10200
+ return () => clearInterval(timer);
10201
+ }
10202
+ function wrap(text, indent, width) {
10203
+ const room = Math.max(10, width - indent.length);
10204
+ const lines = [];
10205
+ let line = "";
10206
+ for (const word of text.split(" ")) {
10207
+ for (let w = word;w.length > 0; ) {
10208
+ const next = line ? `${line} ${w}` : w;
10209
+ if (next.length <= room) {
10210
+ line = next;
10211
+ w = "";
10212
+ } else if (line) {
10213
+ lines.push(line);
10214
+ line = "";
10215
+ } else {
10216
+ lines.push(w.slice(0, room));
10217
+ w = w.slice(room);
10218
+ }
10219
+ }
10220
+ }
10221
+ if (line)
10222
+ lines.push(line);
10223
+ return lines.map((l) => indent + l);
10224
+ }
10225
+ function abortError() {
10226
+ return Object.assign(new Error("Aborted"), { code: "ABORT_ERR" });
10227
+ }
10228
+ function select(block, count, render, initial = 0) {
10229
+ const input = process.stdin;
10230
+ let current = initial;
10231
+ block.draw(render(current));
10232
+ return new Promise((resolve, reject) => {
10233
+ const restore = () => {
10234
+ input.off("keypress", onKey);
10235
+ input.off("end", onEnd);
10236
+ process.off("exit", showCursor);
10237
+ input.setRawMode(false);
10238
+ input.pause();
10239
+ showCursor();
10240
+ };
10241
+ const onKey = (_, key) => {
10242
+ if (!key)
10243
+ return;
10244
+ if (key.ctrl && (key.name === "c" || key.name === "d")) {
10245
+ restore();
10246
+ reject(abortError());
10247
+ } else if (key.name === "return" || key.name === "enter") {
10248
+ restore();
10249
+ resolve(current);
10250
+ } else if (key.name === "up" || key.name === "k") {
10251
+ current = (current + count - 1) % count;
10252
+ block.draw(render(current));
10253
+ } else if (key.name === "down" || key.name === "j") {
10254
+ current = (current + 1) % count;
10255
+ block.draw(render(current));
10256
+ }
10257
+ };
10258
+ const onEnd = () => {
10259
+ restore();
10260
+ reject(abortError());
10261
+ };
10262
+ emitKeypressEvents(input);
10263
+ input.setRawMode(true);
10264
+ input.resume();
10265
+ process.stdout.write("\x1B[?25l");
10266
+ process.on("exit", showCursor);
10267
+ input.on("keypress", onKey);
10268
+ input.on("end", onEnd);
10269
+ });
10270
+ }
10271
+ function showCursor() {
10272
+ process.stdout.write("\x1B[?25h");
10273
+ }
10274
+
10133
10275
  // src/init.ts
10134
10276
  var SKILLS_ARGV = [
10135
10277
  "skills",
@@ -10204,12 +10346,18 @@ runs: 1
10204
10346
  # MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
10205
10347
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
10206
10348
 
10349
+ # Optional: values for {{vars.KEY}} in prompts, checks and build/install/cleanup
10350
+ # steps (pasted in as-is, unquoted); eval --var KEY=value overrides them. Steps
10351
+ # and checks also get each one as an env var ("$KEY": safe for any value).
10352
+ # vars:
10353
+ # ACCOUNT: acme-test
10354
+
10207
10355
  tests:
10208
10356
  # A smoke test that firegoat and claude work. Replace it with tests for your
10209
10357
  # product: the firegoat-onboarding skill writes them.
10210
10358
  - name: goat
10211
10359
  # What a user would ask their agent.
10212
- prompt: Who's the GOAT? Answer in one short sentence.
10360
+ prompt: "Who's the GOAT? Hint: it's me, the person typing this. Answer in one short sentence with the word GOAT in it."
10213
10361
  # True when the user's problem is solved. Not graded by firegoat: it goes to
10214
10362
  # your agent (eval --json, run.json) to judge.
10215
10363
  end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
@@ -10218,7 +10366,7 @@ tests:
10218
10366
  checks:
10219
10367
  - run: >-
10220
10368
  test -s "$FIREGOAT_TRANSCRIPT" &&
10221
- grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
10369
+ grep -Eiq '^agent: .*(you.*goat|goat.*you)' "$FIREGOAT_TRANSCRIPT"
10222
10370
  # Seconds before the agent run is stopped.
10223
10371
  timeout: 600
10224
10372
  `;
@@ -10236,61 +10384,115 @@ function copyToClipboard(text) {
10236
10384
  return !r.error && r.status === 0;
10237
10385
  });
10238
10386
  }
10239
- async function ask(question) {
10240
- const rl = createInterface({ input: process.stdin, output: process.stdout });
10241
- try {
10242
- return (await rl.question(question)).trim();
10243
- } finally {
10244
- rl.close();
10387
+ var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
10388
+ function cut(s, n) {
10389
+ return s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}~`;
10390
+ }
10391
+
10392
+ class Timeline {
10393
+ paint;
10394
+ width;
10395
+ static INDENT = " ".repeat(9);
10396
+ constructor(paint, width) {
10397
+ this.paint = paint;
10398
+ this.width = width;
10399
+ }
10400
+ line(mark, n, label, status = "") {
10401
+ const head = ` ${mark} ${this.paint("90", String(n).padStart(2, "0"))} `;
10402
+ if (!status)
10403
+ return head + cut(label, this.width - 9);
10404
+ return `${head}${label.padEnd(28)}${this.paint("90", cut(status, this.width - 37))}`;
10405
+ }
10406
+ done(n, label, status) {
10407
+ return this.line(this.paint("32", "●"), n, label, status);
10408
+ }
10409
+ failed(n, label) {
10410
+ return this.line(this.paint("31", "●"), n, label, "Failed");
10411
+ }
10412
+ busy(spin, n, label, status) {
10413
+ return this.line(this.paint("38;5;208", spin), n, label, status);
10414
+ }
10415
+ detail(text) {
10416
+ return wrap(text, Timeline.INDENT, this.width).map((l) => this.paint("90", l));
10417
+ }
10418
+ gap() {
10419
+ console.log(this.paint("90", " │"));
10420
+ }
10421
+ ask(block, n, question, options) {
10422
+ const cols = Math.max(...options.map(([l]) => l.length)) + 3;
10423
+ const room = this.width - Timeline.INDENT.length - 2;
10424
+ return select(block, options.length, (current) => [
10425
+ this.line(this.paint("38;5;208", "◉"), n, question),
10426
+ ...options.map(([label, note], i) => {
10427
+ const text = cut(`${label.padEnd(cols)}${note}`, room);
10428
+ return i === current ? `${Timeline.INDENT}${this.paint("38;5;208", `● ${text}`)}` : this.paint("90", `${Timeline.INDENT}○ ${text}`);
10429
+ }),
10430
+ this.paint("90", `${Timeline.INDENT}Up/down to move, enter to pick`)
10431
+ ]);
10245
10432
  }
10246
10433
  }
10247
- async function askYes(question) {
10248
- const a = await ask(`${question} [Y/n] `);
10249
- return a === "" || /^y(es)?$/i.test(a);
10250
- }
10251
- async function askScope(question) {
10252
- for (;; ) {
10253
- const a = await ask(`${question} [G/l/n] `);
10254
- if (a === "" || /^g(lobal)?$/i.test(a))
10255
- return "global";
10256
- if (/^l(ocal)?$/i.test(a))
10257
- return "local";
10258
- if (/^no?$/i.test(a))
10259
- return "none";
10260
- process.stdout.write("\x1B[1A\x1B[2K");
10261
- }
10434
+ function runQuiet(cmd, args, opts) {
10435
+ return new Promise((resolve) => {
10436
+ const child = spawn3(cmd, args, {
10437
+ ...opts,
10438
+ stdio: ["ignore", "pipe", "pipe"]
10439
+ });
10440
+ let output = "";
10441
+ const collect = (d) => {
10442
+ output += d;
10443
+ };
10444
+ child.stdout.on("data", collect);
10445
+ child.stderr.on("data", collect);
10446
+ child.on("error", (error) => resolve({ status: null, output, error }));
10447
+ child.on("close", (status) => resolve({ status, output }));
10448
+ });
10262
10449
  }
10263
- var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
10264
- async function loginStep(interactive, done, paint) {
10450
+ async function loginStep(ui) {
10265
10451
  const label = "firegoat.ai login";
10266
- const uploadsOn = () => console.log(` ${paint("90", UPLOADS_ON)}`);
10267
10452
  const key = resolveKey(baseUrl());
10268
- if (key) {
10269
- const who = key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}`;
10270
- if (interactive) {
10271
- done(label, who);
10272
- uploadsOn();
10273
- } else
10274
- console.log(`${who}
10275
- ${UPLOADS_ON}`);
10453
+ const who = key ? key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}` : undefined;
10454
+ if (!ui) {
10455
+ console.log(who ? `${who}
10456
+ ${UPLOADS_ON}` : LOGIN_HINT);
10276
10457
  return EXIT.pass;
10277
10458
  }
10278
- if (!interactive) {
10279
- console.log(LOGIN_HINT);
10459
+ const block = liveBlock();
10460
+ if (who) {
10461
+ block.draw([ui.done(4, label, who), ...ui.detail(UPLOADS_ON)]);
10280
10462
  return EXIT.pass;
10281
10463
  }
10282
- console.log(` ${paint("90", UPLOAD_NOTICE)}`);
10283
- const yes = await askYes(` ${paint("38;5;208", "◉")} ${paint("90", "04")} Login to firegoat.ai to upload runs?`);
10284
- if (!yes) {
10285
- done(label, "Skipped (npx firegoat login later)");
10464
+ const picked = await ui.ask(block, 4, "Login to firegoat.ai to upload runs?", [
10465
+ ["Yes", "Opens firegoat.ai in your browser"],
10466
+ ["Not now", "Runs stay local"]
10467
+ ]);
10468
+ if (picked !== 0) {
10469
+ block.draw([ui.done(4, label, "Skipped (npx firegoat login later)")]);
10286
10470
  return EXIT.pass;
10287
10471
  }
10472
+ let status = "Connecting";
10473
+ let details = [];
10474
+ const stop = animate(block, (spin) => [
10475
+ ui.busy(spin, 4, label, status),
10476
+ ...details
10477
+ ]);
10288
10478
  try {
10289
- const email = await loginFlow((l) => console.log(` ${l}`), true);
10290
- done(label, `Logged in as ${email}`);
10291
- uploadsOn();
10479
+ const email = await loginFlow(({ page, code, minutes }) => {
10480
+ status = "Waiting for the browser";
10481
+ details = [
10482
+ ...ui.detail(TERMS),
10483
+ ...ui.detail(`Open ${page}`),
10484
+ ...ui.detail(`Code: ${code} (expires in ${minutes}m)`)
10485
+ ];
10486
+ }, true);
10487
+ stop();
10488
+ block.draw([
10489
+ ui.done(4, label, `Logged in as ${email}`),
10490
+ ...ui.detail(UPLOADS_ON)
10491
+ ]);
10292
10492
  return EXIT.pass;
10293
10493
  } catch (e) {
10494
+ stop();
10495
+ block.draw([ui.failed(4, label), ...details]);
10294
10496
  if (!(e instanceof ApiError))
10295
10497
  throw e;
10296
10498
  failWith(e instanceof NetworkError ? "network" : "login_failed");
@@ -10336,8 +10538,8 @@ ${INIT_HELP}`);
10336
10538
  const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
10337
10539
  const width = Math.min(process.stdout.columns || 80, 80);
10338
10540
  const rule = paint("90", ` ${"─".repeat(width - 2)}`);
10339
- const step = (n, label, status) => console.log(` ${paint("32", "●")} ${paint("90", String(n).padStart(2, "0"))} ${label.padEnd(28)}${paint("90", status)}`);
10340
- if (interactive) {
10541
+ const ui = interactive ? new Timeline(paint, width) : undefined;
10542
+ if (ui) {
10341
10543
  for (const line of logoHeader(paint, "firegoat init", paint("90", "Get agent-ready."), width))
10342
10544
  console.log(line);
10343
10545
  console.log(rule);
@@ -10347,39 +10549,45 @@ ${INIT_HELP}`);
10347
10549
  const keep = existsSync4(configPath) && !force;
10348
10550
  if (!keep)
10349
10551
  writeFileSync5(configPath, template(basename4(cwd)));
10350
- if (interactive) {
10351
- step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
10352
- console.log(paint("90", " │"));
10353
- step(2, ".firegoat/", "Ready");
10552
+ if (ui) {
10553
+ console.log(ui.done(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created"));
10554
+ ui.gap();
10555
+ console.log(ui.done(2, ".firegoat/", "Ready"));
10354
10556
  } else
10355
10557
  console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: replace the goat smoke test with tests for your product`);
10356
- if (interactive && !noSkills)
10357
- console.log(paint("90", " │"));
10358
- if (interactive && !noSkills && !localSkills) {
10359
- scope = await askScope(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill globally (~/.claude/skills) or in this repo (.claude/skills)?`);
10360
- process.stdout.write("\x1B[1A\x1B[2K");
10558
+ const skill = "firegoat-onboarding skill";
10559
+ const block = liveBlock();
10560
+ ui?.gap();
10561
+ if (ui && !noSkills && !localSkills) {
10562
+ const picked = await ui.ask(block, 3, "Install the firegoat-onboarding skill", [
10563
+ ["Global", skillsDir("global")],
10564
+ ["This repo", `${skillsDir("local")} (commit it for your team)`],
10565
+ ["Skip", "Don't install it"]
10566
+ ]);
10567
+ scope = ["global", "local", "none"][picked] ?? "none";
10361
10568
  initSkills(scope);
10362
10569
  }
10363
10570
  if (scope !== "none") {
10364
10571
  const argv = [...SKILLS_ARGV, ...scope === "global" ? ["-g"] : []];
10365
10572
  const where = skillsDir(scope);
10366
- if (!interactive)
10573
+ if (!ui)
10367
10574
  console.log(`Installing the skill: npx ${argv.join(" ")}`);
10368
10575
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== "npm_config_package" && !k.startsWith("FIREGOAT_")));
10369
- const r = spawnSync3("npx", ["-y", ...argv], {
10370
- cwd,
10371
- env,
10372
- stdio: "pipe",
10373
- encoding: "utf8"
10374
- });
10576
+ const stop = ui ? animate(block, (spin) => [
10577
+ ui.busy(spin, 3, skill, `Installing → ${where}`)
10578
+ ]) : undefined;
10579
+ const r = await runQuiet("npx", ["-y", ...argv], { cwd, env });
10580
+ stop?.();
10375
10581
  if (r.error || r.status !== 0) {
10376
10582
  failWith("skills_failed");
10377
- process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
10583
+ if (ui)
10584
+ block.draw([ui.failed(3, skill)]);
10585
+ process.stderr.write(r.output);
10378
10586
  console.error(`firegoat init: Installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${argv.join(" ")}`);
10379
10587
  return EXIT.notGraded;
10380
10588
  }
10381
- if (interactive)
10382
- step(3, "firegoat-onboarding skill", `Installed → ${where}`);
10589
+ if (ui)
10590
+ block.draw([ui.done(3, skill, `Installed → ${where}`)]);
10383
10591
  else
10384
10592
  console.log(`Installed skill firegoat-onboarding in ${where}`);
10385
10593
  const ignored = scope === "local" && spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
@@ -10388,32 +10596,35 @@ ${INIT_HELP}`);
10388
10596
  }).status === 0;
10389
10597
  if (ignored)
10390
10598
  console.error("Warning: git ignores .claude/skills, so teammates and CI won't get the skill. Commit them with: git add -f .claude/skills");
10391
- } else if (interactive)
10392
- step(3, "firegoat-onboarding skill", "Skipped");
10599
+ } else if (ui)
10600
+ block.draw([ui.done(3, skill, "Skipped")]);
10393
10601
  if (login) {
10394
- if (interactive)
10395
- console.log(paint("90", " │"));
10396
- const loginExit = await loginStep(interactive, (label, status) => step(4, label, status), paint);
10602
+ ui?.gap();
10603
+ const loginExit = await loginStep(ui);
10397
10604
  if (loginExit !== EXIT.pass)
10398
10605
  return loginExit;
10399
10606
  }
10400
10607
  const telemetryOn = telemetryEnabled();
10401
- if (!interactive) {
10608
+ if (!ui) {
10402
10609
  if (telemetryOn)
10403
10610
  console.log(TELEMETRY_NOTICE);
10404
10611
  console.log(`
10405
10612
  Prompt for your coding agent: ${AGENT_PROMPT}`);
10406
10613
  return EXIT.pass;
10407
10614
  }
10408
- if (telemetryOn)
10409
- console.log(` ${paint("90", TELEMETRY_NOTICE)}`);
10410
10615
  console.log(rule);
10616
+ console.log("");
10411
10617
  console.log(` Next: hand this to your coding agent
10412
10618
  `);
10413
10619
  console.log(AGENT_PROMPT);
10414
10620
  console.log("");
10415
10621
  const copied = copyToClipboard(AGENT_PROMPT);
10416
10622
  console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat@latest eval")}`);
10623
+ if (telemetryOn) {
10624
+ console.log("");
10625
+ for (const l of wrap(TELEMETRY_NOTICE, " ", width))
10626
+ console.log(paint("90", l));
10627
+ }
10417
10628
  return EXIT.pass;
10418
10629
  }
10419
10630
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"