edsger 0.81.1 → 0.82.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/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync3 } from "fs";
4
+ import { readFileSync as readFileSync4 } from "fs";
5
5
  import { resolve as resolve2 } from "path";
6
6
  import { Command, Option } from "commander";
7
- import pc7 from "picocolors";
7
+ import pc9 from "picocolors";
8
8
 
9
9
  // src/commands/benchmark.ts
10
- import { mkdirSync, writeFileSync } from "fs";
10
+ import { appendFileSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { basename, join as join3 } from "path";
12
12
  import pc3 from "picocolors";
13
13
 
@@ -490,6 +490,30 @@ function summarizeTool(name = "tool", input = {}) {
490
490
  }
491
491
  }
492
492
 
493
+ // src/core/git.ts
494
+ import { execFileSync as execFileSync2 } from "child_process";
495
+ function tryGit(args, cwd) {
496
+ try {
497
+ return execFileSync2("git", args, {
498
+ cwd,
499
+ encoding: "utf8",
500
+ stdio: ["ignore", "pipe", "ignore"]
501
+ }).trim();
502
+ } catch {
503
+ return null;
504
+ }
505
+ }
506
+ function gitInfo(cwd) {
507
+ const commit = tryGit(["rev-parse", "--short", "HEAD"], cwd);
508
+ const branch = tryGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
509
+ const status = tryGit(["status", "--porcelain"], cwd);
510
+ return {
511
+ commit,
512
+ branch: branch && branch !== "HEAD" ? branch : null,
513
+ dirty: Boolean(status && status.length > 0)
514
+ };
515
+ }
516
+
493
517
  // src/core/report.ts
494
518
  function scoreBenchmark(standard, result) {
495
519
  const scale = standard.scoreScale || 4;
@@ -715,6 +739,7 @@ async function runBenchmark(opts) {
715
739
  const scored = scoreBenchmark(standard, result);
716
740
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
717
741
  const runId = generatedAt.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
742
+ const git = gitInfo(opts.cwd);
718
743
  const md = renderBenchmarkMarkdown(scored, {
719
744
  repo: basename(opts.cwd),
720
745
  languages: resolved.languages,
@@ -728,6 +753,7 @@ async function runBenchmark(opts) {
728
753
  generatedAt,
729
754
  model: opts.model,
730
755
  repo: basename(opts.cwd),
756
+ git,
731
757
  languages: resolved.languages,
732
758
  standard: { name: standard.name, version: standard.version, layers: resolved.layers },
733
759
  overall: scored.overall,
@@ -739,6 +765,19 @@ async function runBenchmark(opts) {
739
765
  recommendations: scored.recommendations,
740
766
  costUsd
741
767
  };
768
+ const historyRecord = {
769
+ runId,
770
+ generatedAt,
771
+ commit: git.commit,
772
+ branch: git.branch,
773
+ dirty: git.dirty,
774
+ model: opts.model,
775
+ standardVersion: standard.version,
776
+ overall: scored.overall,
777
+ grade: scored.grade,
778
+ categories: Object.fromEntries(scored.categories.map((c) => [c.id, c.fraction])),
779
+ costUsd
780
+ };
742
781
  if (!opts.noWrite && !opts.dryRun) {
743
782
  const baseDir = join3(opts.cwd, opts.out);
744
783
  const mdOut = md + "\n";
@@ -749,6 +788,7 @@ async function runBenchmark(opts) {
749
788
  writeFileSync(join3(runDir, "report.json"), jsonOut, "utf8");
750
789
  writeFileSync(join3(baseDir, "report.md"), mdOut, "utf8");
751
790
  writeFileSync(join3(baseDir, "report.json"), jsonOut, "utf8");
791
+ appendFileSync(join3(baseDir, "history.jsonl"), JSON.stringify(historyRecord) + "\n", "utf8");
752
792
  log.success(
753
793
  `Report written to ${pc3.bold(join3(opts.out, "report.md"))} ${pc3.dim(`(run archived at ${join3(opts.out, "runs", runId)})`)}`
754
794
  );
@@ -1221,49 +1261,212 @@ function buildPrompt3(ctx, feedback, instruction) {
1221
1261
  ].join("\n");
1222
1262
  }
1223
1263
 
1264
+ // src/commands/serve.ts
1265
+ import { createReadStream, existsSync as existsSync4, statSync as statSync2 } from "fs";
1266
+ import { createServer } from "http";
1267
+ import { extname, join as join6, normalize } from "path";
1268
+ import pc6 from "picocolors";
1269
+
1270
+ // src/core/dashboard.ts
1271
+ import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
1272
+ import { join as join5 } from "path";
1273
+ function assembleData(benchmarkDir) {
1274
+ const latest = readJson(join5(benchmarkDir, "report.json"));
1275
+ const history = readJsonl(join5(benchmarkDir, "history.jsonl"));
1276
+ const runs = {};
1277
+ const runsDir = join5(benchmarkDir, "runs");
1278
+ if (existsSync3(runsDir)) {
1279
+ for (const entry of readdirSync2(runsDir)) {
1280
+ const report = readJson(join5(runsDir, entry, "report.json"));
1281
+ if (report) runs[entry] = report;
1282
+ }
1283
+ }
1284
+ const repo = latest && typeof latest === "object" && "repo" in latest ? latest.repo : void 0;
1285
+ return { repo, latest, history, runs };
1286
+ }
1287
+ function hasData(data) {
1288
+ return Boolean(data.latest) || data.history.length > 0;
1289
+ }
1290
+ function findViewerDir() {
1291
+ const dir = join5(findBundledAssetsDir(), "viewer");
1292
+ if (!existsSync3(join5(dir, "index.html"))) {
1293
+ throw new Error(
1294
+ "Dashboard viewer assets not found. Reinstall edsger, or run `npm run sync-viewer` in a dev checkout."
1295
+ );
1296
+ }
1297
+ return dir;
1298
+ }
1299
+ function readViewerSingleTemplate() {
1300
+ const file = join5(findBundledAssetsDir(), "viewer-single.html");
1301
+ if (!existsSync3(file)) {
1302
+ throw new Error(
1303
+ "Single-file viewer template not found. Reinstall edsger, or run `npm run sync-viewer`."
1304
+ );
1305
+ }
1306
+ return readFileSync2(file, "utf8");
1307
+ }
1308
+ function renderSingleFile(template, data) {
1309
+ const json = JSON.stringify(data).replace(/<\//g, "<\\/");
1310
+ const tag = `<script>window.__EDSGER_DATA__=${json}</script>`;
1311
+ if (template.includes("</head>")) return template.replace("</head>", `${tag}</head>`);
1312
+ return template.replace("<body>", `<body>${tag}`);
1313
+ }
1314
+ function readJson(path) {
1315
+ try {
1316
+ return JSON.parse(readFileSync2(path, "utf8"));
1317
+ } catch {
1318
+ return null;
1319
+ }
1320
+ }
1321
+ function readJsonl(path) {
1322
+ if (!existsSync3(path)) return [];
1323
+ const out = [];
1324
+ for (const line of readFileSync2(path, "utf8").split("\n")) {
1325
+ const t = line.trim();
1326
+ if (!t) continue;
1327
+ try {
1328
+ out.push(JSON.parse(t));
1329
+ } catch {
1330
+ }
1331
+ }
1332
+ return out;
1333
+ }
1334
+
1335
+ // src/core/open.ts
1336
+ import { spawn } from "child_process";
1337
+ function openInBrowser(target) {
1338
+ const platform = process.platform;
1339
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1340
+ const args = platform === "win32" ? ["/c", "start", "", target] : [target];
1341
+ try {
1342
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
1343
+ } catch {
1344
+ }
1345
+ }
1346
+
1347
+ // src/commands/serve.ts
1348
+ var MIME = {
1349
+ ".html": "text/html; charset=utf-8",
1350
+ ".js": "text/javascript; charset=utf-8",
1351
+ ".css": "text/css; charset=utf-8",
1352
+ ".json": "application/json; charset=utf-8",
1353
+ ".svg": "image/svg+xml",
1354
+ ".map": "application/json",
1355
+ ".ico": "image/x-icon",
1356
+ ".woff2": "font/woff2",
1357
+ ".woff": "font/woff"
1358
+ };
1359
+ async function runServe(opts) {
1360
+ const benchmarkDir = join6(opts.cwd, opts.dir);
1361
+ const viewerDir = findViewerDir();
1362
+ const server = createServer((req, res) => {
1363
+ const url = (req.url ?? "/").split("?")[0] ?? "/";
1364
+ if (url === "/edsger-data.json") {
1365
+ res.writeHead(200, {
1366
+ "content-type": "application/json; charset=utf-8",
1367
+ "cache-control": "no-store"
1368
+ });
1369
+ res.end(JSON.stringify(assembleData(benchmarkDir)));
1370
+ return;
1371
+ }
1372
+ const rel = url === "/" ? "index.html" : decodeURIComponent(url.replace(/^\/+/, ""));
1373
+ const safe = normalize(rel).replace(/^(\.\.(?:[/\\]|$))+/, "");
1374
+ let file = join6(viewerDir, safe);
1375
+ if (!file.startsWith(viewerDir)) {
1376
+ res.writeHead(403);
1377
+ res.end("forbidden");
1378
+ return;
1379
+ }
1380
+ if (!existsSync4(file) || statSync2(file).isDirectory()) file = join6(viewerDir, "index.html");
1381
+ res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
1382
+ createReadStream(file).pipe(res);
1383
+ });
1384
+ return new Promise((resolve3, reject) => {
1385
+ server.on("error", reject);
1386
+ server.listen(opts.port, opts.host, () => {
1387
+ const addr = server.address();
1388
+ const port = typeof addr === "object" && addr ? addr.port : opts.port;
1389
+ const shownHost = opts.host === "0.0.0.0" ? "localhost" : opts.host;
1390
+ const url = `http://${shownHost}:${port}/`;
1391
+ if (!hasData(assembleData(benchmarkDir))) {
1392
+ log.warn(`No benchmark data in ${opts.dir} yet \u2014 run \`edsger benchmark\` first.`);
1393
+ }
1394
+ log.success(`Dashboard at ${pc6.bold(pc6.cyan(url))} ${pc6.dim("(Ctrl-C to stop)")}`);
1395
+ if (opts.open) openInBrowser(url);
1396
+ });
1397
+ const shutdown = () => {
1398
+ server.close(() => resolve3(0));
1399
+ };
1400
+ process.on("SIGINT", shutdown);
1401
+ process.on("SIGTERM", shutdown);
1402
+ });
1403
+ }
1404
+
1405
+ // src/commands/report.ts
1406
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
1407
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7 } from "path";
1408
+ import pc7 from "picocolors";
1409
+ async function runReport(opts) {
1410
+ const benchmarkDir = join7(opts.cwd, opts.dir);
1411
+ const data = assembleData(benchmarkDir);
1412
+ if (!hasData(data)) {
1413
+ log.error(`No benchmark data in ${opts.dir}. Run \`edsger benchmark\` first.`);
1414
+ return 1;
1415
+ }
1416
+ const html = renderSingleFile(readViewerSingleTemplate(), data);
1417
+ const out = isAbsolute2(opts.html) ? opts.html : join7(opts.cwd, opts.html);
1418
+ mkdirSync2(dirname2(out), { recursive: true });
1419
+ writeFileSync3(out, html, "utf8");
1420
+ log.success(
1421
+ `Wrote self-contained report to ${pc7.bold(opts.html)} ${pc7.dim(`(${Math.round(html.length / 1024)} kB, open in any browser)`)}`
1422
+ );
1423
+ if (opts.open) openInBrowser(out);
1424
+ return 0;
1425
+ }
1426
+
1224
1427
  // src/commands/skills.ts
1225
1428
  import {
1226
1429
  cpSync,
1227
- existsSync as existsSync3,
1228
- mkdirSync as mkdirSync2,
1229
- readFileSync as readFileSync2,
1230
- readdirSync as readdirSync2,
1430
+ existsSync as existsSync5,
1431
+ mkdirSync as mkdirSync3,
1432
+ readFileSync as readFileSync3,
1433
+ readdirSync as readdirSync3,
1231
1434
  rmSync as rmSync2,
1232
- statSync as statSync2,
1233
- writeFileSync as writeFileSync3
1435
+ statSync as statSync3,
1436
+ writeFileSync as writeFileSync4
1234
1437
  } from "fs";
1235
1438
  import { homedir as homedir2 } from "os";
1236
- import { join as join5 } from "path";
1237
- import pc6 from "picocolors";
1439
+ import { join as join8 } from "path";
1440
+ import pc8 from "picocolors";
1238
1441
  var MANAGED = ["benchmark", "pr-review", "pr-resolve"];
1239
1442
  function targetSkillsDir(scope) {
1240
- return scope.global ? join5(homedir2(), ".claude", "skills") : join5(scope.cwd, ".claude", "skills");
1443
+ return scope.global ? join8(homedir2(), ".claude", "skills") : join8(scope.cwd, ".claude", "skills");
1241
1444
  }
1242
1445
  function sourceSkillsDir() {
1243
- return join5(findBundledAssetsDir(), "skills");
1446
+ return join8(findBundledAssetsDir(), "skills");
1244
1447
  }
1245
1448
  async function runSkillsInstall(opts) {
1246
1449
  const assetsDir = findBundledAssetsDir();
1247
1450
  const src = sourceSkillsDir();
1248
- if (!existsSync3(src)) {
1451
+ if (!existsSync5(src)) {
1249
1452
  log.error(`Bundled skills not found at ${src}. Reinstall edsger.`);
1250
1453
  return 1;
1251
1454
  }
1252
1455
  const dest = targetSkillsDir(opts);
1253
- mkdirSync2(dest, { recursive: true });
1254
- const available = readdirSync2(src).filter((n) => existsSync3(join5(src, n, "SKILL.md")));
1456
+ mkdirSync3(dest, { recursive: true });
1457
+ const available = readdirSync3(src).filter((n) => existsSync5(join8(src, n, "SKILL.md")));
1255
1458
  let installed = 0;
1256
1459
  for (const name of available) {
1257
- const outDir = join5(dest, name);
1258
- if (existsSync3(outDir) && !opts.force) {
1460
+ const outDir = join8(dest, name);
1461
+ if (existsSync5(outDir) && !opts.force) {
1259
1462
  log.warn(`skill "${name}" already exists \u2014 use --force to overwrite`);
1260
1463
  continue;
1261
1464
  }
1262
1465
  rmSync2(outDir, { recursive: true, force: true });
1263
- cpSync(join5(src, name), outDir, { recursive: true });
1264
- const skillFile = join5(outDir, "SKILL.md");
1265
- const rewritten = readFileSync2(skillFile, "utf8").split("${CLAUDE_PLUGIN_ROOT}").join(assetsDir);
1266
- writeFileSync3(skillFile, rewritten, "utf8");
1466
+ cpSync(join8(src, name), outDir, { recursive: true });
1467
+ const skillFile = join8(outDir, "SKILL.md");
1468
+ const rewritten = readFileSync3(skillFile, "utf8").split("${CLAUDE_PLUGIN_ROOT}").join(assetsDir);
1469
+ writeFileSync4(skillFile, rewritten, "utf8");
1267
1470
  installed++;
1268
1471
  log.success(`installed /${name}`);
1269
1472
  }
@@ -1271,7 +1474,7 @@ async function runSkillsInstall(opts) {
1271
1474
  log.info("Nothing installed.");
1272
1475
  return 0;
1273
1476
  }
1274
- log.heading(`Installed ${installed} skill(s) into ${pc6.bold(dest)}`);
1477
+ log.heading(`Installed ${installed} skill(s) into ${pc8.bold(dest)}`);
1275
1478
  log.step(`scope: ${opts.global ? "user (all projects)" : "this project"}`);
1276
1479
  log.step(`standards: ${assetsDir}`);
1277
1480
  log.step("Open Claude Code and run /benchmark, /pr-review, or /pr-resolve.");
@@ -1281,8 +1484,8 @@ async function runSkillsUninstall(opts) {
1281
1484
  const dest = targetSkillsDir(opts);
1282
1485
  let removed = 0;
1283
1486
  for (const name of MANAGED) {
1284
- const dir = join5(dest, name);
1285
- if (existsSync3(dir)) {
1487
+ const dir = join8(dest, name);
1488
+ if (existsSync5(dir)) {
1286
1489
  rmSync2(dir, { recursive: true, force: true });
1287
1490
  removed++;
1288
1491
  log.success(`removed /${name}`);
@@ -1298,20 +1501,20 @@ async function runSkillsList(opts) {
1298
1501
  ]) {
1299
1502
  const dir = targetSkillsDir(scope);
1300
1503
  const present = MANAGED.filter((n) => {
1301
- const p = join5(dir, n, "SKILL.md");
1302
- return existsSync3(p) && statSync2(p).isFile();
1504
+ const p = join8(dir, n, "SKILL.md");
1505
+ return existsSync5(p) && statSync3(p).isFile();
1303
1506
  });
1304
1507
  log.heading(`${label}: ${dir}`);
1305
- if (present.length) for (const n of present) process.stderr.write(` ${pc6.green("\u2713")} /${n}
1508
+ if (present.length) for (const n of present) process.stderr.write(` ${pc8.green("\u2713")} /${n}
1306
1509
  `);
1307
- else process.stderr.write(` ${pc6.dim("(none installed)")}
1510
+ else process.stderr.write(` ${pc8.dim("(none installed)")}
1308
1511
  `);
1309
1512
  }
1310
1513
  return 0;
1311
1514
  }
1312
1515
 
1313
1516
  // src/index.ts
1314
- var pkg = JSON.parse(readFileSync3(new URL("../package.json", import.meta.url), "utf8"));
1517
+ var pkg = JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8"));
1315
1518
  var VERSION = pkg.version;
1316
1519
  var DEFAULT_MODEL = "claude-sonnet-4-6";
1317
1520
  function toGlobal(raw) {
@@ -1349,10 +1552,38 @@ program.name("edsger").description(
1349
1552
  "Agentic CLI to benchmark repositories and review/resolve pull requests\nagainst industrial-grade standards (from edsger-assets, fully overridable)."
1350
1553
  ).version(VERSION, "-V, --version");
1351
1554
  addGlobalOptions(
1352
- program.command("benchmark").description("Score a repository against a benchmark rubric and write a report").option("--out <dir>", "report output directory (relative to repo)", ".edsger/benchmark").option("--no-write", "do not write report files into the repo")
1555
+ program.command("benchmark").description("Score a repository against a benchmark rubric and write a report").option("--out <dir>", "report output directory (relative to repo)", ".edsger/benchmark").option("--no-write", "do not write report files into the repo").option("--serve", "open the dashboard after benchmarking")
1353
1556
  ).action(async (opts) => {
1557
+ await guard(async () => {
1558
+ const g = toGlobal(opts);
1559
+ const code = await runBenchmark({ ...g, out: opts.out, noWrite: opts.write === false });
1560
+ if (code === 0 && opts.serve) {
1561
+ return runServe({ cwd: g.cwd, dir: opts.out, port: 4577, host: "127.0.0.1", open: true });
1562
+ }
1563
+ return code;
1564
+ });
1565
+ });
1566
+ program.command("serve").description("Serve the benchmark dashboard (current, history, trends) locally").option("-C, --cwd <dir>", "repository directory").option("--dir <dir>", "benchmark output directory (relative to repo)", ".edsger/benchmark").option("--port <n>", "port to listen on", "4577").option("--host <host>", "host to bind", "127.0.0.1").option("--no-open", "do not open the browser automatically").action(
1567
+ async (opts) => {
1568
+ await guard(
1569
+ () => runServe({
1570
+ cwd: resolve2(opts.cwd ?? process.cwd()),
1571
+ dir: opts.dir,
1572
+ port: Number.parseInt(opts.port, 10) || 4577,
1573
+ host: opts.host,
1574
+ open: opts.open !== false
1575
+ })
1576
+ );
1577
+ }
1578
+ );
1579
+ program.command("report").description("Export a self-contained HTML dashboard (data inlined) for sharing").option("-C, --cwd <dir>", "repository directory").option("--dir <dir>", "benchmark output directory (relative to repo)", ".edsger/benchmark").option("--html <path>", "output HTML file path", "edsger-report.html").option("--open", "open the exported file after writing").action(async (opts) => {
1354
1580
  await guard(
1355
- () => runBenchmark({ ...toGlobal(opts), out: opts.out, noWrite: opts.write === false })
1581
+ () => runReport({
1582
+ cwd: resolve2(opts.cwd ?? process.cwd()),
1583
+ dir: opts.dir,
1584
+ html: opts.html,
1585
+ open: Boolean(opts.open)
1586
+ })
1356
1587
  );
1357
1588
  });
1358
1589
  addGlobalOptions(
@@ -1403,11 +1634,11 @@ program.addHelpText(
1403
1634
  "after",
1404
1635
  `
1405
1636
  Examples:
1406
- ${pc7.dim("$")} edsger benchmark
1407
- ${pc7.dim("$")} edsger benchmark -C ./my-repo --json
1408
- ${pc7.dim("$")} edsger pr-review 123 --post
1409
- ${pc7.dim("$")} edsger pr-resolve 123 --instruction "fix the failing test" --push
1410
- ${pc7.dim("$")} edsger skills install ${pc7.dim("# enable /benchmark etc. in Claude Code")}
1637
+ ${pc9.dim("$")} edsger benchmark
1638
+ ${pc9.dim("$")} edsger benchmark -C ./my-repo --json
1639
+ ${pc9.dim("$")} edsger pr-review 123 --post
1640
+ ${pc9.dim("$")} edsger pr-resolve 123 --instruction "fix the failing test" --push
1641
+ ${pc9.dim("$")} edsger skills install ${pc9.dim("# enable /benchmark etc. in Claude Code")}
1411
1642
 
1412
1643
  Auth: the agent uses your Claude Code login (run \`claude\` once to sign in); run \`gh auth login\` for PR commands.
1413
1644
  `