glassbox 0.6.0 → 0.7.1

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/cli.js CHANGED
@@ -391,11 +391,11 @@ var init_queries = __esm({
391
391
  });
392
392
 
393
393
  // src/cli.ts
394
- init_queries();
395
394
  init_connection();
396
- import { mkdirSync as mkdirSync7 } from "fs";
395
+ init_queries();
396
+ import { mkdirSync as mkdirSync8 } from "fs";
397
397
  import { tmpdir } from "os";
398
- import { join as join10, resolve as resolve5 } from "path";
398
+ import { join as join11, resolve as resolve5 } from "path";
399
399
 
400
400
  // src/debug.ts
401
401
  var debugEnabled = false;
@@ -426,70 +426,15 @@ function debugLog(...args) {
426
426
  }
427
427
 
428
428
  // src/ai/config.ts
429
- import { spawnSync } from "child_process";
430
429
  import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
431
430
  import { homedir } from "os";
432
431
  import { join as join2 } from "path";
433
432
 
434
- // src/ai/models.ts
435
- var PLATFORMS = {
436
- anthropic: "Anthropic",
437
- openai: "OpenAI",
438
- google: "Google"
439
- };
440
- var MODELS = {
441
- anthropic: [
442
- { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", contextWindow: 2e5, isDefault: true },
443
- { id: "claude-haiku-4-20250514", name: "Claude Haiku 4", contextWindow: 2e5, isDefault: false }
444
- ],
445
- openai: [
446
- { id: "gpt-4o", name: "GPT-4o", contextWindow: 128e3, isDefault: true },
447
- { id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128e3, isDefault: false }
448
- ],
449
- google: [
450
- { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextWindow: 1e6, isDefault: true },
451
- { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextWindow: 1e6, isDefault: false }
452
- ]
453
- };
454
- var ENV_KEY_NAMES = {
455
- anthropic: "ANTHROPIC_API_KEY",
456
- openai: "OPENAI_API_KEY",
457
- google: "GEMINI_API_KEY"
458
- };
459
- function getDefaultModel(platform) {
460
- const models = MODELS[platform];
461
- const def = models.find((m) => m.isDefault);
462
- return def ? def.id : models[0].id;
463
- }
464
- function getModelContextWindow(platform, modelId) {
465
- const model = MODELS[platform].find((m) => m.id === modelId);
466
- return model ? model.contextWindow : 128e3;
467
- }
433
+ // src/ai/api-keys.ts
434
+ import { spawnSync as spawnSync2 } from "child_process";
468
435
 
469
- // src/ai/config.ts
470
- var CONFIG_DIR = join2(homedir(), ".glassbox");
471
- var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
472
- function readConfigFile() {
473
- try {
474
- if (existsSync(CONFIG_PATH)) {
475
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
476
- }
477
- } catch {
478
- }
479
- return {};
480
- }
481
- function writeConfigFile(config) {
482
- mkdirSync2(CONFIG_DIR, { recursive: true });
483
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
484
- try {
485
- chmodSync(CONFIG_PATH, 384);
486
- } catch {
487
- }
488
- }
489
- function getKeyFromEnv(platform) {
490
- const envName = ENV_KEY_NAMES[platform];
491
- return process.env[envName] ?? null;
492
- }
436
+ // src/ai/keychain.ts
437
+ import { spawnSync } from "child_process";
493
438
  var WIN_CRED_READ_PS = `
494
439
  Add-Type -TypeDefinition @'
495
440
  using System;
@@ -526,19 +471,19 @@ function getKeyFromKeychain(platform) {
526
471
  try {
527
472
  if (os === "darwin") {
528
473
  const r = spawnSync("security", ["find-generic-password", "-s", "glassbox", "-a", account, "-w"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
529
- const result = (r.stdout ?? "").trim();
474
+ const result = r.stdout.trim();
530
475
  return r.status === 0 && result !== "" ? result : null;
531
476
  }
532
477
  if (os === "linux") {
533
478
  const r = spawnSync("secret-tool", ["lookup", "service", "glassbox", "account", account], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
534
- const result = (r.stdout ?? "").trim();
479
+ const result = r.stdout.trim();
535
480
  return r.status === 0 && result !== "" ? result : null;
536
481
  }
537
482
  if (os === "win32") {
538
483
  const target = winCredTarget(platform);
539
484
  const script = WIN_CRED_READ_PS + `Write-Output ([CredHelper]::Read('${target}'))`;
540
485
  const r = spawnSync("powershell", ["-NoProfile", "-Command", "-"], { input: script, encoding: "utf-8" });
541
- const result = (r.stdout ?? "").trim();
486
+ const result = r.stdout.trim();
542
487
  return r.status === 0 && result !== "" ? result : null;
543
488
  }
544
489
  } catch {
@@ -546,6 +491,81 @@ function getKeyFromKeychain(platform) {
546
491
  }
547
492
  return null;
548
493
  }
494
+ function saveKeyToKeychain(platform, key) {
495
+ const os = process.platform;
496
+ const account = `${platform}-api-key`;
497
+ if (os === "darwin") {
498
+ spawnSync("security", ["delete-generic-password", "-s", "glassbox", "-a", account], { stdio: "pipe" });
499
+ spawnSync("security", ["add-generic-password", "-s", "glassbox", "-a", account, "-w", key]);
500
+ return;
501
+ }
502
+ if (os === "linux") {
503
+ spawnSync("secret-tool", ["store", "--label=Glassbox API Key", "service", "glassbox", "account", account], { input: key, encoding: "utf-8" });
504
+ return;
505
+ }
506
+ if (os === "win32") {
507
+ const target = winCredTarget(platform);
508
+ const escapedKey = key.replace(/'/g, "''");
509
+ const script = `cmdkey /generic:'${target}' /user:'glassbox' /pass:'${escapedKey}'`;
510
+ spawnSync("powershell", ["-NoProfile", "-Command", "-"], { input: script, encoding: "utf-8" });
511
+ }
512
+ }
513
+ function isKeychainAvailable() {
514
+ const os = process.platform;
515
+ if (os === "darwin" || os === "win32") return true;
516
+ if (os === "linux") {
517
+ return spawnSync("which", ["secret-tool"], { stdio: "pipe" }).status === 0;
518
+ }
519
+ return false;
520
+ }
521
+ function getKeychainLabel() {
522
+ const os = process.platform;
523
+ if (os === "darwin") return "Keychain";
524
+ if (os === "linux") return "System Keyring";
525
+ if (os === "win32") return "Credential Manager";
526
+ return "System Keychain";
527
+ }
528
+
529
+ // src/ai/models.ts
530
+ var PLATFORMS = {
531
+ anthropic: "Anthropic",
532
+ openai: "OpenAI",
533
+ google: "Google"
534
+ };
535
+ var MODELS = {
536
+ anthropic: [
537
+ { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", contextWindow: 2e5, isDefault: true },
538
+ { id: "claude-haiku-4-20250514", name: "Claude Haiku 4", contextWindow: 2e5, isDefault: false }
539
+ ],
540
+ openai: [
541
+ { id: "gpt-4o", name: "GPT-4o", contextWindow: 128e3, isDefault: true },
542
+ { id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128e3, isDefault: false }
543
+ ],
544
+ google: [
545
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", contextWindow: 1e6, isDefault: true },
546
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextWindow: 1e6, isDefault: false }
547
+ ]
548
+ };
549
+ var ENV_KEY_NAMES = {
550
+ anthropic: "ANTHROPIC_API_KEY",
551
+ openai: "OPENAI_API_KEY",
552
+ google: "GEMINI_API_KEY"
553
+ };
554
+ function getDefaultModel(platform) {
555
+ const models = MODELS[platform];
556
+ const def = models.find((m) => m.isDefault);
557
+ return def ? def.id : models[0].id;
558
+ }
559
+ function getModelContextWindow(platform, modelId) {
560
+ const model = MODELS[platform].find((m) => m.id === modelId);
561
+ return model ? model.contextWindow : 128e3;
562
+ }
563
+
564
+ // src/ai/api-keys.ts
565
+ function getKeyFromEnv(platform) {
566
+ const envName = ENV_KEY_NAMES[platform];
567
+ return process.env[envName] ?? null;
568
+ }
549
569
  function getKeyFromConfig(platform) {
550
570
  const config = readConfigFile();
551
571
  const encoded = config.ai?.keys?.[platform];
@@ -565,20 +585,6 @@ function resolveAPIKey(platform) {
565
585
  if (configKey !== null) return { key: configKey, source: "config" };
566
586
  return { key: null, source: null };
567
587
  }
568
- function loadAIConfig() {
569
- const config = readConfigFile();
570
- const platform = config.ai?.platform ?? "anthropic";
571
- const model = config.ai?.model ?? getDefaultModel(platform);
572
- const { key, source } = resolveAPIKey(platform);
573
- return { platform, model, apiKey: key, keySource: source };
574
- }
575
- function saveAIConfigPreferences(platform, model) {
576
- const config = readConfigFile();
577
- if (config.ai === void 0) config.ai = {};
578
- config.ai.platform = platform;
579
- config.ai.model = model;
580
- writeConfigFile(config);
581
- }
582
588
  function saveAPIKey(platform, key, storage) {
583
589
  if (storage === "keychain") {
584
590
  saveKeyToKeychain(platform, key);
@@ -590,36 +596,17 @@ function saveAPIKey(platform, key, storage) {
590
596
  writeConfigFile(config);
591
597
  }
592
598
  }
593
- function saveKeyToKeychain(platform, key) {
594
- const os = process.platform;
595
- const account = `${platform}-api-key`;
596
- if (os === "darwin") {
597
- spawnSync("security", ["delete-generic-password", "-s", "glassbox", "-a", account], { stdio: "pipe" });
598
- spawnSync("security", ["add-generic-password", "-s", "glassbox", "-a", account, "-w", key]);
599
- return;
600
- }
601
- if (os === "linux") {
602
- spawnSync("secret-tool", ["store", "--label=Glassbox API Key", "service", "glassbox", "account", account], { input: key, encoding: "utf-8" });
603
- return;
604
- }
605
- if (os === "win32") {
606
- const target = winCredTarget(platform);
607
- const escapedKey = key.replace(/'/g, "''");
608
- const script = `cmdkey /generic:'${target}' /user:'glassbox' /pass:'${escapedKey}'`;
609
- spawnSync("powershell", ["-NoProfile", "-Command", "-"], { input: script, encoding: "utf-8" });
610
- }
611
- }
612
599
  function deleteAPIKey(platform) {
613
600
  const os = process.platform;
614
601
  const account = `${platform}-api-key`;
615
602
  try {
616
603
  if (os === "darwin") {
617
- spawnSync("security", ["delete-generic-password", "-s", "glassbox", "-a", account], { stdio: "pipe" });
604
+ spawnSync2("security", ["delete-generic-password", "-s", "glassbox", "-a", account], { stdio: "pipe" });
618
605
  } else if (os === "linux") {
619
- spawnSync("secret-tool", ["clear", "service", "glassbox", "account", account], { stdio: "pipe" });
606
+ spawnSync2("secret-tool", ["clear", "service", "glassbox", "account", account], { stdio: "pipe" });
620
607
  } else if (os === "win32") {
621
608
  const target = winCredTarget(platform);
622
- spawnSync("powershell", ["-NoProfile", "-Command", "-"], { input: `cmdkey /delete:'${target}'`, encoding: "utf-8" });
609
+ spawnSync2("powershell", ["-NoProfile", "-Command", "-"], { input: `cmdkey /delete:'${target}'`, encoding: "utf-8" });
623
610
  }
624
611
  } catch {
625
612
  }
@@ -639,20 +626,40 @@ function detectAvailablePlatforms() {
639
626
  }
640
627
  return results;
641
628
  }
642
- function isKeychainAvailable() {
643
- const os = process.platform;
644
- if (os === "darwin" || os === "win32") return true;
645
- if (os === "linux") {
646
- return spawnSync("which", ["secret-tool"], { stdio: "pipe" }).status === 0;
629
+
630
+ // src/ai/config.ts
631
+ var CONFIG_DIR = join2(homedir(), ".glassbox");
632
+ var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
633
+ function readConfigFile() {
634
+ try {
635
+ if (existsSync(CONFIG_PATH)) {
636
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
637
+ }
638
+ } catch {
647
639
  }
648
- return false;
640
+ return {};
649
641
  }
650
- function getKeychainLabel() {
651
- const os = process.platform;
652
- if (os === "darwin") return "Keychain";
653
- if (os === "linux") return "System Keyring";
654
- if (os === "win32") return "Credential Manager";
655
- return "System Keychain";
642
+ function writeConfigFile(config) {
643
+ mkdirSync2(CONFIG_DIR, { recursive: true });
644
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
645
+ try {
646
+ chmodSync(CONFIG_PATH, 384);
647
+ } catch {
648
+ }
649
+ }
650
+ function loadAIConfig() {
651
+ const config = readConfigFile();
652
+ const platform = config.ai?.platform ?? "anthropic";
653
+ const model = config.ai?.model ?? getDefaultModel(platform);
654
+ const { key, source } = resolveAPIKey(platform);
655
+ return { platform, model, apiKey: key, keySource: source };
656
+ }
657
+ function saveAIConfigPreferences(platform, model) {
658
+ const config = readConfigFile();
659
+ if (config.ai === void 0) config.ai = {};
660
+ config.ai.platform = platform;
661
+ config.ai.model = model;
662
+ writeConfigFile(config);
656
663
  }
657
664
  function loadGuidedReviewConfig() {
658
665
  const config = readConfigFile();
@@ -1266,11 +1273,14 @@ async function setupAnnotations(fileIdMap) {
1266
1273
  }
1267
1274
 
1268
1275
  // src/git/diff.ts
1269
- import { spawnSync as spawnSync2 } from "child_process";
1276
+ import { spawnSync as spawnSync4 } from "child_process";
1270
1277
  import { readFileSync as readFileSync2 } from "fs";
1271
1278
  import { resolve } from "path";
1279
+
1280
+ // src/git/repo.ts
1281
+ import { spawnSync as spawnSync3 } from "child_process";
1272
1282
  function git(args, cwd) {
1273
- const result = spawnSync2("git", args, { cwd, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
1283
+ const result = spawnSync3("git", args, { cwd, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
1274
1284
  if (result.status === 0) return result.stdout;
1275
1285
  if (result.stdout !== "") return result.stdout;
1276
1286
  const err = new Error(result.stderr);
@@ -1294,6 +1304,21 @@ function isGitRepo(cwd) {
1294
1304
  return false;
1295
1305
  }
1296
1306
  }
1307
+ function getHeadCommit(cwd) {
1308
+ return spawnSync3("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).stdout.trim();
1309
+ }
1310
+
1311
+ // src/git/diff.ts
1312
+ function git2(args, cwd) {
1313
+ const result = spawnSync4("git", args, { cwd, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
1314
+ if (result.status === 0) return result.stdout;
1315
+ if (result.stdout !== "") return result.stdout;
1316
+ const err = new Error(result.stderr);
1317
+ err.stdout = result.stdout;
1318
+ err.stderr = result.stderr;
1319
+ err.status = result.status;
1320
+ throw err;
1321
+ }
1297
1322
  function getDiffArgs(mode) {
1298
1323
  switch (mode.type) {
1299
1324
  case "uncommitted":
@@ -1322,13 +1347,13 @@ function getFileDiffs(mode, cwd) {
1322
1347
  const diffArgs = getDiffArgs(mode);
1323
1348
  let rawDiff;
1324
1349
  try {
1325
- rawDiff = git([...diffArgs, "-U3"], repoRoot);
1350
+ rawDiff = git2([...diffArgs, "-U3"], repoRoot);
1326
1351
  } catch {
1327
1352
  rawDiff = "";
1328
1353
  }
1329
1354
  const diffs = parseDiff(rawDiff);
1330
1355
  if (mode.type === "uncommitted") {
1331
- const untracked = git(["ls-files", "--others", "--exclude-standard"], repoRoot).trim();
1356
+ const untracked = git2(["ls-files", "--others", "--exclude-standard"], repoRoot).trim();
1332
1357
  if (untracked) {
1333
1358
  for (const file of untracked.split("\n").filter(Boolean)) {
1334
1359
  if (!diffs.some((d) => d.filePath === file)) {
@@ -1340,7 +1365,7 @@ function getFileDiffs(mode, cwd) {
1340
1365
  return diffs;
1341
1366
  }
1342
1367
  function getAllFiles(repoRoot) {
1343
- const files = git(["ls-files"], repoRoot).trim().split("\n").filter(Boolean);
1368
+ const files = git2(["ls-files"], repoRoot).trim().split("\n").filter(Boolean);
1344
1369
  return files.map((file) => createNewFileDiff(file, repoRoot));
1345
1370
  }
1346
1371
  function createNewFileDiff(filePath, repoRoot) {
@@ -1469,14 +1494,11 @@ function getFileContent(filePath, ref, cwd) {
1469
1494
  if (ref === "working") {
1470
1495
  return readFileSync2(resolve(repoRoot, filePath), "utf-8");
1471
1496
  }
1472
- return git(["show", `${ref}:${filePath}`], repoRoot);
1497
+ return git2(["show", `${ref}:${filePath}`], repoRoot);
1473
1498
  } catch {
1474
1499
  return "";
1475
1500
  }
1476
1501
  }
1477
- function getHeadCommit(cwd) {
1478
- return spawnSync2("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).stdout.trim();
1479
- }
1480
1502
  function parseModeString(modeStr) {
1481
1503
  if (modeStr === "uncommitted") return { type: "uncommitted" };
1482
1504
  if (modeStr === "staged") return { type: "staged" };
@@ -1501,7 +1523,7 @@ function getSingleFileDiff(mode, filePath, repoRoot, extraFlags = "") {
1501
1523
  args.push("--", filePath);
1502
1524
  let rawDiff;
1503
1525
  try {
1504
- rawDiff = git(args, repoRoot);
1526
+ rawDiff = git2(args, repoRoot);
1505
1527
  } catch {
1506
1528
  rawDiff = "";
1507
1529
  }
@@ -1573,7 +1595,9 @@ function acquireLock(dataDir) {
1573
1595
  }
1574
1596
  }
1575
1597
  writeFileSync2(lockPath, JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1576
- const cleanup = () => releaseLock();
1598
+ const cleanup = () => {
1599
+ releaseLock();
1600
+ };
1577
1601
  process.on("exit", cleanup);
1578
1602
  process.on("SIGINT", () => {
1579
1603
  cleanup();
@@ -1585,7 +1609,7 @@ function acquireLock(dataDir) {
1585
1609
  });
1586
1610
  }
1587
1611
  function releaseLock() {
1588
- if (lockPath) {
1612
+ if (lockPath !== null) {
1589
1613
  try {
1590
1614
  rmSync2(lockPath, { force: true });
1591
1615
  } catch {
@@ -1701,12 +1725,15 @@ async function updateReviewDiffs(reviewId, newDiffs, headCommit) {
1701
1725
  // src/server.ts
1702
1726
  import { serve } from "@hono/node-server";
1703
1727
  import { exec } from "child_process";
1704
- import { existsSync as existsSync6, readFileSync as readFileSync9 } from "fs";
1705
- import { Hono as Hono4 } from "hono";
1706
- import { dirname, join as join7 } from "path";
1728
+ import { existsSync as existsSync7, readFileSync as readFileSync10 } from "fs";
1729
+ import { Hono as Hono7 } from "hono";
1730
+ import { dirname, join as join8 } from "path";
1707
1731
  import { fileURLToPath } from "url";
1708
1732
 
1709
1733
  // src/routes/ai-api.ts
1734
+ import { Hono as Hono3 } from "hono";
1735
+
1736
+ // src/routes/ai-analysis.ts
1710
1737
  import { Hono } from "hono";
1711
1738
 
1712
1739
  // src/ai/client.ts
@@ -2536,71 +2563,24 @@ async function mockGuidedAnalysisBatch(files) {
2536
2563
  }));
2537
2564
  }
2538
2565
 
2539
- // src/routes/ai-api.ts
2566
+ // src/routes/ai-analysis.ts
2540
2567
  init_queries();
2541
- var aiApiRoutes = new Hono();
2568
+ var aiAnalysisRoutes = new Hono();
2569
+ var VALID_SORT_MODES = ["folder", "risk", "narrative", "guided"];
2570
+ var VALID_RISK_DIMENSIONS = ["aggregate", "security", "correctness", "error-handling", "maintainability", "architecture", "performance"];
2571
+ var VALID_SVG_VIEW_MODES = ["code", "rendered"];
2572
+ var VALID_IMAGE_MODES = ["metadata", "side-by-side", "difference", "slice"];
2573
+ var VALID_ANALYSIS_TYPES = ["risk", "narrative", "guided"];
2542
2574
  var cancelledAnalyses = /* @__PURE__ */ new Set();
2543
- aiApiRoutes.get("/config", (c) => {
2544
- const config = loadAIConfig();
2545
- return c.json({
2546
- platform: config.platform,
2547
- model: config.model,
2548
- keyConfigured: config.apiKey !== null || isAIServiceTest() || getDemoMode() !== null,
2549
- keySource: config.keySource,
2550
- guidedReview: loadGuidedReviewConfig()
2551
- });
2552
- });
2553
- aiApiRoutes.post("/config", async (c) => {
2554
- const body = await c.req.json();
2555
- saveAIConfigPreferences(body.platform, body.model);
2556
- if (body.guidedReview !== void 0) {
2557
- saveGuidedReviewConfig(body.guidedReview);
2558
- }
2559
- return c.json({ ok: true });
2560
- });
2561
- aiApiRoutes.get("/models", (c) => {
2562
- return c.json({
2563
- platforms: PLATFORMS,
2564
- models: MODELS
2565
- });
2566
- });
2567
- aiApiRoutes.get("/key-status", (c) => {
2568
- const platforms = ["anthropic", "openai", "google"];
2569
- const status = {};
2570
- for (const platform of platforms) {
2571
- const { source } = resolveAPIKey(platform);
2572
- status[platform] = { configured: source !== null, source };
2573
- }
2574
- return c.json({
2575
- status,
2576
- keychainAvailable: isKeychainAvailable(),
2577
- keychainLabel: getKeychainLabel(),
2578
- availablePlatforms: detectAvailablePlatforms()
2579
- });
2580
- });
2581
- aiApiRoutes.post("/key", async (c) => {
2582
- const body = await c.req.json();
2583
- saveAPIKey(
2584
- body.platform,
2585
- body.key,
2586
- body.storage
2587
- );
2588
- return c.json({ ok: true });
2589
- });
2590
- aiApiRoutes.delete("/key", (c) => {
2591
- const platform = c.req.query("platform") ?? "anthropic";
2592
- deleteAPIKey(platform);
2593
- return c.json({ ok: true });
2594
- });
2595
- aiApiRoutes.post("/analyze", async (c) => {
2575
+ aiAnalysisRoutes.post("/analyze", async (c) => {
2596
2576
  const reviewId = c.req.query("reviewId") ?? "";
2597
2577
  const repoRoot = c.get("repoRoot");
2598
2578
  const body = await c.req.json();
2599
2579
  const analysisType = body.type;
2600
2580
  const invalidateCache = body.invalidateCache === true;
2601
2581
  debugLog(`POST /analyze: type=${analysisType}, reviewId=${reviewId}`);
2602
- if (analysisType !== "risk" && analysisType !== "narrative" && analysisType !== "guided") {
2603
- return c.json({ error: "Invalid analysis type" }, 400);
2582
+ if (!VALID_ANALYSIS_TYPES.includes(analysisType)) {
2583
+ return c.json({ error: `type must be one of: ${VALID_ANALYSIS_TYPES.join(", ")}` }, 400);
2604
2584
  }
2605
2585
  const testMode = isAIServiceTest();
2606
2586
  const config = loadAIConfig();
@@ -2844,9 +2824,12 @@ async function runBatchedGuidedAnalysis(analysisId, batches, allFiles, config, r
2844
2824
  "guided"
2845
2825
  );
2846
2826
  }
2847
- aiApiRoutes.get("/analysis/:type", async (c) => {
2827
+ aiAnalysisRoutes.get("/analysis/:type", async (c) => {
2848
2828
  const reviewId = c.req.query("reviewId") ?? "";
2849
2829
  const analysisType = c.req.param("type");
2830
+ if (!VALID_ANALYSIS_TYPES.includes(analysisType)) {
2831
+ return c.json({ error: `type must be one of: ${VALID_ANALYSIS_TYPES.join(", ")}` }, 400);
2832
+ }
2850
2833
  const analysis = await getLatestAnalysis(reviewId, analysisType);
2851
2834
  if (analysis === void 0) {
2852
2835
  debugLog(`GET /analysis/${analysisType}: no analysis found`);
@@ -2876,9 +2859,12 @@ aiApiRoutes.get("/analysis/:type", async (c) => {
2876
2859
  }))
2877
2860
  });
2878
2861
  });
2879
- aiApiRoutes.get("/analysis/:type/status", async (c) => {
2862
+ aiAnalysisRoutes.get("/analysis/:type/status", async (c) => {
2880
2863
  const reviewId = c.req.query("reviewId") ?? "";
2881
2864
  const analysisType = c.req.param("type");
2865
+ if (!VALID_ANALYSIS_TYPES.includes(analysisType)) {
2866
+ return c.json({ error: `type must be one of: ${VALID_ANALYSIS_TYPES.join(", ")}` }, 400);
2867
+ }
2882
2868
  const analysis = await getLatestAnalysis(reviewId, analysisType);
2883
2869
  if (analysis === void 0) {
2884
2870
  debugLog(`GET /analysis/${analysisType}/status: no analysis found`);
@@ -2900,35 +2886,150 @@ aiApiRoutes.get("/analysis/:type/status", async (c) => {
2900
2886
  progressTotal: analysis.progress_total
2901
2887
  });
2902
2888
  });
2903
- aiApiRoutes.get("/debug-status", (c) => {
2889
+ aiAnalysisRoutes.get("/debug-status", (c) => {
2904
2890
  return c.json({ enabled: isDebug() });
2905
2891
  });
2906
- aiApiRoutes.post("/debug-log", async (c) => {
2892
+ aiAnalysisRoutes.post("/debug-log", async (c) => {
2907
2893
  if (!isDebug()) return c.json({ ok: true });
2908
2894
  const body = await c.req.json();
2895
+ if (typeof body.message !== "string") {
2896
+ return c.json({ error: "message must be a string" }, 400);
2897
+ }
2909
2898
  debugLog(`[client] ${body.message}`);
2910
2899
  return c.json({ ok: true });
2911
2900
  });
2912
- aiApiRoutes.get("/preferences", async (c) => {
2901
+ aiAnalysisRoutes.get("/preferences", async (c) => {
2913
2902
  const prefs = await getUserPreferences();
2914
2903
  return c.json(prefs);
2915
2904
  });
2916
- aiApiRoutes.post("/preferences", async (c) => {
2905
+ aiAnalysisRoutes.post("/preferences", async (c) => {
2917
2906
  const body = await c.req.json();
2907
+ if (body.sort_mode !== void 0 && !VALID_SORT_MODES.includes(body.sort_mode)) {
2908
+ return c.json({ error: `sort_mode must be one of: ${VALID_SORT_MODES.join(", ")}` }, 400);
2909
+ }
2910
+ if (body.risk_sort_dimension !== void 0 && !VALID_RISK_DIMENSIONS.includes(body.risk_sort_dimension)) {
2911
+ return c.json({ error: `risk_sort_dimension must be one of: ${VALID_RISK_DIMENSIONS.join(", ")}` }, 400);
2912
+ }
2913
+ if (body.show_risk_scores !== void 0 && typeof body.show_risk_scores !== "boolean") {
2914
+ return c.json({ error: "show_risk_scores must be a boolean" }, 400);
2915
+ }
2916
+ if (body.ignore_whitespace !== void 0 && typeof body.ignore_whitespace !== "boolean") {
2917
+ return c.json({ error: "ignore_whitespace must be a boolean" }, 400);
2918
+ }
2919
+ if (body.svg_view_mode !== void 0 && !VALID_SVG_VIEW_MODES.includes(body.svg_view_mode)) {
2920
+ return c.json({ error: `svg_view_mode must be one of: ${VALID_SVG_VIEW_MODES.join(", ")}` }, 400);
2921
+ }
2922
+ if (body.last_image_mode !== void 0 && !VALID_IMAGE_MODES.includes(body.last_image_mode)) {
2923
+ return c.json({ error: `last_image_mode must be one of: ${VALID_IMAGE_MODES.join(", ")}` }, 400);
2924
+ }
2918
2925
  await saveUserPreferences(body);
2919
2926
  return c.json({ ok: true });
2920
2927
  });
2921
2928
 
2929
+ // src/routes/ai-config.ts
2930
+ import { Hono as Hono2 } from "hono";
2931
+ var aiConfigRoutes = new Hono2();
2932
+ var VALID_PLATFORMS = ["anthropic", "openai", "google"];
2933
+ var VALID_KEY_STORAGES = ["keychain", "config"];
2934
+ aiConfigRoutes.get("/config", (c) => {
2935
+ const config = loadAIConfig();
2936
+ return c.json({
2937
+ platform: config.platform,
2938
+ model: config.model,
2939
+ keyConfigured: config.apiKey !== null || isAIServiceTest() || getDemoMode() !== null,
2940
+ keySource: config.keySource,
2941
+ guidedReview: loadGuidedReviewConfig()
2942
+ });
2943
+ });
2944
+ aiConfigRoutes.post("/config", async (c) => {
2945
+ const body = await c.req.json();
2946
+ if (!VALID_PLATFORMS.includes(body.platform)) {
2947
+ return c.json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` }, 400);
2948
+ }
2949
+ if (typeof body.model !== "string" || body.model.trim() === "") {
2950
+ return c.json({ error: "model must be a non-empty string" }, 400);
2951
+ }
2952
+ if (body.guidedReview !== void 0) {
2953
+ const gr = body.guidedReview;
2954
+ if (typeof gr !== "object" || gr === null || Array.isArray(gr)) {
2955
+ return c.json({ error: "guidedReview must be an object" }, 400);
2956
+ }
2957
+ const grObj = gr;
2958
+ if (typeof grObj.enabled !== "boolean") {
2959
+ return c.json({ error: "guidedReview.enabled must be a boolean" }, 400);
2960
+ }
2961
+ if (!Array.isArray(grObj.topics) || !grObj.topics.every((t) => typeof t === "string")) {
2962
+ return c.json({ error: "guidedReview.topics must be an array of strings" }, 400);
2963
+ }
2964
+ }
2965
+ saveAIConfigPreferences(body.platform, body.model);
2966
+ if (body.guidedReview !== void 0) {
2967
+ saveGuidedReviewConfig(body.guidedReview);
2968
+ }
2969
+ return c.json({ ok: true });
2970
+ });
2971
+ aiConfigRoutes.get("/models", (c) => {
2972
+ return c.json({
2973
+ platforms: PLATFORMS,
2974
+ models: MODELS
2975
+ });
2976
+ });
2977
+ aiConfigRoutes.get("/key-status", (c) => {
2978
+ const platforms = ["anthropic", "openai", "google"];
2979
+ const status = {};
2980
+ for (const platform of platforms) {
2981
+ const { source } = resolveAPIKey(platform);
2982
+ status[platform] = { configured: source !== null, source };
2983
+ }
2984
+ return c.json({
2985
+ status,
2986
+ keychainAvailable: isKeychainAvailable(),
2987
+ keychainLabel: getKeychainLabel(),
2988
+ availablePlatforms: detectAvailablePlatforms()
2989
+ });
2990
+ });
2991
+ aiConfigRoutes.post("/key", async (c) => {
2992
+ const body = await c.req.json();
2993
+ if (!VALID_PLATFORMS.includes(body.platform)) {
2994
+ return c.json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` }, 400);
2995
+ }
2996
+ if (typeof body.key !== "string" || body.key.trim() === "") {
2997
+ return c.json({ error: "key must be a non-empty string" }, 400);
2998
+ }
2999
+ if (!VALID_KEY_STORAGES.includes(body.storage)) {
3000
+ return c.json({ error: `storage must be one of: ${VALID_KEY_STORAGES.join(", ")}` }, 400);
3001
+ }
3002
+ saveAPIKey(
3003
+ body.platform,
3004
+ body.key,
3005
+ body.storage
3006
+ );
3007
+ return c.json({ ok: true });
3008
+ });
3009
+ aiConfigRoutes.delete("/key", (c) => {
3010
+ const platform = c.req.query("platform") ?? "anthropic";
3011
+ if (!VALID_PLATFORMS.includes(platform)) {
3012
+ return c.json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` }, 400);
3013
+ }
3014
+ deleteAPIKey(platform);
3015
+ return c.json({ ok: true });
3016
+ });
3017
+
3018
+ // src/routes/ai-api.ts
3019
+ var aiApiRoutes = new Hono3();
3020
+ aiApiRoutes.route("/", aiConfigRoutes);
3021
+ aiApiRoutes.route("/", aiAnalysisRoutes);
3022
+
2922
3023
  // src/routes/api.ts
2923
3024
  init_queries();
2924
- import { execFileSync, spawnSync as spawnSync5 } from "child_process";
3025
+ import { execFileSync, spawnSync as spawnSync7 } from "child_process";
2925
3026
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
2926
- import { Hono as Hono2 } from "hono";
3027
+ import { Hono as Hono4 } from "hono";
2927
3028
  import { join as join6, resolve as resolve3 } from "path";
2928
3029
 
2929
3030
  // src/export/generate.ts
2930
3031
  init_queries();
2931
- import { spawnSync as spawnSync3 } from "child_process";
3032
+ import { spawnSync as spawnSync5 } from "child_process";
2932
3033
  import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
2933
3034
  import { homedir as homedir2 } from "os";
2934
3035
  import { join as join4 } from "path";
@@ -2947,7 +3048,7 @@ function saveDismissals(data) {
2947
3048
  writeFileSync3(DISMISS_FILE, JSON.stringify(data), "utf-8");
2948
3049
  }
2949
3050
  function isGlassboxGitignored(repoRoot) {
2950
- const result = spawnSync3("git", ["check-ignore", "-q", ".glassbox"], { cwd: repoRoot, stdio: "pipe" });
3051
+ const result = spawnSync5("git", ["check-ignore", "-q", ".glassbox"], { cwd: repoRoot, stdio: "pipe" });
2951
3052
  return result.status === 0;
2952
3053
  }
2953
3054
  function shouldPromptGitignore(repoRoot) {
@@ -3071,127 +3172,43 @@ function scheduleAutoExport(reviewId, repoRoot) {
3071
3172
  if (debounceTimer !== null) clearTimeout(debounceTimer);
3072
3173
  debounceTimer = setTimeout(() => {
3073
3174
  debounceTimer = null;
3074
- void generateReviewExport(reviewId, repoRoot, true);
3075
- }, DEBOUNCE_MS);
3076
- }
3077
-
3078
- // src/git/image.ts
3079
- import { spawnSync as spawnSync4 } from "child_process";
3080
- import { readFileSync as readFileSync5 } from "fs";
3081
- import { resolve as resolve2 } from "path";
3082
- var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
3083
- function isImageFile(filePath) {
3084
- const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3085
- return IMAGE_EXTENSIONS.has(ext);
3086
- }
3087
- function isSvgFile(filePath) {
3088
- return filePath.slice(filePath.lastIndexOf(".")).toLowerCase() === ".svg";
3089
- }
3090
- function getContentType(filePath) {
3091
- const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3092
- switch (ext) {
3093
- case ".png":
3094
- return "image/png";
3095
- case ".jpg":
3096
- case ".jpeg":
3097
- return "image/jpeg";
3098
- case ".gif":
3099
- return "image/gif";
3100
- case ".webp":
3101
- return "image/webp";
3102
- case ".svg":
3103
- return "image/svg+xml";
3104
- default:
3105
- return "application/octet-stream";
3106
- }
3107
- }
3108
- function getOldRef(mode) {
3109
- switch (mode.type) {
3110
- case "uncommitted":
3111
- return "HEAD";
3112
- case "staged":
3113
- return "HEAD";
3114
- case "unstaged":
3115
- return null;
3116
- // old = index, use ':'
3117
- case "commit":
3118
- return `${mode.sha}~1`;
3119
- case "range":
3120
- return mode.from;
3121
- case "branch":
3122
- return mode.name;
3123
- case "files":
3124
- return "HEAD";
3125
- case "all":
3126
- return null;
3127
- }
3128
- }
3129
- function getNewRef(mode) {
3130
- switch (mode.type) {
3131
- case "uncommitted":
3132
- return null;
3133
- // working tree
3134
- case "staged":
3135
- return null;
3136
- // index, but git show : works
3137
- case "unstaged":
3138
- return null;
3139
- // working tree
3140
- case "commit":
3141
- return mode.sha;
3142
- case "range":
3143
- return mode.to;
3144
- case "branch":
3145
- return "HEAD";
3146
- case "files":
3147
- return null;
3148
- case "all":
3149
- return null;
3150
- }
3151
- }
3152
- function gitShowFile(ref, filePath, repoRoot) {
3153
- const spec = ref === ":" ? `:${filePath}` : `${ref}:${filePath}`;
3154
- const result = spawnSync4("git", ["show", spec], { cwd: repoRoot, maxBuffer: 50 * 1024 * 1024 });
3155
- if (result.status !== 0 || result.stdout.length === 0) return null;
3156
- return result.stdout;
3157
- }
3158
- function readWorkingFile(filePath, repoRoot) {
3159
- try {
3160
- return readFileSync5(resolve2(repoRoot, filePath));
3161
- } catch {
3162
- return null;
3163
- }
3175
+ void generateReviewExport(reviewId, repoRoot, true);
3176
+ }, DEBOUNCE_MS);
3164
3177
  }
3165
- function getOldImage(mode, filePath, oldPath, repoRoot) {
3166
- const ref = getOldRef(mode);
3167
- const path = oldPath ?? filePath;
3168
- if (ref === null) {
3169
- const data2 = readWorkingFile(path, repoRoot);
3170
- if (!data2) return null;
3171
- return { data: data2, size: data2.length };
3172
- }
3173
- const actualRef = mode.type === "unstaged" ? ":" : ref;
3174
- const data = gitShowFile(actualRef, path, repoRoot);
3175
- if (!data) return null;
3176
- return { data, size: data.length };
3178
+
3179
+ // src/git/image.ts
3180
+ import { spawnSync as spawnSync6 } from "child_process";
3181
+ import { readFileSync as readFileSync5 } from "fs";
3182
+ import { resolve as resolve2 } from "path";
3183
+
3184
+ // src/git/image-metadata.ts
3185
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
3186
+ function isImageFile(filePath) {
3187
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3188
+ return IMAGE_EXTENSIONS.has(ext);
3177
3189
  }
3178
- function getNewImage(mode, filePath, repoRoot) {
3179
- const ref = getNewRef(mode);
3180
- if (ref === null) {
3181
- if (mode.type === "staged") {
3182
- const data3 = gitShowFile(":", filePath, repoRoot);
3183
- if (!data3) return null;
3184
- return { data: data3, size: data3.length };
3185
- }
3186
- const data2 = readWorkingFile(filePath, repoRoot);
3187
- if (!data2) return null;
3188
- return { data: data2, size: data2.length };
3190
+ function isSvgFile(filePath) {
3191
+ return filePath.slice(filePath.lastIndexOf(".")).toLowerCase() === ".svg";
3192
+ }
3193
+ function getContentType(filePath) {
3194
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3195
+ switch (ext) {
3196
+ case ".png":
3197
+ return "image/png";
3198
+ case ".jpg":
3199
+ case ".jpeg":
3200
+ return "image/jpeg";
3201
+ case ".gif":
3202
+ return "image/gif";
3203
+ case ".webp":
3204
+ return "image/webp";
3205
+ case ".svg":
3206
+ return "image/svg+xml";
3207
+ default:
3208
+ return "application/octet-stream";
3189
3209
  }
3190
- const data = gitShowFile(ref, filePath, repoRoot);
3191
- if (!data) return null;
3192
- return { data, size: data.length };
3193
3210
  }
3194
- async function extractMetadata(data, filePath) {
3211
+ function extractMetadata(data, filePath) {
3195
3212
  const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
3196
3213
  if (ext === ".svg") {
3197
3214
  const text = data.toString("utf-8");
@@ -3233,9 +3250,9 @@ function formatMetadataLines(meta) {
3233
3250
  lines.push(`Dimensions: ${meta.width} \xD7 ${meta.height}`);
3234
3251
  }
3235
3252
  lines.push(`File size: ${formatBytes(meta.fileSize)}`);
3236
- if (meta.colorSpace) lines.push(`Color space: ${meta.colorSpace}`);
3253
+ if (meta.colorSpace !== null) lines.push(`Color space: ${meta.colorSpace}`);
3237
3254
  if (meta.channels !== null) lines.push(`Channels: ${meta.channels}`);
3238
- if (meta.depth) lines.push(`Bit depth: ${meta.depth}`);
3255
+ if (meta.depth !== null) lines.push(`Bit depth: ${meta.depth}`);
3239
3256
  if (meta.hasAlpha !== null) lines.push(`Alpha: ${meta.hasAlpha ? "yes" : "no"}`);
3240
3257
  if (meta.density !== null) lines.push(`Density: ${meta.density} DPI`);
3241
3258
  if (meta.exif) {
@@ -3376,7 +3393,95 @@ function parseWebp(data) {
3376
3393
  width = (data[24] | data[25] << 8 | data[26] << 16) + 1;
3377
3394
  height = (data[27] | data[28] << 8 | data[29] << 16) + 1;
3378
3395
  }
3379
- return { format: "webp", width, height, colorSpace: "srgb", channels: hasAlpha ? 4 : 3, depth: null, hasAlpha, density: null, exif: null };
3396
+ return { format: "webp", width, height, colorSpace: "srgb", channels: hasAlpha === true ? 4 : 3, depth: null, hasAlpha, density: null, exif: null };
3397
+ }
3398
+
3399
+ // src/git/image.ts
3400
+ function getOldRef(mode) {
3401
+ switch (mode.type) {
3402
+ case "uncommitted":
3403
+ return "HEAD";
3404
+ case "staged":
3405
+ return "HEAD";
3406
+ case "unstaged":
3407
+ return null;
3408
+ // old = index, use ':'
3409
+ case "commit":
3410
+ return `${mode.sha}~1`;
3411
+ case "range":
3412
+ return mode.from;
3413
+ case "branch":
3414
+ return mode.name;
3415
+ case "files":
3416
+ return "HEAD";
3417
+ case "all":
3418
+ return null;
3419
+ }
3420
+ }
3421
+ function getNewRef(mode) {
3422
+ switch (mode.type) {
3423
+ case "uncommitted":
3424
+ return null;
3425
+ // working tree
3426
+ case "staged":
3427
+ return null;
3428
+ // index, but git show : works
3429
+ case "unstaged":
3430
+ return null;
3431
+ // working tree
3432
+ case "commit":
3433
+ return mode.sha;
3434
+ case "range":
3435
+ return mode.to;
3436
+ case "branch":
3437
+ return "HEAD";
3438
+ case "files":
3439
+ return null;
3440
+ case "all":
3441
+ return null;
3442
+ }
3443
+ }
3444
+ function gitShowFile(ref, filePath, repoRoot) {
3445
+ const spec = ref === ":" ? `:${filePath}` : `${ref}:${filePath}`;
3446
+ const result = spawnSync6("git", ["show", spec], { cwd: repoRoot, maxBuffer: 50 * 1024 * 1024 });
3447
+ if (result.status !== 0 || result.stdout.length === 0) return null;
3448
+ return result.stdout;
3449
+ }
3450
+ function readWorkingFile(filePath, repoRoot) {
3451
+ try {
3452
+ return readFileSync5(resolve2(repoRoot, filePath));
3453
+ } catch {
3454
+ return null;
3455
+ }
3456
+ }
3457
+ function getOldImage(mode, filePath, oldPath, repoRoot) {
3458
+ const ref = getOldRef(mode);
3459
+ const path = oldPath ?? filePath;
3460
+ if (ref === null) {
3461
+ const data2 = readWorkingFile(path, repoRoot);
3462
+ if (!data2) return null;
3463
+ return { data: data2, size: data2.length };
3464
+ }
3465
+ const actualRef = mode.type === "unstaged" ? ":" : ref;
3466
+ const data = gitShowFile(actualRef, path, repoRoot);
3467
+ if (!data) return null;
3468
+ return { data, size: data.length };
3469
+ }
3470
+ function getNewImage(mode, filePath, repoRoot) {
3471
+ const ref = getNewRef(mode);
3472
+ if (ref === null) {
3473
+ if (mode.type === "staged") {
3474
+ const data3 = gitShowFile(":", filePath, repoRoot);
3475
+ if (!data3) return null;
3476
+ return { data: data3, size: data3.length };
3477
+ }
3478
+ const data2 = readWorkingFile(filePath, repoRoot);
3479
+ if (!data2) return null;
3480
+ return { data: data2, size: data2.length };
3481
+ }
3482
+ const data = gitShowFile(ref, filePath, repoRoot);
3483
+ if (!data) return null;
3484
+ return { data, size: data.length };
3380
3485
  }
3381
3486
 
3382
3487
  // src/git/svg-rasterize.ts
@@ -3411,63 +3516,62 @@ function loadSystemFonts() {
3411
3516
  return buffers;
3412
3517
  }
3413
3518
  function getFontCandidates() {
3414
- switch (process.platform) {
3415
- case "darwin": {
3416
- const sys = "/System/Library/Fonts";
3417
- const sup = "/System/Library/Fonts/Supplemental";
3418
- return [
3419
- // Core system fonts (serif, sans-serif, monospace)
3420
- join5(sys, "Helvetica.ttc"),
3421
- join5(sys, "Times.ttc"),
3422
- join5(sys, "Courier.ttc"),
3423
- join5(sys, "Menlo.ttc"),
3424
- join5(sys, "SFPro.ttf"),
3425
- join5(sys, "SFNS.ttf"),
3426
- join5(sys, "SFNSMono.ttf"),
3427
- // Supplemental (common named fonts in SVGs)
3428
- join5(sup, "Arial.ttf"),
3429
- join5(sup, "Arial Bold.ttf"),
3430
- join5(sup, "Georgia.ttf"),
3431
- join5(sup, "Verdana.ttf"),
3432
- join5(sup, "Tahoma.ttf"),
3433
- join5(sup, "Trebuchet MS.ttf"),
3434
- join5(sup, "Impact.ttf"),
3435
- join5(sup, "Comic Sans MS.ttf"),
3436
- join5(sup, "Courier New.ttf"),
3437
- join5(sup, "Times New Roman.ttf")
3438
- ];
3439
- }
3440
- case "linux":
3441
- return [
3442
- // DejaVu (most common Linux fallback)
3443
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
3444
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
3445
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
3446
- "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
3447
- // Liberation (metric-compatible with Arial/Times/Courier)
3448
- "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
3449
- "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
3450
- "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
3451
- // Noto (common on modern distros)
3452
- "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
3453
- ];
3454
- case "win32": {
3455
- const winFonts = join5(process.env.WINDIR ?? "C:\\Windows", "Fonts");
3456
- return [
3457
- join5(winFonts, "arial.ttf"),
3458
- join5(winFonts, "arialbd.ttf"),
3459
- join5(winFonts, "times.ttf"),
3460
- join5(winFonts, "cour.ttf"),
3461
- join5(winFonts, "verdana.ttf"),
3462
- join5(winFonts, "tahoma.ttf"),
3463
- join5(winFonts, "georgia.ttf"),
3464
- join5(winFonts, "consola.ttf"),
3465
- join5(winFonts, "segoeui.ttf")
3466
- ];
3467
- }
3468
- default:
3469
- return [];
3519
+ const os = process.platform;
3520
+ if (os === "darwin") {
3521
+ const sys = "/System/Library/Fonts";
3522
+ const sup = "/System/Library/Fonts/Supplemental";
3523
+ return [
3524
+ // Core system fonts (serif, sans-serif, monospace)
3525
+ join5(sys, "Helvetica.ttc"),
3526
+ join5(sys, "Times.ttc"),
3527
+ join5(sys, "Courier.ttc"),
3528
+ join5(sys, "Menlo.ttc"),
3529
+ join5(sys, "SFPro.ttf"),
3530
+ join5(sys, "SFNS.ttf"),
3531
+ join5(sys, "SFNSMono.ttf"),
3532
+ // Supplemental (common named fonts in SVGs)
3533
+ join5(sup, "Arial.ttf"),
3534
+ join5(sup, "Arial Bold.ttf"),
3535
+ join5(sup, "Georgia.ttf"),
3536
+ join5(sup, "Verdana.ttf"),
3537
+ join5(sup, "Tahoma.ttf"),
3538
+ join5(sup, "Trebuchet MS.ttf"),
3539
+ join5(sup, "Impact.ttf"),
3540
+ join5(sup, "Comic Sans MS.ttf"),
3541
+ join5(sup, "Courier New.ttf"),
3542
+ join5(sup, "Times New Roman.ttf")
3543
+ ];
3544
+ }
3545
+ if (os === "linux") {
3546
+ return [
3547
+ // DejaVu (most common Linux fallback)
3548
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
3549
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
3550
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
3551
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
3552
+ // Liberation (metric-compatible with Arial/Times/Courier)
3553
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
3554
+ "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
3555
+ "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
3556
+ // Noto (common on modern distros)
3557
+ "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
3558
+ ];
3470
3559
  }
3560
+ if (os === "win32") {
3561
+ const winFonts = join5(process.env.WINDIR ?? "C:\\Windows", "Fonts");
3562
+ return [
3563
+ join5(winFonts, "arial.ttf"),
3564
+ join5(winFonts, "arialbd.ttf"),
3565
+ join5(winFonts, "times.ttf"),
3566
+ join5(winFonts, "cour.ttf"),
3567
+ join5(winFonts, "verdana.ttf"),
3568
+ join5(winFonts, "tahoma.ttf"),
3569
+ join5(winFonts, "georgia.ttf"),
3570
+ join5(winFonts, "consola.ttf"),
3571
+ join5(winFonts, "segoeui.ttf")
3572
+ ];
3573
+ }
3574
+ return [];
3471
3575
  }
3472
3576
  function parseSvgDimensions(svg) {
3473
3577
  const widthMatch = svg.match(/\bwidth\s*=\s*["']([^"']+)["']/);
@@ -3831,7 +3935,10 @@ function pushIndentSymbol(root, stack, sym, indent, lines, lineIdx) {
3831
3935
  }
3832
3936
 
3833
3937
  // src/routes/api.ts
3834
- var apiRoutes = new Hono2();
3938
+ var apiRoutes = new Hono4();
3939
+ var VALID_CATEGORIES = ["bug", "fix", "style", "pattern-follow", "pattern-avoid", "note", "remember"];
3940
+ var VALID_SIDES = ["old", "new"];
3941
+ var VALID_FILE_STATUSES = ["pending", "reviewed"];
3835
3942
  function resolveReviewId(c) {
3836
3943
  return c.req.query("reviewId") ?? c.get("reviewId");
3837
3944
  }
@@ -3938,6 +4045,9 @@ apiRoutes.get("/files/:fileId", async (c) => {
3938
4045
  });
3939
4046
  apiRoutes.patch("/files/:fileId/status", async (c) => {
3940
4047
  const { status } = await c.req.json();
4048
+ if (!VALID_FILE_STATUSES.includes(status)) {
4049
+ return c.json({ error: `status must be one of: ${VALID_FILE_STATUSES.join(", ")}` }, 400);
4050
+ }
3941
4051
  await updateFileStatus(c.req.param("fileId"), status);
3942
4052
  return c.json({ ok: true });
3943
4053
  });
@@ -3963,6 +4073,21 @@ function autoExport(c) {
3963
4073
  }
3964
4074
  apiRoutes.post("/annotations", async (c) => {
3965
4075
  const body = await c.req.json();
4076
+ if (typeof body.reviewFileId !== "string" || body.reviewFileId === "") {
4077
+ return c.json({ error: "reviewFileId must be a non-empty string" }, 400);
4078
+ }
4079
+ if (typeof body.lineNumber !== "number" || !Number.isInteger(body.lineNumber) || body.lineNumber < 1) {
4080
+ return c.json({ error: "lineNumber must be a positive integer" }, 400);
4081
+ }
4082
+ if (!VALID_SIDES.includes(body.side)) {
4083
+ return c.json({ error: `side must be one of: ${VALID_SIDES.join(", ")}` }, 400);
4084
+ }
4085
+ if (!VALID_CATEGORIES.includes(body.category)) {
4086
+ return c.json({ error: `category must be one of: ${VALID_CATEGORIES.join(", ")}` }, 400);
4087
+ }
4088
+ if (typeof body.content !== "string" || body.content.trim() === "") {
4089
+ return c.json({ error: "content must be a non-empty string" }, 400);
4090
+ }
3966
4091
  const annotation = await addAnnotation(
3967
4092
  body.reviewFileId,
3968
4093
  body.lineNumber,
@@ -3975,6 +4100,12 @@ apiRoutes.post("/annotations", async (c) => {
3975
4100
  });
3976
4101
  apiRoutes.patch("/annotations/:id", async (c) => {
3977
4102
  const { content, category } = await c.req.json();
4103
+ if (typeof content !== "string" || content.trim() === "") {
4104
+ return c.json({ error: "content must be a non-empty string" }, 400);
4105
+ }
4106
+ if (!VALID_CATEGORIES.includes(category)) {
4107
+ return c.json({ error: `category must be one of: ${VALID_CATEGORIES.join(", ")}` }, 400);
4108
+ }
3978
4109
  await updateAnnotation(c.req.param("id"), content, category);
3979
4110
  autoExport(c);
3980
4111
  return c.json({ ok: true });
@@ -3986,6 +4117,12 @@ apiRoutes.delete("/annotations/:id", async (c) => {
3986
4117
  });
3987
4118
  apiRoutes.patch("/annotations/:id/move", async (c) => {
3988
4119
  const { lineNumber, side } = await c.req.json();
4120
+ if (typeof lineNumber !== "number" || !Number.isInteger(lineNumber) || lineNumber < 1) {
4121
+ return c.json({ error: "lineNumber must be a positive integer" }, 400);
4122
+ }
4123
+ if (!VALID_SIDES.includes(side)) {
4124
+ return c.json({ error: `side must be one of: ${VALID_SIDES.join(", ")}` }, 400);
4125
+ }
3989
4126
  await moveAnnotation(c.req.param("id"), lineNumber, side);
3990
4127
  autoExport(c);
3991
4128
  return c.json({ ok: true });
@@ -4034,7 +4171,7 @@ apiRoutes.get("/outline/:fileId", async (c) => {
4034
4171
  apiRoutes.get("/symbol-definition", async (c) => {
4035
4172
  const name = c.req.query("name");
4036
4173
  const currentFileId = c.req.query("currentFileId");
4037
- if (!name) return c.json({ definitions: [] });
4174
+ if (name === void 0 || name === "") return c.json({ definitions: [] });
4038
4175
  const reviewId = resolveReviewId(c);
4039
4176
  const repoRoot = c.get("repoRoot");
4040
4177
  const definitions = [];
@@ -4056,7 +4193,7 @@ apiRoutes.get("/symbol-definition", async (c) => {
4056
4193
  }
4057
4194
  if (definitions.length === 0) {
4058
4195
  try {
4059
- const allFiles = spawnSync5("git", ["ls-files"], { cwd: repoRoot, encoding: "utf-8" }).stdout.trim().split("\n").filter(Boolean);
4196
+ const allFiles = spawnSync7("git", ["ls-files"], { cwd: repoRoot, encoding: "utf-8" }).stdout.trim().split("\n").filter(Boolean);
4060
4197
  for (const filePath of allFiles) {
4061
4198
  if (searchedPaths.has(filePath)) continue;
4062
4199
  const ext = filePath.slice(filePath.lastIndexOf("."));
@@ -4091,7 +4228,7 @@ function collectDefinitions(symbols, targetName, fileId, filePath, out) {
4091
4228
  if (sym.name === targetName) {
4092
4229
  out.push({ fileId, filePath, name: sym.name, kind: sym.kind, line: sym.line });
4093
4230
  }
4094
- if (sym.children?.length > 0) {
4231
+ if (sym.children.length > 0) {
4095
4232
  collectDefinitions(sym.children, targetName, fileId, filePath, out);
4096
4233
  }
4097
4234
  }
@@ -4134,6 +4271,9 @@ apiRoutes.get("/project-settings", (c) => {
4134
4271
  apiRoutes.patch("/project-settings", async (c) => {
4135
4272
  const repoRoot = c.get("repoRoot");
4136
4273
  const body = await c.req.json();
4274
+ if (body.appName !== void 0 && typeof body.appName !== "string") {
4275
+ return c.json({ error: "appName must be a string" }, 400);
4276
+ }
4137
4277
  const current = readProjectSettings(repoRoot);
4138
4278
  if (body.appName !== void 0) current.appName = body.appName || void 0;
4139
4279
  writeProjectSettings(repoRoot, current);
@@ -4152,10 +4292,8 @@ apiRoutes.get("/image/:fileId/metadata", async (c) => {
4152
4292
  const status = diff.status ?? "modified";
4153
4293
  const oldImage = status !== "added" ? getOldImage(mode, file.file_path, oldPath, repoRoot) : null;
4154
4294
  const newImage = status !== "deleted" ? getNewImage(mode, file.file_path, repoRoot) : null;
4155
- const [oldMeta, newMeta] = await Promise.all([
4156
- oldImage ? extractMetadata(oldImage.data, oldPath ?? file.file_path) : null,
4157
- newImage ? extractMetadata(newImage.data, file.file_path) : null
4158
- ]);
4295
+ const oldMeta = oldImage !== null ? extractMetadata(oldImage.data, oldPath ?? file.file_path) : null;
4296
+ const newMeta = newImage !== null ? extractMetadata(newImage.data, file.file_path) : null;
4159
4297
  return c.json({
4160
4298
  old: oldMeta ? formatMetadataLines(oldMeta) : null,
4161
4299
  new: newMeta ? formatMetadataLines(newMeta) : null
@@ -4178,7 +4316,7 @@ apiRoutes.get("/image/:fileId/:side", async (c) => {
4178
4316
  if (isSvgFile(file.file_path)) {
4179
4317
  try {
4180
4318
  const png = await rasterizeSvg(image.data);
4181
- return new Response(png, {
4319
+ return new Response(new Uint8Array(png), {
4182
4320
  headers: { "Content-Type": "image/png", "Cache-Control": "no-cache" }
4183
4321
  });
4184
4322
  } catch {
@@ -4186,14 +4324,14 @@ apiRoutes.get("/image/:fileId/:side", async (c) => {
4186
4324
  }
4187
4325
  }
4188
4326
  const contentType = getContentType(file.file_path);
4189
- return new Response(image.data, {
4327
+ return new Response(new Uint8Array(image.data), {
4190
4328
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4191
4329
  });
4192
4330
  });
4193
4331
 
4194
4332
  // src/routes/pages.tsx
4195
- import { readFileSync as readFileSync8 } from "fs";
4196
- import { Hono as Hono3 } from "hono";
4333
+ import { readFileSync as readFileSync9 } from "fs";
4334
+ import { Hono as Hono5 } from "hono";
4197
4335
  import { resolve as resolve4 } from "path";
4198
4336
 
4199
4337
  // src/utils/escapeHtml.ts
@@ -4508,10 +4646,10 @@ function ImageDiff({ file, diff, fontWarning, baseWidth, baseHeight }) {
4508
4646
  "data-file-path": file.file_path,
4509
4647
  "data-has-old": String(hasOld),
4510
4648
  "data-has-new": String(hasNew),
4511
- ...baseWidth ? { "data-base-width": String(baseWidth) } : {},
4512
- ...baseHeight ? { "data-base-height": String(baseHeight) } : {},
4649
+ ...baseWidth !== void 0 ? { "data-base-width": String(baseWidth) } : {},
4650
+ ...baseHeight !== void 0 ? { "data-base-height": String(baseHeight) } : {},
4513
4651
  children: [
4514
- fontWarning && /* @__PURE__ */ jsx("div", { className: "image-font-warning", children: "This SVG uses text that may render differently depending on locally installed fonts." }),
4652
+ fontWarning === true && /* @__PURE__ */ jsx("div", { className: "image-font-warning", children: "This SVG uses text that may render differently depending on locally installed fonts." }),
4515
4653
  /* @__PURE__ */ jsx("div", { className: "image-diff-panel image-diff-metadata", "data-panel": "metadata", children: /* @__PURE__ */ jsx("div", { className: "image-metadata-loading", children: "Loading metadata..." }) }),
4516
4654
  hasComparison && /* @__PURE__ */ jsx("div", { className: "image-diff-panel image-diff-visual", "data-panel": "difference", children: /* @__PURE__ */ jsx("div", { className: "image-visual-canvas", "data-zoomable": "true", children: /* @__PURE__ */ jsx("div", { className: "image-zoom-wrap", children: [
4517
4655
  /* @__PURE__ */ jsx("img", { className: "image-layer image-layer-old", src: `/api/image/${fileId}/old`, alt: "Old version" }),
@@ -4970,9 +5108,570 @@ function FileList({ files, annotationCounts, staleCounts }) {
4970
5108
  return /* @__PURE__ */ jsx("div", { className: "file-list", children: /* @__PURE__ */ jsx("div", { className: "file-list-items", children: /* @__PURE__ */ jsx(TreeView, { node: tree, depth: 0, annotationCounts, staleCounts }) }) });
4971
5109
  }
4972
5110
 
5111
+ // src/themes/built-in.ts
5112
+ var THEME_VARIABLES = [
5113
+ "bg",
5114
+ "bg-surface",
5115
+ "bg-hover",
5116
+ "bg-active",
5117
+ "text",
5118
+ "text-dim",
5119
+ "text-bright",
5120
+ "accent",
5121
+ "accent-hover",
5122
+ "green",
5123
+ "red",
5124
+ "yellow",
5125
+ "orange",
5126
+ "blue",
5127
+ "purple",
5128
+ "teal",
5129
+ "border",
5130
+ "diff-add-bg",
5131
+ "diff-add-border",
5132
+ "diff-remove-bg",
5133
+ "diff-remove-border",
5134
+ "diff-context-bg",
5135
+ "gutter-bg",
5136
+ "gutter-text"
5137
+ ];
5138
+ var dark = {
5139
+ "bg": "#1e1e2e",
5140
+ "bg-surface": "#252536",
5141
+ "bg-hover": "#2d2d44",
5142
+ "bg-active": "#363652",
5143
+ "text": "#cdd6f4",
5144
+ "text-dim": "#8888aa",
5145
+ "text-bright": "#ffffff",
5146
+ "accent": "#89b4fa",
5147
+ "accent-hover": "#74a8fc",
5148
+ "green": "#a6e3a1",
5149
+ "red": "#f38ba8",
5150
+ "yellow": "#f9e2af",
5151
+ "orange": "#fab387",
5152
+ "blue": "#89b4fa",
5153
+ "purple": "#cba6f7",
5154
+ "teal": "#94e2d5",
5155
+ "border": "#363652",
5156
+ "diff-add-bg": "rgba(166, 227, 161, 0.1)",
5157
+ "diff-add-border": "rgba(166, 227, 161, 0.3)",
5158
+ "diff-remove-bg": "rgba(243, 139, 168, 0.1)",
5159
+ "diff-remove-border": "rgba(243, 139, 168, 0.3)",
5160
+ "diff-context-bg": "transparent",
5161
+ "gutter-bg": "#1a1a2e",
5162
+ "gutter-text": "#555577"
5163
+ };
5164
+ var light = {
5165
+ "bg": "#ffffff",
5166
+ "bg-surface": "#f6f8fa",
5167
+ "bg-hover": "#eaeef2",
5168
+ "bg-active": "#dde3e9",
5169
+ "text": "#1f2328",
5170
+ "text-dim": "#656d76",
5171
+ "text-bright": "#000000",
5172
+ "accent": "#0969da",
5173
+ "accent-hover": "#0550ae",
5174
+ "green": "#1a7f37",
5175
+ "red": "#cf222e",
5176
+ "yellow": "#9a6700",
5177
+ "orange": "#bc4c00",
5178
+ "blue": "#0969da",
5179
+ "purple": "#8250df",
5180
+ "teal": "#0e7c6b",
5181
+ "border": "#d0d7de",
5182
+ "diff-add-bg": "rgba(26, 127, 55, 0.08)",
5183
+ "diff-add-border": "rgba(26, 127, 55, 0.25)",
5184
+ "diff-remove-bg": "rgba(207, 34, 46, 0.08)",
5185
+ "diff-remove-border": "rgba(207, 34, 46, 0.25)",
5186
+ "diff-context-bg": "transparent",
5187
+ "gutter-bg": "#f6f8fa",
5188
+ "gutter-text": "#8b949e"
5189
+ };
5190
+ var highContrastDark = {
5191
+ "bg": "#0a0a0a",
5192
+ "bg-surface": "#1a1a1a",
5193
+ "bg-hover": "#2a2a2a",
5194
+ "bg-active": "#3a3a3a",
5195
+ "text": "#f0f0f0",
5196
+ "text-dim": "#b0b0b0",
5197
+ "text-bright": "#ffffff",
5198
+ "accent": "#6db3f2",
5199
+ "accent-hover": "#8ec5f7",
5200
+ "green": "#73e06e",
5201
+ "red": "#ff6b6b",
5202
+ "yellow": "#ffd93d",
5203
+ "orange": "#ffab57",
5204
+ "blue": "#6db3f2",
5205
+ "purple": "#c59eff",
5206
+ "teal": "#5ee6d0",
5207
+ "border": "#555555",
5208
+ "diff-add-bg": "rgba(115, 224, 110, 0.15)",
5209
+ "diff-add-border": "rgba(115, 224, 110, 0.5)",
5210
+ "diff-remove-bg": "rgba(255, 107, 107, 0.15)",
5211
+ "diff-remove-border": "rgba(255, 107, 107, 0.5)",
5212
+ "diff-context-bg": "transparent",
5213
+ "gutter-bg": "#111111",
5214
+ "gutter-text": "#888888"
5215
+ };
5216
+ var highContrastLight = {
5217
+ "bg": "#ffffff",
5218
+ "bg-surface": "#f0f0f0",
5219
+ "bg-hover": "#e0e0e0",
5220
+ "bg-active": "#d0d0d0",
5221
+ "text": "#111111",
5222
+ "text-dim": "#444444",
5223
+ "text-bright": "#000000",
5224
+ "accent": "#0043a8",
5225
+ "accent-hover": "#003080",
5226
+ "green": "#006b1f",
5227
+ "red": "#b80000",
5228
+ "yellow": "#785600",
5229
+ "orange": "#8a3400",
5230
+ "blue": "#0043a8",
5231
+ "purple": "#5b21b6",
5232
+ "teal": "#005e4f",
5233
+ "border": "#767676",
5234
+ "diff-add-bg": "rgba(0, 107, 31, 0.1)",
5235
+ "diff-add-border": "rgba(0, 107, 31, 0.4)",
5236
+ "diff-remove-bg": "rgba(184, 0, 0, 0.1)",
5237
+ "diff-remove-border": "rgba(184, 0, 0, 0.4)",
5238
+ "diff-context-bg": "transparent",
5239
+ "gutter-bg": "#f0f0f0",
5240
+ "gutter-text": "#555555"
5241
+ };
5242
+ var dracula = {
5243
+ "bg": "#282a36",
5244
+ "bg-surface": "#2d303e",
5245
+ "bg-hover": "#343746",
5246
+ "bg-active": "#3e4151",
5247
+ "text": "#f8f8f2",
5248
+ "text-dim": "#8b8da4",
5249
+ "text-bright": "#ffffff",
5250
+ "accent": "#bd93f9",
5251
+ "accent-hover": "#caa5fb",
5252
+ "green": "#50fa7b",
5253
+ "red": "#ff5555",
5254
+ "yellow": "#f1fa8c",
5255
+ "orange": "#ffb86c",
5256
+ "blue": "#8be9fd",
5257
+ "purple": "#bd93f9",
5258
+ "teal": "#8be9fd",
5259
+ "border": "#44475a",
5260
+ "diff-add-bg": "rgba(80, 250, 123, 0.1)",
5261
+ "diff-add-border": "rgba(80, 250, 123, 0.3)",
5262
+ "diff-remove-bg": "rgba(255, 85, 85, 0.1)",
5263
+ "diff-remove-border": "rgba(255, 85, 85, 0.3)",
5264
+ "diff-context-bg": "transparent",
5265
+ "gutter-bg": "#21222c",
5266
+ "gutter-text": "#6272a4"
5267
+ };
5268
+ var tokyoNight = {
5269
+ "bg": "#1a1b26",
5270
+ "bg-surface": "#1f2233",
5271
+ "bg-hover": "#292d42",
5272
+ "bg-active": "#33374e",
5273
+ "text": "#a9b1d6",
5274
+ "text-dim": "#565f89",
5275
+ "text-bright": "#c0caf5",
5276
+ "accent": "#7aa2f7",
5277
+ "accent-hover": "#89b0fa",
5278
+ "green": "#9ece6a",
5279
+ "red": "#f7768e",
5280
+ "yellow": "#e0af68",
5281
+ "orange": "#ff9e64",
5282
+ "blue": "#7aa2f7",
5283
+ "purple": "#bb9af7",
5284
+ "teal": "#73daca",
5285
+ "border": "#2f3351",
5286
+ "diff-add-bg": "rgba(158, 206, 106, 0.1)",
5287
+ "diff-add-border": "rgba(158, 206, 106, 0.3)",
5288
+ "diff-remove-bg": "rgba(247, 118, 142, 0.1)",
5289
+ "diff-remove-border": "rgba(247, 118, 142, 0.3)",
5290
+ "diff-context-bg": "transparent",
5291
+ "gutter-bg": "#16172a",
5292
+ "gutter-text": "#3b4261"
5293
+ };
5294
+ var oneDarkPro = {
5295
+ "bg": "#282c34",
5296
+ "bg-surface": "#2c313a",
5297
+ "bg-hover": "#333842",
5298
+ "bg-active": "#3b4048",
5299
+ "text": "#abb2bf",
5300
+ "text-dim": "#636d83",
5301
+ "text-bright": "#d7dae0",
5302
+ "accent": "#61afef",
5303
+ "accent-hover": "#519fdf",
5304
+ "green": "#98c379",
5305
+ "red": "#e06c75",
5306
+ "yellow": "#e5c07b",
5307
+ "orange": "#d19a66",
5308
+ "blue": "#61afef",
5309
+ "purple": "#c678dd",
5310
+ "teal": "#56b6c2",
5311
+ "border": "#3b4048",
5312
+ "diff-add-bg": "rgba(152, 195, 121, 0.1)",
5313
+ "diff-add-border": "rgba(152, 195, 121, 0.3)",
5314
+ "diff-remove-bg": "rgba(224, 108, 117, 0.1)",
5315
+ "diff-remove-border": "rgba(224, 108, 117, 0.3)",
5316
+ "diff-context-bg": "transparent",
5317
+ "gutter-bg": "#23272e",
5318
+ "gutter-text": "#495162"
5319
+ };
5320
+ var solarizedDark = {
5321
+ "bg": "#002b36",
5322
+ "bg-surface": "#073642",
5323
+ "bg-hover": "#0a4050",
5324
+ "bg-active": "#0d4d5e",
5325
+ "text": "#839496",
5326
+ "text-dim": "#586e75",
5327
+ "text-bright": "#eee8d5",
5328
+ "accent": "#268bd2",
5329
+ "accent-hover": "#1a7cc0",
5330
+ "green": "#859900",
5331
+ "red": "#dc322f",
5332
+ "yellow": "#b58900",
5333
+ "orange": "#cb4b16",
5334
+ "blue": "#268bd2",
5335
+ "purple": "#6c71c4",
5336
+ "teal": "#2aa198",
5337
+ "border": "#0a4050",
5338
+ "diff-add-bg": "rgba(133, 153, 0, 0.12)",
5339
+ "diff-add-border": "rgba(133, 153, 0, 0.3)",
5340
+ "diff-remove-bg": "rgba(220, 50, 47, 0.12)",
5341
+ "diff-remove-border": "rgba(220, 50, 47, 0.3)",
5342
+ "diff-context-bg": "transparent",
5343
+ "gutter-bg": "#002028",
5344
+ "gutter-text": "#4a6568"
5345
+ };
5346
+ var solarizedLight = {
5347
+ "bg": "#fdf6e3",
5348
+ "bg-surface": "#eee8d5",
5349
+ "bg-hover": "#e6dfca",
5350
+ "bg-active": "#ddd6c1",
5351
+ "text": "#657b83",
5352
+ "text-dim": "#93a1a1",
5353
+ "text-bright": "#073642",
5354
+ "accent": "#268bd2",
5355
+ "accent-hover": "#1a7cc0",
5356
+ "green": "#859900",
5357
+ "red": "#dc322f",
5358
+ "yellow": "#b58900",
5359
+ "orange": "#cb4b16",
5360
+ "blue": "#268bd2",
5361
+ "purple": "#6c71c4",
5362
+ "teal": "#2aa198",
5363
+ "border": "#ddd6c1",
5364
+ "diff-add-bg": "rgba(133, 153, 0, 0.1)",
5365
+ "diff-add-border": "rgba(133, 153, 0, 0.25)",
5366
+ "diff-remove-bg": "rgba(220, 50, 47, 0.1)",
5367
+ "diff-remove-border": "rgba(220, 50, 47, 0.25)",
5368
+ "diff-context-bg": "transparent",
5369
+ "gutter-bg": "#eee8d5",
5370
+ "gutter-text": "#93a1a1"
5371
+ };
5372
+ var monokai = {
5373
+ "bg": "#272822",
5374
+ "bg-surface": "#2d2e27",
5375
+ "bg-hover": "#3e3d32",
5376
+ "bg-active": "#49483e",
5377
+ "text": "#f8f8f2",
5378
+ "text-dim": "#75715e",
5379
+ "text-bright": "#ffffff",
5380
+ "accent": "#66d9ef",
5381
+ "accent-hover": "#55c8de",
5382
+ "green": "#a6e22e",
5383
+ "red": "#f92672",
5384
+ "yellow": "#e6db74",
5385
+ "orange": "#fd971f",
5386
+ "blue": "#66d9ef",
5387
+ "purple": "#ae81ff",
5388
+ "teal": "#66d9ef",
5389
+ "border": "#49483e",
5390
+ "diff-add-bg": "rgba(166, 226, 46, 0.1)",
5391
+ "diff-add-border": "rgba(166, 226, 46, 0.3)",
5392
+ "diff-remove-bg": "rgba(249, 38, 114, 0.1)",
5393
+ "diff-remove-border": "rgba(249, 38, 114, 0.3)",
5394
+ "diff-context-bg": "transparent",
5395
+ "gutter-bg": "#222218",
5396
+ "gutter-text": "#575848"
5397
+ };
5398
+ var nord = {
5399
+ "bg": "#2e3440",
5400
+ "bg-surface": "#3b4252",
5401
+ "bg-hover": "#434c5e",
5402
+ "bg-active": "#4c566a",
5403
+ "text": "#d8dee9",
5404
+ "text-dim": "#7b88a1",
5405
+ "text-bright": "#eceff4",
5406
+ "accent": "#88c0d0",
5407
+ "accent-hover": "#81a1c1",
5408
+ "green": "#a3be8c",
5409
+ "red": "#bf616a",
5410
+ "yellow": "#ebcb8b",
5411
+ "orange": "#d08770",
5412
+ "blue": "#81a1c1",
5413
+ "purple": "#b48ead",
5414
+ "teal": "#8fbcbb",
5415
+ "border": "#4c566a",
5416
+ "diff-add-bg": "rgba(163, 190, 140, 0.1)",
5417
+ "diff-add-border": "rgba(163, 190, 140, 0.3)",
5418
+ "diff-remove-bg": "rgba(191, 97, 106, 0.1)",
5419
+ "diff-remove-border": "rgba(191, 97, 106, 0.3)",
5420
+ "diff-context-bg": "transparent",
5421
+ "gutter-bg": "#2a303c",
5422
+ "gutter-text": "#5b6578"
5423
+ };
5424
+ var gruvboxDark = {
5425
+ "bg": "#282828",
5426
+ "bg-surface": "#3c3836",
5427
+ "bg-hover": "#504945",
5428
+ "bg-active": "#665c54",
5429
+ "text": "#ebdbb2",
5430
+ "text-dim": "#928374",
5431
+ "text-bright": "#fbf1c7",
5432
+ "accent": "#83a598",
5433
+ "accent-hover": "#76988b",
5434
+ "green": "#b8bb26",
5435
+ "red": "#fb4934",
5436
+ "yellow": "#fabd2f",
5437
+ "orange": "#fe8019",
5438
+ "blue": "#83a598",
5439
+ "purple": "#d3869b",
5440
+ "teal": "#8ec07c",
5441
+ "border": "#504945",
5442
+ "diff-add-bg": "rgba(184, 187, 38, 0.1)",
5443
+ "diff-add-border": "rgba(184, 187, 38, 0.3)",
5444
+ "diff-remove-bg": "rgba(251, 73, 52, 0.1)",
5445
+ "diff-remove-border": "rgba(251, 73, 52, 0.3)",
5446
+ "diff-context-bg": "transparent",
5447
+ "gutter-bg": "#232323",
5448
+ "gutter-text": "#665c54"
5449
+ };
5450
+ var gruvboxLight = {
5451
+ "bg": "#fbf1c7",
5452
+ "bg-surface": "#f2e5bc",
5453
+ "bg-hover": "#ebdbb2",
5454
+ "bg-active": "#d5c4a1",
5455
+ "text": "#3c3836",
5456
+ "text-dim": "#7c6f64",
5457
+ "text-bright": "#282828",
5458
+ "accent": "#427b58",
5459
+ "accent-hover": "#376b4c",
5460
+ "green": "#79740e",
5461
+ "red": "#9d0006",
5462
+ "yellow": "#b57614",
5463
+ "orange": "#af3a03",
5464
+ "blue": "#076678",
5465
+ "purple": "#8f3f71",
5466
+ "teal": "#427b58",
5467
+ "border": "#d5c4a1",
5468
+ "diff-add-bg": "rgba(121, 116, 14, 0.1)",
5469
+ "diff-add-border": "rgba(121, 116, 14, 0.25)",
5470
+ "diff-remove-bg": "rgba(157, 0, 6, 0.1)",
5471
+ "diff-remove-border": "rgba(157, 0, 6, 0.25)",
5472
+ "diff-context-bg": "transparent",
5473
+ "gutter-bg": "#f2e5bc",
5474
+ "gutter-text": "#928374"
5475
+ };
5476
+ var githubDark = {
5477
+ "bg": "#0d1117",
5478
+ "bg-surface": "#161b22",
5479
+ "bg-hover": "#1c2128",
5480
+ "bg-active": "#262c36",
5481
+ "text": "#c9d1d9",
5482
+ "text-dim": "#8b949e",
5483
+ "text-bright": "#f0f6fc",
5484
+ "accent": "#58a6ff",
5485
+ "accent-hover": "#4090e0",
5486
+ "green": "#3fb950",
5487
+ "red": "#f85149",
5488
+ "yellow": "#d29922",
5489
+ "orange": "#db6d28",
5490
+ "blue": "#58a6ff",
5491
+ "purple": "#bc8cff",
5492
+ "teal": "#39d353",
5493
+ "border": "#30363d",
5494
+ "diff-add-bg": "rgba(63, 185, 80, 0.1)",
5495
+ "diff-add-border": "rgba(63, 185, 80, 0.3)",
5496
+ "diff-remove-bg": "rgba(248, 81, 73, 0.1)",
5497
+ "diff-remove-border": "rgba(248, 81, 73, 0.3)",
5498
+ "diff-context-bg": "transparent",
5499
+ "gutter-bg": "#0a0e14",
5500
+ "gutter-text": "#484f58"
5501
+ };
5502
+ var rosePine = {
5503
+ "bg": "#191724",
5504
+ "bg-surface": "#1f1d2e",
5505
+ "bg-hover": "#26233a",
5506
+ "bg-active": "#2a2740",
5507
+ "text": "#e0def4",
5508
+ "text-dim": "#6e6a86",
5509
+ "text-bright": "#f0efff",
5510
+ "accent": "#c4a7e7",
5511
+ "accent-hover": "#b498d7",
5512
+ "green": "#31748f",
5513
+ "red": "#eb6f92",
5514
+ "yellow": "#f6c177",
5515
+ "orange": "#ea9a97",
5516
+ "blue": "#9ccfd8",
5517
+ "purple": "#c4a7e7",
5518
+ "teal": "#9ccfd8",
5519
+ "border": "#2a2740",
5520
+ "diff-add-bg": "rgba(49, 116, 143, 0.12)",
5521
+ "diff-add-border": "rgba(49, 116, 143, 0.3)",
5522
+ "diff-remove-bg": "rgba(235, 111, 146, 0.12)",
5523
+ "diff-remove-border": "rgba(235, 111, 146, 0.3)",
5524
+ "diff-context-bg": "transparent",
5525
+ "gutter-bg": "#16141f",
5526
+ "gutter-text": "#524f67"
5527
+ };
5528
+ var ayuDark = {
5529
+ "bg": "#0b0e14",
5530
+ "bg-surface": "#0f131a",
5531
+ "bg-hover": "#151a23",
5532
+ "bg-active": "#1c222d",
5533
+ "text": "#bfbdb6",
5534
+ "text-dim": "#636a76",
5535
+ "text-bright": "#e6e1cf",
5536
+ "accent": "#e6b450",
5537
+ "accent-hover": "#d9a740",
5538
+ "green": "#7fd962",
5539
+ "red": "#d95757",
5540
+ "yellow": "#e6b450",
5541
+ "orange": "#ff8f40",
5542
+ "blue": "#59c2ff",
5543
+ "purple": "#d2a6ff",
5544
+ "teal": "#95e6cb",
5545
+ "border": "#1c222d",
5546
+ "diff-add-bg": "rgba(127, 217, 98, 0.1)",
5547
+ "diff-add-border": "rgba(127, 217, 98, 0.3)",
5548
+ "diff-remove-bg": "rgba(217, 87, 87, 0.1)",
5549
+ "diff-remove-border": "rgba(217, 87, 87, 0.3)",
5550
+ "diff-context-bg": "transparent",
5551
+ "gutter-bg": "#080a10",
5552
+ "gutter-text": "#3d424d"
5553
+ };
5554
+ var BUILT_IN_THEMES = [
5555
+ { id: "dark", name: "Dark", builtIn: true, colors: dark },
5556
+ { id: "light", name: "Light", builtIn: true, colors: light },
5557
+ { id: "high-contrast-dark", name: "High Contrast Dark", builtIn: true, colors: highContrastDark },
5558
+ { id: "high-contrast-light", name: "High Contrast Light", builtIn: true, colors: highContrastLight },
5559
+ { id: "dracula", name: "Dracula", builtIn: true, colors: dracula },
5560
+ { id: "tokyo-night", name: "Tokyo Night", builtIn: true, colors: tokyoNight },
5561
+ { id: "one-dark-pro", name: "One Dark Pro", builtIn: true, colors: oneDarkPro },
5562
+ { id: "solarized-dark", name: "Solarized Dark", builtIn: true, colors: solarizedDark },
5563
+ { id: "solarized-light", name: "Solarized Light", builtIn: true, colors: solarizedLight },
5564
+ { id: "monokai", name: "Monokai", builtIn: true, colors: monokai },
5565
+ { id: "nord", name: "Nord", builtIn: true, colors: nord },
5566
+ { id: "gruvbox-dark", name: "Gruvbox Dark", builtIn: true, colors: gruvboxDark },
5567
+ { id: "gruvbox-light", name: "Gruvbox Light", builtIn: true, colors: gruvboxLight },
5568
+ { id: "github-dark", name: "GitHub Dark", builtIn: true, colors: githubDark },
5569
+ { id: "rose-pine", name: "Ros\xE9 Pine", builtIn: true, colors: rosePine },
5570
+ { id: "ayu-dark", name: "Ayu Dark", builtIn: true, colors: ayuDark }
5571
+ ];
5572
+ var DEFAULT_THEME_ID = "dark";
5573
+ function getBuiltInTheme(id) {
5574
+ return BUILT_IN_THEMES.find((t) => t.id === id);
5575
+ }
5576
+ function themeToInlineStyle(colors) {
5577
+ return THEME_VARIABLES.map((v) => `--${v}:${colors[v]}`).join(";");
5578
+ }
5579
+
5580
+ // src/themes/config.ts
5581
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readdirSync, readFileSync as readFileSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
5582
+ import { homedir as homedir3 } from "os";
5583
+ import { join as join7 } from "path";
5584
+ var CONFIG_DIR2 = join7(homedir3(), ".glassbox");
5585
+ var CONFIG_PATH2 = join7(CONFIG_DIR2, "config.json");
5586
+ var THEMES_DIR = join7(CONFIG_DIR2, "themes");
5587
+ function readConfigFile2() {
5588
+ try {
5589
+ if (existsSync6(CONFIG_PATH2)) {
5590
+ return JSON.parse(readFileSync8(CONFIG_PATH2, "utf-8"));
5591
+ }
5592
+ } catch {
5593
+ }
5594
+ return {};
5595
+ }
5596
+ function writeConfigFile2(config) {
5597
+ mkdirSync5(CONFIG_DIR2, { recursive: true });
5598
+ writeFileSync5(CONFIG_PATH2, JSON.stringify(config, null, 2), "utf-8");
5599
+ }
5600
+ function getActiveThemeId() {
5601
+ const config = readConfigFile2();
5602
+ const theme = config.theme;
5603
+ const active = theme?.active;
5604
+ return active ?? DEFAULT_THEME_ID;
5605
+ }
5606
+ function setActiveThemeId(id) {
5607
+ const config = readConfigFile2();
5608
+ if (config.theme === void 0) config.theme = {};
5609
+ config.theme.active = id;
5610
+ writeConfigFile2(config);
5611
+ }
5612
+ function loadCustomThemes() {
5613
+ if (!existsSync6(THEMES_DIR)) return [];
5614
+ const themes = [];
5615
+ try {
5616
+ const files = readdirSync(THEMES_DIR).filter((f) => f.endsWith(".json"));
5617
+ for (const file of files) {
5618
+ try {
5619
+ const data = JSON.parse(readFileSync8(join7(THEMES_DIR, file), "utf-8"));
5620
+ if (data.id !== void 0 && data.id !== "" && data.name !== void 0 && data.name !== "" && data.colors !== void 0) {
5621
+ themes.push({ id: data.id, name: data.name, colors: data.colors, builtIn: false, baseTheme: data.baseTheme ?? "" });
5622
+ }
5623
+ } catch {
5624
+ }
5625
+ }
5626
+ } catch {
5627
+ }
5628
+ return themes;
5629
+ }
5630
+ function saveCustomTheme(theme) {
5631
+ mkdirSync5(THEMES_DIR, { recursive: true });
5632
+ const filePath = join7(THEMES_DIR, `${theme.id}.json`);
5633
+ writeFileSync5(filePath, JSON.stringify(theme, null, 2), "utf-8");
5634
+ }
5635
+ function deleteCustomTheme(id) {
5636
+ const filePath = join7(THEMES_DIR, `${id}.json`);
5637
+ if (existsSync6(filePath)) {
5638
+ unlinkSync2(filePath);
5639
+ }
5640
+ }
5641
+ function getCustomTheme(id) {
5642
+ const filePath = join7(THEMES_DIR, `${id}.json`);
5643
+ if (!existsSync6(filePath)) return void 0;
5644
+ try {
5645
+ const data = JSON.parse(readFileSync8(filePath, "utf-8"));
5646
+ return { ...data, builtIn: false };
5647
+ } catch {
5648
+ return void 0;
5649
+ }
5650
+ }
5651
+ function getAllThemes() {
5652
+ return [...BUILT_IN_THEMES, ...loadCustomThemes()];
5653
+ }
5654
+ function resolveTheme(id) {
5655
+ return getBuiltInTheme(id) ?? getCustomTheme(id);
5656
+ }
5657
+ function getActiveThemeColors() {
5658
+ const id = getActiveThemeId();
5659
+ const theme = resolveTheme(id);
5660
+ if (theme) return theme.colors;
5661
+ const fallback = getBuiltInTheme(DEFAULT_THEME_ID);
5662
+ if (fallback === void 0) throw new Error(`Default theme '${DEFAULT_THEME_ID}' not found`);
5663
+ return fallback.colors;
5664
+ }
5665
+ function generateThemeId() {
5666
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
5667
+ }
5668
+
4973
5669
  // src/components/layout.tsx
4974
5670
  function Layout({ title, reviewId, children }) {
4975
- return /* @__PURE__ */ jsx("html", { lang: "en", children: [
5671
+ const themeId = getActiveThemeId();
5672
+ const themeColors = getActiveThemeColors();
5673
+ const themeStyle = themeToInlineStyle(themeColors);
5674
+ return /* @__PURE__ */ jsx("html", { lang: "en", style: themeStyle, "data-theme": themeId, children: [
4976
5675
  /* @__PURE__ */ jsx("head", { children: [
4977
5676
  /* @__PURE__ */ jsx("meta", { charset: "utf-8" }),
4978
5677
  /* @__PURE__ */ jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
@@ -5053,7 +5752,7 @@ function ReviewHistory({ reviews, currentReviewId }) {
5053
5752
 
5054
5753
  // src/routes/pages.tsx
5055
5754
  init_queries();
5056
- var pageRoutes = new Hono3();
5755
+ var pageRoutes = new Hono5();
5057
5756
  pageRoutes.get("/", async (c) => {
5058
5757
  const reviewId = c.get("reviewId");
5059
5758
  const review = await getReview(reviewId);
@@ -5207,11 +5906,11 @@ pageRoutes.get("/file/:fileId", async (c) => {
5207
5906
  });
5208
5907
  pageRoutes.get("/file-raw", (c) => {
5209
5908
  const filePath = c.req.query("path");
5210
- if (!filePath) return c.text("Missing path", 400);
5909
+ if (filePath === void 0 || filePath === "") return c.text("Missing path", 400);
5211
5910
  const repoRoot = c.get("repoRoot");
5212
5911
  let content;
5213
5912
  try {
5214
- content = readFileSync8(resolve4(repoRoot, filePath), "utf-8");
5913
+ content = readFileSync9(resolve4(repoRoot, filePath), "utf-8");
5215
5914
  } catch {
5216
5915
  return c.text("File not found", 404);
5217
5916
  }
@@ -5234,7 +5933,7 @@ pageRoutes.get("/file-raw", (c) => {
5234
5933
  }))
5235
5934
  }]
5236
5935
  };
5237
- const fakeFile = { id: "", review_id: "", file_path: filePath, status: "reviewed", diff_data: null };
5936
+ const fakeFile = { id: "", review_id: "", file_path: filePath, status: "reviewed", diff_data: null, created_at: "" };
5238
5937
  const html = /* @__PURE__ */ jsx(DiffView, { file: fakeFile, diff, annotations: [], mode: "unified" });
5239
5938
  return c.html(html.toString());
5240
5939
  });
@@ -5337,10 +6036,144 @@ pageRoutes.get("/history", async (c) => {
5337
6036
  return c.html(html.toString());
5338
6037
  });
5339
6038
 
6039
+ // src/routes/theme-api.ts
6040
+ import { Hono as Hono6 } from "hono";
6041
+ var themeApiRoutes = new Hono6();
6042
+ function validateColors(colors) {
6043
+ if (typeof colors !== "object" || colors === null || Array.isArray(colors)) {
6044
+ return "colors must be an object";
6045
+ }
6046
+ const validKeys = new Set(THEME_VARIABLES);
6047
+ for (const [key, value] of Object.entries(colors)) {
6048
+ if (!validKeys.has(key)) {
6049
+ return `colors contains unknown key: ${key}`;
6050
+ }
6051
+ if (typeof value !== "string") {
6052
+ return `colors.${key} must be a string`;
6053
+ }
6054
+ }
6055
+ return null;
6056
+ }
6057
+ themeApiRoutes.get("/", (c) => {
6058
+ const themes = getAllThemes();
6059
+ const activeId = getActiveThemeId();
6060
+ return c.json({
6061
+ themes: themes.map((t) => ({
6062
+ id: t.id,
6063
+ name: t.name,
6064
+ builtIn: t.builtIn,
6065
+ colors: t.colors
6066
+ })),
6067
+ activeId
6068
+ });
6069
+ });
6070
+ themeApiRoutes.get("/active", (c) => {
6071
+ const id = getActiveThemeId();
6072
+ const colors = getActiveThemeColors();
6073
+ return c.json({ id, colors });
6074
+ });
6075
+ themeApiRoutes.post("/active", async (c) => {
6076
+ const body = await c.req.json();
6077
+ if (typeof body.id !== "string" || body.id === "") return c.json({ error: "id must be a non-empty string" }, 400);
6078
+ const theme = resolveTheme(body.id);
6079
+ if (!theme) return c.json({ error: "Theme not found" }, 404);
6080
+ setActiveThemeId(body.id);
6081
+ return c.json({ id: body.id, colors: theme.colors });
6082
+ });
6083
+ themeApiRoutes.post("/", async (c) => {
6084
+ const body = await c.req.json();
6085
+ if (typeof body.sourceId !== "string" || body.sourceId === "") return c.json({ error: "sourceId must be a non-empty string" }, 400);
6086
+ if (body.name !== void 0 && (typeof body.name !== "string" || body.name.trim() === "")) {
6087
+ return c.json({ error: "name must be a non-empty string when provided" }, 400);
6088
+ }
6089
+ const source = resolveTheme(body.sourceId);
6090
+ if (!source) return c.json({ error: "Source theme not found" }, 404);
6091
+ const baseTheme = source.builtIn ? source.id : source.baseTheme;
6092
+ const name = body.name ?? `${source.name} (Copy)`;
6093
+ const newTheme = {
6094
+ id: generateThemeId(),
6095
+ name,
6096
+ builtIn: false,
6097
+ baseTheme,
6098
+ colors: { ...source.colors }
6099
+ };
6100
+ saveCustomTheme(newTheme);
6101
+ return c.json(newTheme, 201);
6102
+ });
6103
+ themeApiRoutes.post("/:id/edit", async (c) => {
6104
+ const id = c.req.param("id");
6105
+ const body = await c.req.json();
6106
+ if (body.name !== void 0 && (typeof body.name !== "string" || body.name.trim() === "")) {
6107
+ return c.json({ error: "name must be a non-empty string when provided" }, 400);
6108
+ }
6109
+ if (body.colors !== void 0) {
6110
+ const colorsError = validateColors(body.colors);
6111
+ if (colorsError !== null) return c.json({ error: colorsError }, 400);
6112
+ }
6113
+ const source = resolveTheme(id);
6114
+ if (!source) return c.json({ error: "Theme not found" }, 404);
6115
+ if (source.builtIn) {
6116
+ const newTheme = {
6117
+ id: generateThemeId(),
6118
+ name: `${source.name} (Customized)`,
6119
+ builtIn: false,
6120
+ baseTheme: source.id,
6121
+ colors: body.colors ? { ...source.colors, ...body.colors } : { ...source.colors }
6122
+ };
6123
+ if (body.name !== void 0 && body.name !== "") newTheme.name = body.name;
6124
+ saveCustomTheme(newTheme);
6125
+ setActiveThemeId(newTheme.id);
6126
+ return c.json({ theme: newTheme, copied: true }, 201);
6127
+ }
6128
+ const updated = {
6129
+ ...source,
6130
+ name: body.name ?? source.name,
6131
+ colors: body.colors ? { ...source.colors, ...body.colors } : source.colors
6132
+ };
6133
+ saveCustomTheme(updated);
6134
+ return c.json({ theme: updated, copied: false });
6135
+ });
6136
+ themeApiRoutes.patch("/:id", async (c) => {
6137
+ const id = c.req.param("id");
6138
+ if (getBuiltInTheme(id)) {
6139
+ return c.json({ error: "Cannot edit built-in theme" }, 400);
6140
+ }
6141
+ const existing = resolveTheme(id);
6142
+ if (!existing || existing.builtIn) {
6143
+ return c.json({ error: "Theme not found" }, 404);
6144
+ }
6145
+ const body = await c.req.json();
6146
+ if (body.name !== void 0 && (typeof body.name !== "string" || body.name.trim() === "")) {
6147
+ return c.json({ error: "name must be a non-empty string when provided" }, 400);
6148
+ }
6149
+ if (body.colors !== void 0) {
6150
+ const colorsError = validateColors(body.colors);
6151
+ if (colorsError !== null) return c.json({ error: colorsError }, 400);
6152
+ }
6153
+ const updated = {
6154
+ ...existing,
6155
+ name: body.name ?? existing.name,
6156
+ colors: body.colors ? { ...existing.colors, ...body.colors } : existing.colors
6157
+ };
6158
+ saveCustomTheme(updated);
6159
+ return c.json(updated);
6160
+ });
6161
+ themeApiRoutes.delete("/:id", (c) => {
6162
+ const id = c.req.param("id");
6163
+ if (getBuiltInTheme(id)) {
6164
+ return c.json({ error: "Cannot delete built-in theme" }, 400);
6165
+ }
6166
+ deleteCustomTheme(id);
6167
+ if (getActiveThemeId() === id) {
6168
+ setActiveThemeId(BUILT_IN_THEMES[0].id);
6169
+ }
6170
+ return c.json({ ok: true });
6171
+ });
6172
+
5340
6173
  // src/server.ts
5341
- function tryServe(fetch2, port) {
6174
+ function tryServe(appFetch, port) {
5342
6175
  return new Promise((resolve6, reject) => {
5343
- const server = serve({ fetch: fetch2, port, hostname: "127.0.0.1" });
6176
+ const server = serve({ fetch: appFetch, port, hostname: "127.0.0.1" });
5344
6177
  server.on("listening", () => {
5345
6178
  resolve6(port);
5346
6179
  });
@@ -5354,7 +6187,7 @@ function tryServe(fetch2, port) {
5354
6187
  });
5355
6188
  }
5356
6189
  async function startServer(port, reviewId, repoRoot, options) {
5357
- const app = new Hono4();
6190
+ const app = new Hono7();
5358
6191
  app.use("*", async (c, next) => {
5359
6192
  c.set("reviewId", reviewId);
5360
6193
  c.set("currentReviewId", reviewId);
@@ -5362,24 +6195,25 @@ async function startServer(port, reviewId, repoRoot, options) {
5362
6195
  await next();
5363
6196
  });
5364
6197
  const selfDir = dirname(fileURLToPath(import.meta.url));
5365
- const distDir = existsSync6(join7(selfDir, "client", "styles.css")) ? join7(selfDir, "client") : join7(selfDir, "..", "dist", "client");
6198
+ const distDir = existsSync7(join8(selfDir, "client", "styles.css")) ? join8(selfDir, "client") : join8(selfDir, "..", "dist", "client");
5366
6199
  app.get("/static/styles.css", (c) => {
5367
- const css = readFileSync9(join7(distDir, "styles.css"), "utf-8");
6200
+ const css = readFileSync10(join8(distDir, "styles.css"), "utf-8");
5368
6201
  return c.text(css, 200, { "Content-Type": "text/css", "Cache-Control": "no-cache" });
5369
6202
  });
5370
6203
  app.get("/static/app.js", (c) => {
5371
- const js = readFileSync9(join7(distDir, "app.global.js"), "utf-8");
6204
+ const js = readFileSync10(join8(distDir, "app.global.js"), "utf-8");
5372
6205
  return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
5373
6206
  });
5374
6207
  app.get("/static/history.js", (c) => {
5375
- const js = readFileSync9(join7(distDir, "history.global.js"), "utf-8");
6208
+ const js = readFileSync10(join8(distDir, "history.global.js"), "utf-8");
5376
6209
  return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
5377
6210
  });
5378
6211
  app.route("/api", apiRoutes);
5379
6212
  app.route("/api/ai", aiApiRoutes);
6213
+ app.route("/api/themes", themeApiRoutes);
5380
6214
  app.route("/", pageRoutes);
5381
6215
  let actualPort = port;
5382
- if (options?.strictPort) {
6216
+ if (options?.strictPort === true) {
5383
6217
  actualPort = await tryServe(app.fetch, port);
5384
6218
  } else {
5385
6219
  for (let attempt = 0; attempt < 20; attempt++) {
@@ -5401,15 +6235,15 @@ async function startServer(port, reviewId, repoRoot, options) {
5401
6235
  console.log(`
5402
6236
  Glassbox running at ${url}
5403
6237
  `);
5404
- if (!options?.noOpen) {
6238
+ if (options?.noOpen !== true) {
5405
6239
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
5406
6240
  exec(`${openCmd} ${url}`);
5407
6241
  }
5408
6242
  }
5409
6243
 
5410
6244
  // src/skills.ts
5411
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5 } from "fs";
5412
- import { join as join8 } from "path";
6245
+ import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
6246
+ import { join as join9 } from "path";
5413
6247
  var SKILL_VERSION = 1;
5414
6248
  function versionHeader() {
5415
6249
  return `<!-- glassbox-skill-version: ${SKILL_VERSION} -->`;
@@ -5420,14 +6254,14 @@ function parseVersionHeader(content) {
5420
6254
  return parseInt(match[1], 10);
5421
6255
  }
5422
6256
  function updateFile(path, content) {
5423
- if (existsSync7(path)) {
5424
- const existing = readFileSync10(path, "utf-8");
6257
+ if (existsSync8(path)) {
6258
+ const existing = readFileSync11(path, "utf-8");
5425
6259
  const version = parseVersionHeader(existing);
5426
6260
  if (version !== null && version >= SKILL_VERSION) {
5427
6261
  return false;
5428
6262
  }
5429
6263
  }
5430
- writeFileSync5(path, content, "utf-8");
6264
+ writeFileSync6(path, content, "utf-8");
5431
6265
  return true;
5432
6266
  }
5433
6267
  function skillBody() {
@@ -5447,8 +6281,8 @@ function skillBody() {
5447
6281
  ].join("\n");
5448
6282
  }
5449
6283
  function ensureClaudeSkills(cwd) {
5450
- const dir = join8(cwd, ".claude", "skills", "glassbox");
5451
- mkdirSync5(dir, { recursive: true });
6284
+ const dir = join9(cwd, ".claude", "skills", "glassbox");
6285
+ mkdirSync6(dir, { recursive: true });
5452
6286
  const content = [
5453
6287
  "---",
5454
6288
  "name: glassbox",
@@ -5460,11 +6294,11 @@ function ensureClaudeSkills(cwd) {
5460
6294
  skillBody(),
5461
6295
  ""
5462
6296
  ].join("\n");
5463
- return updateFile(join8(dir, "SKILL.md"), content);
6297
+ return updateFile(join9(dir, "SKILL.md"), content);
5464
6298
  }
5465
6299
  function ensureCursorRules(cwd) {
5466
- const rulesDir = join8(cwd, ".cursor", "rules");
5467
- mkdirSync5(rulesDir, { recursive: true });
6300
+ const rulesDir = join9(cwd, ".cursor", "rules");
6301
+ mkdirSync6(rulesDir, { recursive: true });
5468
6302
  const content = [
5469
6303
  "---",
5470
6304
  "description: Read the latest Glassbox code review and apply all feedback annotations",
@@ -5475,11 +6309,11 @@ function ensureCursorRules(cwd) {
5475
6309
  skillBody(),
5476
6310
  ""
5477
6311
  ].join("\n");
5478
- return updateFile(join8(rulesDir, "glassbox.mdc"), content);
6312
+ return updateFile(join9(rulesDir, "glassbox.mdc"), content);
5479
6313
  }
5480
6314
  function ensureCopilotPrompts(cwd) {
5481
- const promptsDir = join8(cwd, ".github", "prompts");
5482
- mkdirSync5(promptsDir, { recursive: true });
6315
+ const promptsDir = join9(cwd, ".github", "prompts");
6316
+ mkdirSync6(promptsDir, { recursive: true });
5483
6317
  const content = [
5484
6318
  "---",
5485
6319
  "description: Read the latest Glassbox code review and apply all feedback annotations",
@@ -5489,11 +6323,11 @@ function ensureCopilotPrompts(cwd) {
5489
6323
  skillBody(),
5490
6324
  ""
5491
6325
  ].join("\n");
5492
- return updateFile(join8(promptsDir, "glassbox.prompt.md"), content);
6326
+ return updateFile(join9(promptsDir, "glassbox.prompt.md"), content);
5493
6327
  }
5494
6328
  function ensureWindsurfRules(cwd) {
5495
- const rulesDir = join8(cwd, ".windsurf", "rules");
5496
- mkdirSync5(rulesDir, { recursive: true });
6329
+ const rulesDir = join9(cwd, ".windsurf", "rules");
6330
+ mkdirSync6(rulesDir, { recursive: true });
5497
6331
  const content = [
5498
6332
  "---",
5499
6333
  "trigger: manual",
@@ -5504,39 +6338,39 @@ function ensureWindsurfRules(cwd) {
5504
6338
  skillBody(),
5505
6339
  ""
5506
6340
  ].join("\n");
5507
- return updateFile(join8(rulesDir, "glassbox.md"), content);
6341
+ return updateFile(join9(rulesDir, "glassbox.md"), content);
5508
6342
  }
5509
6343
  function ensureSkills() {
5510
6344
  const cwd = process.cwd();
5511
6345
  const platforms = [];
5512
- if (existsSync7(join8(cwd, ".claude"))) {
6346
+ if (existsSync8(join9(cwd, ".claude"))) {
5513
6347
  if (ensureClaudeSkills(cwd)) platforms.push("Claude Code");
5514
6348
  }
5515
- if (existsSync7(join8(cwd, ".cursor"))) {
6349
+ if (existsSync8(join9(cwd, ".cursor"))) {
5516
6350
  if (ensureCursorRules(cwd)) platforms.push("Cursor");
5517
6351
  }
5518
- if (existsSync7(join8(cwd, ".github", "prompts")) || existsSync7(join8(cwd, ".github", "copilot-instructions.md"))) {
6352
+ if (existsSync8(join9(cwd, ".github", "prompts")) || existsSync8(join9(cwd, ".github", "copilot-instructions.md"))) {
5519
6353
  if (ensureCopilotPrompts(cwd)) platforms.push("GitHub Copilot");
5520
6354
  }
5521
- if (existsSync7(join8(cwd, ".windsurf"))) {
6355
+ if (existsSync8(join9(cwd, ".windsurf"))) {
5522
6356
  if (ensureWindsurfRules(cwd)) platforms.push("Windsurf");
5523
6357
  }
5524
6358
  return platforms;
5525
6359
  }
5526
6360
 
5527
6361
  // src/update-check.ts
5528
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
6362
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
5529
6363
  import { get } from "https";
5530
- import { homedir as homedir3 } from "os";
5531
- import { dirname as dirname2, join as join9 } from "path";
6364
+ import { homedir as homedir4 } from "os";
6365
+ import { dirname as dirname2, join as join10 } from "path";
5532
6366
  import { fileURLToPath as fileURLToPath2 } from "url";
5533
- var DATA_DIR = join9(homedir3(), ".glassbox");
5534
- var CHECK_FILE = join9(DATA_DIR, "last-update-check");
6367
+ var DATA_DIR = join10(homedir4(), ".glassbox");
6368
+ var CHECK_FILE = join10(DATA_DIR, "last-update-check");
5535
6369
  var PACKAGE_NAME = "glassbox";
5536
6370
  function getCurrentVersion() {
5537
6371
  try {
5538
6372
  const dir = dirname2(fileURLToPath2(import.meta.url));
5539
- const pkg = JSON.parse(readFileSync11(join9(dir, "..", "package.json"), "utf-8"));
6373
+ const pkg = JSON.parse(readFileSync12(join10(dir, "..", "package.json"), "utf-8"));
5540
6374
  return pkg.version;
5541
6375
  } catch {
5542
6376
  return "0.0.0";
@@ -5544,16 +6378,16 @@ function getCurrentVersion() {
5544
6378
  }
5545
6379
  function getLastCheckDate() {
5546
6380
  try {
5547
- if (existsSync8(CHECK_FILE)) {
5548
- return readFileSync11(CHECK_FILE, "utf-8").trim();
6381
+ if (existsSync9(CHECK_FILE)) {
6382
+ return readFileSync12(CHECK_FILE, "utf-8").trim();
5549
6383
  }
5550
6384
  } catch {
5551
6385
  }
5552
6386
  return null;
5553
6387
  }
5554
6388
  function saveCheckDate() {
5555
- mkdirSync6(DATA_DIR, { recursive: true });
5556
- writeFileSync6(CHECK_FILE, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "utf-8");
6389
+ mkdirSync7(DATA_DIR, { recursive: true });
6390
+ writeFileSync7(CHECK_FILE, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "utf-8");
5557
6391
  }
5558
6392
  function isFirstUseToday() {
5559
6393
  const last = getLastCheckDate();
@@ -5636,6 +6470,7 @@ async function checkForUpdates(force) {
5636
6470
  }
5637
6471
 
5638
6472
  // src/cli.ts
6473
+ import { realpathSync } from "fs";
5639
6474
  function printUsage() {
5640
6475
  console.log(`
5641
6476
  glassbox - Review AI-generated code with annotations
@@ -5779,13 +6614,13 @@ async function main() {
5779
6614
  console.log("AI service test mode enabled \u2014 using mock AI responses");
5780
6615
  }
5781
6616
  if (debug) {
5782
- console.log(`[debug] Build timestamp: ${"2026-03-27T03:39:52.371Z"}`);
6617
+ console.log(`[debug] Build timestamp: ${"2026-04-02T11:34:35.783Z"}`);
5783
6618
  }
5784
- if (projectDir) {
6619
+ if (projectDir !== null) {
5785
6620
  process.chdir(projectDir);
5786
6621
  }
5787
6622
  if (dataDir === null) {
5788
- dataDir = join10(process.cwd(), ".glassbox");
6623
+ dataDir = join11(process.cwd(), ".glassbox");
5789
6624
  }
5790
6625
  if (demo !== null) {
5791
6626
  const scenario = DEMO_SCENARIOS.find((s) => s.id === demo);
@@ -5797,13 +6632,13 @@ async function main() {
5797
6632
  }
5798
6633
  process.exit(1);
5799
6634
  }
5800
- dataDir = join10(tmpdir(), `glassbox-demo-${demo}-${Date.now()}`);
6635
+ dataDir = join11(tmpdir(), `glassbox-demo-${demo}-${Date.now()}`);
5801
6636
  setDemoMode(demo);
5802
6637
  console.log(`
5803
6638
  DEMO MODE: ${scenario.label}
5804
6639
  `);
5805
6640
  }
5806
- mkdirSync7(dataDir, { recursive: true });
6641
+ mkdirSync8(dataDir, { recursive: true });
5807
6642
  if (demo === null) {
5808
6643
  acquireLock(dataDir);
5809
6644
  }
@@ -5860,8 +6695,15 @@ async function main() {
5860
6695
  console.log(`Review ${review.id} created.`);
5861
6696
  await startServer(port, review.id, repoRoot, { noOpen, strictPort });
5862
6697
  }
5863
- main().catch((err) => {
5864
- console.error(err);
5865
- process.exit(1);
5866
- });
6698
+ var resolvedArg = process.argv[1] !== void 0 ? realpathSync(process.argv[1]) : "";
6699
+ var isDirectRun = resolvedArg.endsWith("cli.js") || resolvedArg.endsWith("cli.ts");
6700
+ if (isDirectRun) {
6701
+ main().catch((err) => {
6702
+ console.error(err);
6703
+ process.exit(1);
6704
+ });
6705
+ }
6706
+ export {
6707
+ parseArgs
6708
+ };
5867
6709
  //# sourceMappingURL=cli.js.map