uilint 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -237,8 +237,8 @@ async function initializeLangfuseIfEnabled() {
237
237
  },
238
238
  { asType: "generation" }
239
239
  );
240
- await new Promise((resolve7) => {
241
- resolveTrace = resolve7;
240
+ await new Promise((resolve8) => {
241
+ resolveTrace = resolve8;
242
242
  });
243
243
  if (endData && generationRef) {
244
244
  const usageDetails = endData.usage ? Object.fromEntries(
@@ -329,6 +329,26 @@ function registerShutdownHandler() {
329
329
  }
330
330
  registerShutdownHandler();
331
331
 
332
+ // src/utils/timing.ts
333
+ function nsNow() {
334
+ return process.hrtime.bigint();
335
+ }
336
+ function nsToMs(ns) {
337
+ return Number(ns) / 1e6;
338
+ }
339
+ function formatMs(ms) {
340
+ if (!Number.isFinite(ms)) return "n/a";
341
+ if (ms < 1e3) return `${ms.toFixed(ms < 10 ? 2 : ms < 100 ? 1 : 0)}ms`;
342
+ const s = ms / 1e3;
343
+ if (s < 60) return `${s.toFixed(s < 10 ? 2 : 1)}s`;
344
+ const m = Math.floor(s / 60);
345
+ const rem = s - m * 60;
346
+ return `${m}m ${rem.toFixed(rem < 10 ? 1 : 0)}s`;
347
+ }
348
+ function maybeMs(ms) {
349
+ return ms == null ? "n/a" : formatMs(ms);
350
+ }
351
+
332
352
  // src/utils/prompts.ts
333
353
  import * as p from "@clack/prompts";
334
354
  import pc from "picocolors";
@@ -337,8 +357,8 @@ import { dirname, join as join2 } from "path";
337
357
  import { fileURLToPath } from "url";
338
358
  function getCLIVersion() {
339
359
  try {
340
- const __dirname = dirname(fileURLToPath(import.meta.url));
341
- const pkgPath = join2(__dirname, "..", "..", "package.json");
360
+ const __dirname2 = dirname(fileURLToPath(import.meta.url));
361
+ const pkgPath = join2(__dirname2, "..", "..", "package.json");
342
362
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
343
363
  return pkg.version || "0.0.0";
344
364
  } catch {
@@ -369,7 +389,7 @@ async function withSpinner(message, fn) {
369
389
  const s = p.spinner();
370
390
  s.start(message);
371
391
  try {
372
- const result = await fn();
392
+ const result = fn.length >= 1 ? await fn(s) : await fn();
373
393
  s.stop(pc.green("\u2713 ") + message);
374
394
  return result;
375
395
  } catch (error) {
@@ -642,6 +662,7 @@ async function scan(options) {
642
662
  } else if (dbg && styleSummary) {
643
663
  debugLog(dbg, "Style summary (preview)", preview(styleSummary, 800));
644
664
  }
665
+ const prepStartNs = nsNow();
645
666
  if (!isJsonOutput) {
646
667
  await withSpinner("Preparing Ollama", async () => {
647
668
  await ensureOllamaReady();
@@ -649,6 +670,7 @@ async function scan(options) {
649
670
  } else {
650
671
  await ensureOllamaReady();
651
672
  }
673
+ const prepEndNs = nsNow();
652
674
  const client = await createLLMClient({});
653
675
  let result;
654
676
  const prompt = snapshot.kind === "dom" ? buildAnalysisPrompt(styleSummary ?? "", styleGuide) : buildSourceAnalysisPrompt(snapshot.source, styleGuide, {
@@ -764,7 +786,33 @@ async function scan(options) {
764
786
  } else {
765
787
  const s = createSpinner();
766
788
  s.start("Analyzing with LLM");
767
- const onProgress = (latestLine) => {
789
+ let thinkingStarted = false;
790
+ const analysisStartNs = nsNow();
791
+ let firstTokenNs = null;
792
+ let firstThinkingNs = null;
793
+ let lastThinkingNs = null;
794
+ let firstAnswerNs = null;
795
+ let lastAnswerNs = null;
796
+ const onProgress = (latestLine, _fullResponse, delta, thinkingDelta) => {
797
+ const nowNs = nsNow();
798
+ if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
799
+ if (thinkingDelta) {
800
+ if (!firstThinkingNs) firstThinkingNs = nowNs;
801
+ lastThinkingNs = nowNs;
802
+ }
803
+ if (delta) {
804
+ if (!firstAnswerNs) firstAnswerNs = nowNs;
805
+ lastAnswerNs = nowNs;
806
+ }
807
+ if (thinkingDelta) {
808
+ if (!thinkingStarted) {
809
+ thinkingStarted = true;
810
+ s.stop(pc.dim("[scan] streaming thinking:"));
811
+ process.stderr.write(pc.dim("Thinking:\n"));
812
+ }
813
+ process.stderr.write(thinkingDelta);
814
+ return;
815
+ }
768
816
  const maxLen = 60;
769
817
  const displayLine = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
770
818
  s.message(`Analyzing: ${pc.dim(displayLine || "...")}`);
@@ -790,6 +838,29 @@ async function scan(options) {
790
838
  }
791
839
  );
792
840
  s.stop(pc.green("\u2713 ") + "Analyzing with LLM");
841
+ if (process.stdout.isTTY) {
842
+ const analysisEndNs = nsNow();
843
+ const prepMs = nsToMs(prepEndNs - prepStartNs);
844
+ const totalMs = nsToMs(analysisEndNs - analysisStartNs);
845
+ const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
846
+ const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
847
+ const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
848
+ (firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
849
+ ) : null;
850
+ const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
851
+ note2(
852
+ [
853
+ `Prepare Ollama: ${formatMs(prepMs)}`,
854
+ `Time to first token: ${maybeMs(ttftMs)}`,
855
+ `Thinking: ${maybeMs(thinkingMs)}`,
856
+ `Outputting: ${maybeMs(outputMs)}`,
857
+ `LLM total: ${formatMs(totalMs)}`,
858
+ `End-to-end: ${formatMs(endToEndMs)}`,
859
+ result?.analysisTime ? pc.dim(`(core analysisTime: ${formatMs(result.analysisTime)})`) : pc.dim("(core analysisTime: n/a)")
860
+ ].join("\n"),
861
+ "Timings"
862
+ );
863
+ }
793
864
  } catch (error) {
794
865
  s.stop(pc.red("\u2717 ") + "Analyzing with LLM");
795
866
  throw error;
@@ -970,6 +1041,7 @@ async function analyze(options) {
970
1041
  } else if (resolvedStyle.path && !isJsonOutput) {
971
1042
  logSuccess(`Using styleguide: ${pc.dim(resolvedStyle.path)}`);
972
1043
  }
1044
+ const prepStartNs = nsNow();
973
1045
  if (!isJsonOutput) {
974
1046
  await withSpinner("Preparing Ollama", async () => {
975
1047
  await ensureOllamaReady2();
@@ -977,6 +1049,7 @@ async function analyze(options) {
977
1049
  } else {
978
1050
  await ensureOllamaReady2();
979
1051
  }
1052
+ const prepEndNs = nsNow();
980
1053
  const client = await createLLMClient({});
981
1054
  const promptContext = {
982
1055
  filePath: options.filePath || (options.inputFile ? options.inputFile : void 0) || "component.tsx",
@@ -1065,17 +1138,65 @@ async function analyze(options) {
1065
1138
  if (!isJsonOutput) {
1066
1139
  const s = createSpinner();
1067
1140
  s.start("Analyzing with LLM");
1141
+ let thinkingStarted = false;
1142
+ const analysisStartNs = nsNow();
1143
+ let firstTokenNs = null;
1144
+ let firstThinkingNs = null;
1145
+ let lastThinkingNs = null;
1146
+ let firstAnswerNs = null;
1147
+ let lastAnswerNs = null;
1068
1148
  try {
1069
1149
  responseText = await client.complete(prompt, {
1070
1150
  json: true,
1071
1151
  stream: true,
1072
- onProgress: (latestLine) => {
1152
+ onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
1153
+ const nowNs = nsNow();
1154
+ if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
1155
+ if (thinkingDelta) {
1156
+ if (!firstThinkingNs) firstThinkingNs = nowNs;
1157
+ lastThinkingNs = nowNs;
1158
+ }
1159
+ if (delta) {
1160
+ if (!firstAnswerNs) firstAnswerNs = nowNs;
1161
+ lastAnswerNs = nowNs;
1162
+ }
1163
+ if (thinkingDelta) {
1164
+ if (!thinkingStarted) {
1165
+ thinkingStarted = true;
1166
+ s.stop(pc.dim("[analyze] streaming thinking:"));
1167
+ process.stderr.write(pc.dim("Thinking:\n"));
1168
+ }
1169
+ process.stderr.write(thinkingDelta);
1170
+ return;
1171
+ }
1073
1172
  const maxLen = 60;
1074
1173
  const line = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
1075
1174
  s.message(`Analyzing: ${pc.dim(line || "...")}`);
1076
1175
  }
1077
1176
  });
1078
1177
  s.stop(pc.green("\u2713 ") + "Analyzing with LLM");
1178
+ if (process.stdout.isTTY) {
1179
+ const analysisEndNs = nsNow();
1180
+ const prepMs = nsToMs(prepEndNs - prepStartNs);
1181
+ const totalMs = nsToMs(analysisEndNs - analysisStartNs);
1182
+ const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
1183
+ const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
1184
+ const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
1185
+ (firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
1186
+ ) : null;
1187
+ const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
1188
+ note2(
1189
+ [
1190
+ `Prepare Ollama: ${formatMs(prepMs)}`,
1191
+ `Time to first token: ${maybeMs(ttftMs)}`,
1192
+ `Thinking: ${maybeMs(thinkingMs)}`,
1193
+ `Outputting: ${maybeMs(outputMs)}`,
1194
+ `LLM total: ${formatMs(totalMs)}`,
1195
+ `End-to-end: ${formatMs(endToEndMs)}`
1196
+ ].join("\n"),
1197
+ "Timings"
1198
+ );
1199
+ }
1079
1200
  } catch (e) {
1080
1201
  s.stop(pc.red("\u2717 ") + "Analyzing with LLM");
1081
1202
  throw e;
@@ -1128,14 +1249,14 @@ import {
1128
1249
  } from "uilint-core";
1129
1250
  import { ensureOllamaReady as ensureOllamaReady3 } from "uilint-core/node";
1130
1251
  async function readStdin2() {
1131
- return new Promise((resolve7) => {
1252
+ return new Promise((resolve8) => {
1132
1253
  let data = "";
1133
1254
  const rl = createInterface({ input: process.stdin });
1134
1255
  rl.on("line", (line) => {
1135
1256
  data += line;
1136
1257
  });
1137
1258
  rl.on("close", () => {
1138
- resolve7(data);
1259
+ resolve8(data);
1139
1260
  });
1140
1261
  });
1141
1262
  }
@@ -1354,12 +1475,12 @@ async function update(options) {
1354
1475
  }
1355
1476
 
1356
1477
  // src/commands/install.ts
1357
- import { join as join12 } from "path";
1478
+ import { join as join15 } from "path";
1358
1479
  import { ruleRegistry as ruleRegistry2 } from "uilint-eslint";
1359
1480
 
1360
1481
  // src/commands/install/analyze.ts
1361
- import { existsSync as existsSync8, readFileSync as readFileSync4 } from "fs";
1362
- import { join as join7 } from "path";
1482
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
1483
+ import { join as join8 } from "path";
1363
1484
  import { findWorkspaceRoot as findWorkspaceRoot4 } from "uilint-core/node";
1364
1485
 
1365
1486
  // src/utils/next-detect.ts
@@ -1444,10 +1565,113 @@ function findNextAppRouterProjects(rootDir, options) {
1444
1565
  return results;
1445
1566
  }
1446
1567
 
1447
- // src/utils/package-detect.ts
1568
+ // src/utils/vite-detect.ts
1448
1569
  import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
1449
- import { join as join4, relative } from "path";
1570
+ import { join as join4 } from "path";
1571
+ var VITE_CONFIG_EXTS = [".ts", ".mjs", ".js", ".cjs"];
1572
+ function findViteConfigFile(projectPath) {
1573
+ for (const ext of VITE_CONFIG_EXTS) {
1574
+ const rel = `vite.config${ext}`;
1575
+ if (existsSync5(join4(projectPath, rel))) return rel;
1576
+ }
1577
+ return null;
1578
+ }
1579
+ function looksLikeReactPackage(projectPath) {
1580
+ try {
1581
+ const pkgPath = join4(projectPath, "package.json");
1582
+ if (!existsSync5(pkgPath)) return false;
1583
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1584
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1585
+ return "react" in deps || "react-dom" in deps;
1586
+ } catch {
1587
+ return false;
1588
+ }
1589
+ }
1590
+ function fileExists2(projectPath, relPath) {
1591
+ return existsSync5(join4(projectPath, relPath));
1592
+ }
1593
+ function detectViteReact(projectPath) {
1594
+ const configFile = findViteConfigFile(projectPath);
1595
+ if (!configFile) return null;
1596
+ if (!looksLikeReactPackage(projectPath)) return null;
1597
+ const entryRoot = "src";
1598
+ const candidates = [];
1599
+ const entryCandidates = [
1600
+ join4(entryRoot, "main.tsx"),
1601
+ join4(entryRoot, "main.jsx"),
1602
+ join4(entryRoot, "main.ts"),
1603
+ join4(entryRoot, "main.js")
1604
+ ];
1605
+ for (const rel of entryCandidates) {
1606
+ if (fileExists2(projectPath, rel)) candidates.push(rel);
1607
+ }
1608
+ const fallbackCandidates = [
1609
+ join4(entryRoot, "App.tsx"),
1610
+ join4(entryRoot, "App.jsx")
1611
+ ];
1612
+ for (const rel of fallbackCandidates) {
1613
+ if (!candidates.includes(rel) && fileExists2(projectPath, rel)) {
1614
+ candidates.push(rel);
1615
+ }
1616
+ }
1617
+ return {
1618
+ configFile,
1619
+ configFileAbs: join4(projectPath, configFile),
1620
+ entryRoot,
1621
+ candidates
1622
+ };
1623
+ }
1450
1624
  var DEFAULT_IGNORE_DIRS2 = /* @__PURE__ */ new Set([
1625
+ "node_modules",
1626
+ ".git",
1627
+ ".next",
1628
+ "dist",
1629
+ "build",
1630
+ "out",
1631
+ ".turbo",
1632
+ ".vercel",
1633
+ ".cursor",
1634
+ "coverage",
1635
+ ".uilint"
1636
+ ]);
1637
+ function findViteReactProjects(rootDir, options) {
1638
+ const maxDepth = options?.maxDepth ?? 4;
1639
+ const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS2;
1640
+ const results = [];
1641
+ const visited = /* @__PURE__ */ new Set();
1642
+ function walk(dir, depth) {
1643
+ if (depth > maxDepth) return;
1644
+ if (visited.has(dir)) return;
1645
+ visited.add(dir);
1646
+ const detection = detectViteReact(dir);
1647
+ if (detection) {
1648
+ results.push({ projectPath: dir, detection });
1649
+ return;
1650
+ }
1651
+ let entries = [];
1652
+ try {
1653
+ entries = readdirSync2(dir, { withFileTypes: true }).map((d) => ({
1654
+ name: d.name,
1655
+ isDirectory: d.isDirectory()
1656
+ }));
1657
+ } catch {
1658
+ return;
1659
+ }
1660
+ for (const ent of entries) {
1661
+ if (!ent.isDirectory) continue;
1662
+ if (ignoreDirs.has(ent.name)) continue;
1663
+ if (ent.name.startsWith(".") && ent.name !== ".") continue;
1664
+ walk(join4(dir, ent.name), depth + 1);
1665
+ }
1666
+ }
1667
+ walk(rootDir, 0);
1668
+ return results;
1669
+ }
1670
+
1671
+ // src/utils/package-detect.ts
1672
+ import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
1673
+ import { join as join5, relative } from "path";
1674
+ var DEFAULT_IGNORE_DIRS3 = /* @__PURE__ */ new Set([
1451
1675
  "node_modules",
1452
1676
  ".git",
1453
1677
  ".next",
@@ -1463,6 +1687,7 @@ var DEFAULT_IGNORE_DIRS2 = /* @__PURE__ */ new Set([
1463
1687
  ]);
1464
1688
  var ESLINT_CONFIG_FILES = [
1465
1689
  "eslint.config.js",
1690
+ "eslint.config.ts",
1466
1691
  "eslint.config.mjs",
1467
1692
  "eslint.config.cjs",
1468
1693
  ".eslintrc.js",
@@ -1491,14 +1716,14 @@ function isFrontendPackage(pkgJson) {
1491
1716
  }
1492
1717
  function hasEslintConfig(dir) {
1493
1718
  for (const file of ESLINT_CONFIG_FILES) {
1494
- if (existsSync5(join4(dir, file))) {
1719
+ if (existsSync6(join5(dir, file))) {
1495
1720
  return true;
1496
1721
  }
1497
1722
  }
1498
1723
  try {
1499
- const pkgPath = join4(dir, "package.json");
1500
- if (existsSync5(pkgPath)) {
1501
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1724
+ const pkgPath = join5(dir, "package.json");
1725
+ if (existsSync6(pkgPath)) {
1726
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
1502
1727
  if (pkg.eslintConfig) return true;
1503
1728
  }
1504
1729
  } catch {
@@ -1507,14 +1732,14 @@ function hasEslintConfig(dir) {
1507
1732
  }
1508
1733
  function findPackages(rootDir, options) {
1509
1734
  const maxDepth = options?.maxDepth ?? 5;
1510
- const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS2;
1735
+ const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS3;
1511
1736
  const results = [];
1512
1737
  const visited = /* @__PURE__ */ new Set();
1513
1738
  function processPackage(dir, isRoot) {
1514
- const pkgPath = join4(dir, "package.json");
1515
- if (!existsSync5(pkgPath)) return null;
1739
+ const pkgPath = join5(dir, "package.json");
1740
+ if (!existsSync6(pkgPath)) return null;
1516
1741
  try {
1517
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
1742
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
1518
1743
  const name = pkg.name || relative(rootDir, dir) || ".";
1519
1744
  return {
1520
1745
  path: dir,
@@ -1538,7 +1763,7 @@ function findPackages(rootDir, options) {
1538
1763
  }
1539
1764
  let entries = [];
1540
1765
  try {
1541
- entries = readdirSync2(dir, { withFileTypes: true }).map((d) => ({
1766
+ entries = readdirSync3(dir, { withFileTypes: true }).map((d) => ({
1542
1767
  name: d.name,
1543
1768
  isDirectory: d.isDirectory()
1544
1769
  }));
@@ -1549,7 +1774,7 @@ function findPackages(rootDir, options) {
1549
1774
  if (!ent.isDirectory) continue;
1550
1775
  if (ignoreDirs.has(ent.name)) continue;
1551
1776
  if (ent.name.startsWith(".")) continue;
1552
- walk(join4(dir, ent.name), depth + 1);
1777
+ walk(join5(dir, ent.name), depth + 1);
1553
1778
  }
1554
1779
  }
1555
1780
  walk(rootDir, 0);
@@ -1574,18 +1799,18 @@ function formatPackageOption(pkg) {
1574
1799
  }
1575
1800
 
1576
1801
  // src/utils/package-manager.ts
1577
- import { existsSync as existsSync6 } from "fs";
1802
+ import { existsSync as existsSync7 } from "fs";
1578
1803
  import { spawn } from "child_process";
1579
- import { dirname as dirname5, join as join5 } from "path";
1804
+ import { dirname as dirname5, join as join6 } from "path";
1580
1805
  function detectPackageManager(projectPath) {
1581
1806
  let dir = projectPath;
1582
1807
  for (; ; ) {
1583
- if (existsSync6(join5(dir, "pnpm-lock.yaml"))) return "pnpm";
1584
- if (existsSync6(join5(dir, "pnpm-workspace.yaml"))) return "pnpm";
1585
- if (existsSync6(join5(dir, "yarn.lock"))) return "yarn";
1586
- if (existsSync6(join5(dir, "bun.lockb"))) return "bun";
1587
- if (existsSync6(join5(dir, "bun.lock"))) return "bun";
1588
- if (existsSync6(join5(dir, "package-lock.json"))) return "npm";
1808
+ if (existsSync7(join6(dir, "pnpm-lock.yaml"))) return "pnpm";
1809
+ if (existsSync7(join6(dir, "pnpm-workspace.yaml"))) return "pnpm";
1810
+ if (existsSync7(join6(dir, "yarn.lock"))) return "yarn";
1811
+ if (existsSync7(join6(dir, "bun.lockb"))) return "bun";
1812
+ if (existsSync7(join6(dir, "bun.lock"))) return "bun";
1813
+ if (existsSync7(join6(dir, "package-lock.json"))) return "npm";
1589
1814
  const parent = dirname5(dir);
1590
1815
  if (parent === dir) break;
1591
1816
  dir = parent;
@@ -1593,7 +1818,7 @@ function detectPackageManager(projectPath) {
1593
1818
  return "npm";
1594
1819
  }
1595
1820
  function spawnAsync(command, args, cwd) {
1596
- return new Promise((resolve7, reject) => {
1821
+ return new Promise((resolve8, reject) => {
1597
1822
  const child = spawn(command, args, {
1598
1823
  cwd,
1599
1824
  stdio: "inherit",
@@ -1601,7 +1826,7 @@ function spawnAsync(command, args, cwd) {
1601
1826
  });
1602
1827
  child.on("error", reject);
1603
1828
  child.on("close", (code) => {
1604
- if (code === 0) resolve7();
1829
+ if (code === 0) resolve8();
1605
1830
  else
1606
1831
  reject(new Error(`${command} ${args.join(" ")} exited with ${code}`));
1607
1832
  });
@@ -1627,14 +1852,14 @@ async function installDependencies(pm, projectPath, packages) {
1627
1852
  }
1628
1853
 
1629
1854
  // src/utils/eslint-config-inject.ts
1630
- import { existsSync as existsSync7, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1631
- import { join as join6 } from "path";
1855
+ import { existsSync as existsSync8, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1856
+ import { join as join7 } from "path";
1632
1857
  import { parseExpression, parseModule, generateCode } from "magicast";
1633
- var CONFIG_EXTENSIONS = [".mjs", ".js", ".cjs"];
1858
+ var CONFIG_EXTENSIONS = [".ts", ".mjs", ".js", ".cjs"];
1634
1859
  function findEslintConfigFile(projectPath) {
1635
1860
  for (const ext of CONFIG_EXTENSIONS) {
1636
- const configPath = join6(projectPath, `eslint.config${ext}`);
1637
- if (existsSync7(configPath)) {
1861
+ const configPath = join7(projectPath, `eslint.config${ext}`);
1862
+ if (existsSync8(configPath)) {
1638
1863
  return configPath;
1639
1864
  }
1640
1865
  }
@@ -1732,7 +1957,7 @@ function collectUilintRuleIdsFromRulesObject(rulesObj) {
1732
1957
  return ids;
1733
1958
  }
1734
1959
  function findExportedConfigArrayExpression(mod) {
1735
- function unwrapExpression(expr) {
1960
+ function unwrapExpression2(expr) {
1736
1961
  let e = expr;
1737
1962
  while (e) {
1738
1963
  if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
@@ -1758,11 +1983,11 @@ function findExportedConfigArrayExpression(mod) {
1758
1983
  for (const decl of stmt.declarations ?? []) {
1759
1984
  const id = decl?.id;
1760
1985
  if (!isIdentifier(id, name)) continue;
1761
- const init = unwrapExpression(decl?.init);
1986
+ const init = unwrapExpression2(decl?.init);
1762
1987
  if (!init) return null;
1763
1988
  if (init.type === "ArrayExpression") return init;
1764
- if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression(init.arguments?.[0])?.type === "ArrayExpression") {
1765
- return unwrapExpression(init.arguments?.[0]);
1989
+ if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression2(init.arguments?.[0])?.type === "ArrayExpression") {
1990
+ return unwrapExpression2(init.arguments?.[0]);
1766
1991
  }
1767
1992
  return null;
1768
1993
  }
@@ -1773,15 +1998,15 @@ function findExportedConfigArrayExpression(mod) {
1773
1998
  if (program2 && program2.type === "Program") {
1774
1999
  for (const stmt of program2.body ?? []) {
1775
2000
  if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
1776
- const decl = unwrapExpression(stmt.declaration);
2001
+ const decl = unwrapExpression2(stmt.declaration);
1777
2002
  if (!decl) break;
1778
2003
  if (decl.type === "ArrayExpression") {
1779
2004
  return { kind: "esm", arrayExpr: decl, program: program2 };
1780
2005
  }
1781
- if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression(decl.arguments?.[0])?.type === "ArrayExpression") {
2006
+ if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression2(decl.arguments?.[0])?.type === "ArrayExpression") {
1782
2007
  return {
1783
2008
  kind: "esm",
1784
- arrayExpr: unwrapExpression(decl.arguments?.[0]),
2009
+ arrayExpr: unwrapExpression2(decl.arguments?.[0]),
1785
2010
  program: program2
1786
2011
  };
1787
2012
  }
@@ -2041,7 +2266,7 @@ async function installEslintPlugin(opts) {
2041
2266
  };
2042
2267
  }
2043
2268
  const configFilename = getEslintConfigFilename(configPath);
2044
- const original = readFileSync3(configPath, "utf-8");
2269
+ const original = readFileSync4(configPath, "utf-8");
2045
2270
  const isCommonJS = configPath.endsWith(".cjs");
2046
2271
  const ast = getUilintEslintConfigInfoFromSourceAst(original);
2047
2272
  if ("error" in ast) {
@@ -2143,7 +2368,7 @@ async function installEslintPlugin(opts) {
2143
2368
  var LEGACY_HOOK_FILES = ["uilint-validate.sh", "uilint-validate.js"];
2144
2369
  function safeParseJson(filePath) {
2145
2370
  try {
2146
- const content = readFileSync4(filePath, "utf-8");
2371
+ const content = readFileSync5(filePath, "utf-8");
2147
2372
  return JSON.parse(content);
2148
2373
  } catch {
2149
2374
  return void 0;
@@ -2152,27 +2377,27 @@ function safeParseJson(filePath) {
2152
2377
  async function analyze2(projectPath = process.cwd()) {
2153
2378
  const workspaceRoot = findWorkspaceRoot4(projectPath);
2154
2379
  const packageManager = detectPackageManager(projectPath);
2155
- const cursorDir = join7(projectPath, ".cursor");
2156
- const cursorDirExists = existsSync8(cursorDir);
2157
- const mcpPath = join7(cursorDir, "mcp.json");
2158
- const mcpExists = existsSync8(mcpPath);
2380
+ const cursorDir = join8(projectPath, ".cursor");
2381
+ const cursorDirExists = existsSync9(cursorDir);
2382
+ const mcpPath = join8(cursorDir, "mcp.json");
2383
+ const mcpExists = existsSync9(mcpPath);
2159
2384
  const mcpConfig = mcpExists ? safeParseJson(mcpPath) : void 0;
2160
- const hooksPath = join7(cursorDir, "hooks.json");
2161
- const hooksExists = existsSync8(hooksPath);
2385
+ const hooksPath = join8(cursorDir, "hooks.json");
2386
+ const hooksExists = existsSync9(hooksPath);
2162
2387
  const hooksConfig = hooksExists ? safeParseJson(hooksPath) : void 0;
2163
- const hooksDir = join7(cursorDir, "hooks");
2388
+ const hooksDir = join8(cursorDir, "hooks");
2164
2389
  const legacyPaths = [];
2165
2390
  for (const legacyFile of LEGACY_HOOK_FILES) {
2166
- const legacyPath = join7(hooksDir, legacyFile);
2167
- if (existsSync8(legacyPath)) {
2391
+ const legacyPath = join8(hooksDir, legacyFile);
2392
+ if (existsSync9(legacyPath)) {
2168
2393
  legacyPaths.push(legacyPath);
2169
2394
  }
2170
2395
  }
2171
- const styleguidePath = join7(projectPath, ".uilint", "styleguide.md");
2172
- const styleguideExists = existsSync8(styleguidePath);
2173
- const commandsDir = join7(cursorDir, "commands");
2174
- const genstyleguideExists = existsSync8(join7(commandsDir, "genstyleguide.md"));
2175
- const genrulesExists = existsSync8(join7(commandsDir, "genrules.md"));
2396
+ const styleguidePath = join8(projectPath, ".uilint", "styleguide.md");
2397
+ const styleguideExists = existsSync9(styleguidePath);
2398
+ const commandsDir = join8(cursorDir, "commands");
2399
+ const genstyleguideExists = existsSync9(join8(commandsDir, "genstyleguide.md"));
2400
+ const genrulesExists = existsSync9(join8(commandsDir, "genrules.md"));
2176
2401
  const nextApps = [];
2177
2402
  const directDetection = detectNextAppRouter(projectPath);
2178
2403
  if (directDetection) {
@@ -2186,6 +2411,19 @@ async function analyze2(projectPath = process.cwd()) {
2186
2411
  });
2187
2412
  }
2188
2413
  }
2414
+ const viteApps = [];
2415
+ const directVite = detectViteReact(projectPath);
2416
+ if (directVite) {
2417
+ viteApps.push({ projectPath, detection: directVite });
2418
+ } else {
2419
+ const matches = findViteReactProjects(workspaceRoot, { maxDepth: 5 });
2420
+ for (const match of matches) {
2421
+ viteApps.push({
2422
+ projectPath: match.projectPath,
2423
+ detection: match.detection
2424
+ });
2425
+ }
2426
+ }
2189
2427
  const rawPackages = findPackages(workspaceRoot);
2190
2428
  const packages = rawPackages.map((pkg) => {
2191
2429
  const eslintConfigPath = findEslintConfigFile(pkg.path);
@@ -2195,7 +2433,7 @@ async function analyze2(projectPath = process.cwd()) {
2195
2433
  if (eslintConfigPath) {
2196
2434
  eslintConfigFilename = getEslintConfigFilename(eslintConfigPath);
2197
2435
  try {
2198
- const source = readFileSync4(eslintConfigPath, "utf-8");
2436
+ const source = readFileSync5(eslintConfigPath, "utf-8");
2199
2437
  const info = getUilintEslintConfigInfoFromSource(source);
2200
2438
  hasRules = info.configuredRuleIds.size > 0 || info.usesUilintConfigs;
2201
2439
  configuredRuleIds = Array.from(info.configuredRuleIds);
@@ -2239,12 +2477,13 @@ async function analyze2(projectPath = process.cwd()) {
2239
2477
  genrules: genrulesExists
2240
2478
  },
2241
2479
  nextApps,
2480
+ viteApps,
2242
2481
  packages
2243
2482
  };
2244
2483
  }
2245
2484
 
2246
2485
  // src/commands/install/plan.ts
2247
- import { join as join8 } from "path";
2486
+ import { join as join10 } from "path";
2248
2487
  import { createRequire } from "module";
2249
2488
 
2250
2489
  // src/commands/install/constants.ts
@@ -2633,6 +2872,55 @@ Generate in \`.uilint/rules/\`:
2633
2872
  - **Minimal rules** - generate 3-5 high-impact rules, not dozens
2634
2873
  `;
2635
2874
 
2875
+ // src/utils/skill-loader.ts
2876
+ import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync2, existsSync as existsSync10 } from "fs";
2877
+ import { join as join9, dirname as dirname6, relative as relative2 } from "path";
2878
+ import { fileURLToPath as fileURLToPath2 } from "url";
2879
+ var __filename = fileURLToPath2(import.meta.url);
2880
+ var __dirname = dirname6(__filename);
2881
+ function getSkillsDir() {
2882
+ const devPath = join9(__dirname, "..", "..", "skills");
2883
+ const prodPath = join9(__dirname, "..", "skills");
2884
+ if (existsSync10(devPath)) {
2885
+ return devPath;
2886
+ }
2887
+ if (existsSync10(prodPath)) {
2888
+ return prodPath;
2889
+ }
2890
+ throw new Error(
2891
+ "Could not find skills directory. This is a bug in uilint installation."
2892
+ );
2893
+ }
2894
+ function collectFiles(dir, baseDir) {
2895
+ const files = [];
2896
+ const entries = readdirSync4(dir);
2897
+ for (const entry of entries) {
2898
+ const fullPath = join9(dir, entry);
2899
+ const stat = statSync2(fullPath);
2900
+ if (stat.isDirectory()) {
2901
+ files.push(...collectFiles(fullPath, baseDir));
2902
+ } else if (stat.isFile()) {
2903
+ const relativePath = relative2(baseDir, fullPath);
2904
+ const content = readFileSync6(fullPath, "utf-8");
2905
+ files.push({ relativePath, content });
2906
+ }
2907
+ }
2908
+ return files;
2909
+ }
2910
+ function loadSkill(name) {
2911
+ const skillsDir = getSkillsDir();
2912
+ const skillDir = join9(skillsDir, name);
2913
+ if (!existsSync10(skillDir)) {
2914
+ throw new Error(`Skill "${name}" not found in ${skillsDir}`);
2915
+ }
2916
+ const skillMdPath = join9(skillDir, "SKILL.md");
2917
+ if (!existsSync10(skillMdPath)) {
2918
+ throw new Error(`Skill "${name}" is missing SKILL.md`);
2919
+ }
2920
+ const files = collectFiles(skillDir, skillDir);
2921
+ return { name, files };
2922
+ }
2923
+
2636
2924
  // src/commands/install/plan.ts
2637
2925
  var require2 = createRequire(import.meta.url);
2638
2926
  function getSelfDependencyVersionRange(pkgName) {
@@ -2683,7 +2971,7 @@ function createPlan(state, choices, options = {}) {
2683
2971
  const dependencies = [];
2684
2972
  const { force = false } = options;
2685
2973
  const { items } = choices;
2686
- const needsCursorDir = items.includes("mcp") || items.includes("hooks") || items.includes("genstyleguide") || items.includes("genrules");
2974
+ const needsCursorDir = items.includes("mcp") || items.includes("hooks") || items.includes("genstyleguide") || items.includes("genrules") || items.includes("skill");
2687
2975
  if (needsCursorDir && !state.cursorDir.exists) {
2688
2976
  actions.push({
2689
2977
  type: "create_directory",
@@ -2712,7 +3000,7 @@ function createPlan(state, choices, options = {}) {
2712
3000
  }
2713
3001
  }
2714
3002
  if (items.includes("hooks")) {
2715
- const hooksDir = join8(state.cursorDir.path, "hooks");
3003
+ const hooksDir = join10(state.cursorDir.path, "hooks");
2716
3004
  actions.push({
2717
3005
  type: "create_directory",
2718
3006
  path: hooksDir
@@ -2738,47 +3026,78 @@ function createPlan(state, choices, options = {}) {
2738
3026
  });
2739
3027
  actions.push({
2740
3028
  type: "create_file",
2741
- path: join8(hooksDir, "uilint-session-start.sh"),
3029
+ path: join10(hooksDir, "uilint-session-start.sh"),
2742
3030
  content: SESSION_START_SCRIPT,
2743
3031
  permissions: 493
2744
3032
  });
2745
3033
  actions.push({
2746
3034
  type: "create_file",
2747
- path: join8(hooksDir, "uilint-track.sh"),
3035
+ path: join10(hooksDir, "uilint-track.sh"),
2748
3036
  content: TRACK_SCRIPT,
2749
3037
  permissions: 493
2750
3038
  });
2751
3039
  actions.push({
2752
3040
  type: "create_file",
2753
- path: join8(hooksDir, "uilint-session-end.sh"),
3041
+ path: join10(hooksDir, "uilint-session-end.sh"),
2754
3042
  content: SESSION_END_SCRIPT,
2755
3043
  permissions: 493
2756
3044
  });
2757
3045
  }
2758
3046
  if (items.includes("genstyleguide")) {
2759
- const commandsDir = join8(state.cursorDir.path, "commands");
3047
+ const commandsDir = join10(state.cursorDir.path, "commands");
2760
3048
  actions.push({
2761
3049
  type: "create_directory",
2762
3050
  path: commandsDir
2763
3051
  });
2764
3052
  actions.push({
2765
3053
  type: "create_file",
2766
- path: join8(commandsDir, "genstyleguide.md"),
3054
+ path: join10(commandsDir, "genstyleguide.md"),
2767
3055
  content: GENSTYLEGUIDE_COMMAND_MD
2768
3056
  });
2769
3057
  }
2770
3058
  if (items.includes("genrules")) {
2771
- const commandsDir = join8(state.cursorDir.path, "commands");
3059
+ const commandsDir = join10(state.cursorDir.path, "commands");
2772
3060
  actions.push({
2773
3061
  type: "create_directory",
2774
3062
  path: commandsDir
2775
3063
  });
2776
3064
  actions.push({
2777
3065
  type: "create_file",
2778
- path: join8(commandsDir, "genrules.md"),
3066
+ path: join10(commandsDir, "genrules.md"),
2779
3067
  content: GENRULES_COMMAND_MD
2780
3068
  });
2781
3069
  }
3070
+ if (items.includes("skill")) {
3071
+ const skillsDir = join10(state.cursorDir.path, "skills");
3072
+ actions.push({
3073
+ type: "create_directory",
3074
+ path: skillsDir
3075
+ });
3076
+ try {
3077
+ const skill = loadSkill("ui-consistency-enforcer");
3078
+ const skillDir = join10(skillsDir, skill.name);
3079
+ actions.push({
3080
+ type: "create_directory",
3081
+ path: skillDir
3082
+ });
3083
+ for (const file of skill.files) {
3084
+ const filePath = join10(skillDir, file.relativePath);
3085
+ const fileDir = join10(skillDir, file.relativePath.split("/").slice(0, -1).join("/"));
3086
+ if (fileDir !== skillDir && file.relativePath.includes("/")) {
3087
+ actions.push({
3088
+ type: "create_directory",
3089
+ path: fileDir
3090
+ });
3091
+ }
3092
+ actions.push({
3093
+ type: "create_file",
3094
+ path: filePath,
3095
+ content: file.content
3096
+ });
3097
+ }
3098
+ } catch {
3099
+ }
3100
+ }
2782
3101
  if (items.includes("next") && choices.next) {
2783
3102
  const { projectPath, detection } = choices.next;
2784
3103
  actions.push({
@@ -2801,6 +3120,24 @@ function createPlan(state, choices, options = {}) {
2801
3120
  projectPath
2802
3121
  });
2803
3122
  }
3123
+ if (items.includes("vite") && choices.vite) {
3124
+ const { projectPath, detection } = choices.vite;
3125
+ dependencies.push({
3126
+ packagePath: projectPath,
3127
+ packageManager: state.packageManager,
3128
+ packages: ["uilint-react", "uilint-core", "jsx-loc-plugin"]
3129
+ });
3130
+ actions.push({
3131
+ type: "inject_react",
3132
+ projectPath,
3133
+ appRoot: detection.entryRoot,
3134
+ mode: "vite"
3135
+ });
3136
+ actions.push({
3137
+ type: "inject_vite_config",
3138
+ projectPath
3139
+ });
3140
+ }
2804
3141
  if (items.includes("eslint") && choices.eslint) {
2805
3142
  const { packagePaths, selectedRules } = choices.eslint;
2806
3143
  for (const pkgPath of packagePaths) {
@@ -2820,7 +3157,7 @@ function createPlan(state, choices, options = {}) {
2820
3157
  });
2821
3158
  }
2822
3159
  }
2823
- const gitignorePath = join8(state.workspaceRoot, ".gitignore");
3160
+ const gitignorePath = join10(state.workspaceRoot, ".gitignore");
2824
3161
  actions.push({
2825
3162
  type: "append_to_file",
2826
3163
  path: gitignorePath,
@@ -2833,34 +3170,49 @@ function createPlan(state, choices, options = {}) {
2833
3170
 
2834
3171
  // src/commands/install/execute.ts
2835
3172
  import {
2836
- existsSync as existsSync12,
3173
+ existsSync as existsSync15,
2837
3174
  mkdirSync as mkdirSync3,
2838
- writeFileSync as writeFileSync6,
2839
- readFileSync as readFileSync7,
3175
+ writeFileSync as writeFileSync7,
3176
+ readFileSync as readFileSync10,
2840
3177
  unlinkSync,
2841
3178
  chmodSync
2842
3179
  } from "fs";
2843
- import { dirname as dirname6 } from "path";
3180
+ import { dirname as dirname7 } from "path";
2844
3181
 
2845
3182
  // src/utils/react-inject.ts
2846
- import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2847
- import { join as join9 } from "path";
3183
+ import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
3184
+ import { join as join11 } from "path";
2848
3185
  import { parseModule as parseModule2, generateCode as generateCode2 } from "magicast";
2849
3186
  function getDefaultCandidates(projectPath, appRoot) {
3187
+ const viteMainCandidates = [
3188
+ join11(appRoot, "main.tsx"),
3189
+ join11(appRoot, "main.jsx"),
3190
+ join11(appRoot, "main.ts"),
3191
+ join11(appRoot, "main.js")
3192
+ ];
3193
+ const existingViteMain = viteMainCandidates.filter(
3194
+ (rel) => existsSync11(join11(projectPath, rel))
3195
+ );
3196
+ if (existingViteMain.length > 0) return existingViteMain;
3197
+ const viteAppCandidates = [join11(appRoot, "App.tsx"), join11(appRoot, "App.jsx")];
3198
+ const existingViteApp = viteAppCandidates.filter(
3199
+ (rel) => existsSync11(join11(projectPath, rel))
3200
+ );
3201
+ if (existingViteApp.length > 0) return existingViteApp;
2850
3202
  const layoutCandidates = [
2851
- join9(appRoot, "layout.tsx"),
2852
- join9(appRoot, "layout.jsx"),
2853
- join9(appRoot, "layout.ts"),
2854
- join9(appRoot, "layout.js")
3203
+ join11(appRoot, "layout.tsx"),
3204
+ join11(appRoot, "layout.jsx"),
3205
+ join11(appRoot, "layout.ts"),
3206
+ join11(appRoot, "layout.js")
2855
3207
  ];
2856
3208
  const existingLayouts = layoutCandidates.filter(
2857
- (rel) => existsSync9(join9(projectPath, rel))
3209
+ (rel) => existsSync11(join11(projectPath, rel))
2858
3210
  );
2859
3211
  if (existingLayouts.length > 0) {
2860
3212
  return existingLayouts;
2861
3213
  }
2862
- const pageCandidates = [join9(appRoot, "page.tsx"), join9(appRoot, "page.jsx")];
2863
- return pageCandidates.filter((rel) => existsSync9(join9(projectPath, rel)));
3214
+ const pageCandidates = [join11(appRoot, "page.tsx"), join11(appRoot, "page.jsx")];
3215
+ return pageCandidates.filter((rel) => existsSync11(join11(projectPath, rel)));
2864
3216
  }
2865
3217
  function isUseClientDirective(stmt) {
2866
3218
  return stmt?.type === "ExpressionStatement" && stmt.expression?.type === "StringLiteral" && stmt.expression.value === "use client";
@@ -2947,11 +3299,44 @@ function wrapFirstChildrenExpressionWithProvider(program2) {
2947
3299
  }
2948
3300
  return { changed: true };
2949
3301
  }
3302
+ function wrapFirstRenderCallArgumentWithProvider(program2) {
3303
+ if (!program2 || program2.type !== "Program") return { changed: false };
3304
+ if (hasUILintProviderJsx(program2)) return { changed: false };
3305
+ const providerMod = parseModule2(
3306
+ 'const __uilint_provider = (<UILintProvider enabled={process.env.NODE_ENV !== "production"}></UILintProvider>);'
3307
+ );
3308
+ const providerJsx = providerMod.$ast.body?.[0]?.declarations?.[0]?.init ?? null;
3309
+ if (!providerJsx || providerJsx.type !== "JSXElement")
3310
+ return { changed: false };
3311
+ providerJsx.children = providerJsx.children ?? [];
3312
+ let wrapped = false;
3313
+ walkAst2(program2, (node) => {
3314
+ if (wrapped) return;
3315
+ if (node.type !== "CallExpression") return;
3316
+ const callee = node.callee;
3317
+ if (callee?.type !== "MemberExpression") return;
3318
+ const prop = callee.property;
3319
+ const isRender = prop?.type === "Identifier" && prop.name === "render" || prop?.type === "StringLiteral" && prop.value === "render" || prop?.type === "Literal" && prop.value === "render";
3320
+ if (!isRender) return;
3321
+ const arg0 = node.arguments?.[0];
3322
+ if (!arg0) return;
3323
+ if (arg0.type !== "JSXElement" && arg0.type !== "JSXFragment") return;
3324
+ providerJsx.children = [arg0];
3325
+ node.arguments[0] = providerJsx;
3326
+ wrapped = true;
3327
+ });
3328
+ if (!wrapped) {
3329
+ throw new Error(
3330
+ "Could not find a `.render(<...>)` call to wrap. Expected a React entry like `createRoot(...).render(<App />)`."
3331
+ );
3332
+ }
3333
+ return { changed: true };
3334
+ }
2950
3335
  async function installReactUILintOverlay(opts) {
2951
3336
  const candidates = getDefaultCandidates(opts.projectPath, opts.appRoot);
2952
3337
  if (!candidates.length) {
2953
3338
  throw new Error(
2954
- `No suitable Next.js entry files found under ${opts.appRoot} (expected layout.* or page.*).`
3339
+ `No suitable entry files found under ${opts.appRoot} (expected Next.js layout/page or Vite main/App).`
2955
3340
  );
2956
3341
  }
2957
3342
  let chosen;
@@ -2960,8 +3345,8 @@ async function installReactUILintOverlay(opts) {
2960
3345
  } else {
2961
3346
  chosen = candidates[0];
2962
3347
  }
2963
- const absTarget = join9(opts.projectPath, chosen);
2964
- const original = readFileSync5(absTarget, "utf-8");
3348
+ const absTarget = join11(opts.projectPath, chosen);
3349
+ const original = readFileSync7(absTarget, "utf-8");
2965
3350
  let mod;
2966
3351
  try {
2967
3352
  mod = parseModule2(original);
@@ -2979,7 +3364,8 @@ async function installReactUILintOverlay(opts) {
2979
3364
  "UILintProvider"
2980
3365
  );
2981
3366
  if (importRes.changed) changed = true;
2982
- const wrapRes = wrapFirstChildrenExpressionWithProvider(program2);
3367
+ const mode = opts.mode ?? "next";
3368
+ const wrapRes = mode === "vite" ? wrapFirstRenderCallArgumentWithProvider(program2) : wrapFirstChildrenExpressionWithProvider(program2);
2983
3369
  if (wrapRes.changed) changed = true;
2984
3370
  const updated = changed ? generateCode2(mod).code : original;
2985
3371
  const modified = updated !== original;
@@ -2994,14 +3380,14 @@ async function installReactUILintOverlay(opts) {
2994
3380
  }
2995
3381
 
2996
3382
  // src/utils/next-config-inject.ts
2997
- import { existsSync as existsSync10, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2998
- import { join as join10 } from "path";
3383
+ import { existsSync as existsSync12, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
3384
+ import { join as join12 } from "path";
2999
3385
  import { parseModule as parseModule3, generateCode as generateCode3 } from "magicast";
3000
3386
  var CONFIG_EXTENSIONS2 = [".ts", ".mjs", ".js", ".cjs"];
3001
3387
  function findNextConfigFile(projectPath) {
3002
3388
  for (const ext of CONFIG_EXTENSIONS2) {
3003
- const configPath = join10(projectPath, `next.config${ext}`);
3004
- if (existsSync10(configPath)) {
3389
+ const configPath = join12(projectPath, `next.config${ext}`);
3390
+ if (existsSync12(configPath)) {
3005
3391
  return configPath;
3006
3392
  }
3007
3393
  }
@@ -3114,7 +3500,7 @@ async function installJsxLocPlugin(opts) {
3114
3500
  return { configFile: null, modified: false };
3115
3501
  }
3116
3502
  const configFilename = getNextConfigFilename(configPath);
3117
- const original = readFileSync6(configPath, "utf-8");
3503
+ const original = readFileSync8(configPath, "utf-8");
3118
3504
  let mod;
3119
3505
  try {
3120
3506
  mod = parseModule3(original);
@@ -3143,98 +3529,323 @@ async function installJsxLocPlugin(opts) {
3143
3529
  return { configFile: configFilename, modified: false };
3144
3530
  }
3145
3531
 
3146
- // src/utils/next-routes.ts
3147
- import { existsSync as existsSync11 } from "fs";
3148
- import { mkdir, writeFile } from "fs/promises";
3149
- import { join as join11 } from "path";
3150
- var DEV_SOURCE_ROUTE_TS = `/**
3151
- * Dev-only API route for fetching source files
3152
- *
3153
- * This route allows the UILint overlay to fetch and display source code
3154
- * for components rendered on the page.
3155
- *
3156
- * Security:
3157
- * - Only available in development mode
3158
- * - Validates file path is within project root
3159
- * - Only allows specific file extensions
3160
- */
3161
-
3162
- import { NextRequest, NextResponse } from "next/server";
3163
- import { readFileSync, existsSync } from "fs";
3164
- import { resolve, relative, dirname, extname } from "path";
3165
-
3166
- export const runtime = "nodejs";
3167
-
3168
- // Allowed file extensions
3169
- const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
3170
-
3171
- /**
3172
- * Find the project root by looking for package.json or next.config
3173
- */
3174
- function findProjectRoot(startDir: string): string {
3175
- let dir = startDir;
3176
- for (let i = 0; i < 10; i++) {
3177
- if (
3178
- existsSync(resolve(dir, "package.json")) ||
3179
- existsSync(resolve(dir, "next.config.js")) ||
3180
- existsSync(resolve(dir, "next.config.ts"))
3181
- ) {
3182
- return dir;
3183
- }
3184
- const parent = dirname(dir);
3185
- if (parent === dir) break;
3186
- dir = parent;
3532
+ // src/utils/vite-config-inject.ts
3533
+ import { existsSync as existsSync13, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
3534
+ import { join as join13 } from "path";
3535
+ import { parseModule as parseModule4, generateCode as generateCode4 } from "magicast";
3536
+ var CONFIG_EXTENSIONS3 = [".ts", ".mjs", ".js", ".cjs"];
3537
+ function findViteConfigFile2(projectPath) {
3538
+ for (const ext of CONFIG_EXTENSIONS3) {
3539
+ const configPath = join13(projectPath, `vite.config${ext}`);
3540
+ if (existsSync13(configPath)) return configPath;
3187
3541
  }
3188
- return startDir;
3542
+ return null;
3189
3543
  }
3190
-
3191
- /**
3192
- * Validate that a path is within the allowed directory
3193
- */
3194
- function isPathWithinRoot(filePath: string, root: string): boolean {
3195
- const resolved = resolve(filePath);
3196
- const resolvedRoot = resolve(root);
3197
- return resolved.startsWith(resolvedRoot + "/") || resolved === resolvedRoot;
3544
+ function getViteConfigFilename(configPath) {
3545
+ const parts = configPath.split("/");
3546
+ return parts[parts.length - 1] || "vite.config.ts";
3198
3547
  }
3199
-
3200
- /**
3201
- * Find workspace root by walking up looking for pnpm-workspace.yaml or .git
3202
- */
3203
- function findWorkspaceRoot(startDir: string): string {
3204
- let dir = startDir;
3205
- for (let i = 0; i < 10; i++) {
3206
- if (
3207
- existsSync(resolve(dir, "pnpm-workspace.yaml")) ||
3208
- existsSync(resolve(dir, ".git"))
3209
- ) {
3210
- return dir;
3548
+ function isIdentifier3(node, name) {
3549
+ return !!node && node.type === "Identifier" && (name ? node.name === name : typeof node.name === "string");
3550
+ }
3551
+ function isStringLiteral3(node) {
3552
+ return !!node && (node.type === "StringLiteral" || node.type === "Literal") && typeof node.value === "string";
3553
+ }
3554
+ function unwrapExpression(expr) {
3555
+ let e = expr;
3556
+ while (e) {
3557
+ if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
3558
+ e = e.expression;
3559
+ continue;
3211
3560
  }
3212
- const parent = dirname(dir);
3213
- if (parent === dir) break;
3214
- dir = parent;
3561
+ if (e.type === "TSSatisfiesExpression") {
3562
+ e = e.expression;
3563
+ continue;
3564
+ }
3565
+ if (e.type === "ParenthesizedExpression") {
3566
+ e = e.expression;
3567
+ continue;
3568
+ }
3569
+ break;
3215
3570
  }
3216
- return startDir;
3571
+ return e;
3217
3572
  }
3218
-
3219
- export async function GET(request: NextRequest) {
3220
- // Block in production
3221
- if (process.env.NODE_ENV === "production") {
3222
- return NextResponse.json(
3223
- { error: "Not available in production" },
3224
- { status: 404 }
3225
- );
3226
- }
3227
-
3228
- const { searchParams } = new URL(request.url);
3229
- const filePath = searchParams.get("path");
3230
-
3231
- if (!filePath) {
3232
- return NextResponse.json(
3233
- { error: "Missing 'path' query parameter" },
3234
- { status: 400 }
3235
- );
3573
+ function findExportedConfigObjectExpression(mod) {
3574
+ const program2 = mod?.$ast;
3575
+ if (!program2 || program2.type !== "Program") return null;
3576
+ for (const stmt of program2.body ?? []) {
3577
+ if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
3578
+ const decl = unwrapExpression(stmt.declaration);
3579
+ if (!decl) break;
3580
+ if (decl.type === "ObjectExpression") {
3581
+ return { kind: "esm", objExpr: decl, program: program2 };
3582
+ }
3583
+ if (decl.type === "CallExpression" && isIdentifier3(decl.callee, "defineConfig") && unwrapExpression(decl.arguments?.[0])?.type === "ObjectExpression") {
3584
+ return {
3585
+ kind: "esm",
3586
+ objExpr: unwrapExpression(decl.arguments?.[0]),
3587
+ program: program2
3588
+ };
3589
+ }
3590
+ break;
3236
3591
  }
3237
-
3592
+ for (const stmt of program2.body ?? []) {
3593
+ if (!stmt || stmt.type !== "ExpressionStatement") continue;
3594
+ const expr = stmt.expression;
3595
+ if (!expr || expr.type !== "AssignmentExpression") continue;
3596
+ const left = expr.left;
3597
+ const right = unwrapExpression(expr.right);
3598
+ const isModuleExports = left?.type === "MemberExpression" && isIdentifier3(left.object, "module") && isIdentifier3(left.property, "exports");
3599
+ if (!isModuleExports) continue;
3600
+ if (right?.type === "ObjectExpression") {
3601
+ return { kind: "cjs", objExpr: right, program: program2 };
3602
+ }
3603
+ if (right?.type === "CallExpression" && isIdentifier3(right.callee, "defineConfig") && unwrapExpression(right.arguments?.[0])?.type === "ObjectExpression") {
3604
+ return {
3605
+ kind: "cjs",
3606
+ objExpr: unwrapExpression(right.arguments?.[0]),
3607
+ program: program2
3608
+ };
3609
+ }
3610
+ }
3611
+ return null;
3612
+ }
3613
+ function getObjectProperty(obj, keyName) {
3614
+ if (!obj || obj.type !== "ObjectExpression") return null;
3615
+ for (const prop of obj.properties ?? []) {
3616
+ if (!prop) continue;
3617
+ if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
3618
+ const key = prop.key;
3619
+ const keyMatch = key?.type === "Identifier" && key.name === keyName || isStringLiteral3(key) && key.value === keyName;
3620
+ if (keyMatch) return prop;
3621
+ }
3622
+ return null;
3623
+ }
3624
+ function ensureEsmJsxLocImport(program2) {
3625
+ if (!program2 || program2.type !== "Program") return { changed: false };
3626
+ const existing = (program2.body ?? []).find(
3627
+ (s) => s?.type === "ImportDeclaration" && s.source?.value === "jsx-loc-plugin/vite"
3628
+ );
3629
+ if (existing) {
3630
+ const has = (existing.specifiers ?? []).some(
3631
+ (sp) => sp?.type === "ImportSpecifier" && (sp.imported?.name === "jsxLoc" || sp.imported?.value === "jsxLoc")
3632
+ );
3633
+ if (has) return { changed: false };
3634
+ const spec = parseModule4('import { jsxLoc } from "jsx-loc-plugin/vite";').$ast.body?.[0]?.specifiers?.[0];
3635
+ if (!spec) return { changed: false };
3636
+ existing.specifiers = [...existing.specifiers ?? [], spec];
3637
+ return { changed: true };
3638
+ }
3639
+ const importDecl = parseModule4('import { jsxLoc } from "jsx-loc-plugin/vite";').$ast.body?.[0];
3640
+ if (!importDecl) return { changed: false };
3641
+ const body = program2.body ?? [];
3642
+ let insertAt = 0;
3643
+ while (insertAt < body.length && body[insertAt]?.type === "ImportDeclaration") {
3644
+ insertAt++;
3645
+ }
3646
+ program2.body.splice(insertAt, 0, importDecl);
3647
+ return { changed: true };
3648
+ }
3649
+ function ensureCjsJsxLocRequire(program2) {
3650
+ if (!program2 || program2.type !== "Program") return { changed: false };
3651
+ for (const stmt of program2.body ?? []) {
3652
+ if (stmt?.type !== "VariableDeclaration") continue;
3653
+ for (const decl of stmt.declarations ?? []) {
3654
+ const init = decl?.init;
3655
+ if (init?.type === "CallExpression" && isIdentifier3(init.callee, "require") && isStringLiteral3(init.arguments?.[0]) && init.arguments[0].value === "jsx-loc-plugin/vite") {
3656
+ if (decl.id?.type === "ObjectPattern") {
3657
+ const has = (decl.id.properties ?? []).some((p2) => {
3658
+ if (p2?.type !== "ObjectProperty" && p2?.type !== "Property") return false;
3659
+ return isIdentifier3(p2.key, "jsxLoc");
3660
+ });
3661
+ if (has) return { changed: false };
3662
+ const prop = parseModule4('const { jsxLoc } = require("jsx-loc-plugin/vite");').$ast.body?.[0]?.declarations?.[0]?.id?.properties?.[0];
3663
+ if (!prop) return { changed: false };
3664
+ decl.id.properties = [...decl.id.properties ?? [], prop];
3665
+ return { changed: true };
3666
+ }
3667
+ return { changed: false };
3668
+ }
3669
+ }
3670
+ }
3671
+ const reqDecl = parseModule4('const { jsxLoc } = require("jsx-loc-plugin/vite");').$ast.body?.[0];
3672
+ if (!reqDecl) return { changed: false };
3673
+ program2.body.unshift(reqDecl);
3674
+ return { changed: true };
3675
+ }
3676
+ function pluginsHasJsxLoc(arr) {
3677
+ if (!arr || arr.type !== "ArrayExpression") return false;
3678
+ for (const el of arr.elements ?? []) {
3679
+ const e = unwrapExpression(el);
3680
+ if (!e) continue;
3681
+ if (e.type === "CallExpression" && isIdentifier3(e.callee, "jsxLoc")) return true;
3682
+ }
3683
+ return false;
3684
+ }
3685
+ function ensurePluginsContainsJsxLoc(configObj) {
3686
+ const pluginsProp = getObjectProperty(configObj, "plugins");
3687
+ if (!pluginsProp) {
3688
+ const prop = parseModule4("export default { plugins: [jsxLoc()] };").$ast.body?.find((s) => s.type === "ExportDefaultDeclaration")?.declaration?.properties?.find((p2) => {
3689
+ const k = p2?.key;
3690
+ return k?.type === "Identifier" && k.name === "plugins" || isStringLiteral3(k) && k.value === "plugins";
3691
+ });
3692
+ if (!prop) return { changed: false };
3693
+ configObj.properties = [...configObj.properties ?? [], prop];
3694
+ return { changed: true };
3695
+ }
3696
+ const value = unwrapExpression(pluginsProp.value);
3697
+ if (!value) return { changed: false };
3698
+ if (value.type === "ArrayExpression") {
3699
+ if (pluginsHasJsxLoc(value)) return { changed: false };
3700
+ const jsxLocCall2 = parseModule4("const __x = jsxLoc();").$ast.body?.[0]?.declarations?.[0]?.init;
3701
+ if (!jsxLocCall2) return { changed: false };
3702
+ value.elements.push(jsxLocCall2);
3703
+ return { changed: true };
3704
+ }
3705
+ const jsxLocCall = parseModule4("const __x = jsxLoc();").$ast.body?.[0]?.declarations?.[0]?.init;
3706
+ if (!jsxLocCall) return { changed: false };
3707
+ const spread = { type: "SpreadElement", argument: value };
3708
+ pluginsProp.value = { type: "ArrayExpression", elements: [spread, jsxLocCall] };
3709
+ return { changed: true };
3710
+ }
3711
+ async function installViteJsxLocPlugin(opts) {
3712
+ const configPath = findViteConfigFile2(opts.projectPath);
3713
+ if (!configPath) return { configFile: null, modified: false };
3714
+ const configFilename = getViteConfigFilename(configPath);
3715
+ const original = readFileSync9(configPath, "utf-8");
3716
+ const isCjs = configPath.endsWith(".cjs");
3717
+ let mod;
3718
+ try {
3719
+ mod = parseModule4(original);
3720
+ } catch {
3721
+ return { configFile: configFilename, modified: false };
3722
+ }
3723
+ const found = findExportedConfigObjectExpression(mod);
3724
+ if (!found) return { configFile: configFilename, modified: false };
3725
+ let changed = false;
3726
+ if (isCjs) {
3727
+ const reqRes = ensureCjsJsxLocRequire(found.program);
3728
+ if (reqRes.changed) changed = true;
3729
+ } else {
3730
+ const impRes = ensureEsmJsxLocImport(found.program);
3731
+ if (impRes.changed) changed = true;
3732
+ }
3733
+ const pluginsRes = ensurePluginsContainsJsxLoc(found.objExpr);
3734
+ if (pluginsRes.changed) changed = true;
3735
+ const updated = changed ? generateCode4(mod).code : original;
3736
+ if (updated !== original) {
3737
+ writeFileSync6(configPath, updated, "utf-8");
3738
+ return { configFile: configFilename, modified: true };
3739
+ }
3740
+ return { configFile: configFilename, modified: false };
3741
+ }
3742
+
3743
+ // src/utils/next-routes.ts
3744
+ import { existsSync as existsSync14 } from "fs";
3745
+ import { mkdir, writeFile } from "fs/promises";
3746
+ import { join as join14 } from "path";
3747
+ var DEV_SOURCE_ROUTE_TS = `/**
3748
+ * Dev-only API route for fetching source files
3749
+ *
3750
+ * This route allows the UILint overlay to fetch and display source code
3751
+ * for components rendered on the page.
3752
+ *
3753
+ * Security:
3754
+ * - Only available in development mode
3755
+ * - Validates file path is within project root
3756
+ * - Only allows specific file extensions
3757
+ */
3758
+
3759
+ import { NextRequest, NextResponse } from "next/server";
3760
+ import { readFileSync, existsSync } from "fs";
3761
+ import { resolve, relative, dirname, extname, sep } from "path";
3762
+ import { fileURLToPath } from "url";
3763
+
3764
+ export const runtime = "nodejs";
3765
+
3766
+ // Allowed file extensions
3767
+ const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
3768
+
3769
+ /**
3770
+ * Best-effort: resolve the Next.js project root (the dir that owns .next/) even in monorepos.
3771
+ *
3772
+ * Why: In monorepos, process.cwd() might be the workspace root (it also has package.json),
3773
+ * which would incorrectly store/read files under the wrong directory.
3774
+ */
3775
+ function findNextProjectRoot(): string {
3776
+ // Prefer discovering via this route module's on-disk path.
3777
+ // In Next, route code is executed from within ".next/server/...".
3778
+ try {
3779
+ const selfPath = fileURLToPath(import.meta.url);
3780
+ const marker = sep + ".next" + sep;
3781
+ const idx = selfPath.lastIndexOf(marker);
3782
+ if (idx !== -1) {
3783
+ return selfPath.slice(0, idx);
3784
+ }
3785
+ } catch {
3786
+ // ignore
3787
+ }
3788
+
3789
+ // Fallback: walk up from cwd looking for .next/
3790
+ let dir = process.cwd();
3791
+ for (let i = 0; i < 20; i++) {
3792
+ if (existsSync(resolve(dir, ".next"))) return dir;
3793
+ const parent = dirname(dir);
3794
+ if (parent === dir) break;
3795
+ dir = parent;
3796
+ }
3797
+
3798
+ // Final fallback: cwd
3799
+ return process.cwd();
3800
+ }
3801
+
3802
+ /**
3803
+ * Validate that a path is within the allowed directory
3804
+ */
3805
+ function isPathWithinRoot(filePath: string, root: string): boolean {
3806
+ const resolved = resolve(filePath);
3807
+ const resolvedRoot = resolve(root);
3808
+ return resolved.startsWith(resolvedRoot + "/") || resolved === resolvedRoot;
3809
+ }
3810
+
3811
+ /**
3812
+ * Find workspace root by walking up looking for pnpm-workspace.yaml or .git
3813
+ */
3814
+ function findWorkspaceRoot(startDir: string): string {
3815
+ let dir = startDir;
3816
+ for (let i = 0; i < 10; i++) {
3817
+ if (
3818
+ existsSync(resolve(dir, "pnpm-workspace.yaml")) ||
3819
+ existsSync(resolve(dir, ".git"))
3820
+ ) {
3821
+ return dir;
3822
+ }
3823
+ const parent = dirname(dir);
3824
+ if (parent === dir) break;
3825
+ dir = parent;
3826
+ }
3827
+ return startDir;
3828
+ }
3829
+
3830
+ export async function GET(request: NextRequest) {
3831
+ // Block in production
3832
+ if (process.env.NODE_ENV === "production") {
3833
+ return NextResponse.json(
3834
+ { error: "Not available in production" },
3835
+ { status: 404 }
3836
+ );
3837
+ }
3838
+
3839
+ const { searchParams } = new URL(request.url);
3840
+ const filePath = searchParams.get("path");
3841
+
3842
+ if (!filePath) {
3843
+ return NextResponse.json(
3844
+ { error: "Missing 'path' query parameter" },
3845
+ { status: 400 }
3846
+ );
3847
+ }
3848
+
3238
3849
  // Validate extension
3239
3850
  const ext = extname(filePath).toLowerCase();
3240
3851
  if (!ALLOWED_EXTENSIONS.has(ext)) {
@@ -3244,8 +3855,8 @@ export async function GET(request: NextRequest) {
3244
3855
  );
3245
3856
  }
3246
3857
 
3247
- // Find project root
3248
- const projectRoot = findProjectRoot(process.cwd());
3858
+ // Find project root (prefer Next project root over workspace root)
3859
+ const projectRoot = findNextProjectRoot();
3249
3860
 
3250
3861
  // Resolve the file path
3251
3862
  const resolvedPath = resolve(filePath);
@@ -3274,6 +3885,8 @@ export async function GET(request: NextRequest) {
3274
3885
  return NextResponse.json({
3275
3886
  content,
3276
3887
  relativePath,
3888
+ projectRoot,
3889
+ workspaceRoot,
3277
3890
  });
3278
3891
  } catch (error) {
3279
3892
  console.error("[Dev Source API] Error reading file:", error);
@@ -3281,20 +3894,331 @@ export async function GET(request: NextRequest) {
3281
3894
  }
3282
3895
  }
3283
3896
  `;
3897
+ var SCREENSHOT_ROUTE_TS = `/**
3898
+ * Dev-only API route for saving and retrieving vision analysis screenshots
3899
+ *
3900
+ * This route allows the UILint overlay to:
3901
+ * - POST: Save screenshots and element manifests for vision analysis
3902
+ * - GET: Retrieve screenshots or list available screenshots
3903
+ *
3904
+ * Security:
3905
+ * - Only available in development mode
3906
+ * - Saves to .uilint/screenshots/ directory within project
3907
+ */
3908
+
3909
+ import { NextRequest, NextResponse } from "next/server";
3910
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
3911
+ import { resolve, join, dirname, basename, sep } from "path";
3912
+ import { fileURLToPath } from "url";
3913
+
3914
+ export const runtime = "nodejs";
3915
+
3916
+ // Maximum screenshot size (10MB)
3917
+ const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
3918
+
3919
+ /**
3920
+ * Best-effort: resolve the Next.js project root (the dir that owns .next/) even in monorepos.
3921
+ */
3922
+ function findNextProjectRoot(): string {
3923
+ try {
3924
+ const selfPath = fileURLToPath(import.meta.url);
3925
+ const marker = sep + ".next" + sep;
3926
+ const idx = selfPath.lastIndexOf(marker);
3927
+ if (idx !== -1) {
3928
+ return selfPath.slice(0, idx);
3929
+ }
3930
+ } catch {
3931
+ // ignore
3932
+ }
3933
+
3934
+ let dir = process.cwd();
3935
+ for (let i = 0; i < 20; i++) {
3936
+ if (existsSync(resolve(dir, ".next"))) return dir;
3937
+ const parent = dirname(dir);
3938
+ if (parent === dir) break;
3939
+ dir = parent;
3940
+ }
3941
+
3942
+ return process.cwd();
3943
+ }
3944
+
3945
+ /**
3946
+ * Get the screenshots directory path, creating it if needed
3947
+ */
3948
+ function getScreenshotsDir(projectRoot: string): string {
3949
+ const screenshotsDir = join(projectRoot, ".uilint", "screenshots");
3950
+ if (!existsSync(screenshotsDir)) {
3951
+ mkdirSync(screenshotsDir, { recursive: true });
3952
+ }
3953
+ return screenshotsDir;
3954
+ }
3955
+
3956
+ /**
3957
+ * Validate filename to prevent path traversal
3958
+ */
3959
+ function isValidFilename(filename: string): boolean {
3960
+ // Only allow alphanumeric, hyphens, underscores, and dots
3961
+ // Must end with .png, .jpeg, .jpg, or .json
3962
+ const validPattern = /^[a-zA-Z0-9_-]+\\.(png|jpeg|jpg|json)$/;
3963
+ return validPattern.test(filename) && !filename.includes("..");
3964
+ }
3965
+
3966
+ /**
3967
+ * POST: Save a screenshot and optionally its manifest
3968
+ */
3969
+ export async function POST(request: NextRequest) {
3970
+ // Block in production
3971
+ if (process.env.NODE_ENV === "production") {
3972
+ return NextResponse.json(
3973
+ { error: "Not available in production" },
3974
+ { status: 404 }
3975
+ );
3976
+ }
3977
+
3978
+ try {
3979
+ const body = await request.json();
3980
+ const { filename, imageData, manifest, analysisResult } = body;
3981
+
3982
+ if (!filename) {
3983
+ return NextResponse.json({ error: "Missing 'filename'" }, { status: 400 });
3984
+ }
3985
+
3986
+ // Validate filename
3987
+ if (!isValidFilename(filename)) {
3988
+ return NextResponse.json(
3989
+ { error: "Invalid filename format" },
3990
+ { status: 400 }
3991
+ );
3992
+ }
3993
+
3994
+ // Allow "sidecar-only" updates (manifest/analysisResult) without re-sending image bytes.
3995
+ const hasImageData = typeof imageData === "string" && imageData.length > 0;
3996
+ const hasSidecar =
3997
+ typeof manifest !== "undefined" || typeof analysisResult !== "undefined";
3998
+
3999
+ if (!hasImageData && !hasSidecar) {
4000
+ return NextResponse.json(
4001
+ { error: "Nothing to save (provide imageData and/or manifest/analysisResult)" },
4002
+ { status: 400 }
4003
+ );
4004
+ }
4005
+
4006
+ // Check size (image only)
4007
+ if (hasImageData && imageData.length > MAX_SCREENSHOT_SIZE) {
4008
+ return NextResponse.json(
4009
+ { error: "Screenshot too large (max 10MB)" },
4010
+ { status: 413 }
4011
+ );
4012
+ }
4013
+
4014
+ const projectRoot = findNextProjectRoot();
4015
+ const screenshotsDir = getScreenshotsDir(projectRoot);
4016
+
4017
+ const imagePath = join(screenshotsDir, filename);
4018
+
4019
+ // Save the image (base64 data URL) if provided
4020
+ if (hasImageData) {
4021
+ const base64Data = imageData.includes(",")
4022
+ ? imageData.split(",")[1]
4023
+ : imageData;
4024
+ writeFileSync(imagePath, Buffer.from(base64Data, "base64"));
4025
+ }
4026
+
4027
+ // Save manifest and analysis result as JSON sidecar
4028
+ if (hasSidecar) {
4029
+ const jsonFilename = filename.replace(/\\.(png|jpeg|jpg)$/, ".json");
4030
+ const jsonPath = join(screenshotsDir, jsonFilename);
4031
+
4032
+ // If a sidecar already exists, merge updates (lets us POST analysisResult later without re-sending image).
4033
+ let existing: any = null;
4034
+ if (existsSync(jsonPath)) {
4035
+ try {
4036
+ existing = JSON.parse(readFileSync(jsonPath, "utf-8"));
4037
+ } catch {
4038
+ existing = null;
4039
+ }
4040
+ }
4041
+
4042
+ const routeFromAnalysis =
4043
+ analysisResult && typeof analysisResult === "object"
4044
+ ? (analysisResult as any).route
4045
+ : undefined;
4046
+ const issuesFromAnalysis =
4047
+ analysisResult && typeof analysisResult === "object"
4048
+ ? (analysisResult as any).issues
4049
+ : undefined;
4050
+
4051
+ const jsonData = {
4052
+ ...(existing && typeof existing === "object" ? existing : {}),
4053
+ timestamp: Date.now(),
4054
+ filename,
4055
+ screenshotFile: filename,
4056
+ route:
4057
+ typeof routeFromAnalysis === "string"
4058
+ ? routeFromAnalysis
4059
+ : (existing as any)?.route ?? null,
4060
+ issues:
4061
+ Array.isArray(issuesFromAnalysis)
4062
+ ? issuesFromAnalysis
4063
+ : (existing as any)?.issues ?? null,
4064
+ manifest: typeof manifest === "undefined" ? existing?.manifest ?? null : manifest,
4065
+ analysisResult:
4066
+ typeof analysisResult === "undefined"
4067
+ ? existing?.analysisResult ?? null
4068
+ : analysisResult,
4069
+ };
4070
+ writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2));
4071
+ }
4072
+
4073
+ return NextResponse.json({
4074
+ success: true,
4075
+ path: imagePath,
4076
+ projectRoot,
4077
+ screenshotsDir,
4078
+ });
4079
+ } catch (error) {
4080
+ console.error("[Screenshot API] Error saving screenshot:", error);
4081
+ return NextResponse.json(
4082
+ { error: "Failed to save screenshot" },
4083
+ { status: 500 }
4084
+ );
4085
+ }
4086
+ }
4087
+
4088
+ /**
4089
+ * GET: Retrieve a screenshot or list available screenshots
4090
+ */
4091
+ export async function GET(request: NextRequest) {
4092
+ // Block in production
4093
+ if (process.env.NODE_ENV === "production") {
4094
+ return NextResponse.json(
4095
+ { error: "Not available in production" },
4096
+ { status: 404 }
4097
+ );
4098
+ }
4099
+
4100
+ const { searchParams } = new URL(request.url);
4101
+ const filename = searchParams.get("filename");
4102
+ const list = searchParams.get("list");
4103
+
4104
+ const projectRoot = findNextProjectRoot();
4105
+ const screenshotsDir = getScreenshotsDir(projectRoot);
4106
+
4107
+ // List mode: return all screenshots
4108
+ if (list === "true") {
4109
+ try {
4110
+ const files = readdirSync(screenshotsDir);
4111
+ const screenshots = files
4112
+ .filter((f) => /\\.(png|jpeg|jpg)$/.test(f))
4113
+ .map((f) => {
4114
+ const jsonFile = f.replace(/\\.(png|jpeg|jpg)$/, ".json");
4115
+ const jsonPath = join(screenshotsDir, jsonFile);
4116
+ let metadata = null;
4117
+ if (existsSync(jsonPath)) {
4118
+ try {
4119
+ metadata = JSON.parse(readFileSync(jsonPath, "utf-8"));
4120
+ } catch {
4121
+ // Ignore parse errors
4122
+ }
4123
+ }
4124
+ return {
4125
+ filename: f,
4126
+ metadata,
4127
+ };
4128
+ })
4129
+ .sort((a, b) => {
4130
+ // Sort by timestamp descending (newest first)
4131
+ const aTime = a.metadata?.timestamp || 0;
4132
+ const bTime = b.metadata?.timestamp || 0;
4133
+ return bTime - aTime;
4134
+ });
4135
+
4136
+ return NextResponse.json({ screenshots, projectRoot, screenshotsDir });
4137
+ } catch (error) {
4138
+ console.error("[Screenshot API] Error listing screenshots:", error);
4139
+ return NextResponse.json(
4140
+ { error: "Failed to list screenshots" },
4141
+ { status: 500 }
4142
+ );
4143
+ }
4144
+ }
4145
+
4146
+ // Retrieve mode: get specific screenshot
4147
+ if (!filename) {
4148
+ return NextResponse.json(
4149
+ { error: "Missing 'filename' parameter" },
4150
+ { status: 400 }
4151
+ );
4152
+ }
4153
+
4154
+ if (!isValidFilename(filename)) {
4155
+ return NextResponse.json(
4156
+ { error: "Invalid filename format" },
4157
+ { status: 400 }
4158
+ );
4159
+ }
4160
+
4161
+ const filePath = join(screenshotsDir, filename);
4162
+
4163
+ if (!existsSync(filePath)) {
4164
+ return NextResponse.json(
4165
+ { error: "Screenshot not found" },
4166
+ { status: 404 }
4167
+ );
4168
+ }
4169
+
4170
+ try {
4171
+ const content = readFileSync(filePath);
4172
+
4173
+ // Determine content type
4174
+ const ext = filename.split(".").pop()?.toLowerCase();
4175
+ const contentType =
4176
+ ext === "json"
4177
+ ? "application/json"
4178
+ : ext === "png"
4179
+ ? "image/png"
4180
+ : "image/jpeg";
4181
+
4182
+ if (ext === "json") {
4183
+ return NextResponse.json(JSON.parse(content.toString()));
4184
+ }
4185
+
4186
+ return new NextResponse(content, {
4187
+ headers: {
4188
+ "Content-Type": contentType,
4189
+ "Cache-Control": "no-cache",
4190
+ },
4191
+ });
4192
+ } catch (error) {
4193
+ console.error("[Screenshot API] Error reading screenshot:", error);
4194
+ return NextResponse.json(
4195
+ { error: "Failed to read screenshot" },
4196
+ { status: 500 }
4197
+ );
4198
+ }
4199
+ }
4200
+ `;
3284
4201
  async function writeRouteFile(absPath, relPath, content, opts) {
3285
- if (existsSync11(absPath) && !opts.force) return;
4202
+ if (existsSync14(absPath) && !opts.force) return;
3286
4203
  await writeFile(absPath, content, "utf-8");
3287
4204
  }
3288
4205
  async function installNextUILintRoutes(opts) {
3289
- const baseRel = join11(opts.appRoot, "api", ".uilint");
3290
- const baseAbs = join11(opts.projectPath, baseRel);
3291
- await mkdir(join11(baseAbs, "source"), { recursive: true });
4206
+ const baseRel = join14(opts.appRoot, "api", ".uilint");
4207
+ const baseAbs = join14(opts.projectPath, baseRel);
4208
+ await mkdir(join14(baseAbs, "source"), { recursive: true });
3292
4209
  await writeRouteFile(
3293
- join11(baseAbs, "source", "route.ts"),
3294
- join11(baseRel, "source", "route.ts"),
4210
+ join14(baseAbs, "source", "route.ts"),
4211
+ join14(baseRel, "source", "route.ts"),
3295
4212
  DEV_SOURCE_ROUTE_TS,
3296
4213
  opts
3297
4214
  );
4215
+ await mkdir(join14(baseAbs, "screenshots"), { recursive: true });
4216
+ await writeRouteFile(
4217
+ join14(baseAbs, "screenshots", "route.ts"),
4218
+ join14(baseRel, "screenshots", "route.ts"),
4219
+ SCREENSHOT_ROUTE_TS,
4220
+ opts
4221
+ );
3298
4222
  }
3299
4223
 
3300
4224
  // src/commands/install/execute.ts
@@ -3310,7 +4234,7 @@ async function executeAction(action, options) {
3310
4234
  wouldDo: `Create directory: ${action.path}`
3311
4235
  };
3312
4236
  }
3313
- if (!existsSync12(action.path)) {
4237
+ if (!existsSync15(action.path)) {
3314
4238
  mkdirSync3(action.path, { recursive: true });
3315
4239
  }
3316
4240
  return { action, success: true };
@@ -3323,11 +4247,11 @@ async function executeAction(action, options) {
3323
4247
  wouldDo: `Create file: ${action.path}${action.permissions ? ` (mode: ${action.permissions.toString(8)})` : ""}`
3324
4248
  };
3325
4249
  }
3326
- const dir = dirname6(action.path);
3327
- if (!existsSync12(dir)) {
4250
+ const dir = dirname7(action.path);
4251
+ if (!existsSync15(dir)) {
3328
4252
  mkdirSync3(dir, { recursive: true });
3329
4253
  }
3330
- writeFileSync6(action.path, action.content, "utf-8");
4254
+ writeFileSync7(action.path, action.content, "utf-8");
3331
4255
  if (action.permissions) {
3332
4256
  chmodSync(action.path, action.permissions);
3333
4257
  }
@@ -3342,18 +4266,18 @@ async function executeAction(action, options) {
3342
4266
  };
3343
4267
  }
3344
4268
  let existing = {};
3345
- if (existsSync12(action.path)) {
4269
+ if (existsSync15(action.path)) {
3346
4270
  try {
3347
- existing = JSON.parse(readFileSync7(action.path, "utf-8"));
4271
+ existing = JSON.parse(readFileSync10(action.path, "utf-8"));
3348
4272
  } catch {
3349
4273
  }
3350
4274
  }
3351
4275
  const merged = deepMerge(existing, action.merge);
3352
- const dir = dirname6(action.path);
3353
- if (!existsSync12(dir)) {
4276
+ const dir = dirname7(action.path);
4277
+ if (!existsSync15(dir)) {
3354
4278
  mkdirSync3(dir, { recursive: true });
3355
4279
  }
3356
- writeFileSync6(action.path, JSON.stringify(merged, null, 2), "utf-8");
4280
+ writeFileSync7(action.path, JSON.stringify(merged, null, 2), "utf-8");
3357
4281
  return { action, success: true };
3358
4282
  }
3359
4283
  case "delete_file": {
@@ -3364,7 +4288,7 @@ async function executeAction(action, options) {
3364
4288
  wouldDo: `Delete file: ${action.path}`
3365
4289
  };
3366
4290
  }
3367
- if (existsSync12(action.path)) {
4291
+ if (existsSync15(action.path)) {
3368
4292
  unlinkSync(action.path);
3369
4293
  }
3370
4294
  return { action, success: true };
@@ -3377,12 +4301,12 @@ async function executeAction(action, options) {
3377
4301
  wouldDo: `Append to file: ${action.path}`
3378
4302
  };
3379
4303
  }
3380
- if (existsSync12(action.path)) {
3381
- const content = readFileSync7(action.path, "utf-8");
4304
+ if (existsSync15(action.path)) {
4305
+ const content = readFileSync10(action.path, "utf-8");
3382
4306
  if (action.ifNotContains && content.includes(action.ifNotContains)) {
3383
4307
  return { action, success: true };
3384
4308
  }
3385
- writeFileSync6(action.path, content + action.content, "utf-8");
4309
+ writeFileSync7(action.path, content + action.content, "utf-8");
3386
4310
  }
3387
4311
  return { action, success: true };
3388
4312
  }
@@ -3395,6 +4319,9 @@ async function executeAction(action, options) {
3395
4319
  case "inject_next_config": {
3396
4320
  return await executeInjectNextConfig(action, options);
3397
4321
  }
4322
+ case "inject_vite_config": {
4323
+ return await executeInjectViteConfig(action, options);
4324
+ }
3398
4325
  case "install_next_routes": {
3399
4326
  return await executeInstallNextRoutes(action, options);
3400
4327
  }
@@ -3450,6 +4377,7 @@ async function executeInjectReact(action, options) {
3450
4377
  const result = await installReactUILintOverlay({
3451
4378
  projectPath: action.projectPath,
3452
4379
  appRoot: action.appRoot,
4380
+ mode: action.mode,
3453
4381
  force: false,
3454
4382
  // Auto-select first choice for execute phase
3455
4383
  confirmFileChoice: async (choices) => choices[0]
@@ -3461,6 +4389,25 @@ async function executeInjectReact(action, options) {
3461
4389
  error: success ? void 0 : "Failed to configure React overlay"
3462
4390
  };
3463
4391
  }
4392
+ async function executeInjectViteConfig(action, options) {
4393
+ const { dryRun = false } = options;
4394
+ if (dryRun) {
4395
+ return {
4396
+ action,
4397
+ success: true,
4398
+ wouldDo: `Inject jsx-loc-plugin into vite.config: ${action.projectPath}`
4399
+ };
4400
+ }
4401
+ const result = await installViteJsxLocPlugin({
4402
+ projectPath: action.projectPath,
4403
+ force: false
4404
+ });
4405
+ return {
4406
+ action,
4407
+ success: result.modified || result.configFile !== null,
4408
+ error: result.configFile === null ? "No vite.config found" : void 0
4409
+ };
4410
+ }
3464
4411
  async function executeInjectNextConfig(action, options) {
3465
4412
  const { dryRun = false } = options;
3466
4413
  if (dryRun) {
@@ -3518,6 +4465,7 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3518
4465
  const filesDeleted = [];
3519
4466
  const eslintTargets = [];
3520
4467
  let nextApp;
4468
+ let viteApp;
3521
4469
  for (const result of actionsPerformed) {
3522
4470
  if (!result.success) continue;
3523
4471
  const { action } = result;
@@ -3540,6 +4488,12 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3540
4488
  });
3541
4489
  break;
3542
4490
  case "inject_react":
4491
+ if (action.mode === "vite") {
4492
+ viteApp = { entryRoot: action.appRoot };
4493
+ } else {
4494
+ nextApp = { appRoot: action.appRoot };
4495
+ }
4496
+ break;
3543
4497
  case "install_next_routes":
3544
4498
  nextApp = { appRoot: action.appRoot };
3545
4499
  break;
@@ -3561,7 +4515,8 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3561
4515
  filesDeleted,
3562
4516
  dependenciesInstalled,
3563
4517
  eslintTargets,
3564
- nextApp
4518
+ nextApp,
4519
+ viteApp
3565
4520
  };
3566
4521
  }
3567
4522
  async function execute(plan, options = {}) {
@@ -3611,11 +4566,14 @@ async function execute(plan, options = {}) {
3611
4566
  if (action.path.includes("hooks.json")) items.push("hooks");
3612
4567
  if (action.path.includes("genstyleguide.md")) items.push("genstyleguide");
3613
4568
  if (action.path.includes("genrules.md")) items.push("genrules");
4569
+ if (action.path.includes("/skills/") && action.path.includes("SKILL.md")) items.push("skill");
3614
4570
  }
3615
4571
  if (action.type === "inject_eslint") items.push("eslint");
3616
- if (action.type === "inject_react" || action.type === "install_next_routes") {
3617
- items.push("next");
4572
+ if (action.type === "install_next_routes") items.push("next");
4573
+ if (action.type === "inject_react") {
4574
+ items.push(action.mode === "vite" ? "vite" : "next");
3618
4575
  }
4576
+ if (action.type === "inject_vite_config") items.push("vite");
3619
4577
  }
3620
4578
  const uniqueItems = [...new Set(items)];
3621
4579
  const summary = buildSummary(
@@ -3641,13 +4599,18 @@ var cliPrompter = {
3641
4599
  {
3642
4600
  value: "eslint",
3643
4601
  label: "ESLint plugin",
3644
- hint: "Installs uilint-eslint and configures eslint.config.js"
4602
+ hint: "Installs uilint-eslint and configures eslint.config.*"
3645
4603
  },
3646
4604
  {
3647
4605
  value: "next",
3648
4606
  label: "UI overlay",
3649
4607
  hint: "Installs routes + UILintProvider (Alt+Click to inspect)"
3650
4608
  },
4609
+ {
4610
+ value: "vite",
4611
+ label: "UI overlay (Vite)",
4612
+ hint: "Installs jsx-loc-plugin + UILintProvider (Alt+Click to inspect)"
4613
+ },
3651
4614
  {
3652
4615
  value: "genstyleguide",
3653
4616
  label: "/genstyleguide command",
@@ -3667,10 +4630,15 @@ var cliPrompter = {
3667
4630
  value: "genrules",
3668
4631
  label: "/genrules command",
3669
4632
  hint: "Adds .cursor/commands/genrules.md for ESLint rule generation"
4633
+ },
4634
+ {
4635
+ value: "skill",
4636
+ label: "UI Consistency Agent Skill",
4637
+ hint: "Cursor agent skill for generating ESLint rules from UI patterns"
3670
4638
  }
3671
4639
  ],
3672
4640
  required: true,
3673
- initialValues: ["eslint", "next", "genstyleguide"]
4641
+ initialValues: ["eslint", "next", "genstyleguide", "skill"]
3674
4642
  });
3675
4643
  },
3676
4644
  async confirmMcpMerge() {
@@ -3700,6 +4668,17 @@ var cliPrompter = {
3700
4668
  });
3701
4669
  return apps.find((a) => a.projectPath === chosen) || apps[0];
3702
4670
  },
4671
+ async selectViteApp(apps) {
4672
+ const chosen = await select2({
4673
+ message: "Which Vite + React project should UILint install into?",
4674
+ options: apps.map((app) => ({
4675
+ value: app.projectPath,
4676
+ label: app.projectPath
4677
+ })),
4678
+ initialValue: apps[0].projectPath
4679
+ });
4680
+ return apps.find((a) => a.projectPath === chosen) || apps[0];
4681
+ },
3703
4682
  async selectEslintPackages(packages) {
3704
4683
  if (packages.length === 1) {
3705
4684
  const confirmed = await confirm2({
@@ -3856,13 +4835,14 @@ async function promptForField(field, ruleName) {
3856
4835
  }
3857
4836
  async function gatherChoices(state, options, prompter) {
3858
4837
  let items;
3859
- const hasExplicitFlags = options.mcp !== void 0 || options.hooks !== void 0 || options.genstyleguide !== void 0 || options.genrules !== void 0 || options.routes !== void 0 || options.react !== void 0;
4838
+ const hasExplicitFlags = options.mcp !== void 0 || options.hooks !== void 0 || options.genstyleguide !== void 0 || options.genrules !== void 0 || options.skill !== void 0 || options.routes !== void 0 || options.react !== void 0;
3860
4839
  if (hasExplicitFlags || options.eslint) {
3861
4840
  items = [];
3862
4841
  if (options.mcp) items.push("mcp");
3863
4842
  if (options.hooks) items.push("hooks");
3864
4843
  if (options.genstyleguide) items.push("genstyleguide");
3865
4844
  if (options.genrules) items.push("genrules");
4845
+ if (options.skill) items.push("skill");
3866
4846
  if (options.routes || options.react) items.push("next");
3867
4847
  if (options.eslint) items.push("eslint");
3868
4848
  } else if (options.mode) {
@@ -3901,6 +4881,25 @@ async function gatherChoices(state, options, prompter) {
3901
4881
  };
3902
4882
  }
3903
4883
  }
4884
+ let viteChoices;
4885
+ if (items.includes("vite")) {
4886
+ if (state.viteApps.length === 0) {
4887
+ throw new Error(
4888
+ "Could not find a Vite + React project (expected vite.config.* + react deps). Run this from your Vite project root."
4889
+ );
4890
+ } else if (state.viteApps.length === 1) {
4891
+ viteChoices = {
4892
+ projectPath: state.viteApps[0].projectPath,
4893
+ detection: state.viteApps[0].detection
4894
+ };
4895
+ } else {
4896
+ const selected = await prompter.selectViteApp(state.viteApps);
4897
+ viteChoices = {
4898
+ projectPath: selected.projectPath,
4899
+ detection: selected.detection
4900
+ };
4901
+ }
4902
+ }
3904
4903
  let eslintChoices;
3905
4904
  if (items.includes("eslint")) {
3906
4905
  const packagesWithEslint = state.packages.filter(
@@ -3908,7 +4907,7 @@ async function gatherChoices(state, options, prompter) {
3908
4907
  );
3909
4908
  if (packagesWithEslint.length === 0) {
3910
4909
  throw new Error(
3911
- "No packages with eslint.config.{mjs,js,cjs} found. Create an ESLint config first."
4910
+ "No packages with eslint.config.{ts,mjs,js,cjs} found. Create an ESLint config first."
3912
4911
  );
3913
4912
  }
3914
4913
  const packagePaths = await prompter.selectEslintPackages(
@@ -3940,6 +4939,7 @@ async function gatherChoices(state, options, prompter) {
3940
4939
  mcpMerge,
3941
4940
  hooksMerge,
3942
4941
  next: nextChoices,
4942
+ vite: viteChoices,
3943
4943
  eslint: eslintChoices
3944
4944
  };
3945
4945
  }
@@ -3988,7 +4988,7 @@ function displayResults(result) {
3988
4988
  if (summary.nextApp) {
3989
4989
  installedItems.push(
3990
4990
  `${pc.cyan("Next Routes")} \u2192 ${pc.dim(
3991
- join12(summary.nextApp.appRoot, "api/.uilint")
4991
+ join15(summary.nextApp.appRoot, "api/.uilint")
3992
4992
  )}`
3993
4993
  );
3994
4994
  installedItems.push(
@@ -3996,7 +4996,17 @@ function displayResults(result) {
3996
4996
  );
3997
4997
  installedItems.push(
3998
4998
  `${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
3999
- "next.config wrapped with withJsxLoc"
4999
+ "next.config wrapped with withJsxLoc"
5000
+ )}`
5001
+ );
5002
+ }
5003
+ if (summary.viteApp) {
5004
+ installedItems.push(
5005
+ `${pc.cyan("Vite Overlay")} \u2192 ${pc.dim("<UILintProvider> injected")}`
5006
+ );
5007
+ installedItems.push(
5008
+ `${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
5009
+ "vite.config plugins patched with jsxLoc()"
4000
5010
  )}`
4001
5011
  );
4002
5012
  }
@@ -4044,6 +5054,11 @@ function displayResults(result) {
4044
5054
  "Run your Next.js dev server - use Alt+Click on any element to inspect"
4045
5055
  );
4046
5056
  }
5057
+ if (summary.viteApp) {
5058
+ steps.push(
5059
+ "Run your Vite dev server - use Alt+Click on any element to inspect"
5060
+ );
5061
+ }
4047
5062
  if (summary.eslintTargets.length > 0) {
4048
5063
  steps.push(`Run ${pc.cyan("npx eslint src/")} to check for issues`);
4049
5064
  steps.push(
@@ -4108,12 +5123,207 @@ async function install(options = {}, prompter = cliPrompter, executeOptions = {}
4108
5123
  }
4109
5124
 
4110
5125
  // src/commands/serve.ts
4111
- import { existsSync as existsSync13, statSync as statSync2, readdirSync as readdirSync3, readFileSync as readFileSync8 } from "fs";
5126
+ import { existsSync as existsSync17, statSync as statSync4, readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
4112
5127
  import { createRequire as createRequire2 } from "module";
4113
- import { dirname as dirname7, resolve as resolve5, relative as relative2, join as join13 } from "path";
5128
+ import { dirname as dirname9, resolve as resolve5, relative as relative3, join as join17, parse as parse2 } from "path";
4114
5129
  import { WebSocketServer, WebSocket } from "ws";
4115
5130
  import { watch } from "chokidar";
4116
- import { findWorkspaceRoot as findWorkspaceRoot5 } from "uilint-core/node";
5131
+ import {
5132
+ findWorkspaceRoot as findWorkspaceRoot5,
5133
+ getVisionAnalyzer as getCoreVisionAnalyzer
5134
+ } from "uilint-core/node";
5135
+
5136
+ // src/utils/vision-run.ts
5137
+ import { dirname as dirname8, join as join16, parse } from "path";
5138
+ import { existsSync as existsSync16, statSync as statSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync8 } from "fs";
5139
+ import {
5140
+ ensureOllamaReady as ensureOllamaReady5,
5141
+ findStyleGuidePath as findStyleGuidePath4,
5142
+ findUILintStyleGuideUpwards as findUILintStyleGuideUpwards3,
5143
+ readStyleGuide as readStyleGuide4,
5144
+ VisionAnalyzer,
5145
+ UILINT_DEFAULT_VISION_MODEL
5146
+ } from "uilint-core/node";
5147
+ async function resolveVisionStyleGuide(args) {
5148
+ const projectPath = args.projectPath;
5149
+ const startDir = args.startDir ?? projectPath;
5150
+ if (args.styleguide) {
5151
+ const styleguideArg = resolvePathSpecifier(args.styleguide, projectPath);
5152
+ if (existsSync16(styleguideArg)) {
5153
+ const stat = statSync3(styleguideArg);
5154
+ if (stat.isFile()) {
5155
+ return {
5156
+ styleguideLocation: styleguideArg,
5157
+ styleGuide: await readStyleGuide4(styleguideArg)
5158
+ };
5159
+ }
5160
+ if (stat.isDirectory()) {
5161
+ const found = findStyleGuidePath4(styleguideArg);
5162
+ return {
5163
+ styleguideLocation: found,
5164
+ styleGuide: found ? await readStyleGuide4(found) : null
5165
+ };
5166
+ }
5167
+ }
5168
+ return { styleGuide: null, styleguideLocation: null };
5169
+ }
5170
+ const upwards = findUILintStyleGuideUpwards3(startDir);
5171
+ const fallback = upwards ?? findStyleGuidePath4(projectPath);
5172
+ return {
5173
+ styleguideLocation: fallback,
5174
+ styleGuide: fallback ? await readStyleGuide4(fallback) : null
5175
+ };
5176
+ }
5177
+ var ollamaReadyOnce = /* @__PURE__ */ new Map();
5178
+ async function ensureOllamaReadyCached(params) {
5179
+ const key = `${params.baseUrl}::${params.model}`;
5180
+ const existing = ollamaReadyOnce.get(key);
5181
+ if (existing) return existing;
5182
+ const p2 = ensureOllamaReady5({ model: params.model, baseUrl: params.baseUrl }).then(() => void 0).catch((e) => {
5183
+ ollamaReadyOnce.delete(key);
5184
+ throw e;
5185
+ });
5186
+ ollamaReadyOnce.set(key, p2);
5187
+ return p2;
5188
+ }
5189
+ function writeVisionDebugDump(params) {
5190
+ const resolvedDirOrFile = resolvePathSpecifier(
5191
+ params.dumpPath,
5192
+ process.cwd()
5193
+ );
5194
+ const safeStamp = params.now.toISOString().replace(/[:.]/g, "-");
5195
+ const dumpFile = resolvedDirOrFile.endsWith(".json") || resolvedDirOrFile.endsWith(".jsonl") ? resolvedDirOrFile : `${resolvedDirOrFile}/vision-debug-${safeStamp}.json`;
5196
+ mkdirSync4(dirname8(dumpFile), { recursive: true });
5197
+ writeFileSync8(
5198
+ dumpFile,
5199
+ JSON.stringify(
5200
+ {
5201
+ version: 1,
5202
+ timestamp: params.now.toISOString(),
5203
+ runtime: params.runtime,
5204
+ metadata: params.metadata ?? null,
5205
+ inputs: {
5206
+ imageBase64: params.includeSensitive ? params.inputs.imageBase64 : "(omitted; set debugDumpIncludeSensitive=true)",
5207
+ manifest: params.inputs.manifest,
5208
+ styleguideLocation: params.inputs.styleguideLocation,
5209
+ styleGuide: params.includeSensitive ? params.inputs.styleGuide : "(omitted; set debugDumpIncludeSensitive=true)"
5210
+ }
5211
+ },
5212
+ null,
5213
+ 2
5214
+ ),
5215
+ "utf-8"
5216
+ );
5217
+ return dumpFile;
5218
+ }
5219
+ async function runVisionAnalysis(args) {
5220
+ const visionModel = args.model || UILINT_DEFAULT_VISION_MODEL;
5221
+ const baseUrl = args.baseUrl ?? "http://localhost:11434";
5222
+ let styleGuide = null;
5223
+ let styleguideLocation = null;
5224
+ if (args.styleGuide !== void 0) {
5225
+ styleGuide = args.styleGuide;
5226
+ styleguideLocation = args.styleguideLocation ?? null;
5227
+ } else {
5228
+ args.onPhase?.("Resolving styleguide...");
5229
+ const resolved = await resolveVisionStyleGuide({
5230
+ projectPath: args.projectPath,
5231
+ styleguide: args.styleguide,
5232
+ startDir: args.styleguideStartDir
5233
+ });
5234
+ styleGuide = resolved.styleGuide;
5235
+ styleguideLocation = resolved.styleguideLocation;
5236
+ }
5237
+ if (!args.skipEnsureOllama) {
5238
+ args.onPhase?.("Preparing Ollama...");
5239
+ await ensureOllamaReadyCached({ model: visionModel, baseUrl });
5240
+ }
5241
+ if (args.debugDump) {
5242
+ writeVisionDebugDump({
5243
+ dumpPath: args.debugDump,
5244
+ now: /* @__PURE__ */ new Date(),
5245
+ runtime: { visionModel, baseUrl },
5246
+ inputs: {
5247
+ imageBase64: args.imageBase64,
5248
+ manifest: args.manifest,
5249
+ styleguideLocation,
5250
+ styleGuide
5251
+ },
5252
+ includeSensitive: Boolean(args.debugDumpIncludeSensitive),
5253
+ metadata: args.debugDumpMetadata
5254
+ });
5255
+ }
5256
+ const analyzer = args.analyzer ?? new VisionAnalyzer({
5257
+ baseUrl: args.baseUrl,
5258
+ visionModel
5259
+ });
5260
+ args.onPhase?.(`Analyzing ${args.manifest.length} elements...`);
5261
+ const result = await analyzer.analyzeScreenshot(
5262
+ args.imageBase64,
5263
+ args.manifest,
5264
+ {
5265
+ styleGuide,
5266
+ onProgress: args.onProgress
5267
+ }
5268
+ );
5269
+ args.onPhase?.(
5270
+ `Done (${result.issues.length} issues, ${result.analysisTime}ms)`
5271
+ );
5272
+ return {
5273
+ issues: result.issues,
5274
+ analysisTime: result.analysisTime,
5275
+ // Prompt is available in newer uilint-core versions; keep this resilient across versions.
5276
+ prompt: result.prompt,
5277
+ rawResponse: result.rawResponse,
5278
+ styleguideLocation,
5279
+ visionModel,
5280
+ baseUrl
5281
+ };
5282
+ }
5283
+ function writeVisionMarkdownReport(args) {
5284
+ const p2 = parse(args.imagePath);
5285
+ const outPath = args.outPath ?? join16(p2.dir, `${p2.name || p2.base}.vision.md`);
5286
+ const lines = [];
5287
+ lines.push(`# UILint Vision Report`);
5288
+ lines.push(``);
5289
+ lines.push(`- Image: \`${p2.base}\``);
5290
+ if (args.route) lines.push(`- Route: \`${args.route}\``);
5291
+ if (typeof args.timestamp === "number") {
5292
+ lines.push(`- Timestamp: \`${new Date(args.timestamp).toISOString()}\``);
5293
+ }
5294
+ if (args.visionModel) lines.push(`- Model: \`${args.visionModel}\``);
5295
+ if (args.baseUrl) lines.push(`- Ollama baseUrl: \`${args.baseUrl}\``);
5296
+ if (typeof args.analysisTimeMs === "number")
5297
+ lines.push(`- Analysis time: \`${args.analysisTimeMs}ms\``);
5298
+ lines.push(`- Generated: \`${(/* @__PURE__ */ new Date()).toISOString()}\``);
5299
+ lines.push(``);
5300
+ if (args.metadata && Object.keys(args.metadata).length > 0) {
5301
+ lines.push(`## Metadata`);
5302
+ lines.push(``);
5303
+ lines.push("```json");
5304
+ lines.push(JSON.stringify(args.metadata, null, 2));
5305
+ lines.push("```");
5306
+ lines.push(``);
5307
+ }
5308
+ lines.push(`## Prompt`);
5309
+ lines.push(``);
5310
+ lines.push("```text");
5311
+ lines.push((args.prompt ?? "").trim());
5312
+ lines.push("```");
5313
+ lines.push(``);
5314
+ lines.push(`## Raw Response`);
5315
+ lines.push(``);
5316
+ lines.push("```text");
5317
+ lines.push((args.rawResponse ?? "").trim());
5318
+ lines.push("```");
5319
+ lines.push(``);
5320
+ const content = lines.join("\n");
5321
+ mkdirSync4(dirname8(outPath), { recursive: true });
5322
+ writeFileSync8(outPath, content, "utf-8");
5323
+ return { outPath, content };
5324
+ }
5325
+
5326
+ // src/commands/serve.ts
4117
5327
  function pickAppRoot(params) {
4118
5328
  const { cwd, workspaceRoot } = params;
4119
5329
  if (detectNextAppRouter(cwd)) return cwd;
@@ -4128,6 +5338,18 @@ function pickAppRoot(params) {
4128
5338
  }
4129
5339
  var cache = /* @__PURE__ */ new Map();
4130
5340
  var eslintInstances = /* @__PURE__ */ new Map();
5341
+ var visionAnalyzer = null;
5342
+ function getVisionAnalyzerInstance() {
5343
+ if (!visionAnalyzer) {
5344
+ visionAnalyzer = getCoreVisionAnalyzer();
5345
+ }
5346
+ return visionAnalyzer;
5347
+ }
5348
+ var serverAppRootForVision = process.cwd();
5349
+ function isValidScreenshotFilename(filename) {
5350
+ const validPattern = /^[a-zA-Z0-9_-]+\.(png|jpeg|jpg)$/;
5351
+ return validPattern.test(filename) && !filename.includes("..");
5352
+ }
4131
5353
  var resolvedPathCache = /* @__PURE__ */ new Map();
4132
5354
  var subscriptions = /* @__PURE__ */ new Map();
4133
5355
  var fileWatcher = null;
@@ -4146,8 +5368,8 @@ function offsetFromLineCol(lineStarts, line1, col0, codeLength) {
4146
5368
  return Math.max(0, Math.min(codeLength, base + Math.max(0, col0)));
4147
5369
  }
4148
5370
  function buildJsxElementSpans(code, dataLocFile) {
4149
- const { parse } = localRequire("@typescript-eslint/typescript-estree");
4150
- const ast = parse(code, {
5371
+ const { parse: parse3 } = localRequire("@typescript-eslint/typescript-estree");
5372
+ const ast = parse3(code, {
4151
5373
  loc: true,
4152
5374
  range: true,
4153
5375
  jsx: true,
@@ -4210,10 +5432,10 @@ function findESLintCwd(startDir) {
4210
5432
  let dir = startDir;
4211
5433
  for (let i = 0; i < 30; i++) {
4212
5434
  for (const cfg of ESLINT_CONFIG_FILES2) {
4213
- if (existsSync13(join13(dir, cfg))) return dir;
5435
+ if (existsSync17(join17(dir, cfg))) return dir;
4214
5436
  }
4215
- if (existsSync13(join13(dir, "package.json"))) return dir;
4216
- const parent = dirname7(dir);
5437
+ if (existsSync17(join17(dir, "package.json"))) return dir;
5438
+ const parent = dirname9(dir);
4217
5439
  if (parent === dir) break;
4218
5440
  dir = parent;
4219
5441
  }
@@ -4226,7 +5448,7 @@ function normalizeDataLocFilePath(absoluteFilePath, projectCwd) {
4226
5448
  const abs = normalizePathSlashes(resolve5(absoluteFilePath));
4227
5449
  const cwd = normalizePathSlashes(resolve5(projectCwd));
4228
5450
  if (abs === cwd || abs.startsWith(cwd + "/")) {
4229
- return normalizePathSlashes(relative2(cwd, abs));
5451
+ return normalizePathSlashes(relative3(cwd, abs));
4230
5452
  }
4231
5453
  return abs;
4232
5454
  }
@@ -4238,25 +5460,25 @@ function resolveRequestedFilePath(filePath) {
4238
5460
  if (cached) return cached;
4239
5461
  const cwd = process.cwd();
4240
5462
  const fromCwd = resolve5(cwd, filePath);
4241
- if (existsSync13(fromCwd)) {
5463
+ if (existsSync17(fromCwd)) {
4242
5464
  resolvedPathCache.set(filePath, fromCwd);
4243
5465
  return fromCwd;
4244
5466
  }
4245
5467
  const wsRoot = findWorkspaceRoot5(cwd);
4246
5468
  const fromWs = resolve5(wsRoot, filePath);
4247
- if (existsSync13(fromWs)) {
5469
+ if (existsSync17(fromWs)) {
4248
5470
  resolvedPathCache.set(filePath, fromWs);
4249
5471
  return fromWs;
4250
5472
  }
4251
5473
  for (const top of ["apps", "packages"]) {
4252
- const base = join13(wsRoot, top);
4253
- if (!existsSync13(base)) continue;
5474
+ const base = join17(wsRoot, top);
5475
+ if (!existsSync17(base)) continue;
4254
5476
  try {
4255
- const entries = readdirSync3(base, { withFileTypes: true });
5477
+ const entries = readdirSync5(base, { withFileTypes: true });
4256
5478
  for (const ent of entries) {
4257
5479
  if (!ent.isDirectory()) continue;
4258
5480
  const p2 = resolve5(base, ent.name, filePath);
4259
- if (existsSync13(p2)) {
5481
+ if (existsSync17(p2)) {
4260
5482
  resolvedPathCache.set(filePath, p2);
4261
5483
  return p2;
4262
5484
  }
@@ -4271,7 +5493,7 @@ async function getESLintForProject(projectCwd) {
4271
5493
  const cached = eslintInstances.get(projectCwd);
4272
5494
  if (cached) return cached;
4273
5495
  try {
4274
- const req = createRequire2(join13(projectCwd, "package.json"));
5496
+ const req = createRequire2(join17(projectCwd, "package.json"));
4275
5497
  const mod = req("eslint");
4276
5498
  const ESLintCtor = mod?.ESLint ?? mod?.default?.ESLint ?? mod?.default ?? mod;
4277
5499
  if (!ESLintCtor) return null;
@@ -4284,13 +5506,13 @@ async function getESLintForProject(projectCwd) {
4284
5506
  }
4285
5507
  async function lintFile(filePath, onProgress) {
4286
5508
  const absolutePath = resolveRequestedFilePath(filePath);
4287
- if (!existsSync13(absolutePath)) {
5509
+ if (!existsSync17(absolutePath)) {
4288
5510
  onProgress(`File not found: ${pc.dim(absolutePath)}`);
4289
5511
  return [];
4290
5512
  }
4291
5513
  const mtimeMs = (() => {
4292
5514
  try {
4293
- return statSync2(absolutePath).mtimeMs;
5515
+ return statSync4(absolutePath).mtimeMs;
4294
5516
  } catch {
4295
5517
  return 0;
4296
5518
  }
@@ -4300,7 +5522,7 @@ async function lintFile(filePath, onProgress) {
4300
5522
  onProgress("Cache hit (unchanged)");
4301
5523
  return cached.issues;
4302
5524
  }
4303
- const fileDir = dirname7(absolutePath);
5525
+ const fileDir = dirname9(absolutePath);
4304
5526
  const projectCwd = findESLintCwd(fileDir);
4305
5527
  onProgress(`Resolving ESLint project... ${pc.dim(projectCwd)}`);
4306
5528
  const eslint = await getESLintForProject(projectCwd);
@@ -4323,7 +5545,7 @@ async function lintFile(filePath, onProgress) {
4323
5545
  let codeLength = 0;
4324
5546
  try {
4325
5547
  onProgress("Building JSX map...");
4326
- const code = readFileSync8(absolutePath, "utf-8");
5548
+ const code = readFileSync11(absolutePath, "utf-8");
4327
5549
  codeLength = code.length;
4328
5550
  lineStarts = buildLineStarts(code);
4329
5551
  spans = buildJsxElementSpans(code, dataLocFile);
@@ -4392,6 +5614,7 @@ async function handleMessage(ws, data) {
4392
5614
  message.filePath ?? "(all)"
4393
5615
  )}`
4394
5616
  );
5617
+ } else if (message.type === "vision:analyze") {
4395
5618
  }
4396
5619
  switch (message.type) {
4397
5620
  case "lint:file": {
@@ -4404,7 +5627,7 @@ async function handleMessage(ws, data) {
4404
5627
  });
4405
5628
  const startedAt = Date.now();
4406
5629
  const resolved = resolveRequestedFilePath(filePath);
4407
- if (!existsSync13(resolved)) {
5630
+ if (!existsSync17(resolved)) {
4408
5631
  const cwd = process.cwd();
4409
5632
  const wsRoot = findWorkspaceRoot5(cwd);
4410
5633
  logWarning(
@@ -4492,6 +5715,167 @@ async function handleMessage(ws, data) {
4492
5715
  }
4493
5716
  break;
4494
5717
  }
5718
+ case "vision:analyze": {
5719
+ const {
5720
+ route,
5721
+ timestamp,
5722
+ screenshot,
5723
+ screenshotFile,
5724
+ manifest,
5725
+ requestId
5726
+ } = message;
5727
+ logInfo(
5728
+ `${pc.dim("[ws]")} ${pc.bold("vision:analyze")} ${pc.dim(route)}${requestId ? ` ${pc.dim(`(req ${requestId})`)}` : ""}`
5729
+ );
5730
+ sendMessage(ws, {
5731
+ type: "vision:progress",
5732
+ route,
5733
+ requestId,
5734
+ phase: "Starting vision analysis..."
5735
+ });
5736
+ const startedAt = Date.now();
5737
+ const analyzer = getVisionAnalyzerInstance();
5738
+ try {
5739
+ const screenshotBytes = typeof screenshot === "string" ? Buffer.byteLength(screenshot) : 0;
5740
+ const analyzerModel = typeof analyzer.getModel === "function" ? analyzer.getModel() : void 0;
5741
+ const analyzerBaseUrl = typeof analyzer.getBaseUrl === "function" ? analyzer.getBaseUrl() : void 0;
5742
+ logInfo(
5743
+ [
5744
+ `${pc.dim("[ws]")} ${pc.dim("vision")} details`,
5745
+ ` route: ${pc.dim(route)}`,
5746
+ ` requestId: ${pc.dim(requestId ?? "(none)")}`,
5747
+ ` manifest: ${pc.dim(String(manifest.length))} element(s)`,
5748
+ ` screenshot: ${pc.dim(
5749
+ screenshot ? `${Math.round(screenshotBytes / 1024)}kb` : "none"
5750
+ )}`,
5751
+ ` screenshotFile: ${pc.dim(screenshotFile ?? "(none)")}`,
5752
+ ` ollamaUrl: ${pc.dim(analyzerBaseUrl ?? "(default)")}`,
5753
+ ` visionModel: ${pc.dim(analyzerModel ?? "(default)")}`
5754
+ ].join("\n")
5755
+ );
5756
+ if (!screenshot) {
5757
+ sendMessage(ws, {
5758
+ type: "vision:result",
5759
+ route,
5760
+ issues: [],
5761
+ analysisTime: Date.now() - startedAt,
5762
+ error: "No screenshot provided for vision analysis",
5763
+ requestId
5764
+ });
5765
+ break;
5766
+ }
5767
+ const result = await runVisionAnalysis({
5768
+ imageBase64: screenshot,
5769
+ manifest,
5770
+ projectPath: serverAppRootForVision,
5771
+ // In the overlay/server context, default to upward search from app root.
5772
+ baseUrl: analyzerBaseUrl,
5773
+ model: analyzerModel,
5774
+ analyzer,
5775
+ onPhase: (phase) => {
5776
+ sendMessage(ws, {
5777
+ type: "vision:progress",
5778
+ route,
5779
+ requestId,
5780
+ phase
5781
+ });
5782
+ }
5783
+ });
5784
+ if (typeof screenshotFile === "string" && screenshotFile.length > 0) {
5785
+ if (!isValidScreenshotFilename(screenshotFile)) {
5786
+ logWarning(
5787
+ `Skipping vision report write: invalid screenshotFile ${pc.dim(
5788
+ screenshotFile
5789
+ )}`
5790
+ );
5791
+ } else {
5792
+ const screenshotsDir = join17(
5793
+ serverAppRootForVision,
5794
+ ".uilint",
5795
+ "screenshots"
5796
+ );
5797
+ const imagePath = join17(screenshotsDir, screenshotFile);
5798
+ try {
5799
+ if (!existsSync17(imagePath)) {
5800
+ logWarning(
5801
+ `Skipping vision report write: screenshot file not found ${pc.dim(
5802
+ imagePath
5803
+ )}`
5804
+ );
5805
+ } else {
5806
+ const report = writeVisionMarkdownReport({
5807
+ imagePath,
5808
+ route,
5809
+ timestamp,
5810
+ visionModel: result.visionModel,
5811
+ baseUrl: result.baseUrl,
5812
+ analysisTimeMs: result.analysisTime,
5813
+ prompt: result.prompt ?? null,
5814
+ rawResponse: result.rawResponse ?? null,
5815
+ metadata: {
5816
+ screenshotFile: parse2(imagePath).base,
5817
+ appRoot: serverAppRootForVision,
5818
+ manifestElements: manifest.length,
5819
+ requestId: requestId ?? null
5820
+ }
5821
+ });
5822
+ logInfo(
5823
+ `${pc.dim("[ws]")} wrote vision report ${pc.dim(
5824
+ report.outPath
5825
+ )}`
5826
+ );
5827
+ }
5828
+ } catch (e) {
5829
+ logWarning(
5830
+ `Failed to write vision report for ${pc.dim(screenshotFile)}: ${e instanceof Error ? e.message : String(e)}`
5831
+ );
5832
+ }
5833
+ }
5834
+ }
5835
+ const elapsed = Date.now() - startedAt;
5836
+ logInfo(
5837
+ `${pc.dim("[ws]")} vision:analyze done ${pc.dim(route)} \u2192 ${pc.bold(
5838
+ `${result.issues.length}`
5839
+ )} issue(s) ${pc.dim(`(${elapsed}ms)`)}`
5840
+ );
5841
+ if (result.rawResponse) {
5842
+ logInfo(
5843
+ `${pc.dim("[ws]")} vision rawResponse ${pc.dim(
5844
+ `${result.rawResponse.length} chars`
5845
+ )}`
5846
+ );
5847
+ }
5848
+ sendMessage(ws, {
5849
+ type: "vision:result",
5850
+ route,
5851
+ issues: result.issues,
5852
+ analysisTime: result.analysisTime,
5853
+ requestId
5854
+ });
5855
+ } catch (error) {
5856
+ const errorMessage = error instanceof Error ? error.message : String(error);
5857
+ const stack = error instanceof Error ? error.stack : void 0;
5858
+ logError(
5859
+ [
5860
+ `Vision analysis failed`,
5861
+ ` route: ${route}`,
5862
+ ` requestId: ${requestId ?? "(none)"}`,
5863
+ ` error: ${errorMessage}`,
5864
+ stack ? ` stack:
5865
+ ${stack}` : ""
5866
+ ].filter(Boolean).join("\n")
5867
+ );
5868
+ sendMessage(ws, {
5869
+ type: "vision:result",
5870
+ route,
5871
+ issues: [],
5872
+ analysisTime: Date.now() - startedAt,
5873
+ error: errorMessage,
5874
+ requestId
5875
+ });
5876
+ }
5877
+ break;
5878
+ }
4495
5879
  }
4496
5880
  }
4497
5881
  function handleDisconnect(ws) {
@@ -4522,6 +5906,7 @@ async function serve(options) {
4522
5906
  const cwd = process.cwd();
4523
5907
  const wsRoot = findWorkspaceRoot5(cwd);
4524
5908
  const appRoot = pickAppRoot({ cwd, workspaceRoot: wsRoot });
5909
+ serverAppRootForVision = appRoot;
4525
5910
  logInfo(`Workspace root: ${pc.dim(wsRoot)}`);
4526
5911
  logInfo(`App root: ${pc.dim(appRoot)}`);
4527
5912
  logInfo(`Server cwd: ${pc.dim(cwd)}`);
@@ -4561,22 +5946,505 @@ async function serve(options) {
4561
5946
  `UILint WebSocket server running on ${pc.cyan(`ws://localhost:${port}`)}`
4562
5947
  );
4563
5948
  logInfo("Press Ctrl+C to stop");
4564
- await new Promise((resolve7) => {
5949
+ await new Promise((resolve8) => {
4565
5950
  process.on("SIGINT", () => {
4566
5951
  logInfo("Shutting down...");
4567
5952
  wss.close();
4568
5953
  fileWatcher?.close();
4569
- resolve7();
5954
+ resolve8();
4570
5955
  });
4571
5956
  });
4572
5957
  }
4573
5958
 
5959
+ // src/commands/vision.ts
5960
+ import { dirname as dirname10, resolve as resolve6, join as join18 } from "path";
5961
+ import {
5962
+ existsSync as existsSync18,
5963
+ readFileSync as readFileSync12,
5964
+ readdirSync as readdirSync6
5965
+ } from "fs";
5966
+ import {
5967
+ ensureOllamaReady as ensureOllamaReady6,
5968
+ STYLEGUIDE_PATHS as STYLEGUIDE_PATHS2,
5969
+ UILINT_DEFAULT_VISION_MODEL as UILINT_DEFAULT_VISION_MODEL2
5970
+ } from "uilint-core/node";
5971
+ function envTruthy3(name) {
5972
+ const v = process.env[name];
5973
+ if (!v) return false;
5974
+ return v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes";
5975
+ }
5976
+ function preview3(text3, maxLen) {
5977
+ if (text3.length <= maxLen) return text3;
5978
+ return text3.slice(0, maxLen) + "\n\u2026<truncated>\u2026\n" + text3.slice(-maxLen);
5979
+ }
5980
+ function debugEnabled3(options) {
5981
+ return Boolean(options.debug) || envTruthy3("UILINT_DEBUG");
5982
+ }
5983
+ function debugFullEnabled3(options) {
5984
+ return Boolean(options.debugFull) || envTruthy3("UILINT_DEBUG_FULL");
5985
+ }
5986
+ function debugDumpPath3(options) {
5987
+ const v = options.debugDump ?? process.env.UILINT_DEBUG_DUMP;
5988
+ if (!v) return null;
5989
+ if (v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes") {
5990
+ return resolve6(process.cwd(), ".uilint");
5991
+ }
5992
+ return v;
5993
+ }
5994
+ function debugLog3(enabled, message, obj) {
5995
+ if (!enabled) return;
5996
+ if (obj === void 0) {
5997
+ console.error(pc.dim("[uilint:debug]"), message);
5998
+ } else {
5999
+ try {
6000
+ console.error(pc.dim("[uilint:debug]"), message, obj);
6001
+ } catch {
6002
+ console.error(pc.dim("[uilint:debug]"), message);
6003
+ }
6004
+ }
6005
+ }
6006
+ function findScreenshotsDirUpwards(startDir) {
6007
+ let dir = startDir;
6008
+ for (let i = 0; i < 20; i++) {
6009
+ const candidate = join18(dir, ".uilint", "screenshots");
6010
+ if (existsSync18(candidate)) return candidate;
6011
+ const parent = dirname10(dir);
6012
+ if (parent === dir) break;
6013
+ dir = parent;
6014
+ }
6015
+ return null;
6016
+ }
6017
+ function listScreenshotSidecars(dirPath) {
6018
+ if (!existsSync18(dirPath)) return [];
6019
+ const entries = readdirSync6(dirPath).filter((f) => f.endsWith(".json")).map((f) => join18(dirPath, f));
6020
+ const out = [];
6021
+ for (const p2 of entries) {
6022
+ try {
6023
+ const json = loadJsonFile(p2);
6024
+ const issues = Array.isArray(json?.issues) ? json.issues : json?.analysisResult?.issues;
6025
+ out.push({
6026
+ path: p2,
6027
+ filename: json?.filename || json?.screenshotFile || p2.split("/").pop() || p2,
6028
+ timestamp: typeof json?.timestamp === "number" ? json.timestamp : void 0,
6029
+ route: typeof json?.route === "string" ? json.route : void 0,
6030
+ issueCount: Array.isArray(issues) ? issues.length : void 0
6031
+ });
6032
+ } catch {
6033
+ out.push({
6034
+ path: p2,
6035
+ filename: p2.split("/").pop() || p2
6036
+ });
6037
+ }
6038
+ }
6039
+ out.sort((a, b) => {
6040
+ const at = a.timestamp ?? 0;
6041
+ const bt = b.timestamp ?? 0;
6042
+ if (at !== bt) return bt - at;
6043
+ return b.path.localeCompare(a.path);
6044
+ });
6045
+ return out;
6046
+ }
6047
+ function readImageAsBase64(imagePath) {
6048
+ const bytes = readFileSync12(imagePath);
6049
+ return { base64: bytes.toString("base64"), sizeBytes: bytes.byteLength };
6050
+ }
6051
+ function loadJsonFile(filePath) {
6052
+ const raw = readFileSync12(filePath, "utf-8");
6053
+ return JSON.parse(raw);
6054
+ }
6055
+ function formatIssuesText(issues) {
6056
+ if (issues.length === 0) return "No vision issues found.\n";
6057
+ return issues.map((i) => {
6058
+ const sev = i.severity || "info";
6059
+ const cat = i.category || "other";
6060
+ const where = i.dataLoc ? ` (${i.dataLoc})` : "";
6061
+ return `- [${sev}/${cat}] ${i.message}${where}`;
6062
+ }).join("\n") + "\n";
6063
+ }
6064
+ async function vision(options) {
6065
+ const isJsonOutput = options.output === "json";
6066
+ const dbg = debugEnabled3(options);
6067
+ const dbgFull = debugFullEnabled3(options);
6068
+ const dbgDump = debugDumpPath3(options);
6069
+ if (!isJsonOutput) intro2("Vision (Screenshot) Analysis");
6070
+ try {
6071
+ const projectPath = process.cwd();
6072
+ if (options.list) {
6073
+ const base = (options.screenshotsDir ? resolvePathSpecifier(options.screenshotsDir, projectPath) : null) || findScreenshotsDirUpwards(projectPath);
6074
+ if (!base) {
6075
+ if (isJsonOutput) {
6076
+ printJSON({ screenshotsDir: null, sidecars: [] });
6077
+ } else {
6078
+ logWarning(
6079
+ "No `.uilint/screenshots` directory found (walked up from cwd)."
6080
+ );
6081
+ }
6082
+ await flushLangfuse();
6083
+ return;
6084
+ }
6085
+ const sidecars = listScreenshotSidecars(base);
6086
+ if (isJsonOutput) {
6087
+ printJSON({ screenshotsDir: base, sidecars });
6088
+ } else {
6089
+ logInfo(`Screenshots dir: ${pc.dim(base)}`);
6090
+ if (sidecars.length === 0) {
6091
+ process.stdout.write("No sidecars found.\n");
6092
+ } else {
6093
+ process.stdout.write(
6094
+ sidecars.map((s, idx) => {
6095
+ const stamp = s.timestamp ? new Date(s.timestamp).toLocaleString() : "(no timestamp)";
6096
+ const route = s.route ? ` ${pc.dim(s.route)}` : "";
6097
+ const count = typeof s.issueCount === "number" ? ` ${pc.dim(`(${s.issueCount} issues)`)}` : "";
6098
+ return `${idx === 0 ? "*" : "-"} ${s.path}${pc.dim(
6099
+ ` \u2014 ${stamp}`
6100
+ )}${route}${count}`;
6101
+ }).join("\n") + "\n"
6102
+ );
6103
+ process.stdout.write(
6104
+ pc.dim(
6105
+ `Tip: run \`uilint vision --sidecar <path>\` (the newest is marked with "*").
6106
+ `
6107
+ )
6108
+ );
6109
+ }
6110
+ }
6111
+ await flushLangfuse();
6112
+ return;
6113
+ }
6114
+ const imagePath = options.image ? resolvePathSpecifier(options.image, projectPath) : void 0;
6115
+ const sidecarPath = options.sidecar ? resolvePathSpecifier(options.sidecar, projectPath) : void 0;
6116
+ const manifestFilePath = options.manifestFile ? resolvePathSpecifier(options.manifestFile, projectPath) : void 0;
6117
+ if (!imagePath && !sidecarPath) {
6118
+ if (isJsonOutput) {
6119
+ printJSON({ error: "No input provided", issues: [] });
6120
+ } else {
6121
+ logError("No input provided. Use --image or --sidecar.");
6122
+ }
6123
+ await flushLangfuse();
6124
+ process.exit(1);
6125
+ }
6126
+ if (imagePath && !existsSync18(imagePath)) {
6127
+ throw new Error(`Image not found: ${imagePath}`);
6128
+ }
6129
+ if (sidecarPath && !existsSync18(sidecarPath)) {
6130
+ throw new Error(`Sidecar not found: ${sidecarPath}`);
6131
+ }
6132
+ if (manifestFilePath && !existsSync18(manifestFilePath)) {
6133
+ throw new Error(`Manifest file not found: ${manifestFilePath}`);
6134
+ }
6135
+ const sidecar = sidecarPath ? loadJsonFile(sidecarPath) : null;
6136
+ const routeLabel = options.route || (typeof sidecar?.route === "string" ? sidecar.route : void 0) || (sidecarPath ? `(from ${sidecarPath})` : "(unknown)");
6137
+ let manifest = null;
6138
+ if (options.manifestJson) {
6139
+ manifest = JSON.parse(options.manifestJson);
6140
+ } else if (manifestFilePath) {
6141
+ manifest = loadJsonFile(manifestFilePath);
6142
+ } else if (sidecar && Array.isArray(sidecar.manifest)) {
6143
+ manifest = sidecar.manifest;
6144
+ }
6145
+ if (!manifest || manifest.length === 0) {
6146
+ throw new Error(
6147
+ "No manifest provided. Supply --manifest-json, --manifest-file, or a sidecar JSON with a `manifest` array."
6148
+ );
6149
+ }
6150
+ let styleGuide = null;
6151
+ let styleguideLocation = null;
6152
+ const startPath = (imagePath ?? sidecarPath ?? manifestFilePath ?? void 0) || void 0;
6153
+ {
6154
+ const resolved = await resolveVisionStyleGuide({
6155
+ projectPath,
6156
+ styleguide: options.styleguide,
6157
+ startDir: startPath ? dirname10(startPath) : projectPath
6158
+ });
6159
+ styleGuide = resolved.styleGuide;
6160
+ styleguideLocation = resolved.styleguideLocation;
6161
+ }
6162
+ if (styleguideLocation && styleGuide) {
6163
+ if (!isJsonOutput)
6164
+ logSuccess(`Using styleguide: ${pc.dim(styleguideLocation)}`);
6165
+ } else if (!styleGuide && !isJsonOutput) {
6166
+ logWarning("No styleguide found");
6167
+ note2(
6168
+ [
6169
+ `Searched in: ${options.styleguide || projectPath}`,
6170
+ "",
6171
+ "Looked for:",
6172
+ ...STYLEGUIDE_PATHS2.map((p2) => ` \u2022 ${p2}`),
6173
+ "",
6174
+ `Create ${pc.cyan(
6175
+ ".uilint/styleguide.md"
6176
+ )} (recommended: run ${pc.cyan("/genstyleguide")} in Cursor).`
6177
+ ].join("\n"),
6178
+ "Missing Styleguide"
6179
+ );
6180
+ }
6181
+ debugLog3(dbg, "Vision input (high-level)", {
6182
+ imagePath: imagePath ?? null,
6183
+ sidecarPath: sidecarPath ?? null,
6184
+ manifestFile: manifestFilePath ?? null,
6185
+ manifestElements: manifest.length,
6186
+ route: routeLabel,
6187
+ styleguideLocation,
6188
+ styleGuideLength: styleGuide ? styleGuide.length : 0
6189
+ });
6190
+ const visionModel = options.model || UILINT_DEFAULT_VISION_MODEL2;
6191
+ const prepStartNs = nsNow();
6192
+ if (!isJsonOutput) {
6193
+ await withSpinner("Preparing Ollama", async () => {
6194
+ await ensureOllamaReady6({
6195
+ model: visionModel,
6196
+ baseUrl: options.baseUrl
6197
+ });
6198
+ });
6199
+ } else {
6200
+ await ensureOllamaReady6({ model: visionModel, baseUrl: options.baseUrl });
6201
+ }
6202
+ const prepEndNs = nsNow();
6203
+ const resolvedImagePath = imagePath || (() => {
6204
+ const screenshotFile = typeof sidecar?.screenshotFile === "string" ? sidecar.screenshotFile : typeof sidecar?.filename === "string" ? sidecar.filename : void 0;
6205
+ if (!screenshotFile) return null;
6206
+ const baseDir = sidecarPath ? dirname10(sidecarPath) : projectPath;
6207
+ const abs = resolve6(baseDir, screenshotFile);
6208
+ return abs;
6209
+ })();
6210
+ if (!resolvedImagePath) {
6211
+ throw new Error(
6212
+ "No image path could be resolved. Provide --image or a sidecar with `screenshotFile`/`filename`."
6213
+ );
6214
+ }
6215
+ if (!existsSync18(resolvedImagePath)) {
6216
+ throw new Error(`Image not found: ${resolvedImagePath}`);
6217
+ }
6218
+ const { base64, sizeBytes } = readImageAsBase64(resolvedImagePath);
6219
+ debugLog3(dbg, "Image loaded", {
6220
+ imagePath: resolvedImagePath,
6221
+ sizeBytes,
6222
+ base64Length: base64.length
6223
+ });
6224
+ if (dbgFull && styleGuide) {
6225
+ debugLog3(dbg, "Styleguide (full)", styleGuide);
6226
+ } else if (dbg && styleGuide) {
6227
+ debugLog3(dbg, "Styleguide (preview)", preview3(styleGuide, 800));
6228
+ }
6229
+ let result = null;
6230
+ const analysisStartNs = nsNow();
6231
+ let firstTokenNs = null;
6232
+ let firstThinkingNs = null;
6233
+ let lastThinkingNs = null;
6234
+ let firstAnswerNs = null;
6235
+ let lastAnswerNs = null;
6236
+ if (isJsonOutput) {
6237
+ result = await runVisionAnalysis({
6238
+ imageBase64: base64,
6239
+ manifest,
6240
+ projectPath,
6241
+ styleGuide,
6242
+ styleguideLocation,
6243
+ baseUrl: options.baseUrl,
6244
+ model: visionModel,
6245
+ skipEnsureOllama: true,
6246
+ debugDump: dbgDump ?? void 0,
6247
+ debugDumpIncludeSensitive: dbgFull,
6248
+ debugDumpMetadata: {
6249
+ route: routeLabel,
6250
+ imagePath: resolvedImagePath,
6251
+ imageSizeBytes: sizeBytes,
6252
+ imageBase64Length: base64.length
6253
+ }
6254
+ });
6255
+ } else {
6256
+ if (options.stream) {
6257
+ let lastStatus = "";
6258
+ let printedAnyText = false;
6259
+ let inThinking = false;
6260
+ result = await runVisionAnalysis({
6261
+ imageBase64: base64,
6262
+ manifest,
6263
+ projectPath,
6264
+ styleGuide,
6265
+ styleguideLocation,
6266
+ baseUrl: options.baseUrl,
6267
+ model: visionModel,
6268
+ skipEnsureOllama: true,
6269
+ debugDump: dbgDump ?? void 0,
6270
+ debugDumpIncludeSensitive: dbgFull,
6271
+ debugDumpMetadata: {
6272
+ route: routeLabel,
6273
+ imagePath: resolvedImagePath,
6274
+ imageSizeBytes: sizeBytes,
6275
+ imageBase64Length: base64.length
6276
+ },
6277
+ onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
6278
+ const nowNs = nsNow();
6279
+ if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
6280
+ if (thinkingDelta) {
6281
+ if (!firstThinkingNs) firstThinkingNs = nowNs;
6282
+ lastThinkingNs = nowNs;
6283
+ }
6284
+ if (delta) {
6285
+ if (!firstAnswerNs) firstAnswerNs = nowNs;
6286
+ lastAnswerNs = nowNs;
6287
+ }
6288
+ if (thinkingDelta) {
6289
+ if (!printedAnyText) {
6290
+ printedAnyText = true;
6291
+ console.error(pc.dim("[vision] streaming:"));
6292
+ process.stderr.write(pc.dim("Thinking:\n"));
6293
+ inThinking = true;
6294
+ } else if (!inThinking) {
6295
+ process.stderr.write(pc.dim("\n\nThinking:\n"));
6296
+ inThinking = true;
6297
+ }
6298
+ process.stderr.write(thinkingDelta);
6299
+ return;
6300
+ }
6301
+ if (delta) {
6302
+ if (!printedAnyText) {
6303
+ printedAnyText = true;
6304
+ console.error(pc.dim("[vision] streaming:"));
6305
+ }
6306
+ if (inThinking) {
6307
+ process.stderr.write(pc.dim("\n\nAnswer:\n"));
6308
+ inThinking = false;
6309
+ }
6310
+ process.stderr.write(delta);
6311
+ return;
6312
+ }
6313
+ const line = (latestLine || "").trim();
6314
+ if (!line || line === lastStatus) return;
6315
+ lastStatus = line;
6316
+ console.error(pc.dim("[vision]"), line);
6317
+ }
6318
+ });
6319
+ } else {
6320
+ result = await withSpinner(
6321
+ "Analyzing screenshot with vision model",
6322
+ async (s) => {
6323
+ return await runVisionAnalysis({
6324
+ imageBase64: base64,
6325
+ manifest,
6326
+ projectPath,
6327
+ styleGuide,
6328
+ styleguideLocation,
6329
+ baseUrl: options.baseUrl,
6330
+ model: visionModel,
6331
+ skipEnsureOllama: true,
6332
+ debugDump: dbgDump ?? void 0,
6333
+ debugDumpIncludeSensitive: dbgFull,
6334
+ debugDumpMetadata: {
6335
+ route: routeLabel,
6336
+ imagePath: resolvedImagePath,
6337
+ imageSizeBytes: sizeBytes,
6338
+ imageBase64Length: base64.length
6339
+ },
6340
+ onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
6341
+ const nowNs = nsNow();
6342
+ if (!firstTokenNs && (thinkingDelta || delta))
6343
+ firstTokenNs = nowNs;
6344
+ if (thinkingDelta) {
6345
+ if (!firstThinkingNs) firstThinkingNs = nowNs;
6346
+ lastThinkingNs = nowNs;
6347
+ }
6348
+ if (delta) {
6349
+ if (!firstAnswerNs) firstAnswerNs = nowNs;
6350
+ lastAnswerNs = nowNs;
6351
+ }
6352
+ const maxLen = 60;
6353
+ const displayLine = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
6354
+ s.message(`Analyzing: ${pc.dim(displayLine || "...")}`);
6355
+ }
6356
+ });
6357
+ }
6358
+ );
6359
+ }
6360
+ }
6361
+ const analysisEndNs = nsNow();
6362
+ const issues = result?.issues ?? [];
6363
+ if (isJsonOutput) {
6364
+ printJSON({
6365
+ route: routeLabel,
6366
+ model: visionModel,
6367
+ issues,
6368
+ analysisTime: result?.analysisTime ?? 0,
6369
+ imagePath: resolvedImagePath,
6370
+ imageSizeBytes: sizeBytes
6371
+ });
6372
+ } else {
6373
+ logInfo(`Route: ${pc.dim(routeLabel)}`);
6374
+ logInfo(`Model: ${pc.dim(visionModel)}`);
6375
+ process.stdout.write(formatIssuesText(issues));
6376
+ if (process.stdout.isTTY) {
6377
+ const prepMs = nsToMs(prepEndNs - prepStartNs);
6378
+ const totalMs = nsToMs(analysisEndNs - analysisStartNs);
6379
+ const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
6380
+ const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
6381
+ const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
6382
+ (firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
6383
+ ) : null;
6384
+ const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
6385
+ note2(
6386
+ [
6387
+ `Prepare Ollama: ${formatMs(prepMs)}`,
6388
+ `Time to first token: ${maybeMs(ttftMs)}`,
6389
+ `Thinking: ${maybeMs(thinkingMs)}`,
6390
+ `Outputting: ${maybeMs(outputMs)}`,
6391
+ `LLM total: ${formatMs(totalMs)}`,
6392
+ `End-to-end: ${formatMs(endToEndMs)}`,
6393
+ result?.analysisTime ? pc.dim(`(core analysisTime: ${formatMs(result.analysisTime)})`) : pc.dim("(core analysisTime: n/a)")
6394
+ ].join("\n"),
6395
+ "Timings"
6396
+ );
6397
+ }
6398
+ }
6399
+ try {
6400
+ writeVisionMarkdownReport({
6401
+ imagePath: resolvedImagePath,
6402
+ route: routeLabel,
6403
+ visionModel,
6404
+ baseUrl: options.baseUrl ?? "http://localhost:11434",
6405
+ analysisTimeMs: result?.analysisTime ?? 0,
6406
+ prompt: result?.prompt ?? null,
6407
+ rawResponse: result?.rawResponse ?? null,
6408
+ metadata: {
6409
+ imageSizeBytes: sizeBytes,
6410
+ styleguideLocation
6411
+ }
6412
+ });
6413
+ debugLog3(dbg, "Wrote .vision.md report alongside image");
6414
+ } catch (e) {
6415
+ debugLog3(
6416
+ dbg,
6417
+ "Failed to write .vision.md report",
6418
+ e instanceof Error ? e.message : e
6419
+ );
6420
+ }
6421
+ if (issues.length > 0) {
6422
+ await flushLangfuse();
6423
+ process.exit(1);
6424
+ }
6425
+ } catch (error) {
6426
+ if (options.output === "json") {
6427
+ printJSON({
6428
+ error: error instanceof Error ? error.message : "Unknown error",
6429
+ issues: []
6430
+ });
6431
+ } else {
6432
+ logError(
6433
+ error instanceof Error ? error.message : "Vision analysis failed"
6434
+ );
6435
+ }
6436
+ await flushLangfuse();
6437
+ process.exit(1);
6438
+ }
6439
+ await flushLangfuse();
6440
+ }
6441
+
4574
6442
  // src/commands/session.ts
4575
- import { existsSync as existsSync14, readFileSync as readFileSync9, writeFileSync as writeFileSync7, unlinkSync as unlinkSync2 } from "fs";
4576
- import { basename, dirname as dirname8, resolve as resolve6 } from "path";
6443
+ import { existsSync as existsSync19, readFileSync as readFileSync13, writeFileSync as writeFileSync10, unlinkSync as unlinkSync2 } from "fs";
6444
+ import { basename, dirname as dirname11, resolve as resolve7 } from "path";
4577
6445
  import { createStyleSummary as createStyleSummary3 } from "uilint-core";
4578
6446
  import {
4579
- ensureOllamaReady as ensureOllamaReady5,
6447
+ ensureOllamaReady as ensureOllamaReady7,
4580
6448
  parseCLIInput as parseCLIInput2,
4581
6449
  readStyleGuideFromProject as readStyleGuideFromProject2,
4582
6450
  readTailwindThemeTokens as readTailwindThemeTokens3
@@ -4584,18 +6452,18 @@ import {
4584
6452
  var SESSION_FILE = "/tmp/uilint-session.json";
4585
6453
  var UI_FILE_EXTENSIONS = [".tsx", ".jsx", ".css", ".scss", ".module.css"];
4586
6454
  function readSession() {
4587
- if (!existsSync14(SESSION_FILE)) {
6455
+ if (!existsSync19(SESSION_FILE)) {
4588
6456
  return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
4589
6457
  }
4590
6458
  try {
4591
- const content = readFileSync9(SESSION_FILE, "utf-8");
6459
+ const content = readFileSync13(SESSION_FILE, "utf-8");
4592
6460
  return JSON.parse(content);
4593
6461
  } catch {
4594
6462
  return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
4595
6463
  }
4596
6464
  }
4597
6465
  function writeSession(state) {
4598
- writeFileSync7(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
6466
+ writeFileSync10(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
4599
6467
  }
4600
6468
  function isUIFile(filePath) {
4601
6469
  return UI_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
@@ -4606,7 +6474,7 @@ function isScannableMarkupFile(filePath) {
4606
6474
  );
4607
6475
  }
4608
6476
  async function sessionClear() {
4609
- if (existsSync14(SESSION_FILE)) {
6477
+ if (existsSync19(SESSION_FILE)) {
4610
6478
  unlinkSync2(SESSION_FILE);
4611
6479
  }
4612
6480
  console.log(JSON.stringify({ cleared: true }));
@@ -4673,17 +6541,17 @@ async function sessionScan(options = {}) {
4673
6541
  }
4674
6542
  return;
4675
6543
  }
4676
- await ensureOllamaReady5();
6544
+ await ensureOllamaReady7();
4677
6545
  const client = await createLLMClient({});
4678
6546
  const results = [];
4679
6547
  for (const filePath of session.files) {
4680
- if (!existsSync14(filePath)) continue;
6548
+ if (!existsSync19(filePath)) continue;
4681
6549
  if (!isScannableMarkupFile(filePath)) continue;
4682
6550
  try {
4683
- const absolutePath = resolve6(process.cwd(), filePath);
4684
- const htmlLike = readFileSync9(filePath, "utf-8");
6551
+ const absolutePath = resolve7(process.cwd(), filePath);
6552
+ const htmlLike = readFileSync13(filePath, "utf-8");
4685
6553
  const snapshot = parseCLIInput2(htmlLike);
4686
- const tailwindSearchDir = dirname8(absolutePath);
6554
+ const tailwindSearchDir = dirname11(absolutePath);
4687
6555
  const tailwindTheme = readTailwindThemeTokens3(tailwindSearchDir);
4688
6556
  const styleSummary = createStyleSummary3(snapshot.styles, {
4689
6557
  html: snapshot.html,
@@ -4736,7 +6604,7 @@ async function sessionScan(options = {}) {
4736
6604
  };
4737
6605
  console.log(JSON.stringify(result));
4738
6606
  }
4739
- if (existsSync14(SESSION_FILE)) {
6607
+ if (existsSync19(SESSION_FILE)) {
4740
6608
  unlinkSync2(SESSION_FILE);
4741
6609
  }
4742
6610
  await flushLangfuse();
@@ -4747,9 +6615,9 @@ async function sessionList() {
4747
6615
  }
4748
6616
 
4749
6617
  // src/index.ts
4750
- import { readFileSync as readFileSync10 } from "fs";
4751
- import { dirname as dirname9, join as join14 } from "path";
4752
- import { fileURLToPath as fileURLToPath2 } from "url";
6618
+ import { readFileSync as readFileSync14 } from "fs";
6619
+ import { dirname as dirname12, join as join19 } from "path";
6620
+ import { fileURLToPath as fileURLToPath3 } from "url";
4753
6621
  function assertNodeVersion(minMajor) {
4754
6622
  const ver = process.versions.node || "";
4755
6623
  const majorStr = ver.split(".")[0] || "";
@@ -4765,9 +6633,9 @@ assertNodeVersion(20);
4765
6633
  var program = new Command();
4766
6634
  function getCLIVersion2() {
4767
6635
  try {
4768
- const __dirname = dirname9(fileURLToPath2(import.meta.url));
4769
- const pkgPath = join14(__dirname, "..", "package.json");
4770
- const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
6636
+ const __dirname2 = dirname12(fileURLToPath3(import.meta.url));
6637
+ const pkgPath = join19(__dirname2, "..", "package.json");
6638
+ const pkg = JSON.parse(readFileSync14(pkgPath, "utf-8"));
4771
6639
  return pkg.version || "0.0.0";
4772
6640
  } catch {
4773
6641
  return "0.0.0";
@@ -4870,6 +6738,40 @@ program.command("serve").description("Start WebSocket server for real-time UI li
4870
6738
  port: parseInt(options.port, 10)
4871
6739
  });
4872
6740
  });
6741
+ program.command("vision").description("Analyze a screenshot with Ollama vision models (requires a manifest)").option("--list", "List available .uilint/screenshots sidecars and exit").option(
6742
+ "--screenshots-dir <path>",
6743
+ "Screenshots directory for --list (default: nearest .uilint/screenshots)"
6744
+ ).option("--image <path>", "Path to a screenshot image (png/jpg)").option(
6745
+ "--sidecar <path>",
6746
+ "Path to a .uilint/screenshots/*.json sidecar (contains manifest + metadata)"
6747
+ ).option("--manifest-file <path>", "Path to a manifest JSON file (array)").option("--manifest-json <json>", "Inline manifest JSON (array)").option("--route <route>", "Optional route label (e.g., /todos)").option(
6748
+ "-s, --styleguide <path>",
6749
+ "Path to style guide file OR project directory (falls back to upward search)"
6750
+ ).option("-o, --output <format>", "Output format: text or json", "text").option("--model <name>", "Ollama vision model override", void 0).option("--base-url <url>", "Ollama base URL (default: http://localhost:11434)").option("--stream", "Stream model output/progress to stderr (text mode only)").option("--debug", "Enable debug logging (stderr)").option(
6751
+ "--debug-full",
6752
+ "Print full prompt/styleguide and include base64 in dumps (can be very large)"
6753
+ ).option(
6754
+ "--debug-dump <path>",
6755
+ "Write full analysis payload dump to JSON file (or directory to auto-name)"
6756
+ ).action(async (options) => {
6757
+ await vision({
6758
+ list: options.list,
6759
+ screenshotsDir: options.screenshotsDir,
6760
+ image: options.image,
6761
+ sidecar: options.sidecar,
6762
+ manifestFile: options.manifestFile,
6763
+ manifestJson: options.manifestJson,
6764
+ route: options.route,
6765
+ styleguide: options.styleguide,
6766
+ output: options.output,
6767
+ model: options.model,
6768
+ baseUrl: options.baseUrl,
6769
+ stream: options.stream,
6770
+ debug: options.debug,
6771
+ debugFull: options.debugFull,
6772
+ debugDump: options.debugDump
6773
+ });
6774
+ });
4873
6775
  var sessionCmd = program.command("session").description(
4874
6776
  "Manage file tracking for agentic sessions (used by Cursor hooks)"
4875
6777
  );