uilint 0.2.0 → 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,14 +1957,69 @@ function collectUilintRuleIdsFromRulesObject(rulesObj) {
1732
1957
  return ids;
1733
1958
  }
1734
1959
  function findExportedConfigArrayExpression(mod) {
1735
- if (mod?.exports?.default) {
1736
- const exported = mod.exports.default;
1737
- const node = exported.$type === "function-call" ? exported.$args?.[0] : exported;
1738
- if (node?.$ast?.type === "ArrayExpression") {
1739
- return { kind: "esm", arrayExpr: node.$ast, program: mod.$ast };
1960
+ function unwrapExpression2(expr) {
1961
+ let e = expr;
1962
+ while (e) {
1963
+ if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
1964
+ e = e.expression;
1965
+ continue;
1966
+ }
1967
+ if (e.type === "TSSatisfiesExpression") {
1968
+ e = e.expression;
1969
+ continue;
1970
+ }
1971
+ if (e.type === "ParenthesizedExpression") {
1972
+ e = e.expression;
1973
+ continue;
1974
+ }
1975
+ break;
1976
+ }
1977
+ return e;
1978
+ }
1979
+ function resolveTopLevelIdentifierToArrayExpr(program3, name) {
1980
+ if (!program3 || program3.type !== "Program") return null;
1981
+ for (const stmt of program3.body ?? []) {
1982
+ if (stmt?.type !== "VariableDeclaration") continue;
1983
+ for (const decl of stmt.declarations ?? []) {
1984
+ const id = decl?.id;
1985
+ if (!isIdentifier(id, name)) continue;
1986
+ const init = unwrapExpression2(decl?.init);
1987
+ if (!init) return null;
1988
+ if (init.type === "ArrayExpression") return init;
1989
+ if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression2(init.arguments?.[0])?.type === "ArrayExpression") {
1990
+ return unwrapExpression2(init.arguments?.[0]);
1991
+ }
1992
+ return null;
1993
+ }
1740
1994
  }
1995
+ return null;
1741
1996
  }
1742
1997
  const program2 = mod?.$ast;
1998
+ if (program2 && program2.type === "Program") {
1999
+ for (const stmt of program2.body ?? []) {
2000
+ if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
2001
+ const decl = unwrapExpression2(stmt.declaration);
2002
+ if (!decl) break;
2003
+ if (decl.type === "ArrayExpression") {
2004
+ return { kind: "esm", arrayExpr: decl, program: program2 };
2005
+ }
2006
+ if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression2(decl.arguments?.[0])?.type === "ArrayExpression") {
2007
+ return {
2008
+ kind: "esm",
2009
+ arrayExpr: unwrapExpression2(decl.arguments?.[0]),
2010
+ program: program2
2011
+ };
2012
+ }
2013
+ if (decl.type === "Identifier" && typeof decl.name === "string") {
2014
+ const resolved = resolveTopLevelIdentifierToArrayExpr(
2015
+ program2,
2016
+ decl.name
2017
+ );
2018
+ if (resolved) return { kind: "esm", arrayExpr: resolved, program: program2 };
2019
+ }
2020
+ break;
2021
+ }
2022
+ }
1743
2023
  if (!program2 || program2.type !== "Program") return null;
1744
2024
  for (const stmt of program2.body ?? []) {
1745
2025
  if (!stmt || stmt.type !== "ExpressionStatement") continue;
@@ -1755,6 +2035,13 @@ function findExportedConfigArrayExpression(mod) {
1755
2035
  if (right?.type === "CallExpression" && isIdentifier(right.callee, "defineConfig") && right.arguments?.[0]?.type === "ArrayExpression") {
1756
2036
  return { kind: "cjs", arrayExpr: right.arguments[0], program: program2 };
1757
2037
  }
2038
+ if (right?.type === "Identifier" && typeof right.name === "string") {
2039
+ const resolved = resolveTopLevelIdentifierToArrayExpr(
2040
+ program2,
2041
+ right.name
2042
+ );
2043
+ if (resolved) return { kind: "cjs", arrayExpr: resolved, program: program2 };
2044
+ }
1758
2045
  }
1759
2046
  return null;
1760
2047
  }
@@ -1979,7 +2266,7 @@ async function installEslintPlugin(opts) {
1979
2266
  };
1980
2267
  }
1981
2268
  const configFilename = getEslintConfigFilename(configPath);
1982
- const original = readFileSync3(configPath, "utf-8");
2269
+ const original = readFileSync4(configPath, "utf-8");
1983
2270
  const isCommonJS = configPath.endsWith(".cjs");
1984
2271
  const ast = getUilintEslintConfigInfoFromSourceAst(original);
1985
2272
  if ("error" in ast) {
@@ -2081,7 +2368,7 @@ async function installEslintPlugin(opts) {
2081
2368
  var LEGACY_HOOK_FILES = ["uilint-validate.sh", "uilint-validate.js"];
2082
2369
  function safeParseJson(filePath) {
2083
2370
  try {
2084
- const content = readFileSync4(filePath, "utf-8");
2371
+ const content = readFileSync5(filePath, "utf-8");
2085
2372
  return JSON.parse(content);
2086
2373
  } catch {
2087
2374
  return void 0;
@@ -2090,27 +2377,27 @@ function safeParseJson(filePath) {
2090
2377
  async function analyze2(projectPath = process.cwd()) {
2091
2378
  const workspaceRoot = findWorkspaceRoot4(projectPath);
2092
2379
  const packageManager = detectPackageManager(projectPath);
2093
- const cursorDir = join7(projectPath, ".cursor");
2094
- const cursorDirExists = existsSync8(cursorDir);
2095
- const mcpPath = join7(cursorDir, "mcp.json");
2096
- 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);
2097
2384
  const mcpConfig = mcpExists ? safeParseJson(mcpPath) : void 0;
2098
- const hooksPath = join7(cursorDir, "hooks.json");
2099
- const hooksExists = existsSync8(hooksPath);
2385
+ const hooksPath = join8(cursorDir, "hooks.json");
2386
+ const hooksExists = existsSync9(hooksPath);
2100
2387
  const hooksConfig = hooksExists ? safeParseJson(hooksPath) : void 0;
2101
- const hooksDir = join7(cursorDir, "hooks");
2388
+ const hooksDir = join8(cursorDir, "hooks");
2102
2389
  const legacyPaths = [];
2103
2390
  for (const legacyFile of LEGACY_HOOK_FILES) {
2104
- const legacyPath = join7(hooksDir, legacyFile);
2105
- if (existsSync8(legacyPath)) {
2391
+ const legacyPath = join8(hooksDir, legacyFile);
2392
+ if (existsSync9(legacyPath)) {
2106
2393
  legacyPaths.push(legacyPath);
2107
2394
  }
2108
2395
  }
2109
- const styleguidePath = join7(projectPath, ".uilint", "styleguide.md");
2110
- const styleguideExists = existsSync8(styleguidePath);
2111
- const commandsDir = join7(cursorDir, "commands");
2112
- const genstyleguideExists = existsSync8(join7(commandsDir, "genstyleguide.md"));
2113
- 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"));
2114
2401
  const nextApps = [];
2115
2402
  const directDetection = detectNextAppRouter(projectPath);
2116
2403
  if (directDetection) {
@@ -2124,6 +2411,19 @@ async function analyze2(projectPath = process.cwd()) {
2124
2411
  });
2125
2412
  }
2126
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
+ }
2127
2427
  const rawPackages = findPackages(workspaceRoot);
2128
2428
  const packages = rawPackages.map((pkg) => {
2129
2429
  const eslintConfigPath = findEslintConfigFile(pkg.path);
@@ -2133,7 +2433,7 @@ async function analyze2(projectPath = process.cwd()) {
2133
2433
  if (eslintConfigPath) {
2134
2434
  eslintConfigFilename = getEslintConfigFilename(eslintConfigPath);
2135
2435
  try {
2136
- const source = readFileSync4(eslintConfigPath, "utf-8");
2436
+ const source = readFileSync5(eslintConfigPath, "utf-8");
2137
2437
  const info = getUilintEslintConfigInfoFromSource(source);
2138
2438
  hasRules = info.configuredRuleIds.size > 0 || info.usesUilintConfigs;
2139
2439
  configuredRuleIds = Array.from(info.configuredRuleIds);
@@ -2177,12 +2477,13 @@ async function analyze2(projectPath = process.cwd()) {
2177
2477
  genrules: genrulesExists
2178
2478
  },
2179
2479
  nextApps,
2480
+ viteApps,
2180
2481
  packages
2181
2482
  };
2182
2483
  }
2183
2484
 
2184
2485
  // src/commands/install/plan.ts
2185
- import { join as join8 } from "path";
2486
+ import { join as join10 } from "path";
2186
2487
  import { createRequire } from "module";
2187
2488
 
2188
2489
  // src/commands/install/constants.ts
@@ -2571,6 +2872,55 @@ Generate in \`.uilint/rules/\`:
2571
2872
  - **Minimal rules** - generate 3-5 high-impact rules, not dozens
2572
2873
  `;
2573
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
+
2574
2924
  // src/commands/install/plan.ts
2575
2925
  var require2 = createRequire(import.meta.url);
2576
2926
  function getSelfDependencyVersionRange(pkgName) {
@@ -2621,7 +2971,7 @@ function createPlan(state, choices, options = {}) {
2621
2971
  const dependencies = [];
2622
2972
  const { force = false } = options;
2623
2973
  const { items } = choices;
2624
- 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");
2625
2975
  if (needsCursorDir && !state.cursorDir.exists) {
2626
2976
  actions.push({
2627
2977
  type: "create_directory",
@@ -2650,7 +3000,7 @@ function createPlan(state, choices, options = {}) {
2650
3000
  }
2651
3001
  }
2652
3002
  if (items.includes("hooks")) {
2653
- const hooksDir = join8(state.cursorDir.path, "hooks");
3003
+ const hooksDir = join10(state.cursorDir.path, "hooks");
2654
3004
  actions.push({
2655
3005
  type: "create_directory",
2656
3006
  path: hooksDir
@@ -2676,47 +3026,78 @@ function createPlan(state, choices, options = {}) {
2676
3026
  });
2677
3027
  actions.push({
2678
3028
  type: "create_file",
2679
- path: join8(hooksDir, "uilint-session-start.sh"),
3029
+ path: join10(hooksDir, "uilint-session-start.sh"),
2680
3030
  content: SESSION_START_SCRIPT,
2681
3031
  permissions: 493
2682
3032
  });
2683
3033
  actions.push({
2684
3034
  type: "create_file",
2685
- path: join8(hooksDir, "uilint-track.sh"),
3035
+ path: join10(hooksDir, "uilint-track.sh"),
2686
3036
  content: TRACK_SCRIPT,
2687
3037
  permissions: 493
2688
3038
  });
2689
3039
  actions.push({
2690
3040
  type: "create_file",
2691
- path: join8(hooksDir, "uilint-session-end.sh"),
3041
+ path: join10(hooksDir, "uilint-session-end.sh"),
2692
3042
  content: SESSION_END_SCRIPT,
2693
3043
  permissions: 493
2694
3044
  });
2695
3045
  }
2696
3046
  if (items.includes("genstyleguide")) {
2697
- const commandsDir = join8(state.cursorDir.path, "commands");
3047
+ const commandsDir = join10(state.cursorDir.path, "commands");
2698
3048
  actions.push({
2699
3049
  type: "create_directory",
2700
3050
  path: commandsDir
2701
3051
  });
2702
3052
  actions.push({
2703
3053
  type: "create_file",
2704
- path: join8(commandsDir, "genstyleguide.md"),
3054
+ path: join10(commandsDir, "genstyleguide.md"),
2705
3055
  content: GENSTYLEGUIDE_COMMAND_MD
2706
3056
  });
2707
3057
  }
2708
3058
  if (items.includes("genrules")) {
2709
- const commandsDir = join8(state.cursorDir.path, "commands");
3059
+ const commandsDir = join10(state.cursorDir.path, "commands");
2710
3060
  actions.push({
2711
3061
  type: "create_directory",
2712
3062
  path: commandsDir
2713
3063
  });
2714
3064
  actions.push({
2715
3065
  type: "create_file",
2716
- path: join8(commandsDir, "genrules.md"),
3066
+ path: join10(commandsDir, "genrules.md"),
2717
3067
  content: GENRULES_COMMAND_MD
2718
3068
  });
2719
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
+ }
2720
3101
  if (items.includes("next") && choices.next) {
2721
3102
  const { projectPath, detection } = choices.next;
2722
3103
  actions.push({
@@ -2739,6 +3120,24 @@ function createPlan(state, choices, options = {}) {
2739
3120
  projectPath
2740
3121
  });
2741
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
+ }
2742
3141
  if (items.includes("eslint") && choices.eslint) {
2743
3142
  const { packagePaths, selectedRules } = choices.eslint;
2744
3143
  for (const pkgPath of packagePaths) {
@@ -2758,7 +3157,7 @@ function createPlan(state, choices, options = {}) {
2758
3157
  });
2759
3158
  }
2760
3159
  }
2761
- const gitignorePath = join8(state.workspaceRoot, ".gitignore");
3160
+ const gitignorePath = join10(state.workspaceRoot, ".gitignore");
2762
3161
  actions.push({
2763
3162
  type: "append_to_file",
2764
3163
  path: gitignorePath,
@@ -2771,34 +3170,49 @@ function createPlan(state, choices, options = {}) {
2771
3170
 
2772
3171
  // src/commands/install/execute.ts
2773
3172
  import {
2774
- existsSync as existsSync12,
3173
+ existsSync as existsSync15,
2775
3174
  mkdirSync as mkdirSync3,
2776
- writeFileSync as writeFileSync6,
2777
- readFileSync as readFileSync7,
3175
+ writeFileSync as writeFileSync7,
3176
+ readFileSync as readFileSync10,
2778
3177
  unlinkSync,
2779
3178
  chmodSync
2780
3179
  } from "fs";
2781
- import { dirname as dirname6 } from "path";
3180
+ import { dirname as dirname7 } from "path";
2782
3181
 
2783
3182
  // src/utils/react-inject.ts
2784
- import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2785
- 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";
2786
3185
  import { parseModule as parseModule2, generateCode as generateCode2 } from "magicast";
2787
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;
2788
3202
  const layoutCandidates = [
2789
- join9(appRoot, "layout.tsx"),
2790
- join9(appRoot, "layout.jsx"),
2791
- join9(appRoot, "layout.ts"),
2792
- join9(appRoot, "layout.js")
3203
+ join11(appRoot, "layout.tsx"),
3204
+ join11(appRoot, "layout.jsx"),
3205
+ join11(appRoot, "layout.ts"),
3206
+ join11(appRoot, "layout.js")
2793
3207
  ];
2794
3208
  const existingLayouts = layoutCandidates.filter(
2795
- (rel) => existsSync9(join9(projectPath, rel))
3209
+ (rel) => existsSync11(join11(projectPath, rel))
2796
3210
  );
2797
3211
  if (existingLayouts.length > 0) {
2798
3212
  return existingLayouts;
2799
3213
  }
2800
- const pageCandidates = [join9(appRoot, "page.tsx"), join9(appRoot, "page.jsx")];
2801
- 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)));
2802
3216
  }
2803
3217
  function isUseClientDirective(stmt) {
2804
3218
  return stmt?.type === "ExpressionStatement" && stmt.expression?.type === "StringLiteral" && stmt.expression.value === "use client";
@@ -2885,11 +3299,44 @@ function wrapFirstChildrenExpressionWithProvider(program2) {
2885
3299
  }
2886
3300
  return { changed: true };
2887
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
+ }
2888
3335
  async function installReactUILintOverlay(opts) {
2889
3336
  const candidates = getDefaultCandidates(opts.projectPath, opts.appRoot);
2890
3337
  if (!candidates.length) {
2891
3338
  throw new Error(
2892
- `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).`
2893
3340
  );
2894
3341
  }
2895
3342
  let chosen;
@@ -2898,8 +3345,8 @@ async function installReactUILintOverlay(opts) {
2898
3345
  } else {
2899
3346
  chosen = candidates[0];
2900
3347
  }
2901
- const absTarget = join9(opts.projectPath, chosen);
2902
- const original = readFileSync5(absTarget, "utf-8");
3348
+ const absTarget = join11(opts.projectPath, chosen);
3349
+ const original = readFileSync7(absTarget, "utf-8");
2903
3350
  let mod;
2904
3351
  try {
2905
3352
  mod = parseModule2(original);
@@ -2917,7 +3364,8 @@ async function installReactUILintOverlay(opts) {
2917
3364
  "UILintProvider"
2918
3365
  );
2919
3366
  if (importRes.changed) changed = true;
2920
- const wrapRes = wrapFirstChildrenExpressionWithProvider(program2);
3367
+ const mode = opts.mode ?? "next";
3368
+ const wrapRes = mode === "vite" ? wrapFirstRenderCallArgumentWithProvider(program2) : wrapFirstChildrenExpressionWithProvider(program2);
2921
3369
  if (wrapRes.changed) changed = true;
2922
3370
  const updated = changed ? generateCode2(mod).code : original;
2923
3371
  const modified = updated !== original;
@@ -2932,14 +3380,14 @@ async function installReactUILintOverlay(opts) {
2932
3380
  }
2933
3381
 
2934
3382
  // src/utils/next-config-inject.ts
2935
- import { existsSync as existsSync10, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2936
- 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";
2937
3385
  import { parseModule as parseModule3, generateCode as generateCode3 } from "magicast";
2938
3386
  var CONFIG_EXTENSIONS2 = [".ts", ".mjs", ".js", ".cjs"];
2939
3387
  function findNextConfigFile(projectPath) {
2940
3388
  for (const ext of CONFIG_EXTENSIONS2) {
2941
- const configPath = join10(projectPath, `next.config${ext}`);
2942
- if (existsSync10(configPath)) {
3389
+ const configPath = join12(projectPath, `next.config${ext}`);
3390
+ if (existsSync12(configPath)) {
2943
3391
  return configPath;
2944
3392
  }
2945
3393
  }
@@ -3052,7 +3500,7 @@ async function installJsxLocPlugin(opts) {
3052
3500
  return { configFile: null, modified: false };
3053
3501
  }
3054
3502
  const configFilename = getNextConfigFilename(configPath);
3055
- const original = readFileSync6(configPath, "utf-8");
3503
+ const original = readFileSync8(configPath, "utf-8");
3056
3504
  let mod;
3057
3505
  try {
3058
3506
  mod = parseModule3(original);
@@ -3081,82 +3529,307 @@ async function installJsxLocPlugin(opts) {
3081
3529
  return { configFile: configFilename, modified: false };
3082
3530
  }
3083
3531
 
3084
- // src/utils/next-routes.ts
3085
- import { existsSync as existsSync11 } from "fs";
3086
- import { mkdir, writeFile } from "fs/promises";
3087
- import { join as join11 } from "path";
3088
- var DEV_SOURCE_ROUTE_TS = `/**
3089
- * Dev-only API route for fetching source files
3090
- *
3091
- * This route allows the UILint overlay to fetch and display source code
3092
- * for components rendered on the page.
3093
- *
3094
- * Security:
3095
- * - Only available in development mode
3096
- * - Validates file path is within project root
3097
- * - Only allows specific file extensions
3098
- */
3099
-
3100
- import { NextRequest, NextResponse } from "next/server";
3101
- import { readFileSync, existsSync } from "fs";
3102
- import { resolve, relative, dirname, extname } from "path";
3103
-
3104
- export const runtime = "nodejs";
3105
-
3106
- // Allowed file extensions
3107
- const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
3108
-
3109
- /**
3110
- * Find the project root by looking for package.json or next.config
3111
- */
3112
- function findProjectRoot(startDir: string): string {
3113
- let dir = startDir;
3114
- for (let i = 0; i < 10; i++) {
3115
- if (
3116
- existsSync(resolve(dir, "package.json")) ||
3117
- existsSync(resolve(dir, "next.config.js")) ||
3118
- existsSync(resolve(dir, "next.config.ts"))
3119
- ) {
3120
- return dir;
3121
- }
3122
- const parent = dirname(dir);
3123
- if (parent === dir) break;
3124
- 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;
3125
3541
  }
3126
- return startDir;
3542
+ return null;
3127
3543
  }
3128
-
3129
- /**
3130
- * Validate that a path is within the allowed directory
3131
- */
3132
- function isPathWithinRoot(filePath: string, root: string): boolean {
3133
- const resolved = resolve(filePath);
3134
- const resolvedRoot = resolve(root);
3135
- return resolved.startsWith(resolvedRoot + "/") || resolved === resolvedRoot;
3544
+ function getViteConfigFilename(configPath) {
3545
+ const parts = configPath.split("/");
3546
+ return parts[parts.length - 1] || "vite.config.ts";
3136
3547
  }
3137
-
3138
- /**
3139
- * Find workspace root by walking up looking for pnpm-workspace.yaml or .git
3140
- */
3141
- function findWorkspaceRoot(startDir: string): string {
3142
- let dir = startDir;
3143
- for (let i = 0; i < 10; i++) {
3144
- if (
3145
- existsSync(resolve(dir, "pnpm-workspace.yaml")) ||
3146
- existsSync(resolve(dir, ".git"))
3147
- ) {
3148
- 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;
3149
3560
  }
3150
- const parent = dirname(dir);
3151
- if (parent === dir) break;
3152
- 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;
3153
3570
  }
3154
- return startDir;
3571
+ return e;
3155
3572
  }
3156
-
3157
- export async function GET(request: NextRequest) {
3158
- // Block in production
3159
- if (process.env.NODE_ENV === "production") {
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;
3591
+ }
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") {
3160
3833
  return NextResponse.json(
3161
3834
  { error: "Not available in production" },
3162
3835
  { status: 404 }
@@ -3182,8 +3855,8 @@ export async function GET(request: NextRequest) {
3182
3855
  );
3183
3856
  }
3184
3857
 
3185
- // Find project root
3186
- const projectRoot = findProjectRoot(process.cwd());
3858
+ // Find project root (prefer Next project root over workspace root)
3859
+ const projectRoot = findNextProjectRoot();
3187
3860
 
3188
3861
  // Resolve the file path
3189
3862
  const resolvedPath = resolve(filePath);
@@ -3212,6 +3885,8 @@ export async function GET(request: NextRequest) {
3212
3885
  return NextResponse.json({
3213
3886
  content,
3214
3887
  relativePath,
3888
+ projectRoot,
3889
+ workspaceRoot,
3215
3890
  });
3216
3891
  } catch (error) {
3217
3892
  console.error("[Dev Source API] Error reading file:", error);
@@ -3219,20 +3894,331 @@ export async function GET(request: NextRequest) {
3219
3894
  }
3220
3895
  }
3221
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
+ `;
3222
4201
  async function writeRouteFile(absPath, relPath, content, opts) {
3223
- if (existsSync11(absPath) && !opts.force) return;
4202
+ if (existsSync14(absPath) && !opts.force) return;
3224
4203
  await writeFile(absPath, content, "utf-8");
3225
4204
  }
3226
4205
  async function installNextUILintRoutes(opts) {
3227
- const baseRel = join11(opts.appRoot, "api", ".uilint");
3228
- const baseAbs = join11(opts.projectPath, baseRel);
3229
- 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 });
3230
4209
  await writeRouteFile(
3231
- join11(baseAbs, "source", "route.ts"),
3232
- join11(baseRel, "source", "route.ts"),
4210
+ join14(baseAbs, "source", "route.ts"),
4211
+ join14(baseRel, "source", "route.ts"),
3233
4212
  DEV_SOURCE_ROUTE_TS,
3234
4213
  opts
3235
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
+ );
3236
4222
  }
3237
4223
 
3238
4224
  // src/commands/install/execute.ts
@@ -3248,7 +4234,7 @@ async function executeAction(action, options) {
3248
4234
  wouldDo: `Create directory: ${action.path}`
3249
4235
  };
3250
4236
  }
3251
- if (!existsSync12(action.path)) {
4237
+ if (!existsSync15(action.path)) {
3252
4238
  mkdirSync3(action.path, { recursive: true });
3253
4239
  }
3254
4240
  return { action, success: true };
@@ -3261,11 +4247,11 @@ async function executeAction(action, options) {
3261
4247
  wouldDo: `Create file: ${action.path}${action.permissions ? ` (mode: ${action.permissions.toString(8)})` : ""}`
3262
4248
  };
3263
4249
  }
3264
- const dir = dirname6(action.path);
3265
- if (!existsSync12(dir)) {
4250
+ const dir = dirname7(action.path);
4251
+ if (!existsSync15(dir)) {
3266
4252
  mkdirSync3(dir, { recursive: true });
3267
4253
  }
3268
- writeFileSync6(action.path, action.content, "utf-8");
4254
+ writeFileSync7(action.path, action.content, "utf-8");
3269
4255
  if (action.permissions) {
3270
4256
  chmodSync(action.path, action.permissions);
3271
4257
  }
@@ -3280,18 +4266,18 @@ async function executeAction(action, options) {
3280
4266
  };
3281
4267
  }
3282
4268
  let existing = {};
3283
- if (existsSync12(action.path)) {
4269
+ if (existsSync15(action.path)) {
3284
4270
  try {
3285
- existing = JSON.parse(readFileSync7(action.path, "utf-8"));
4271
+ existing = JSON.parse(readFileSync10(action.path, "utf-8"));
3286
4272
  } catch {
3287
4273
  }
3288
4274
  }
3289
4275
  const merged = deepMerge(existing, action.merge);
3290
- const dir = dirname6(action.path);
3291
- if (!existsSync12(dir)) {
4276
+ const dir = dirname7(action.path);
4277
+ if (!existsSync15(dir)) {
3292
4278
  mkdirSync3(dir, { recursive: true });
3293
4279
  }
3294
- writeFileSync6(action.path, JSON.stringify(merged, null, 2), "utf-8");
4280
+ writeFileSync7(action.path, JSON.stringify(merged, null, 2), "utf-8");
3295
4281
  return { action, success: true };
3296
4282
  }
3297
4283
  case "delete_file": {
@@ -3302,7 +4288,7 @@ async function executeAction(action, options) {
3302
4288
  wouldDo: `Delete file: ${action.path}`
3303
4289
  };
3304
4290
  }
3305
- if (existsSync12(action.path)) {
4291
+ if (existsSync15(action.path)) {
3306
4292
  unlinkSync(action.path);
3307
4293
  }
3308
4294
  return { action, success: true };
@@ -3315,12 +4301,12 @@ async function executeAction(action, options) {
3315
4301
  wouldDo: `Append to file: ${action.path}`
3316
4302
  };
3317
4303
  }
3318
- if (existsSync12(action.path)) {
3319
- const content = readFileSync7(action.path, "utf-8");
4304
+ if (existsSync15(action.path)) {
4305
+ const content = readFileSync10(action.path, "utf-8");
3320
4306
  if (action.ifNotContains && content.includes(action.ifNotContains)) {
3321
4307
  return { action, success: true };
3322
4308
  }
3323
- writeFileSync6(action.path, content + action.content, "utf-8");
4309
+ writeFileSync7(action.path, content + action.content, "utf-8");
3324
4310
  }
3325
4311
  return { action, success: true };
3326
4312
  }
@@ -3333,6 +4319,9 @@ async function executeAction(action, options) {
3333
4319
  case "inject_next_config": {
3334
4320
  return await executeInjectNextConfig(action, options);
3335
4321
  }
4322
+ case "inject_vite_config": {
4323
+ return await executeInjectViteConfig(action, options);
4324
+ }
3336
4325
  case "install_next_routes": {
3337
4326
  return await executeInstallNextRoutes(action, options);
3338
4327
  }
@@ -3388,6 +4377,7 @@ async function executeInjectReact(action, options) {
3388
4377
  const result = await installReactUILintOverlay({
3389
4378
  projectPath: action.projectPath,
3390
4379
  appRoot: action.appRoot,
4380
+ mode: action.mode,
3391
4381
  force: false,
3392
4382
  // Auto-select first choice for execute phase
3393
4383
  confirmFileChoice: async (choices) => choices[0]
@@ -3399,6 +4389,25 @@ async function executeInjectReact(action, options) {
3399
4389
  error: success ? void 0 : "Failed to configure React overlay"
3400
4390
  };
3401
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
+ }
3402
4411
  async function executeInjectNextConfig(action, options) {
3403
4412
  const { dryRun = false } = options;
3404
4413
  if (dryRun) {
@@ -3456,6 +4465,7 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3456
4465
  const filesDeleted = [];
3457
4466
  const eslintTargets = [];
3458
4467
  let nextApp;
4468
+ let viteApp;
3459
4469
  for (const result of actionsPerformed) {
3460
4470
  if (!result.success) continue;
3461
4471
  const { action } = result;
@@ -3478,6 +4488,12 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3478
4488
  });
3479
4489
  break;
3480
4490
  case "inject_react":
4491
+ if (action.mode === "vite") {
4492
+ viteApp = { entryRoot: action.appRoot };
4493
+ } else {
4494
+ nextApp = { appRoot: action.appRoot };
4495
+ }
4496
+ break;
3481
4497
  case "install_next_routes":
3482
4498
  nextApp = { appRoot: action.appRoot };
3483
4499
  break;
@@ -3499,7 +4515,8 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
3499
4515
  filesDeleted,
3500
4516
  dependenciesInstalled,
3501
4517
  eslintTargets,
3502
- nextApp
4518
+ nextApp,
4519
+ viteApp
3503
4520
  };
3504
4521
  }
3505
4522
  async function execute(plan, options = {}) {
@@ -3549,11 +4566,14 @@ async function execute(plan, options = {}) {
3549
4566
  if (action.path.includes("hooks.json")) items.push("hooks");
3550
4567
  if (action.path.includes("genstyleguide.md")) items.push("genstyleguide");
3551
4568
  if (action.path.includes("genrules.md")) items.push("genrules");
4569
+ if (action.path.includes("/skills/") && action.path.includes("SKILL.md")) items.push("skill");
3552
4570
  }
3553
4571
  if (action.type === "inject_eslint") items.push("eslint");
3554
- if (action.type === "inject_react" || action.type === "install_next_routes") {
3555
- 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");
3556
4575
  }
4576
+ if (action.type === "inject_vite_config") items.push("vite");
3557
4577
  }
3558
4578
  const uniqueItems = [...new Set(items)];
3559
4579
  const summary = buildSummary(
@@ -3579,13 +4599,18 @@ var cliPrompter = {
3579
4599
  {
3580
4600
  value: "eslint",
3581
4601
  label: "ESLint plugin",
3582
- hint: "Installs uilint-eslint and configures eslint.config.js"
4602
+ hint: "Installs uilint-eslint and configures eslint.config.*"
3583
4603
  },
3584
4604
  {
3585
4605
  value: "next",
3586
4606
  label: "UI overlay",
3587
4607
  hint: "Installs routes + UILintProvider (Alt+Click to inspect)"
3588
4608
  },
4609
+ {
4610
+ value: "vite",
4611
+ label: "UI overlay (Vite)",
4612
+ hint: "Installs jsx-loc-plugin + UILintProvider (Alt+Click to inspect)"
4613
+ },
3589
4614
  {
3590
4615
  value: "genstyleguide",
3591
4616
  label: "/genstyleguide command",
@@ -3605,10 +4630,15 @@ var cliPrompter = {
3605
4630
  value: "genrules",
3606
4631
  label: "/genrules command",
3607
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"
3608
4638
  }
3609
4639
  ],
3610
4640
  required: true,
3611
- initialValues: ["eslint", "next", "genstyleguide"]
4641
+ initialValues: ["eslint", "next", "genstyleguide", "skill"]
3612
4642
  });
3613
4643
  },
3614
4644
  async confirmMcpMerge() {
@@ -3638,6 +4668,17 @@ var cliPrompter = {
3638
4668
  });
3639
4669
  return apps.find((a) => a.projectPath === chosen) || apps[0];
3640
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
+ },
3641
4682
  async selectEslintPackages(packages) {
3642
4683
  if (packages.length === 1) {
3643
4684
  const confirmed = await confirm2({
@@ -3794,13 +4835,14 @@ async function promptForField(field, ruleName) {
3794
4835
  }
3795
4836
  async function gatherChoices(state, options, prompter) {
3796
4837
  let items;
3797
- 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;
3798
4839
  if (hasExplicitFlags || options.eslint) {
3799
4840
  items = [];
3800
4841
  if (options.mcp) items.push("mcp");
3801
4842
  if (options.hooks) items.push("hooks");
3802
4843
  if (options.genstyleguide) items.push("genstyleguide");
3803
4844
  if (options.genrules) items.push("genrules");
4845
+ if (options.skill) items.push("skill");
3804
4846
  if (options.routes || options.react) items.push("next");
3805
4847
  if (options.eslint) items.push("eslint");
3806
4848
  } else if (options.mode) {
@@ -3839,6 +4881,25 @@ async function gatherChoices(state, options, prompter) {
3839
4881
  };
3840
4882
  }
3841
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
+ }
3842
4903
  let eslintChoices;
3843
4904
  if (items.includes("eslint")) {
3844
4905
  const packagesWithEslint = state.packages.filter(
@@ -3846,7 +4907,7 @@ async function gatherChoices(state, options, prompter) {
3846
4907
  );
3847
4908
  if (packagesWithEslint.length === 0) {
3848
4909
  throw new Error(
3849
- "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."
3850
4911
  );
3851
4912
  }
3852
4913
  const packagePaths = await prompter.selectEslintPackages(
@@ -3878,6 +4939,7 @@ async function gatherChoices(state, options, prompter) {
3878
4939
  mcpMerge,
3879
4940
  hooksMerge,
3880
4941
  next: nextChoices,
4942
+ vite: viteChoices,
3881
4943
  eslint: eslintChoices
3882
4944
  };
3883
4945
  }
@@ -3926,7 +4988,7 @@ function displayResults(result) {
3926
4988
  if (summary.nextApp) {
3927
4989
  installedItems.push(
3928
4990
  `${pc.cyan("Next Routes")} \u2192 ${pc.dim(
3929
- join12(summary.nextApp.appRoot, "api/.uilint")
4991
+ join15(summary.nextApp.appRoot, "api/.uilint")
3930
4992
  )}`
3931
4993
  );
3932
4994
  installedItems.push(
@@ -3934,7 +4996,17 @@ function displayResults(result) {
3934
4996
  );
3935
4997
  installedItems.push(
3936
4998
  `${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
3937
- "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()"
3938
5010
  )}`
3939
5011
  );
3940
5012
  }
@@ -3982,6 +5054,11 @@ function displayResults(result) {
3982
5054
  "Run your Next.js dev server - use Alt+Click on any element to inspect"
3983
5055
  );
3984
5056
  }
5057
+ if (summary.viteApp) {
5058
+ steps.push(
5059
+ "Run your Vite dev server - use Alt+Click on any element to inspect"
5060
+ );
5061
+ }
3985
5062
  if (summary.eslintTargets.length > 0) {
3986
5063
  steps.push(`Run ${pc.cyan("npx eslint src/")} to check for issues`);
3987
5064
  steps.push(
@@ -4046,12 +5123,207 @@ async function install(options = {}, prompter = cliPrompter, executeOptions = {}
4046
5123
  }
4047
5124
 
4048
5125
  // src/commands/serve.ts
4049
- 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";
4050
5127
  import { createRequire as createRequire2 } from "module";
4051
- 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";
4052
5129
  import { WebSocketServer, WebSocket } from "ws";
4053
5130
  import { watch } from "chokidar";
4054
- 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
4055
5327
  function pickAppRoot(params) {
4056
5328
  const { cwd, workspaceRoot } = params;
4057
5329
  if (detectNextAppRouter(cwd)) return cwd;
@@ -4066,6 +5338,18 @@ function pickAppRoot(params) {
4066
5338
  }
4067
5339
  var cache = /* @__PURE__ */ new Map();
4068
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
+ }
4069
5353
  var resolvedPathCache = /* @__PURE__ */ new Map();
4070
5354
  var subscriptions = /* @__PURE__ */ new Map();
4071
5355
  var fileWatcher = null;
@@ -4084,8 +5368,8 @@ function offsetFromLineCol(lineStarts, line1, col0, codeLength) {
4084
5368
  return Math.max(0, Math.min(codeLength, base + Math.max(0, col0)));
4085
5369
  }
4086
5370
  function buildJsxElementSpans(code, dataLocFile) {
4087
- const { parse } = localRequire("@typescript-eslint/typescript-estree");
4088
- const ast = parse(code, {
5371
+ const { parse: parse3 } = localRequire("@typescript-eslint/typescript-estree");
5372
+ const ast = parse3(code, {
4089
5373
  loc: true,
4090
5374
  range: true,
4091
5375
  jsx: true,
@@ -4148,10 +5432,10 @@ function findESLintCwd(startDir) {
4148
5432
  let dir = startDir;
4149
5433
  for (let i = 0; i < 30; i++) {
4150
5434
  for (const cfg of ESLINT_CONFIG_FILES2) {
4151
- if (existsSync13(join13(dir, cfg))) return dir;
5435
+ if (existsSync17(join17(dir, cfg))) return dir;
4152
5436
  }
4153
- if (existsSync13(join13(dir, "package.json"))) return dir;
4154
- const parent = dirname7(dir);
5437
+ if (existsSync17(join17(dir, "package.json"))) return dir;
5438
+ const parent = dirname9(dir);
4155
5439
  if (parent === dir) break;
4156
5440
  dir = parent;
4157
5441
  }
@@ -4164,7 +5448,7 @@ function normalizeDataLocFilePath(absoluteFilePath, projectCwd) {
4164
5448
  const abs = normalizePathSlashes(resolve5(absoluteFilePath));
4165
5449
  const cwd = normalizePathSlashes(resolve5(projectCwd));
4166
5450
  if (abs === cwd || abs.startsWith(cwd + "/")) {
4167
- return normalizePathSlashes(relative2(cwd, abs));
5451
+ return normalizePathSlashes(relative3(cwd, abs));
4168
5452
  }
4169
5453
  return abs;
4170
5454
  }
@@ -4176,25 +5460,25 @@ function resolveRequestedFilePath(filePath) {
4176
5460
  if (cached) return cached;
4177
5461
  const cwd = process.cwd();
4178
5462
  const fromCwd = resolve5(cwd, filePath);
4179
- if (existsSync13(fromCwd)) {
5463
+ if (existsSync17(fromCwd)) {
4180
5464
  resolvedPathCache.set(filePath, fromCwd);
4181
5465
  return fromCwd;
4182
5466
  }
4183
5467
  const wsRoot = findWorkspaceRoot5(cwd);
4184
5468
  const fromWs = resolve5(wsRoot, filePath);
4185
- if (existsSync13(fromWs)) {
5469
+ if (existsSync17(fromWs)) {
4186
5470
  resolvedPathCache.set(filePath, fromWs);
4187
5471
  return fromWs;
4188
5472
  }
4189
5473
  for (const top of ["apps", "packages"]) {
4190
- const base = join13(wsRoot, top);
4191
- if (!existsSync13(base)) continue;
5474
+ const base = join17(wsRoot, top);
5475
+ if (!existsSync17(base)) continue;
4192
5476
  try {
4193
- const entries = readdirSync3(base, { withFileTypes: true });
5477
+ const entries = readdirSync5(base, { withFileTypes: true });
4194
5478
  for (const ent of entries) {
4195
5479
  if (!ent.isDirectory()) continue;
4196
5480
  const p2 = resolve5(base, ent.name, filePath);
4197
- if (existsSync13(p2)) {
5481
+ if (existsSync17(p2)) {
4198
5482
  resolvedPathCache.set(filePath, p2);
4199
5483
  return p2;
4200
5484
  }
@@ -4209,7 +5493,7 @@ async function getESLintForProject(projectCwd) {
4209
5493
  const cached = eslintInstances.get(projectCwd);
4210
5494
  if (cached) return cached;
4211
5495
  try {
4212
- const req = createRequire2(join13(projectCwd, "package.json"));
5496
+ const req = createRequire2(join17(projectCwd, "package.json"));
4213
5497
  const mod = req("eslint");
4214
5498
  const ESLintCtor = mod?.ESLint ?? mod?.default?.ESLint ?? mod?.default ?? mod;
4215
5499
  if (!ESLintCtor) return null;
@@ -4222,13 +5506,13 @@ async function getESLintForProject(projectCwd) {
4222
5506
  }
4223
5507
  async function lintFile(filePath, onProgress) {
4224
5508
  const absolutePath = resolveRequestedFilePath(filePath);
4225
- if (!existsSync13(absolutePath)) {
5509
+ if (!existsSync17(absolutePath)) {
4226
5510
  onProgress(`File not found: ${pc.dim(absolutePath)}`);
4227
5511
  return [];
4228
5512
  }
4229
5513
  const mtimeMs = (() => {
4230
5514
  try {
4231
- return statSync2(absolutePath).mtimeMs;
5515
+ return statSync4(absolutePath).mtimeMs;
4232
5516
  } catch {
4233
5517
  return 0;
4234
5518
  }
@@ -4238,7 +5522,7 @@ async function lintFile(filePath, onProgress) {
4238
5522
  onProgress("Cache hit (unchanged)");
4239
5523
  return cached.issues;
4240
5524
  }
4241
- const fileDir = dirname7(absolutePath);
5525
+ const fileDir = dirname9(absolutePath);
4242
5526
  const projectCwd = findESLintCwd(fileDir);
4243
5527
  onProgress(`Resolving ESLint project... ${pc.dim(projectCwd)}`);
4244
5528
  const eslint = await getESLintForProject(projectCwd);
@@ -4261,7 +5545,7 @@ async function lintFile(filePath, onProgress) {
4261
5545
  let codeLength = 0;
4262
5546
  try {
4263
5547
  onProgress("Building JSX map...");
4264
- const code = readFileSync8(absolutePath, "utf-8");
5548
+ const code = readFileSync11(absolutePath, "utf-8");
4265
5549
  codeLength = code.length;
4266
5550
  lineStarts = buildLineStarts(code);
4267
5551
  spans = buildJsxElementSpans(code, dataLocFile);
@@ -4330,6 +5614,7 @@ async function handleMessage(ws, data) {
4330
5614
  message.filePath ?? "(all)"
4331
5615
  )}`
4332
5616
  );
5617
+ } else if (message.type === "vision:analyze") {
4333
5618
  }
4334
5619
  switch (message.type) {
4335
5620
  case "lint:file": {
@@ -4342,7 +5627,7 @@ async function handleMessage(ws, data) {
4342
5627
  });
4343
5628
  const startedAt = Date.now();
4344
5629
  const resolved = resolveRequestedFilePath(filePath);
4345
- if (!existsSync13(resolved)) {
5630
+ if (!existsSync17(resolved)) {
4346
5631
  const cwd = process.cwd();
4347
5632
  const wsRoot = findWorkspaceRoot5(cwd);
4348
5633
  logWarning(
@@ -4430,6 +5715,167 @@ async function handleMessage(ws, data) {
4430
5715
  }
4431
5716
  break;
4432
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
+ }
4433
5879
  }
4434
5880
  }
4435
5881
  function handleDisconnect(ws) {
@@ -4460,6 +5906,7 @@ async function serve(options) {
4460
5906
  const cwd = process.cwd();
4461
5907
  const wsRoot = findWorkspaceRoot5(cwd);
4462
5908
  const appRoot = pickAppRoot({ cwd, workspaceRoot: wsRoot });
5909
+ serverAppRootForVision = appRoot;
4463
5910
  logInfo(`Workspace root: ${pc.dim(wsRoot)}`);
4464
5911
  logInfo(`App root: ${pc.dim(appRoot)}`);
4465
5912
  logInfo(`Server cwd: ${pc.dim(cwd)}`);
@@ -4499,22 +5946,505 @@ async function serve(options) {
4499
5946
  `UILint WebSocket server running on ${pc.cyan(`ws://localhost:${port}`)}`
4500
5947
  );
4501
5948
  logInfo("Press Ctrl+C to stop");
4502
- await new Promise((resolve7) => {
5949
+ await new Promise((resolve8) => {
4503
5950
  process.on("SIGINT", () => {
4504
5951
  logInfo("Shutting down...");
4505
5952
  wss.close();
4506
5953
  fileWatcher?.close();
4507
- resolve7();
5954
+ resolve8();
4508
5955
  });
4509
5956
  });
4510
5957
  }
4511
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
+
4512
6442
  // src/commands/session.ts
4513
- import { existsSync as existsSync14, readFileSync as readFileSync9, writeFileSync as writeFileSync7, unlinkSync as unlinkSync2 } from "fs";
4514
- 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";
4515
6445
  import { createStyleSummary as createStyleSummary3 } from "uilint-core";
4516
6446
  import {
4517
- ensureOllamaReady as ensureOllamaReady5,
6447
+ ensureOllamaReady as ensureOllamaReady7,
4518
6448
  parseCLIInput as parseCLIInput2,
4519
6449
  readStyleGuideFromProject as readStyleGuideFromProject2,
4520
6450
  readTailwindThemeTokens as readTailwindThemeTokens3
@@ -4522,18 +6452,18 @@ import {
4522
6452
  var SESSION_FILE = "/tmp/uilint-session.json";
4523
6453
  var UI_FILE_EXTENSIONS = [".tsx", ".jsx", ".css", ".scss", ".module.css"];
4524
6454
  function readSession() {
4525
- if (!existsSync14(SESSION_FILE)) {
6455
+ if (!existsSync19(SESSION_FILE)) {
4526
6456
  return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
4527
6457
  }
4528
6458
  try {
4529
- const content = readFileSync9(SESSION_FILE, "utf-8");
6459
+ const content = readFileSync13(SESSION_FILE, "utf-8");
4530
6460
  return JSON.parse(content);
4531
6461
  } catch {
4532
6462
  return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
4533
6463
  }
4534
6464
  }
4535
6465
  function writeSession(state) {
4536
- writeFileSync7(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
6466
+ writeFileSync10(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
4537
6467
  }
4538
6468
  function isUIFile(filePath) {
4539
6469
  return UI_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
@@ -4544,7 +6474,7 @@ function isScannableMarkupFile(filePath) {
4544
6474
  );
4545
6475
  }
4546
6476
  async function sessionClear() {
4547
- if (existsSync14(SESSION_FILE)) {
6477
+ if (existsSync19(SESSION_FILE)) {
4548
6478
  unlinkSync2(SESSION_FILE);
4549
6479
  }
4550
6480
  console.log(JSON.stringify({ cleared: true }));
@@ -4611,17 +6541,17 @@ async function sessionScan(options = {}) {
4611
6541
  }
4612
6542
  return;
4613
6543
  }
4614
- await ensureOllamaReady5();
6544
+ await ensureOllamaReady7();
4615
6545
  const client = await createLLMClient({});
4616
6546
  const results = [];
4617
6547
  for (const filePath of session.files) {
4618
- if (!existsSync14(filePath)) continue;
6548
+ if (!existsSync19(filePath)) continue;
4619
6549
  if (!isScannableMarkupFile(filePath)) continue;
4620
6550
  try {
4621
- const absolutePath = resolve6(process.cwd(), filePath);
4622
- const htmlLike = readFileSync9(filePath, "utf-8");
6551
+ const absolutePath = resolve7(process.cwd(), filePath);
6552
+ const htmlLike = readFileSync13(filePath, "utf-8");
4623
6553
  const snapshot = parseCLIInput2(htmlLike);
4624
- const tailwindSearchDir = dirname8(absolutePath);
6554
+ const tailwindSearchDir = dirname11(absolutePath);
4625
6555
  const tailwindTheme = readTailwindThemeTokens3(tailwindSearchDir);
4626
6556
  const styleSummary = createStyleSummary3(snapshot.styles, {
4627
6557
  html: snapshot.html,
@@ -4674,7 +6604,7 @@ async function sessionScan(options = {}) {
4674
6604
  };
4675
6605
  console.log(JSON.stringify(result));
4676
6606
  }
4677
- if (existsSync14(SESSION_FILE)) {
6607
+ if (existsSync19(SESSION_FILE)) {
4678
6608
  unlinkSync2(SESSION_FILE);
4679
6609
  }
4680
6610
  await flushLangfuse();
@@ -4685,9 +6615,9 @@ async function sessionList() {
4685
6615
  }
4686
6616
 
4687
6617
  // src/index.ts
4688
- import { readFileSync as readFileSync10 } from "fs";
4689
- import { dirname as dirname9, join as join14 } from "path";
4690
- 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";
4691
6621
  function assertNodeVersion(minMajor) {
4692
6622
  const ver = process.versions.node || "";
4693
6623
  const majorStr = ver.split(".")[0] || "";
@@ -4703,9 +6633,9 @@ assertNodeVersion(20);
4703
6633
  var program = new Command();
4704
6634
  function getCLIVersion2() {
4705
6635
  try {
4706
- const __dirname = dirname9(fileURLToPath2(import.meta.url));
4707
- const pkgPath = join14(__dirname, "..", "package.json");
4708
- 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"));
4709
6639
  return pkg.version || "0.0.0";
4710
6640
  } catch {
4711
6641
  return "0.0.0";
@@ -4808,6 +6738,40 @@ program.command("serve").description("Start WebSocket server for real-time UI li
4808
6738
  port: parseInt(options.port, 10)
4809
6739
  });
4810
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
+ });
4811
6775
  var sessionCmd = program.command("session").description(
4812
6776
  "Manage file tracking for agentic sessions (used by Cursor hooks)"
4813
6777
  );